From b2843d72fd0c1011744278472dc1a498ba863ab5 Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Tue, 29 Aug 2023 10:11:12 -0400 Subject: [PATCH 01/19] intersection memberships --- entity/constants.py | 4 ++ .../0002_entitygroup_membership_type.py | 18 +++++++ entity/models.py | 25 +++++++-- entity/tests/model_tests.py | 51 ++++++++++++++++++- entity/version.py | 2 +- release_notes.md | 2 + 6 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 entity/constants.py create mode 100644 entity/migrations/0002_entitygroup_membership_type.py diff --git a/entity/constants.py b/entity/constants.py new file mode 100644 index 0000000..4660df2 --- /dev/null +++ b/entity/constants.py @@ -0,0 +1,4 @@ + +class MembershipType: + UNION = 'UNION' + INTERSECTION = 'INTERSECTION' diff --git a/entity/migrations/0002_entitygroup_membership_type.py b/entity/migrations/0002_entitygroup_membership_type.py new file mode 100644 index 0000000..526185d --- /dev/null +++ b/entity/migrations/0002_entitygroup_membership_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.4 on 2023-08-29 13:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('entity', '0001_0010_squashed'), + ] + + operations = [ + migrations.AddField( + model_name='entitygroup', + name='membership_type', + field=models.CharField(choices=[('UNION', 'Union'), ('INTERSECTION', 'Intersection')], default='UNION'), + ), + ] diff --git a/entity/models.py b/entity/models.py index fa5745a..5aefff8 100644 --- a/entity/models.py +++ b/entity/models.py @@ -9,6 +9,8 @@ from python3_utils import compare_on_attr from functools import reduce +from entity.constants import MembershipType + class AllEntityKindManager(ActivatableManager): """ @@ -363,6 +365,13 @@ class EntityGroup(models.Model): objects = EntityGroupManager() + membership_type_choices = [ + (MembershipType.UNION, 'Union'), + (MembershipType.INTERSECTION, 'Intersection'), + ] + + membership_type = models.CharField(choices=membership_type_choices, default=MembershipType.UNION) + def all_entities(self, is_active=True): """ Return all the entities in the group. @@ -405,16 +414,26 @@ def get_all_entities(self, membership_cache=None, entities_by_kind=None, return_ # Loop over each membership in this group for entity_id, entity_kind_id in membership_cache[self.id]: + entity_ids_to_apply = set() if entity_id: if entity_kind_id: # All sub entities of this kind under this entity - entity_ids.update(entities_by_kind[entity_kind_id][entity_id]) + entity_ids_to_apply.update(entities_by_kind[entity_kind_id][entity_id]) else: # Individual entity - entity_ids.add(entity_id) + entity_ids_to_apply.add(entity_id) else: # All entities of this kind - entity_ids.update(entities_by_kind[entity_kind_id]['all']) + entity_ids_to_apply.update(entities_by_kind[entity_kind_id]['all']) + + # Check membership type + if self.membership_type == MembershipType.UNION: + entity_ids.update(entity_ids_to_apply) + elif self.membership_type == MembershipType.INTERSECTION: + if not entity_ids: + entity_ids.update(entity_ids_to_apply) + else: + entity_ids = entity_ids.intersection(entity_ids_to_apply) # Check if a queryset needs to be returned if return_models: diff --git a/entity/tests/model_tests.py b/entity/tests/model_tests.py index 1000ca5..ad7ed7a 100644 --- a/entity/tests/model_tests.py +++ b/entity/tests/model_tests.py @@ -5,10 +5,10 @@ from entity.signal_handlers import turn_off_syncing, turn_on_syncing +from entity.constants import MembershipType from entity.models import ( Entity, EntityKind, EntityRelationship, EntityGroup, EntityGroupMembership, get_entities_by_kind ) - from entity.tests.models import Account, Team, TeamGroup, Competitor from entity.tests.utils import EntityTestCase @@ -758,6 +758,55 @@ def setUp(self): self.group = G(EntityGroup) + def test_membership_type_intersection(self): + """ + Given two memberships of entities under different entity kinds, verify that only the intersection is returned + instead of the union. + + This test sets up: + - 5 sub entities under super 1 + - 5 sub entities under super 2 + - 3 sub entities under both + """ + super_entity_kind1 = G(EntityKind) + super_entity_kind2 = G(EntityKind) + sub_entity_kind = G(EntityKind) + super_entity1 = G(Entity, entity_kind=super_entity_kind1) + super_entity2 = G(Entity, entity_kind=super_entity_kind2) + sub_entities1 = [ + G(Entity, entity_kind=sub_entity_kind) + for _ in range(5) + ] + sub_entities2 = [ + G(Entity, entity_kind=sub_entity_kind) + for _ in range(5) + ] + + # Create the relationships + for entity in sub_entities1: + G(EntityRelationship, sub_entity=entity, super_entity=super_entity1) + for entity in sub_entities2: + G(EntityRelationship, sub_entity=entity, super_entity=super_entity2) + + # Create the intersection relationships + G(EntityRelationship, sub_entity=sub_entities1[0], super_entity=super_entity2) + G(EntityRelationship, sub_entity=sub_entities1[1], super_entity=super_entity2) + G(EntityRelationship, sub_entity=sub_entities1[2], super_entity=super_entity2) + + # Create the entity group + entity_group = G(EntityGroup, membership_type=MembershipType.INTERSECTION) + + # Create the memberships -- two memberships of all subs under a kind + G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=super_entity1) + G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=super_entity2) + + entity_ids = entity_group.get_all_entities() + self.assertEqual(entity_ids, set([ + sub_entities1[0].id, + sub_entities1[1].id, + sub_entities1[2].id, + ])) + def test_individual_entities_returned(self): e = self.super_entities[0] G(EntityGroupMembership, entity_group=self.group, entity=e, sub_entity_kind=None) diff --git a/entity/version.py b/entity/version.py index cf37bf5..e7c2779 100644 --- a/entity/version.py +++ b/entity/version.py @@ -1 +1 @@ -__version__ = '6.1.1' +__version__ = '6.2.0' diff --git a/release_notes.md b/release_notes.md index ea77ecd..61776fb 100644 --- a/release_notes.md +++ b/release_notes.md @@ -1,5 +1,7 @@ ## Release Notes +- 6.2.0: + - Add support for intersection type memberships - 6.1.1: - django support for 4.2 - drop django 2.2 From ae1d4bf0957fc711291322fafd3398ac23bd483c Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Wed, 30 Aug 2023 17:53:46 -0400 Subject: [PATCH 02/19] proof of concept --- entity/constants.py | 4 - ...pe.py => 0002_entitygroup_logic_string.py} | 6 +- entity/models.py | 165 ++++++++++++++---- entity/tests/model_tests.py | 94 ++++++---- release_notes.md | 2 +- 5 files changed, 196 insertions(+), 75 deletions(-) delete mode 100644 entity/constants.py rename entity/migrations/{0002_entitygroup_membership_type.py => 0002_entitygroup_logic_string.py} (56%) diff --git a/entity/constants.py b/entity/constants.py deleted file mode 100644 index 4660df2..0000000 --- a/entity/constants.py +++ /dev/null @@ -1,4 +0,0 @@ - -class MembershipType: - UNION = 'UNION' - INTERSECTION = 'INTERSECTION' diff --git a/entity/migrations/0002_entitygroup_membership_type.py b/entity/migrations/0002_entitygroup_logic_string.py similarity index 56% rename from entity/migrations/0002_entitygroup_membership_type.py rename to entity/migrations/0002_entitygroup_logic_string.py index 526185d..c65242a 100644 --- a/entity/migrations/0002_entitygroup_membership_type.py +++ b/entity/migrations/0002_entitygroup_logic_string.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.4 on 2023-08-29 13:41 +# Generated by Django 4.2.4 on 2023-08-30 18:09 from django.db import migrations, models @@ -12,7 +12,7 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name='entitygroup', - name='membership_type', - field=models.CharField(choices=[('UNION', 'Union'), ('INTERSECTION', 'Intersection')], default='UNION'), + name='logic_string', + field=models.TextField(blank=True, default=None, null=True), ), ] diff --git a/entity/models.py b/entity/models.py index 5aefff8..8a67f75 100644 --- a/entity/models.py +++ b/entity/models.py @@ -1,4 +1,5 @@ -from itertools import compress +import ast +from itertools import compress, chain from activatable_model.models import BaseActivatableModel, ActivatableManager, ActivatableQuerySet from django.contrib.contenttypes.fields import GenericForeignKey @@ -9,8 +10,6 @@ from python3_utils import compare_on_attr from functools import reduce -from entity.constants import MembershipType - class AllEntityKindManager(ActivatableManager): """ @@ -336,6 +335,8 @@ def get_membership_cache(self, group_ids=None, is_active=True): if group_ids: membership_queryset = membership_queryset.filter(entity_group_id__in=group_ids) + membership_queryset = membership_queryset.order_by('id') + membership_queryset = membership_queryset.values_list('entity_group_id', 'entity_id', 'sub_entity_kind_id') # Iterate over the query results and build the cache dict @@ -365,12 +366,7 @@ class EntityGroup(models.Model): objects = EntityGroupManager() - membership_type_choices = [ - (MembershipType.UNION, 'Union'), - (MembershipType.INTERSECTION, 'Intersection'), - ] - - membership_type = models.CharField(choices=membership_type_choices, default=MembershipType.UNION) + logic_string = models.TextField(default=None, null=True, blank=True) def all_entities(self, is_active=True): """ @@ -382,6 +378,96 @@ def all_entities(self, is_active=True): """ return self.get_all_entities(return_models=True, is_active=is_active) + def get_filter_indices(self, node): + """ + Makes sure that each filter referenced actually exists + """ + if hasattr(node, 'op'): + # multi-operand operators + if hasattr(node, 'values'): + return list(chain(*[self.get_filter_indices(value) for value in node.values])) + # unary operators + elif hasattr(node, 'operand'): + return list(chain(*[self.get_filter_indices(node.operand)])) + elif hasattr(node, 'n'): + return [node.n] + return None + + def validate_filter_indices(self, indices, memberships): + """ + Raises an error if an invalid filter index is referenced or if an index is not referenced + """ + for index in indices: + if hasattr(index, '__iter__'): + return self.validate_filter_indices(index, memberships) + if index < 1 or index > len(memberships): + raise ValidationError('Filter logic contains an invalid filter index ({0})'.format(index)) + + for i in range(1, len(memberships) + 1): + if i not in indices: + raise ValidationError('Filter logic is missing a filter index ({0})'.format(i)) + + return True + + def _node_to_kmatch(self, node): + """ + Looks at an ast node and either returns the value or recursively returns the kmatch syntax. This is meant + to convert the boolean logic like "1 AND 2" to kmatch syntax like ['&', [1, 2]] + :return: kmatch syntax where memberships are represented by numbers + :rtype: list + """ + if hasattr(node, 'op'): + if hasattr(node, 'values'): + return [node.op, [self._node_to_kmatch(value) for value in node.values]] + elif hasattr(node, 'operand'): + return [node.op, self._node_to_kmatch(node.operand)] + elif hasattr(node, 'n'): + return node.n + return None + + def _map_kmatch_values(self, kmatch, memberships): + """ + Replaces index placeholders in the kmatch with the actual memberships. Any memberships that could not be matched + up with a field will be replaced with None + :return: the complete kmatch pattern + :rtype: list + """ + # Check if single item + if isinstance(kmatch, int): + return memberships[kmatch - 1] + if hasattr(kmatch, '__iter__'): + return [self._map_kmatch_values(value, memberships) for value in kmatch] + + cls = getattr(kmatch, '__class__') + if cls == ast.And: + return '&' + elif cls == ast.Or: + return '|' + elif cls == ast.Not: + return '!' + + def _process_kmatch(self, kmatch, full_set): + """ + Every item is 2 elements - the operator and the value or list of values + """ + entity_ids = set() + operators = {'&', '|', '!'} + + if isinstance(kmatch, set): + return kmatch + + if len(kmatch) == 2 and kmatch[0] not in operators: + return kmatch + + if kmatch[0] == '&': + entity_ids = self._process_kmatch(kmatch[1][0], full_set) & self._process_kmatch(kmatch[1][1], full_set) + elif kmatch[0] == '|': + entity_ids = self._process_kmatch(kmatch[1][0], full_set) | self._process_kmatch(kmatch[1][1], full_set) + elif kmatch[0] == '!': + entity_ids = full_set - self._process_kmatch(kmatch[1], full_set) + + return entity_ids + def get_all_entities(self, membership_cache=None, entities_by_kind=None, return_models=False, is_active=True): """ Returns a list of all entity ids in this group or optionally returns a queryset for all entity models. @@ -410,30 +496,47 @@ def get_all_entities(self, membership_cache=None, entities_by_kind=None, return_ entity_ids = set() # This group does have entities - if membership_cache.get(self.id): - - # Loop over each membership in this group - for entity_id, entity_kind_id in membership_cache[self.id]: - entity_ids_to_apply = set() - if entity_id: - if entity_kind_id: - # All sub entities of this kind under this entity - entity_ids_to_apply.update(entities_by_kind[entity_kind_id][entity_id]) + memberships = membership_cache.get(self.id) + if memberships: + if self.logic_string: + try: + filter_tree = ast.parse(self.logic_string.lower()) + except: + raise Exception + + expanded_memberships = [] + for entity_id, entity_kind_id in memberships: + if entity_id: + if entity_kind_id: + # All sub entities of this kind under this entity + expanded_memberships.append(set(entities_by_kind[entity_kind_id][entity_id])) + else: + # Individual entity + expanded_memberships.append({entity_id}) else: - # Individual entity - entity_ids_to_apply.add(entity_id) - else: - # All entities of this kind - entity_ids_to_apply.update(entities_by_kind[entity_kind_id]['all']) - - # Check membership type - if self.membership_type == MembershipType.UNION: - entity_ids.update(entity_ids_to_apply) - elif self.membership_type == MembershipType.INTERSECTION: - if not entity_ids: - entity_ids.update(entity_ids_to_apply) + # All entities of this kind + expanded_memberships.append(set(entities_by_kind[entity_kind_id]['all'])) + + # Make sure each index is valid + indices = self.get_filter_indices(filter_tree.body[0].value) + self.validate_filter_indices(indices, expanded_memberships) + kmatch = self._node_to_kmatch(filter_tree.body[0].value) + kmatch = self._map_kmatch_values(kmatch, expanded_memberships) + entity_ids = self._process_kmatch(kmatch, full_set=expanded_memberships[-1]) + + else: + # Loop over each membership in this group + for entity_id, entity_kind_id in membership_cache[self.id]: + if entity_id: + if entity_kind_id: + # All sub entities of this kind under this entity + entity_ids.update(entities_by_kind[entity_kind_id][entity_id]) + else: + # Individual entity + entity_ids.add(entity_id) else: - entity_ids = entity_ids.intersection(entity_ids_to_apply) + # All entities of this kind + entity_ids.update(entities_by_kind[entity_kind_id]['all']) # Check if a queryset needs to be returned if return_models: diff --git a/entity/tests/model_tests.py b/entity/tests/model_tests.py index ad7ed7a..e241171 100644 --- a/entity/tests/model_tests.py +++ b/entity/tests/model_tests.py @@ -5,7 +5,6 @@ from entity.signal_handlers import turn_off_syncing, turn_on_syncing -from entity.constants import MembershipType from entity.models import ( Entity, EntityKind, EntityRelationship, EntityGroup, EntityGroupMembership, get_entities_by_kind ) @@ -758,53 +757,76 @@ def setUp(self): self.group = G(EntityGroup) - def test_membership_type_intersection(self): - """ - Given two memberships of entities under different entity kinds, verify that only the intersection is returned - instead of the union. - - This test sets up: - - 5 sub entities under super 1 - - 5 sub entities under super 2 - - 3 sub entities under both - """ - super_entity_kind1 = G(EntityKind) - super_entity_kind2 = G(EntityKind) + def test_logic_string(self): + """ + Given 10 users User 0 - User 9 and 4 groups Group A - Group D + Group A: 0, 1, 2 + Group B: 1, 2, 3 + Group C: 4, 5, 6 + Group D: 6, 7, 8 + + Memberships: + 1. User in Group A + 2. User in Group B + 3. User in Group C + 4. User in Group D + 5. User = User 1 + 6. User = User 9 + + Logic: (1 AND 2) OR (3 AND 4) AND NOT(5) OR 6 + ((0, 1, 2) AND (1, 2, 3)) OR ((4, 5, 6) AND (6, 7, 8)) AND NOT(1) OR (9) + (1, 2) OR (6) AND NOT(1) OR 9 + (1, 2, 6) AND NOT(1) OR 9 + 2, 6, 9 + """ + super_entity_kind = G(EntityKind) sub_entity_kind = G(EntityKind) - super_entity1 = G(Entity, entity_kind=super_entity_kind1) - super_entity2 = G(Entity, entity_kind=super_entity_kind2) - sub_entities1 = [ + super_entity_a = G(Entity, entity_kind=super_entity_kind) + super_entity_b = G(Entity, entity_kind=super_entity_kind) + super_entity_c = G(Entity, entity_kind=super_entity_kind) + super_entity_d = G(Entity, entity_kind=super_entity_kind) + sub_entities = [ G(Entity, entity_kind=sub_entity_kind) - for _ in range(5) - ] - sub_entities2 = [ - G(Entity, entity_kind=sub_entity_kind) - for _ in range(5) + for _ in range(10) ] # Create the relationships - for entity in sub_entities1: - G(EntityRelationship, sub_entity=entity, super_entity=super_entity1) - for entity in sub_entities2: - G(EntityRelationship, sub_entity=entity, super_entity=super_entity2) - - # Create the intersection relationships - G(EntityRelationship, sub_entity=sub_entities1[0], super_entity=super_entity2) - G(EntityRelationship, sub_entity=sub_entities1[1], super_entity=super_entity2) - G(EntityRelationship, sub_entity=sub_entities1[2], super_entity=super_entity2) + relationships = [ + EntityRelationship(sub_entity=sub_entities[0], super_entity=super_entity_a), + EntityRelationship(sub_entity=sub_entities[1], super_entity=super_entity_a), + EntityRelationship(sub_entity=sub_entities[2], super_entity=super_entity_a), + + EntityRelationship(sub_entity=sub_entities[1], super_entity=super_entity_b), + EntityRelationship(sub_entity=sub_entities[2], super_entity=super_entity_b), + EntityRelationship(sub_entity=sub_entities[3], super_entity=super_entity_b), + + EntityRelationship(sub_entity=sub_entities[4], super_entity=super_entity_c), + EntityRelationship(sub_entity=sub_entities[5], super_entity=super_entity_c), + EntityRelationship(sub_entity=sub_entities[6], super_entity=super_entity_c), + + EntityRelationship(sub_entity=sub_entities[6], super_entity=super_entity_d), + EntityRelationship(sub_entity=sub_entities[7], super_entity=super_entity_d), + EntityRelationship(sub_entity=sub_entities[8], super_entity=super_entity_d), + ] + EntityRelationship.objects.bulk_create(relationships) # Create the entity group - entity_group = G(EntityGroup, membership_type=MembershipType.INTERSECTION) + entity_group = G(EntityGroup, logic_string='(((1 AND 2) OR (3 AND 4)) AND NOT(5) OR 6) AND 7') # Create the memberships -- two memberships of all subs under a kind - G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=super_entity1) - G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=super_entity2) + G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=super_entity_a) + G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=super_entity_b) + G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=super_entity_c) + G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=super_entity_d) + G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=None, entity=sub_entities[1]) + G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=None, entity=sub_entities[9]) + G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=None) entity_ids = entity_group.get_all_entities() self.assertEqual(entity_ids, set([ - sub_entities1[0].id, - sub_entities1[1].id, - sub_entities1[2].id, + sub_entities[2].id, + sub_entities[6].id, + sub_entities[9].id, ])) def test_individual_entities_returned(self): diff --git a/release_notes.md b/release_notes.md index 61776fb..088cc5d 100644 --- a/release_notes.md +++ b/release_notes.md @@ -1,7 +1,7 @@ ## Release Notes - 6.2.0: - - Add support for intersection type memberships + - Add support for boolean logic strings to apply to entity group memberships - 6.1.1: - django support for 4.2 - drop django 2.2 From 0c66781b154163a1f3d8ca75b17aa4c7af9a8af5 Mon Sep 17 00:00:00 2001 From: Kris Plunkett Date: Tue, 19 Sep 2023 17:01:19 -0500 Subject: [PATCH 03/19] import validation error --- entity/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/entity/models.py b/entity/models.py index 8a67f75..d1842dc 100644 --- a/entity/models.py +++ b/entity/models.py @@ -4,6 +4,7 @@ from activatable_model.models import BaseActivatableModel, ActivatableManager, ActivatableQuerySet from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.db.models import Count, Q, JSONField From ae0ffcced617306dfdbfcb0955ac1ac129256676 Mon Sep 17 00:00:00 2001 From: Kris Plunkett Date: Wed, 20 Sep 2023 16:11:54 -0500 Subject: [PATCH 04/19] Add docker setup and docs for local development --- CONTRIBUTING.md | 42 ++++++++++++++++++++++++++--------------- Dockerfile | 8 ++++++++ docker-compose.yml | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b5d9ec..58bdba0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,29 +7,41 @@ a great idea but it involves big changes, please file a ticket before making a pull request! We want to make sure you don't spend your time coding something that might not fit the scope of the project. -## Running the tests +## Development -To get the source source code and run the unit tests, run: -```bash -git clone git://github.com/ambitioninc/django-entity.git -cd django-entity -virtualenv env -. env/bin/activate -python setup.py install -coverage run setup.py test -coverage report --fail-under=100 +> Prerequisites: +> - [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running on your system +> - Postgres default host port (5432) is available (likely need to stop any other running postgres servers) + +Fork and clone your fork then use the docker compose services for development tasks. + +**Lint** +```shell +docker compose run --rm lint +``` + +**Test** +```shell +docker compose run --rm test +``` + +**Test (with coverage)** +```shell +docker compose run --rm test-coverage ``` While 100% code coverage does not make a library bug-free, it significantly reduces the number of easily caught bugs! Please make sure coverage is at 100% before submitting a pull request! -## Code Quality +**Shell** +```shell +docker compose run --rm shell +``` -For code quality, please run flake8: -```bash -pip install flake8 -flake8 . +For dependency changes, rebuild before running: +```shell +docker compose build ``` ## Code Styling diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9d5c33c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.9 + +WORKDIR /django-entity + +COPY requirements/*.txt /tmp + +RUN pip install -r /tmp/requirements.txt +RUN pip install -r /tmp/requirements-testing.txt diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f9da7ec --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + # Shared services + entity-base: + build: . + volumes: + - ./:/django-entity + entity: + extends: entity-base + environment: + DB_SETTINGS: >- + { + "ENGINE":"django.db.backends.postgresql", + "NAME":"entity", + "USER":"postgres", + "PASSWORD":"postgres", + "HOST":"db", + "PORT":"5432" + } + depends_on: + - db + db: + image: postgres:latest + environment: + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - "5432:5432" + healthcheck: + test: pg_isready + start_interval: 5s + interval: 10s + retries: 5 + + # Task specific services + lint: + extends: entity-base + command: flake8 . + test: + extends: entity + command: python manage.py test entity + test-coverage: + extends: entity + command: bash -c "rm -rf .coverage/ && coverage run manage.py test entity && coverage report --fail-under=99" + shell: + extends: entity + command: /bin/bash From 3b8d9d769ea282bfb6d510bb5583ba75cac52ceb Mon Sep 17 00:00:00 2001 From: Kris Plunkett Date: Thu, 21 Sep 2023 12:03:57 -0500 Subject: [PATCH 05/19] add sort order to entity group membership and update migration order --- ...ing.py => 0011_entitygroup_logic_string.py} | 2 +- .../0012_entitygroupmembership_sort_order.py | 18 ++++++++++++++++++ entity/models.py | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) rename entity/migrations/{0002_entitygroup_logic_string.py => 0011_entitygroup_logic_string.py} (88%) create mode 100644 entity/migrations/0012_entitygroupmembership_sort_order.py diff --git a/entity/migrations/0002_entitygroup_logic_string.py b/entity/migrations/0011_entitygroup_logic_string.py similarity index 88% rename from entity/migrations/0002_entitygroup_logic_string.py rename to entity/migrations/0011_entitygroup_logic_string.py index c65242a..a64d880 100644 --- a/entity/migrations/0002_entitygroup_logic_string.py +++ b/entity/migrations/0011_entitygroup_logic_string.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('entity', '0001_0010_squashed'), + ('entity', '0010_auto_20181213_1817'), ] operations = [ diff --git a/entity/migrations/0012_entitygroupmembership_sort_order.py b/entity/migrations/0012_entitygroupmembership_sort_order.py new file mode 100644 index 0000000..561c601 --- /dev/null +++ b/entity/migrations/0012_entitygroupmembership_sort_order.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-09-21 16:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('entity', '0011_entitygroup_logic_string'), + ] + + operations = [ + migrations.AddField( + model_name='entitygroupmembership', + name='sort_order', + field=models.IntegerField(default=0), + ), + ] diff --git a/entity/models.py b/entity/models.py index d1842dc..b5dd473 100644 --- a/entity/models.py +++ b/entity/models.py @@ -666,6 +666,7 @@ class EntityGroupMembership(models.Model): entity_group = models.ForeignKey(EntityGroup, on_delete=models.CASCADE) entity = models.ForeignKey(Entity, null=True, on_delete=models.CASCADE) sub_entity_kind = models.ForeignKey(EntityKind, null=True, on_delete=models.CASCADE) + sort_order = models.IntegerField(default=0) def get_entities_by_kind(membership_cache=None, is_active=True): From 9f40ecf8b37c45b0b3a689a66d289b29e58644df Mon Sep 17 00:00:00 2001 From: Kris Plunkett Date: Thu, 21 Sep 2023 12:37:04 -0500 Subject: [PATCH 06/19] use one migration and fix the db --- CONTRIBUTING.md | 5 +++++ docker-compose.yml | 2 +- .../0011_entitygroup_logic_string.py | 5 +++++ .../0012_entitygroupmembership_sort_order.py | 18 ------------------ 4 files changed, 11 insertions(+), 19 deletions(-) delete mode 100644 entity/migrations/0012_entitygroupmembership_sort_order.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58bdba0..611c959 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,11 @@ For dependency changes, rebuild before running: docker compose build ``` +To reset everything, use docker compose down with additional flags for images and volumes: +```shell +docker compose down --volumes --remove-orphans --rmi local +``` + ## Code Styling Please arrange imports with the following style diff --git a/docker-compose.yml b/docker-compose.yml index f9da7ec..c17f1b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: db: image: postgres:latest environment: - POSTGRES_DB: postgres + POSTGRES_DB: entity POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres ports: diff --git a/entity/migrations/0011_entitygroup_logic_string.py b/entity/migrations/0011_entitygroup_logic_string.py index a64d880..40dc824 100644 --- a/entity/migrations/0011_entitygroup_logic_string.py +++ b/entity/migrations/0011_entitygroup_logic_string.py @@ -15,4 +15,9 @@ class Migration(migrations.Migration): name='logic_string', field=models.TextField(blank=True, default=None, null=True), ), + migrations.AddField( + model_name='entitygroupmembership', + name='sort_order', + field=models.IntegerField(default=0), + ), ] diff --git a/entity/migrations/0012_entitygroupmembership_sort_order.py b/entity/migrations/0012_entitygroupmembership_sort_order.py deleted file mode 100644 index 561c601..0000000 --- a/entity/migrations/0012_entitygroupmembership_sort_order.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.5 on 2023-09-21 16:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('entity', '0011_entitygroup_logic_string'), - ] - - operations = [ - migrations.AddField( - model_name='entitygroupmembership', - name='sort_order', - field=models.IntegerField(default=0), - ), - ] From 9860fd327a1478c62c898c4bcfb78a42f78fc117 Mon Sep 17 00:00:00 2001 From: Kris Plunkett Date: Thu, 21 Sep 2023 13:16:19 -0500 Subject: [PATCH 07/19] more local dev edits --- CONTRIBUTING.md | 12 ++++++++++++ README.md | 5 +++++ docker-compose.yml | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 611c959..e2baec4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,18 @@ before submitting a pull request! docker compose run --rm shell ``` +You can also pass arguments to the shell command for django manage tasks: + +Add migrations for model changes +```shell +docker compose run --rm shell python manage.py makemigrations +``` + +Run migrations +```shell +docker compose run --rm shell python manage.py migrate +``` + For dependency changes, rebuild before running: ```shell docker compose build diff --git a/README.md b/README.md index b12ca4d..ff7d2d2 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ Imagine that you have a Django project that defines many types of groupings of y Using Django Entity, the email app could be written to take an Entity model rather than having to understand the complex relationships of each group. The Entity model passed to the email app could be a CompanyPosition model, and the get_sub_entities().is_any_type(ContentType.objects.get_for_model(User)) would return all of the User models under that CompanyPosition model. This allows the email app to be completely segregated from how the main project defines its relationships. Similarly, the query to obtain all User models under a CompanyPosition could be much more efficient than querying directly from the project (depending on how the project has its models structured). +## Development + +For more info on making changes to this project +see the Development section of [CONTRIBUTING.md](CONTRIBUTING.md#development). + ## Getting Started - Configuring Entity Syncing ### Basic Use Case diff --git a/docker-compose.yml b/docker-compose.yml index c17f1b8..cd32d0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,7 @@ services: command: flake8 . test: extends: entity - command: python manage.py test entity + command: python manage.py test test-coverage: extends: entity command: bash -c "rm -rf .coverage/ && coverage run manage.py test entity && coverage report --fail-under=99" From 4d72cf44b9ebb24300aefd22bce0649f2f2f93dd Mon Sep 17 00:00:00 2001 From: Kris Plunkett Date: Thu, 21 Sep 2023 13:51:41 -0500 Subject: [PATCH 08/19] undo local dev stuff --- CONTRIBUTING.md | 59 ++++++++++++---------------------------------- Dockerfile | 8 ------- README.md | 5 ---- docker-compose.yml | 47 ------------------------------------ 4 files changed, 15 insertions(+), 104 deletions(-) delete mode 100644 Dockerfile delete mode 100644 docker-compose.yml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2baec4..9b5d9ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,58 +7,29 @@ a great idea but it involves big changes, please file a ticket before making a pull request! We want to make sure you don't spend your time coding something that might not fit the scope of the project. -## Development +## Running the tests -> Prerequisites: -> - [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running on your system -> - Postgres default host port (5432) is available (likely need to stop any other running postgres servers) - -Fork and clone your fork then use the docker compose services for development tasks. - -**Lint** -```shell -docker compose run --rm lint -``` - -**Test** -```shell -docker compose run --rm test -``` - -**Test (with coverage)** -```shell -docker compose run --rm test-coverage +To get the source source code and run the unit tests, run: +```bash +git clone git://github.com/ambitioninc/django-entity.git +cd django-entity +virtualenv env +. env/bin/activate +python setup.py install +coverage run setup.py test +coverage report --fail-under=100 ``` While 100% code coverage does not make a library bug-free, it significantly reduces the number of easily caught bugs! Please make sure coverage is at 100% before submitting a pull request! -**Shell** -```shell -docker compose run --rm shell -``` - -You can also pass arguments to the shell command for django manage tasks: - -Add migrations for model changes -```shell -docker compose run --rm shell python manage.py makemigrations -``` - -Run migrations -```shell -docker compose run --rm shell python manage.py migrate -``` - -For dependency changes, rebuild before running: -```shell -docker compose build -``` +## Code Quality -To reset everything, use docker compose down with additional flags for images and volumes: -```shell -docker compose down --volumes --remove-orphans --rmi local +For code quality, please run flake8: +```bash +pip install flake8 +flake8 . ``` ## Code Styling diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9d5c33c..0000000 --- a/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM python:3.9 - -WORKDIR /django-entity - -COPY requirements/*.txt /tmp - -RUN pip install -r /tmp/requirements.txt -RUN pip install -r /tmp/requirements-testing.txt diff --git a/README.md b/README.md index ff7d2d2..b12ca4d 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,6 @@ Imagine that you have a Django project that defines many types of groupings of y Using Django Entity, the email app could be written to take an Entity model rather than having to understand the complex relationships of each group. The Entity model passed to the email app could be a CompanyPosition model, and the get_sub_entities().is_any_type(ContentType.objects.get_for_model(User)) would return all of the User models under that CompanyPosition model. This allows the email app to be completely segregated from how the main project defines its relationships. Similarly, the query to obtain all User models under a CompanyPosition could be much more efficient than querying directly from the project (depending on how the project has its models structured). -## Development - -For more info on making changes to this project -see the Development section of [CONTRIBUTING.md](CONTRIBUTING.md#development). - ## Getting Started - Configuring Entity Syncing ### Basic Use Case diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index cd32d0f..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,47 +0,0 @@ -services: - # Shared services - entity-base: - build: . - volumes: - - ./:/django-entity - entity: - extends: entity-base - environment: - DB_SETTINGS: >- - { - "ENGINE":"django.db.backends.postgresql", - "NAME":"entity", - "USER":"postgres", - "PASSWORD":"postgres", - "HOST":"db", - "PORT":"5432" - } - depends_on: - - db - db: - image: postgres:latest - environment: - POSTGRES_DB: entity - POSTGRES_PASSWORD: postgres - POSTGRES_USER: postgres - ports: - - "5432:5432" - healthcheck: - test: pg_isready - start_interval: 5s - interval: 10s - retries: 5 - - # Task specific services - lint: - extends: entity-base - command: flake8 . - test: - extends: entity - command: python manage.py test - test-coverage: - extends: entity - command: bash -c "rm -rf .coverage/ && coverage run manage.py test entity && coverage report --fail-under=99" - shell: - extends: entity - command: /bin/bash From ce31472be9bb8a49ad6554adae93d4524d210983 Mon Sep 17 00:00:00 2001 From: Kris Plunkett Date: Fri, 22 Sep 2023 13:39:59 -0500 Subject: [PATCH 09/19] undo local migration rename --- ...tygroup_logic_string.py => 0002_entitygroup_logic_string.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename entity/migrations/{0011_entitygroup_logic_string.py => 0002_entitygroup_logic_string.py} (91%) diff --git a/entity/migrations/0011_entitygroup_logic_string.py b/entity/migrations/0002_entitygroup_logic_string.py similarity index 91% rename from entity/migrations/0011_entitygroup_logic_string.py rename to entity/migrations/0002_entitygroup_logic_string.py index 40dc824..aa5aa2a 100644 --- a/entity/migrations/0011_entitygroup_logic_string.py +++ b/entity/migrations/0002_entitygroup_logic_string.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('entity', '0010_auto_20181213_1817'), + ('entity', '0001_0010_squashed'), ] operations = [ From 3739d3070184cb8f05dc99f36396ce83b717d0b5 Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Tue, 26 Sep 2023 12:46:48 -0400 Subject: [PATCH 10/19] rework full set ids --- entity/models.py | 15 +++++++++++- entity/tests/model_tests.py | 48 +++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/entity/models.py b/entity/models.py index b5dd473..df1da97 100644 --- a/entity/models.py +++ b/entity/models.py @@ -500,6 +500,8 @@ def get_all_entities(self, membership_cache=None, entities_by_kind=None, return_ memberships = membership_cache.get(self.id) if memberships: if self.logic_string: + entity_kind_id = memberships[0][1] + full_set = set(entities_by_kind[entity_kind_id]['all']) try: filter_tree = ast.parse(self.logic_string.lower()) except: @@ -523,7 +525,7 @@ def get_all_entities(self, membership_cache=None, entities_by_kind=None, return_ self.validate_filter_indices(indices, expanded_memberships) kmatch = self._node_to_kmatch(filter_tree.body[0].value) kmatch = self._map_kmatch_values(kmatch, expanded_memberships) - entity_ids = self._process_kmatch(kmatch, full_set=expanded_memberships[-1]) + entity_ids = self._process_kmatch(kmatch, full_set=full_set) else: # Loop over each membership in this group @@ -693,6 +695,12 @@ def get_entities_by_kind(membership_cache=None, is_active=True): kinds_with_supers = set() super_ids = set() + # Determine if we need to include the "universal set" aka all for a kind based on the presence of a logic_string + group_ids_with_logic_string = set(EntityGroup.objects.filter( + id__in=membership_cache.keys(), + logic_string__isnull=False, + ).values_list('id', flat=True)) + # Loop over each group for group_id, memberships in membership_cache.items(): @@ -705,6 +713,11 @@ def get_entities_by_kind(membership_cache=None, is_active=True): # Make sure a dict exists for this kind entities_by_kind.setdefault(entity_kind_id, {}) + # Always include all if there is a logic string + if group_id in group_ids_with_logic_string: + entities_by_kind[entity_kind_id]['all'] = [] + kinds_with_all.add(entity_kind_id) + # Check if this is all entities of a kind under a specific entity if entity_id: entities_by_kind[entity_kind_id][entity_id] = [] diff --git a/entity/tests/model_tests.py b/entity/tests/model_tests.py index e241171..af51aa1 100644 --- a/entity/tests/model_tests.py +++ b/entity/tests/model_tests.py @@ -811,7 +811,7 @@ def test_logic_string(self): EntityRelationship.objects.bulk_create(relationships) # Create the entity group - entity_group = G(EntityGroup, logic_string='(((1 AND 2) OR (3 AND 4)) AND NOT(5) OR 6) AND 7') + entity_group = G(EntityGroup, logic_string='((1 AND 2) OR (3 AND 4)) AND NOT(5) OR 6') # Create the memberships -- two memberships of all subs under a kind G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=super_entity_a) @@ -820,7 +820,6 @@ def test_logic_string(self): G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=super_entity_d) G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=None, entity=sub_entities[1]) G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=None, entity=sub_entities[9]) - G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=None) entity_ids = entity_group.get_all_entities() self.assertEqual(entity_ids, set([ @@ -829,6 +828,51 @@ def test_logic_string(self): sub_entities[9].id, ])) + def test_logic_string_not(self): + """ + Verifies that the universal set is properly fetched and used to NOT a set + Group A: 0, 1, 2 + NOT(A) = 3, 4, 5, 6, 7, 8 + + Memberships: + 1. User in Group A + + Logic: NOT(1) + (3, 4, 5, 6, 7, 8) + """ + super_entity_kind = G(EntityKind) + sub_entity_kind = G(EntityKind) + super_entity_a = G(Entity, entity_kind=super_entity_kind) + sub_entities = [ + G(Entity, entity_kind=sub_entity_kind) + for _ in range(10) + ] + + # Create the relationships + relationships = [ + EntityRelationship(sub_entity=sub_entities[0], super_entity=super_entity_a), + EntityRelationship(sub_entity=sub_entities[1], super_entity=super_entity_a), + EntityRelationship(sub_entity=sub_entities[2], super_entity=super_entity_a), + ] + EntityRelationship.objects.bulk_create(relationships) + + # Create the entity group + entity_group = G(EntityGroup, logic_string='NOT(1)') + + # Create the membership + G(EntityGroupMembership, entity_group=entity_group, sub_entity_kind=sub_entity_kind, entity=super_entity_a) + + entity_ids = entity_group.get_all_entities() + self.assertEqual(entity_ids, set([ + sub_entities[3].id, + sub_entities[4].id, + sub_entities[5].id, + sub_entities[6].id, + sub_entities[7].id, + sub_entities[8].id, + sub_entities[9].id, + ])) + def test_individual_entities_returned(self): e = self.super_entities[0] G(EntityGroupMembership, entity_group=self.group, entity=e, sub_entity_kind=None) From a93c1f7ac53919a109aafc96ceb7e47e8786dee8 Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Tue, 26 Sep 2023 12:56:23 -0400 Subject: [PATCH 11/19] update num queries --- entity/tests/model_tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/entity/tests/model_tests.py b/entity/tests/model_tests.py index af51aa1..c9299b2 100644 --- a/entity/tests/model_tests.py +++ b/entity/tests/model_tests.py @@ -1125,14 +1125,14 @@ def test_get_all_entities(self): [None, account_kind], ]) - with self.assertNumQueries(3): + with self.assertNumQueries(4): membership_cache = EntityGroup.objects.get_membership_cache() entities_by_kind = get_entities_by_kind(membership_cache=membership_cache) for entity_group in entity_groups: entity_group.get_all_entities(membership_cache, entities_by_kind) - with self.assertNumQueries(3): + with self.assertNumQueries(4): get_entities_by_kind() # Make sure to hit the no group cache case From be5fbbe50440a8dd306ee0ee54824f17458886b4 Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Tue, 26 Sep 2023 13:01:11 -0400 Subject: [PATCH 12/19] update queries --- entity/tests/model_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entity/tests/model_tests.py b/entity/tests/model_tests.py index c9299b2..5e4e297 100644 --- a/entity/tests/model_tests.py +++ b/entity/tests/model_tests.py @@ -948,7 +948,7 @@ def test_number_of_queries(self): G(EntityGroupMembership, entity_group=self.group, entity=e2, sub_entity_kind=self.kind2) - with self.assertNumQueries(3): + with self.assertNumQueries(4): list(self.group.all_entities()) From 10e119beb1ec8da2f3c52e1581e12171b130f229 Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Tue, 3 Oct 2023 17:08:01 -0400 Subject: [PATCH 13/19] set as alpha version --- entity/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entity/version.py b/entity/version.py index e7c2779..ed44f5c 100644 --- a/entity/version.py +++ b/entity/version.py @@ -1 +1 @@ -__version__ = '6.2.0' +__version__ = '6.2.0a' From 8663e95139da04830637b57e40e2fc71f9005373 Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Tue, 3 Oct 2023 17:12:49 -0400 Subject: [PATCH 14/19] update publish.py --- publish.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/publish.py b/publish.py index 6b7aa85..c49964d 100644 --- a/publish.py +++ b/publish.py @@ -1,10 +1,8 @@ import subprocess subprocess.call(['rm', '-r', 'dist/']) -subprocess.call(['pip', 'install', 'wheel']) -subprocess.call(['pip', 'install', 'twine']) -subprocess.call(['python', 'setup.py', 'clean', '--all']) -subprocess.call(['python', 'setup.py', 'register', 'sdist', 'bdist_wheel']) +subprocess.call(['python', '-m', 'pip', 'install', 'build', 'twine']) +subprocess.call(['python', '-m', 'build']) +subprocess.call(['twine', 'check', 'dist/*']) subprocess.call(['twine', 'upload', 'dist/*']) subprocess.call(['rm', '-r', 'dist/']) -subprocess.call(['rm', '-r', 'build/']) From addb899696df6582a5eef89d19b07faad8d41392 Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Mon, 30 Oct 2023 06:11:23 -0400 Subject: [PATCH 15/19] catch and handle exception --- entity/exceptions.py | 5 ++++ entity/models.py | 60 ++++++++++++++++++++++++-------------------- 2 files changed, 38 insertions(+), 27 deletions(-) create mode 100644 entity/exceptions.py diff --git a/entity/exceptions.py b/entity/exceptions.py new file mode 100644 index 0000000..b61fb57 --- /dev/null +++ b/entity/exceptions.py @@ -0,0 +1,5 @@ + + +class InvalidLogicStringException(Exception): + def __str__(self): + return 'Invalid logic string' diff --git a/entity/models.py b/entity/models.py index df1da97..8085648 100644 --- a/entity/models.py +++ b/entity/models.py @@ -11,6 +11,8 @@ from python3_utils import compare_on_attr from functools import reduce +from entity.exceptions import InvalidLogicStringException + class AllEntityKindManager(ActivatableManager): """ @@ -500,33 +502,7 @@ def get_all_entities(self, membership_cache=None, entities_by_kind=None, return_ memberships = membership_cache.get(self.id) if memberships: if self.logic_string: - entity_kind_id = memberships[0][1] - full_set = set(entities_by_kind[entity_kind_id]['all']) - try: - filter_tree = ast.parse(self.logic_string.lower()) - except: - raise Exception - - expanded_memberships = [] - for entity_id, entity_kind_id in memberships: - if entity_id: - if entity_kind_id: - # All sub entities of this kind under this entity - expanded_memberships.append(set(entities_by_kind[entity_kind_id][entity_id])) - else: - # Individual entity - expanded_memberships.append({entity_id}) - else: - # All entities of this kind - expanded_memberships.append(set(entities_by_kind[entity_kind_id]['all'])) - - # Make sure each index is valid - indices = self.get_filter_indices(filter_tree.body[0].value) - self.validate_filter_indices(indices, expanded_memberships) - kmatch = self._node_to_kmatch(filter_tree.body[0].value) - kmatch = self._map_kmatch_values(kmatch, expanded_memberships) - entity_ids = self._process_kmatch(kmatch, full_set=full_set) - + entity_ids = self.get_entity_ids_from_logic_string(entities_by_kind, memberships) else: # Loop over each membership in this group for entity_id, entity_kind_id in membership_cache[self.id]: @@ -547,6 +523,36 @@ def get_all_entities(self, membership_cache=None, entities_by_kind=None, return_ return entity_ids + def get_entity_ids_from_logic_string(self, entities_by_kind, memberships): + entity_kind_id = memberships[0][1] + full_set = set(entities_by_kind[entity_kind_id]['all']) + try: + filter_tree = ast.parse(self.logic_string.lower()) + except: + raise InvalidLogicStringException() + + expanded_memberships = [] + for entity_id, entity_kind_id in memberships: + if entity_id: + if entity_kind_id: + # All sub entities of this kind under this entity + expanded_memberships.append(set(entities_by_kind[entity_kind_id][entity_id])) + else: + # Individual entity + expanded_memberships.append({entity_id}) + else: + # All entities of this kind + expanded_memberships.append(set(entities_by_kind[entity_kind_id]['all'])) + + # Make sure each index is valid + indices = self.get_filter_indices(filter_tree.body[0].value) + self.validate_filter_indices(indices, expanded_memberships) + kmatch = self._node_to_kmatch(filter_tree.body[0].value) + kmatch = self._map_kmatch_values(kmatch, expanded_memberships) + entity_ids = self._process_kmatch(kmatch, full_set=full_set) + + return entity_ids + def add_entity(self, entity, sub_entity_kind=None): """ Add an entity, or sub-entity group to this EntityGroup. From e7ec5ec76eaec2e817f33dd8b299a60a1d62a5c0 Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Mon, 30 Oct 2023 06:11:28 -0400 Subject: [PATCH 16/19] final version --- entity/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entity/version.py b/entity/version.py index ed44f5c..e7c2779 100644 --- a/entity/version.py +++ b/entity/version.py @@ -1 +1 @@ -__version__ = '6.2.0a' +__version__ = '6.2.0' From e1a91469444a8688497f352d42d4ec9467d873ec Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Mon, 30 Oct 2023 12:15:15 -0400 Subject: [PATCH 17/19] order by sort order and id --- entity/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entity/models.py b/entity/models.py index 8085648..3852642 100644 --- a/entity/models.py +++ b/entity/models.py @@ -338,7 +338,7 @@ def get_membership_cache(self, group_ids=None, is_active=True): if group_ids: membership_queryset = membership_queryset.filter(entity_group_id__in=group_ids) - membership_queryset = membership_queryset.order_by('id') + membership_queryset = membership_queryset.order_by('sort_order', 'id') membership_queryset = membership_queryset.values_list('entity_group_id', 'entity_id', 'sub_entity_kind_id') From 972391a12c8ec78df46edd0916cea756eb390e28 Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Tue, 31 Oct 2023 12:18:07 -0400 Subject: [PATCH 18/19] beta version for now --- entity/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entity/version.py b/entity/version.py index e7c2779..453264e 100644 --- a/entity/version.py +++ b/entity/version.py @@ -1 +1 @@ -__version__ = '6.2.0' +__version__ = '6.2.0b' From a09544ffaa7e1918bbf4ba9e3b66a32a7f466fa4 Mon Sep 17 00:00:00 2001 From: Wes Okes Date: Tue, 31 Oct 2023 15:11:11 -0400 Subject: [PATCH 19/19] final version --- entity/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entity/version.py b/entity/version.py index 453264e..e7c2779 100644 --- a/entity/version.py +++ b/entity/version.py @@ -1 +1 @@ -__version__ = '6.2.0b' +__version__ = '6.2.0'