From 1c3fc68cfaf9953677002188f344503d56a22026 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Thu, 20 Dec 2018 00:47:32 -0500 Subject: [PATCH] adding necessary apis for sqlalchemy models to work in django admin Signed-off-by: Miroslav Shubernetskiy --- .gitignore | 1 + django_sorcery/admin/__init__.py | 2 ++ django_sorcery/admin/options.py | 32 +++++++++++++++++ django_sorcery/db/meta/column.py | 5 +++ django_sorcery/db/meta/relations.py | 4 +++ django_sorcery/db/models.py | 56 ++++++++++++++++++++++++++++- django_sorcery/db/query.py | 36 +++++++++++++++++-- django_sorcery/db/sqlalchemy.py | 7 ++-- django_sorcery/forms.py | 8 ++--- requirements.txt | 25 +++++++------ test_site/manage.py | 4 ++- test_site/polls/admin.py | 15 ++++++-- test_site/polls/models.py | 6 ++++ 13 files changed, 177 insertions(+), 24 deletions(-) create mode 100644 django_sorcery/admin/__init__.py create mode 100644 django_sorcery/admin/options.py diff --git a/.gitignore b/.gitignore index ec52646..73b5780 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ docs/_build # IDEs /.idea .venv +.envrc .pytest_cache db.sqlite3 .mypy_cache diff --git a/django_sorcery/admin/__init__.py b/django_sorcery/admin/__init__.py new file mode 100644 index 0000000..ba25ec7 --- /dev/null +++ b/django_sorcery/admin/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, print_function, unicode_literals diff --git a/django_sorcery/admin/options.py b/django_sorcery/admin/options.py new file mode 100644 index 0000000..e9622b8 --- /dev/null +++ b/django_sorcery/admin/options.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, print_function, unicode_literals +from functools import wraps + +from django.contrib.admin import options +from django.contrib.admin.options import ModelAdmin as DjangoModelAdmin + +from ..forms import ModelForm, modelform_factory + + +def swap(module, attr, replacement): + def wrapper(f): + @wraps(f) + def inner(*args, **kwargs): + original = getattr(module, attr) + setattr(module, attr, replacement) + try: + return f(*args, **kwargs) + finally: + setattr(module, attr, original) + + return inner + + return wrapper + + +class ModelAdmin(DjangoModelAdmin): + form = ModelForm + + @swap(options, "modelform_factory", modelform_factory) + def get_form(self, *args, **kwargs): + return super(ModelAdmin, self).get_form(*args, **kwargs) diff --git a/django_sorcery/db/meta/column.py b/django_sorcery/db/meta/column.py index 2b316ab..fa50337 100644 --- a/django_sorcery/db/meta/column.py +++ b/django_sorcery/db/meta/column.py @@ -91,6 +91,8 @@ def required(self): def name(self): return self.property.key if self.property is not None else self.column.key + attname = name + @property def parent_model(self): return self.property.parent.class_ if self.property else None @@ -146,6 +148,9 @@ def field_kwargs(self): return kwargs + def to_python(self, value): + return self.formfield().clean(value) + class string_column_info(column_info): default_form_class = djangofields.CharField diff --git a/django_sorcery/db/meta/relations.py b/django_sorcery/db/meta/relations.py index a045b60..ef7917f 100644 --- a/django_sorcery/db/meta/relations.py +++ b/django_sorcery/db/meta/relations.py @@ -17,6 +17,10 @@ class relation_info(object): def __init__(self, relationship): self.relationship = relationship + @property + def choices(self): + return True + @property def attribute(self): return getattr(self.parent_model, self.name) diff --git a/django_sorcery/db/models.py b/django_sorcery/db/models.py index 514d327..dcbe1bd 100644 --- a/django_sorcery/db/models.py +++ b/django_sorcery/db/models.py @@ -10,9 +10,11 @@ import sqlalchemy.ext.declarative # noqa import sqlalchemy.orm # noqa from sqlalchemy.orm.base import MANYTOONE, NO_VALUE +from sqlalchemy.orm.exc import NoResultFound +from django.apps import apps from django.conf import settings -from django.core.exceptions import ValidationError +from django.core.exceptions import FieldDoesNotExist, ValidationError from django.forms.fields import DateField from django.utils.text import camel_case_to_spaces @@ -268,6 +270,46 @@ def clone(instance, *rels, **kwargs): return cloned +class Options(object): + def __init__(self, model, options, app_label=None): + self.model = model + self.meta = options + + self.apps = apps + self.app_label = app_label or apps.get_containing_app_config(self.model.__module__).label + + self.db_table = model.__tablename__ + self.pk = next(iter(meta.model_info(model).primary_keys.values())) + + self.object_name = model.__name__ + self.model_name = self.object_name.lower() + self.verbose_name = getattr(meta, "verbose_name", camel_case_to_spaces(self.object_name)) + self.verbose_name_plural = getattr(meta, "verbose_name_plural", "{}s".format(self.verbose_name)) + + self.abstract = False + self.swapped = False + self.ordering = [] + + @property + def app_config(self): + # Don't go through get_app_config to avoid triggering imports. + return self.apps.app_configs.get(self.app_label) + + def get_field(self, name): + try: + return dict(meta.model_info(self.model).column_properties)[name] + except KeyError: + raise FieldDoesNotExist + + def __repr__(self): + return "<{}({})>".format(self.__class__.__name__, self.object_name) + + +class OptionsDescriptor(object): + def __get__(self, instance, owner): + return Options(owner, getattr(owner, "Meta", None)) + + class BaseMeta(sqlalchemy.ext.declarative.DeclarativeMeta): """ Base metaclass for models which registers models to DB model registry @@ -277,6 +319,7 @@ class BaseMeta(sqlalchemy.ext.declarative.DeclarativeMeta): def __new__(typ, name, bases, attrs): klass = super(BaseMeta, typ).__new__(typ, name, bases, attrs) typ.db.models_registry.append(klass) + klass.session = typ.db return klass @@ -343,6 +386,17 @@ def _get_relation_objects_for_validation(self): """ return meta.model_info(self.__class__).relationships + # ---------- + # Django API + # ---------- + + _meta = OptionsDescriptor() + + DoesNotExist = NoResultFound + + def serializable_value(self, field_name): + return getattr(self, field_name) + _instant_defaults = set() diff --git a/django_sorcery/db/query.py b/django_sorcery/db/query.py index 7e60c5f..bb1f676 100644 --- a/django_sorcery/db/query.py +++ b/django_sorcery/db/query.py @@ -48,19 +48,29 @@ } +class DjangoCompiledQuery(object): + def __init__(self, query): + self.query = query + self.order_by = [] + self.select_related = True + + class Query(sa.orm.Query): """ A customized sqlalchemy query """ + @property + def model(self): + return self._only_full_mapper_zero("get").class_ + def get(self, *args, **kwargs): """ Return an instance based on the given primary key identifier, either as args or kwargs for composite keys. If no instance is found, returns ``None``. """ if kwargs: - mapper = self._only_full_mapper_zero("get") - pk = meta.model_info(mapper).primary_keys_from_dict(kwargs) + pk = meta.model_info(self.model).primary_keys_from_dict(kwargs) if pk is not None: return super(Query, self).get(pk) @@ -83,9 +93,22 @@ def filter(self, *args, **kwargs): args = args + tuple(self._lookup_to_expression(k, v) for k, v in kwargs.items()) return super(Query, self).filter(*args) + def get_queryset(self): + """ + Necessary to comply with Django API + """ + return self + + @property + def query(self): + """ + Necessary to comply with Django API + """ + return DjangoCompiledQuery(self) + def _lookup_to_expression(self, lookup, value): parts = lookup.split(LOOKUP_SEP) - info = meta.model_info(self._only_full_mapper_zero("get")) + info = meta.model_info(self.model) props = dict(info.column_properties) lhs = None @@ -121,6 +144,13 @@ def _lookup_to_expression(self, lookup, value): return lhs == value + # todo need to make len(query) == len(cl.result_list.all()) without recursion + # currently requires patch in django/contrib/admin/options.py + # 1792 - 'selection_note': _('0 of %(cnt)s selected') % {'cnt': len(cl.result_list)}, + # 1792 + 'selection_note': _('0 of %(cnt)s selected') % {'cnt': len(cl.result_list.all())}, + # def __len__(self): + # return self.count() + class QueryProperty(object): def __init__(self, db, model=None, *args, **kwargs): diff --git a/django_sorcery/db/sqlalchemy.py b/django_sorcery/db/sqlalchemy.py index a918c9b..cd3b267 100644 --- a/django_sorcery/db/sqlalchemy.py +++ b/django_sorcery/db/sqlalchemy.py @@ -194,10 +194,13 @@ def _make_declarative(self, model): # allow to customize things in custom base model if not hasattr(base, "query_class"): base.query_class = self.query_class + prop = self.queryproperty() if not hasattr(base, "query"): - base.query = self.queryproperty() + base.query = prop if not hasattr(base, "objects"): - base.objects = self.queryproperty() + base.objects = prop + if not hasattr(base, "_default_manager"): + base._default_manager = prop return base diff --git a/django_sorcery/forms.py b/django_sorcery/forms.py index 2581fe0..1cba942 100644 --- a/django_sorcery/forms.py +++ b/django_sorcery/forms.py @@ -9,8 +9,8 @@ import six from django.core.exceptions import NON_FIELD_ERRORS, ImproperlyConfigured, ValidationError -from django.forms import ALL_FIELDS -from django.forms.forms import BaseForm, DeclarativeFieldsMetaclass +from django.forms import ALL_FIELDS, BaseModelForm as DjangoBaseModelForm +from django.forms.forms import DeclarativeFieldsMetaclass from django.forms.models import ModelFormOptions from django.forms.utils import ErrorList @@ -240,7 +240,7 @@ def __new__(cls, name, bases, attrs): return mcs -class BaseModelForm(BaseForm): +class BaseModelForm(DjangoBaseModelForm): def __init__( self, data=None, @@ -257,10 +257,10 @@ def __init__( session=None, ): opts = self._meta - opts.session = opts.session or session if opts.model is None: raise ValueError("ModelForm has no model class specified.") + opts.session = opts.session or session or getattr(opts.model, "session", None) if opts.session is None: raise ValueError("ModelForm has no session specified.") diff --git a/requirements.txt b/requirements.txt index 6c3264e..734e70c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,22 @@ -e . -pytest -pytest-cov +attrs +beautifulsoup4 +coveralls +django-extensions flake8 -simplejson -sphinx-autobuild gitchangelog -yapf +mock; python_version <= '2.7' pdbpp pre_commit +psycopg2 +ptpython +pytest +pytest-cov +simplejson Sphinx +sphinx-autobuild sphinx_rtd_theme -mock; python_version <= '2.7' -beautifulsoup4 -attrs -tox -coveralls sqlalchemy_utils -psycopg2 +tox +Werkzeug +yapf diff --git a/test_site/manage.py b/test_site/manage.py index 6ff815b..c2a2c95 100755 --- a/test_site/manage.py +++ b/test_site/manage.py @@ -5,7 +5,9 @@ import sys -sys.path.insert(0, "..") +sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.dirname(__file__)))) +sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) + if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_site.settings") diff --git a/test_site/polls/admin.py b/test_site/polls/admin.py index b33d6ac..17ed85a 100644 --- a/test_site/polls/admin.py +++ b/test_site/polls/admin.py @@ -1,7 +1,18 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, print_function, unicode_literals -from django.contrib import admin # noqa +from django.contrib import admin +from django_sorcery.admin.options import ModelAdmin -# Register your models here. +from .models import Choice, Question + + +@admin.register(Question) +class QuestionAdmin(ModelAdmin): + pass + + +@admin.register(Choice) +class ChoiceAdmin(ModelAdmin): + pass diff --git a/test_site/polls/models.py b/test_site/polls/models.py index 75469b8..105c772 100644 --- a/test_site/polls/models.py +++ b/test_site/polls/models.py @@ -12,6 +12,9 @@ class Question(db.Model): question_text = db.Column(db.String(length=200)) pub_date = db.Column(db.DateTime()) + def __str__(self): + return self.question_text + class Choice(db.Model): pk = db.Column(db.Integer(), autoincrement=True, primary_key=True) @@ -20,6 +23,9 @@ class Choice(db.Model): question = db.ManyToOne(Question, backref=db.backref("choices", cascade="all, delete-orphan")) + def __str__(self): + return self.choice_text + db.configure_mappers() db.create_all()