From 59f218c138a2d5e30ba7809d9ead3880e2552382 Mon Sep 17 00:00:00 2001 From: EvdH0 Date: Sat, 10 Jul 2021 15:38:28 +0200 Subject: [PATCH 01/13] The variable is not is not recognized, so moved to the Docker .env file --- tests/.env | 3 +++ tests/docker-compose.yml | 3 +++ tests/docker-entrypoint.sh | 2 ++ tests/settings.py | 3 --- 4 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 tests/.env diff --git a/tests/.env b/tests/.env new file mode 100644 index 0000000..47c372c --- /dev/null +++ b/tests/.env @@ -0,0 +1,3 @@ +DJANGO_SUPERUSER_PASSWORD=1234 +DJANGO_SUPERUSER_EMAIL=example@example.com +DJANGO_SUPERUSER_USERNAME=admin diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index f68422a..822fb2a 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -19,6 +19,9 @@ services: environment: - NEO4J_BOLT_URL=bolt://neo4j:foobar@neo4j_db:7687 - DJANGO_SETTINGS_MODULE=settings + - DJANGO_SUPERUSER_PASSWORD + - DJANGO_SUPERUSER_EMAIL + - DJANGO_SUPERUSER_USERNAME neo4j_db: diff --git a/tests/docker-entrypoint.sh b/tests/docker-entrypoint.sh index 42c79e7..2b56b00 100755 --- a/tests/docker-entrypoint.sh +++ b/tests/docker-entrypoint.sh @@ -15,6 +15,8 @@ then --noinput \ --username $DJANGO_SUPERUSER_USERNAME \ --email $DJANGO_SUPERUSER_EMAIL +else + echo "No superusername found" fi $@ diff --git a/tests/settings.py b/tests/settings.py index fccb4f9..bb20632 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -73,6 +73,3 @@ STATIC_ROOT = "./static/" STATIC_URL = '/static/' -DJANGO_SUPERUSER_PASSWORD = "1234" -DJANGO_SUPERUSER_EMAIL = "example@example.com" -DJANGO_SUPERUSER_USERNAME = "admin" \ No newline at end of file From d8c063524a41b874b118ed2bb93907bc93debe2c Mon Sep 17 00:00:00 2001 From: Eric van der Helm Date: Fri, 23 Jul 2021 13:03:30 +0200 Subject: [PATCH 02/13] Prevent failing when superuser already exists --- tests/docker-entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/docker-entrypoint.sh b/tests/docker-entrypoint.sh index 2b56b00..d207de5 100755 --- a/tests/docker-entrypoint.sh +++ b/tests/docker-entrypoint.sh @@ -14,7 +14,7 @@ then python manage.py createsuperuser \ --noinput \ --username $DJANGO_SUPERUSER_USERNAME \ - --email $DJANGO_SUPERUSER_EMAIL + --email $DJANGO_SUPERUSER_EMAIL || true else echo "No superusername found" fi From 66d4a4f7ce83c873399b01fd5f2fc8d481bf2f64 Mon Sep 17 00:00:00 2001 From: EvdH0 Date: Wed, 29 Dec 2021 21:48:10 +0100 Subject: [PATCH 03/13] Implement Relationships and fix delete button in admin --- django_neomodel/__init__.py | 309 ++++++++++++++++++++++--- tests/someapp/admin.py | 8 +- tests/someapp/models.py | 42 +++- tests/someapp/tests/test_model_form.py | 184 ++++++++++++++- 4 files changed, 492 insertions(+), 51 deletions(-) diff --git a/django_neomodel/__init__.py b/django_neomodel/__init__.py index 3b63d05..d71cb6a 100644 --- a/django_neomodel/__init__.py +++ b/django_neomodel/__init__.py @@ -2,14 +2,22 @@ from django.db.models import signals from django.db.models.fields import BLANK_CHOICE_DASH +from django.db.models.base import ModelState +from django.db.models import ManyToManyField + from django.conf import settings from django.forms import fields as form_fields +from django.forms import ModelMultipleChoiceField as form_ModelMultipleChoiceField from django.db.models.options import Options -from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError -from neomodel import RequiredProperty, DeflateError, StructuredNode, UniqueIdProperty +from neomodel import RequiredProperty, DeflateError, StructuredNode, UniqueIdProperty, AliasProperty, UniqueProperty from neomodel.core import NodeMeta from neomodel.match import NodeSet +from neomodel.cardinality import OneOrMore, One, ZeroOrOne, ZeroOrMore + +from importlib import import_module +from types import SimpleNamespace __author__ = 'Robin Edwards' @@ -32,22 +40,83 @@ def __get__(self, obj, type=None): return cpf(f) +class NOT_PROVIDED: + pass + + +class DjangoFormFieldMultipleChoice(form_fields.MultipleChoiceField): + """ Sublcass of Djangos MultipleChoiceField but without working validator """ + def validate(self, value): + return True + + +class DjangoFormFieldTypedChoice(form_fields.TypedChoiceField): + """ Sublcass of Djangos TypedChoiceField but without working validator """ + def validate(self, value): + return True + + @total_ordering -class DjangoField(object): +class DjangoBaseField(object): + """ Base field where Properties and Relations Field should subclass from """ + + is_relation = False + concrete = True + editable = True + creation_counter = 0 + unique = False + primary_key = False + auto_created = False + + # Then from class RelatedField(FieldCacheMixin, Field): see https://docs.djangoproject.com/en/2.0/_modules/django/db/models/fields/related/ + # Field flags + one_to_many = None + one_to_one = None + many_to_many = None + many_to_one = None + + creation_counter = 0 + + def __init__(self): + self.creation_counter = DjangoBaseField.creation_counter + DjangoBaseField.creation_counter += 1 + + def __eq__(self, other): + # Needed for @total_ordering + if isinstance(other, DjangoBaseField): + return self.creation_counter == other.creation_counter + return NotImplemented + + def __lt__(self, other): + # This is needed because bisect does not take a comparison function. + if isinstance(other, DjangoBaseField): + return self.creation_counter < other.creation_counter + return NotImplemented + + def has_default(self): + return self._has_default + + def to_python(self, value): + return value + + def __hash__(self): + # The delete function in the Admin requires a hash + return hash(self.creation_counter) + + +class DjangoPropertyField(DjangoBaseField): """ Fake Django model field object which wraps a neomodel Property """ is_relation = False concrete = True - editable = True - creation_counter = 0 + editable = True unique = False primary_key = False auto_created = False def __init__(self, prop, name): self.prop = prop - self.name = name self.remote_field = name self.attname = name @@ -70,23 +139,7 @@ def __init__(self, prop, name): self.blank = not self.required self.choices = getattr(prop, 'choices', None) - self.creation_counter = DjangoField.creation_counter - DjangoField.creation_counter += 1 - - def __eq__(self, other): - # Needed for @total_ordering - if isinstance(other, DjangoField): - return self.creation_counter == other.creation_counter - return NotImplemented - - def __lt__(self, other): - # This is needed because bisect does not take a comparison function. - if isinstance(other, DjangoField): - return self.creation_counter < other.creation_counter - return NotImplemented - - def has_default(self): - return self._has_default + super().__init__() def save_form_data(self, instance, data): setattr(instance, self.name, data) @@ -123,20 +176,15 @@ def formfield(self, **kwargs): del kwargs[k] defaults.update(kwargs) - return self.form_class(**defaults) - def to_python(self, value): - return value - def get_choices(self, include_blank=True): blank_defined = False blank_choice = BLANK_CHOICE_DASH choices = list(self.choices) if self.choices else [] - if issubclass(type(self.choices), dict): - choices = list(enumerate(self.choices)) - + # Ensure list of tuples with proper key-value pairing + choices = [(k, v) for k, v in self.choices.items()] for choice, __ in choices: if choice in ('', None): blank_defined = True @@ -147,6 +195,176 @@ def get_choices(self, include_blank=True): return first_choice + choices +class DjangoRemoteField(object): + """ Fake RemoteField to let the Django Admin work """ + + def __init__(self, b): + # Fake this call https://github.com/django/django/blob/ac5cc6cf01463d90aa333d5f6f046c311019827b/django/contrib/admin/widgets.py#L278 + self.model = b + self.through = SimpleNamespace(_meta=SimpleNamespace(auto_created=True)) + + def get_related_field(self): + # Fake call https://github.com/django/django/blob/ac5cc6cf01463d90aa333d5f6f046c311019827b/django/contrib/admin/widgets.py#L282 + # from the Django Admin + return SimpleNamespace(name=self.model.pk.target) + + +class DjangoRelationField(DjangoBaseField): + """ + Fake Django model field object which wraps a neomodel Relationship + """ + one_to_many = False + one_to_one = False + many_to_one = False + + many_to_many = True + + @property + def __class__(self): + # Fake the class for + # https://github.com/django/django/blob/ac5cc6cf01463d90aa333d5f6f046c311019827b/django/contrib/admin/options.py#L144 + # so we can get the admin ManyToMany field widgets to work + return ManyToManyField + + def __init__(self, prop, name): + self.prop = prop + self.choices = None + + self.required = False + if prop.manager is OneOrMore or prop.manager is One: + self.required = True + + self.blank = False + + # See https://docs.djangoproject.com/en/2.0/_modules/django/db/models/fields/ + # Need a way to signal that there is no default + self._has_default = NOT_PROVIDED + + self.name = name + self.attname = name + self.verbose_name = name + self.help_text = getattr(prop, 'help_text', '') + + if prop.manager is ZeroOrOne: + # This form_class has its validator set to True + self.form_class = DjangoFormFieldTypedChoice + else: + # This form_class has its validator set to True + self.form_class = DjangoFormFieldMultipleChoice + + # Need to load the related model in so we can fetch + # all nodes. + + a = import_module(self.prop.module_name) + b = getattr(a, self.prop._raw_class) + self._related_model = b + + self.remote_field = DjangoRemoteField(b) + + super().__init__() + + def value_from_object(self, instance): + instance_relation = getattr(instance, self.name) + node_ids_selected = [] + for this_object in instance_relation.all(): + node_ids_selected.append(this_object.pk) + return node_ids_selected + + def save_form_data(self, instance, data): + # instance is the current node which needs to get connected + # data is a list of ids/uids of the nodes-to-connect-to + + instance_relation = getattr(instance, self.name) + # Need to define which nodes to disconnect from first! + all_possible_nodes = self._related_model.nodes.all() + + # Gather the pks from these nodes + list_of_ids = [] + for this_node in all_possible_nodes: + list_of_ids.append(this_node.pk) + # So which nodes are not selected? + should_not_be_connected = set(list_of_ids) - set(data) + + # Need to save the instance before relations can be made + try: + instance.save() + except UniqueProperty as e: + raise ValidationError(e) + # Cardinality needs to be observed, so use following order: + # if One: replace + # if OneOreMore: first connect, then disconnect + # if ZeroOrMore: doesn't matter + # if ZeroOrOne: First disconnect, then connect + + if self.prop.manager is ZeroOrMore or self.prop.manager is ZeroOrOne: + # Instead of checking the relationship exists, just disconnect + # In the future when specific relationships are implemented, this + # should be updated + self._disconnect_node(should_not_be_connected, instance_relation) + + # Now time to setup new connections + if data: # In case we selected an empty unit, don't do anything + self._connect_node(data, instance_relation) + + elif self.prop.manager is OneOrMore: + # First setup new connections + self._connect_node(data, instance_relation) + try: + instance.save() + except UniqueProperty as e: + raise ValidationError(e) + + # Instead of checking the relationship exists, just disconnect + self._disconnect_node(should_not_be_connected, instance_relation) + else: + # This would require replacing the current relation with a new one + raise NotImplementedError('Cardinality of One is not supported yet') + + def _disconnect_node(self, should_not_be_connected, instance_relation): + """ Given a list pk's, remove the relationship """ + + # Internals used by save_form_data to + for this_object in should_not_be_connected: + remover = self._related_model.nodes.get_or_none(pk=this_object) + instance_relation.disconnect(remover) + + def _connect_node(self, data, instance_relation): + """ Given a list pk's, add the relationship """ + + # If ChoiceField, it is not a list + data = [data] if not isinstance(data, list) else data + + for this_object in data: + # Retreive the node-to-connect-to + adder = self._related_model.nodes.get_or_none(pk=this_object) + # If the connection is there, leave it + if not adder: + raise ValidationError({self.name: ' not found'}) + + if not instance_relation.is_connected(adder): + instance_relation.connect(adder) + + def formfield(self, *args, **kwargs): + """Return a django.forms.Field instance for this field.""" + + node_options = [] + + if self.prop.manager is ZeroOrOne: + node_options = BLANK_CHOICE_DASH.copy() + + for this_object in self._related_model.nodes.all(): + node_options.append((this_object.pk, this_object.__str__)) + + defaults = {'required': self.required, + 'label': self.verbose_name, + 'help_text': self.help_text, + **kwargs, + } + + defaults['choices'] = node_options + return self.form_class(**defaults) + + class Query: select_related = False order_by = ['pk'] @@ -158,7 +376,7 @@ class NeoNodeSet(NodeSet): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.model = self.source - + def count(self): return len(self) @@ -189,18 +407,39 @@ class DjangoNode(StructuredNode, metaclass=MetaClass): def _meta(self): if hasattr(self.Meta, 'unique_together'): raise NotImplementedError('unique_together property not supported by neomodel') + + # Need a ModelState for the admin to delete an object + self._state = ModelState() + self._state.adding = False opts = Options(self.Meta, app_label=self.Meta.app_label) opts.contribute_to_class(self, self.__name__) + # Again, otherwise delete from admin doesn't work, see: + # https://github.com/django/django/blob/0e656c02fe945389246f0c08f51c6db4a0849bd2/django/db/models/deletion.py#L252 + opts.concrete_model = self + for key, prop in self.__all_properties__: - opts.add_field(DjangoField(prop, key), getattr(prop, 'private', False)) + opts.add_field(DjangoPropertyField(prop, key), getattr(prop, 'private', False)) if getattr(prop, "primary_key", False): - self.pk = prop - self.pk.auto_created = True + # a reference using self.pk = prop fails in some cases where + # django references the .pk attribute directly. ie in + # https://github.com/django/django/blob/ac5cc6cf01463d90aa333d5f6f046c311019827b/django/contrib/admin/options.py#L860 + # causes non-consistent behaviour because Django sometimes looks up the + # attribute name via 'pk = cl.lookup_opts.pk.attname'. + # instead provide an AliasProperty to the property tagged + # as primary_key + self.pk = AliasProperty(to=key) + + for key, relation in self.__all_relationships__: + opts.add_field(DjangoRelationField(relation,key), getattr(prop, 'private', False)) return opts + def __hash__(self): + # The delete function in the Admin requires a hash + return hash(self.pk) + def full_clean(self, exclude, validate_unique=False): """ Validate node, on error raising ValidationErrors which can be handled by django forms @@ -217,6 +456,8 @@ def full_clean(self, exclude, validate_unique=False): raise ValidationError({e.property_name: e.msg}) except RequiredProperty as e: raise ValidationError({e.property_name: 'is required'}) + except UniqueProperty as e: + raise ValidationError({e.property_name: e.msg}) def validate_unique(self, exclude): # get unique indexed properties diff --git a/tests/someapp/admin.py b/tests/someapp/admin.py index 6bcded4..01cf1c8 100644 --- a/tests/someapp/admin.py +++ b/tests/someapp/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin as dj_admin from django_neomodel import admin as neo_admin -from .models import Library, Book, Shelf +from .models import Library, Book, Shelf, Author + class LibraryAdmin(dj_admin.ModelAdmin): @@ -10,9 +11,14 @@ class LibraryAdmin(dj_admin.ModelAdmin): ) dj_admin.site.register(Library, LibraryAdmin) +class AuthorAdmin(dj_admin.ModelAdmin): + list_display = ("name",) +neo_admin.register(Author, AuthorAdmin) class BookAdmin(dj_admin.ModelAdmin): list_display = ("title", "created") + filter_horizontal = ('authored_by',) + #fields = ('title', 'shelf') neo_admin.register(Book, BookAdmin) diff --git a/tests/someapp/models.py b/tests/someapp/models.py index 37eeef6..e4a0d1e 100644 --- a/tests/someapp/models.py +++ b/tests/someapp/models.py @@ -1,8 +1,9 @@ from datetime import datetime from django.db import models -from django_neomodel import DjangoNode -from neomodel import StringProperty, DateTimeProperty, UniqueIdProperty +from django_neomodel import DjangoNode +from neomodel import StringProperty, DateTimeProperty, UniqueIdProperty, RelationshipTo, RelationshipFrom, AliasProperty, StructuredRel +from neomodel.cardinality import OneOrMore, ZeroOrOne class Library(models.Model): @@ -12,10 +13,35 @@ class Meta: app_label = 'someapp' +class BaseRel(StructuredRel): + """ A simple relationship that stores timestqmps as properties on the relationship """ + + + created_at = DateTimeProperty(default_now=True) + updated_at = DateTimeProperty(default_now=True) + group_id = StringProperty() + + def pre_save(self): + self.updated_at = datetime.utcnow() + def post_save(self): + pass + +class Author(DjangoNode): + unique_id = UniqueIdProperty(primary_key=True) + name = StringProperty() + wrote = RelationshipTo('Book','WROTE',model=BaseRel) + + class Meta: + app_label = "someapp" + + def __str__(self): + return self.name + class Book(DjangoNode): uid = UniqueIdProperty(primary_key=True) - title = StringProperty(unique_index=True) + title = StringProperty(unique_index=True, help_text="Catchy title of the book") format = StringProperty(required=True) # check required field can be omitted on update + status = StringProperty(choices=( ('available', 'A'), ('on_loan', 'L'), @@ -23,6 +49,13 @@ class Book(DjangoNode): ), default='available', coerce=str) created = DateTimeProperty(default=datetime.utcnow) + # A book can only be stored in one shelf, thus ZeroOrOne + shelf = RelationshipTo('Shelf', 'STORED_IN',cardinality=ZeroOrOne) + + #A book can be written by multiple authors, so the default ZeroOrMore + # applies. As example this relationship has a model with properties: BaseRel + authored_by = RelationshipFrom('Author','WROTE', model=BaseRel) + class Meta: app_label = "someapp" @@ -33,9 +66,10 @@ def __str__(self): class Shelf(DjangoNode): uid = UniqueIdProperty(primary_key=True) name = StringProperty() + contains = RelationshipFrom('Book','STORED_IN') class Meta: app_label = "someapp" def __str__(self): - return self.name \ No newline at end of file + return self.name diff --git a/tests/someapp/tests/test_model_form.py b/tests/someapp/tests/test_model_form.py index 5eb74e3..9c09f6f 100644 --- a/tests/someapp/tests/test_model_form.py +++ b/tests/someapp/tests/test_model_form.py @@ -2,8 +2,11 @@ from django.forms.models import ModelForm import django django.setup() -from tests.someapp.models import Book +from tests.someapp.models import Book, Author, Shelf from django import forms +from django.db.models.fields import BLANK_CHOICE_DASH + +from neomodel import db, clear_neo4j_database GEEKS_CHOICES =(('available', 'A'), ('on_loan', 'L'), @@ -15,7 +18,7 @@ class BookForm(ModelForm): class Meta: model = Book - fields = ('title', 'status', 'format') + fields = ('title', 'status', 'format', 'authored_by', 'shelf') class UpdateBookStatusForm(ModelForm): @@ -23,11 +26,15 @@ class UpdateBookStatusForm(ModelForm): class Meta: model = Book - fields = ('title', 'status') + fields = ('title', 'status', 'authored_by') class BookFormTest(DjangoTestCase): + def setUp(self): + pass + # clear_neo4j_database(db) + def test_define(self): self.assertTrue(issubclass(BookForm, ModelForm)) @@ -43,21 +50,21 @@ def test_can_save(self): self.assertTrue(bf.is_valid()) bf.save(True) - + def test_unique_is_checked(self): BookForm(data={'title': 'book1', 'format': 'P'}).save() bf = BookForm(data={'title': 'book1', 'format': 'P'}) self.assertFalse(bf.is_valid()) - + def test_can_update(self): hp = Book(title='Harrry', format='P').save() - bf = BookForm(data={'title': 'Harry Potter', 'status': 'damaged', 'format': 'P'}, instance=hp) + bf = BookForm(data={'title': 'Harry Potter the New One', 'status': 'damaged', 'format': 'P'}, instance=hp) self.assertTrue(bf.is_valid()) bf.save(True) def test_can_update_subclass(self): hp = Book(title='potterr', format='P').save() - bf = UpdateBookStatusForm(data={'title': 'Hary Potter', 'format': 'P'}, instance=hp) + bf = UpdateBookStatusForm(data={'title': 'Hary Potter', 'format': 'P','status':'damaged'}, instance=hp) self.assertTrue(bf.is_valid()) bf.save(True) @@ -69,10 +76,73 @@ def test_can_update_w_out_required_field(self): bf.save(True) def test_can_render(self): - bf = BookForm(data={'title': 'Harry Potter', 'format': 'P'}) + + author_ageta = Author(name='Ageta').save() + author_jk = Author(name='JK').save() + author_medea = Author(name='Medea').save() + + shelf_drama = Shelf(name='Drama').save() + shelf_travel = Shelf(name='Travel').save() + + # Need a new form, otherwise the choices don't load + class BookForm2(ModelForm): + status = forms.ChoiceField(choices=GEEKS_CHOICES, initial='available', required=False) + + class Meta: + model = Book + fields = ('title', 'status', 'format',) + + fields = ('title', 'status', 'format', 'authored_by', 'shelf') + + bf = BookForm2(data={'title': 'Harry Potter', 'format': 'P'}) + self.assertIn('Harry Potter', bf.__html__()) - self.assertIn('Ageta', bf.__html__()) + self.assertIn(author_jk.pk + '">JK', bf.__html__()) + self.assertIn(author_medea.pk + '">Medea', bf.__html__()) + + # Emtpy value should NOT be present in the MultiChoiceField of ZeroOrMore + self.assertNotIn(BLANK_CHOICE_DASH[0], bf.fields['authored_by']._choices) + + # The single choice list for shelf + self.assertIn('