Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(analysis): add path attribute / mixin to structural models #1570

Merged
merged 2 commits into from
Oct 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions caluma/caluma_core/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ def ready(self):
import_module(module)

import_module("caluma.caluma_form.signals")
import_module("caluma.caluma_core.signals")
5 changes: 4 additions & 1 deletion caluma/caluma_core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ def filter_events(predicate):
def decorate(func):
@wraps(func)
def wrapper(sender, *args, **kwargs):
predicate_args = {arg: kwargs[arg] for arg in predicate_arg_names}
# add sender to kwargs as well, so predicates can work on it
kwargs_lookup = kwargs.copy()
kwargs_lookup["sender"] = sender
predicate_args = {arg: kwargs_lookup[arg] for arg in predicate_arg_names}
if predicate(**predicate_args):
return func(sender, *args, **kwargs)

Expand Down
58 changes: 58 additions & 0 deletions caluma/caluma_core/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import uuid

from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.db.utils import ProgrammingError
from graphene.utils.str_converters import to_camel_case
from simple_history.models import HistoricalRecords

Expand Down Expand Up @@ -89,6 +91,62 @@ class Meta:
abstract = True


class PathModelMixin(models.Model):
"""
Mixin that stores a path to the object.

The path attribute is used for analytics and allows direct access
and faster SELECTs.

To you use this mixin, you must define a property named `path_parent_attrs`
on the model class. It's supposed to be a list of strings that contain the
attributes to check. The first attribute that exists will be used.
This way, you can define multiple possible parents (in a document, for example
you can first check if it's attached to a case, or a work item, then a document family)
"""

path = ArrayField(
models.CharField(max_length=150),
default=list,
help_text="Stores a path to the given object",
)

def calculate_path(self, _seen_keys=None):
if not _seen_keys:
_seen_keys = set()
self_pk_list = [str(self.pk)]
if str(self.pk) in _seen_keys:
# Not recursing any more. Root elements *may* point
# to themselves in certain circumstances
return []

path_parent_attrs = getattr(self, "path_parent_attrs", None)

if not isinstance(path_parent_attrs, list):
raise ProgrammingError( # pragma: no cover
"If you use the PathModelMixin, you must define "
"`path_parent_attrs` on the model (a list of "
"strings that contains the attributes to check)"
)

for attr in path_parent_attrs:
parent = getattr(self, attr, None)
if parent:
parent_path = parent.calculate_path(set([*self_pk_list, *_seen_keys]))
if parent_path:
return parent_path + self_pk_list

# Else case: If parent returns an empty list (loop case), we may
# be in the wrong parent attribute. We continue checking the other
# attributes (if any). If we don't find any other parents that work,
# we'll just return as if we're the root object.

return self_pk_list

class Meta:
abstract = True


class NaturalKeyModel(BaseModel, HistoricalModel):
"""Models which use a natural key as primary key."""

Expand Down
19 changes: 19 additions & 0 deletions caluma/caluma_core/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.db.models.signals import pre_save
from django.dispatch import receiver

from caluma.caluma_core.events import filter_events
from caluma.caluma_core.models import PathModelMixin


@receiver(pre_save)
@filter_events(lambda sender: PathModelMixin in sender.mro())
def store_path(sender, instance, **kwargs):
"""Store/update the path of the object.

Note: Due to the fact that this structure is relatively rigid,
we don't update our children. For one, they may be difficult to
collect, but also, the parent-child relationship is not expected
to change, and structures are built top-down, so any object
is expected to exist before it's children come into play.
"""
instance.path = instance.calculate_path()
8 changes: 4 additions & 4 deletions caluma/caluma_form/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@ class AnswerFactory(DjangoModelFactory):

@lazy_attribute
def value(self):
if (
self.question.type == models.Question.TYPE_MULTIPLE_CHOICE
or self.question.type == models.Question.TYPE_DYNAMIC_MULTIPLE_CHOICE
):
if self.question.type in [
models.Question.TYPE_MULTIPLE_CHOICE,
models.Question.TYPE_DYNAMIC_MULTIPLE_CHOICE,
]:
return [faker.Faker().name(), faker.Faker().name()]
elif self.question.type == models.Question.TYPE_FLOAT:
return faker.Faker().pyfloat()
Expand Down
34 changes: 34 additions & 0 deletions caluma/caluma_form/migrations/0041_add_path_attribute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 2.2.22 on 2021-09-29 15:13

import django.contrib.postgres.fields
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("caluma_form", "0040_add_modified_by_user_group"),
]

operations = [
migrations.AddField(
model_name="document",
name="path",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=150),
default=list,
help_text="Stores a path to the given object",
size=None,
),
),
migrations.AddField(
model_name="historicaldocument",
name="path",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=150),
default=list,
help_text="Stores a path to the given object",
size=None,
),
),
]
18 changes: 18 additions & 0 deletions caluma/caluma_form/migrations/0042_fill_path_attributes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 2.2.22 on 2021-09-29 16:48

from django.db import migrations


def add_path_attribute(apps, schema_editor):
for doc in apps.get_model("caluma_form.document").objects.all():
doc.path = doc.calculate_path()
doc.save()


class Migration(migrations.Migration):

dependencies = [
("caluma_form", "0041_add_path_attribute"),
]

operations = [migrations.RunPython(add_path_attribute, migrations.RunPython.noop)]
4 changes: 3 additions & 1 deletion caluma/caluma_form/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,11 @@ def create_document_for_task(self, task, user):
return SaveDocumentLogic.create({"form": task.form}, user=user)


class Document(core_models.UUIDModel):
class Document(core_models.UUIDModel, core_models.PathModelMixin):
objects = DocumentManager()

path_parent_attrs = ["work_item", "case", "family"]

family = models.ForeignKey(
"self",
help_text="Family id which document belongs too.",
Expand Down
54 changes: 54 additions & 0 deletions caluma/caluma_workflow/migrations/0028_add_path_attribute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Generated by Django 2.2.22 on 2021-09-29 15:13

import django.contrib.postgres.fields
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("caluma_workflow", "0027_add_modified_by_user_group"),
]

operations = [
migrations.AddField(
model_name="case",
name="path",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=150),
default=list,
help_text="Stores a path to the given object",
size=None,
),
),
migrations.AddField(
model_name="historicalcase",
name="path",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=150),
default=list,
help_text="Stores a path to the given object",
size=None,
),
),
migrations.AddField(
model_name="historicalworkitem",
name="path",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=150),
default=list,
help_text="Stores a path to the given object",
size=None,
),
),
migrations.AddField(
model_name="workitem",
name="path",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=150),
default=list,
help_text="Stores a path to the given object",
size=None,
),
),
]
22 changes: 22 additions & 0 deletions caluma/caluma_workflow/migrations/0029_fill_path_attributes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 2.2.22 on 2021-09-29 15:20

from django.db import migrations


def add_path_attribute(apps, schema_editor):
for model in [
"caluma_workflow.workitem",
"caluma_workflow.case",
]:
for obj in apps.get_model(model).objects.all():
obj.path = obj.calculate_path()
obj.save()


class Migration(migrations.Migration):

dependencies = [
("caluma_workflow", "0028_add_path_attribute"),
]

operations = [migrations.RunPython(add_path_attribute, migrations.RunPython.noop)]
10 changes: 7 additions & 3 deletions caluma/caluma_workflow/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.utils import timezone
from localized_fields.fields import LocalizedField

from ..caluma_core.models import ChoicesCharField, SlugModel, UUIDModel
from ..caluma_core.models import ChoicesCharField, PathModelMixin, SlugModel, UUIDModel


class Task(SlugModel):
Expand Down Expand Up @@ -106,7 +106,7 @@ class Meta:
unique_together = ("workflow", "task")


class Case(UUIDModel):
class Case(UUIDModel, PathModelMixin):
STATUS_RUNNING = "running"
STATUS_COMPLETED = "completed"
STATUS_CANCELED = "canceled"
Expand All @@ -119,6 +119,8 @@ class Case(UUIDModel):
(STATUS_SUSPENDED, "Case is suspended."),
)

path_parent_attrs = ["parent_work_item"]

family = models.ForeignKey(
"self",
help_text="Family id which case belongs to.",
Expand Down Expand Up @@ -163,7 +165,7 @@ def set_case_family(sender, instance, **kwargs):
instance.family = instance


class WorkItem(UUIDModel):
class WorkItem(UUIDModel, PathModelMixin):
STATUS_READY = "ready"
STATUS_COMPLETED = "completed"
STATUS_CANCELED = "canceled"
Expand All @@ -178,6 +180,8 @@ class WorkItem(UUIDModel):
(STATUS_SUSPENDED, "Work item is suspended."),
)

path_parent_attrs = ["case"]

name = LocalizedField(
blank=False,
null=False,
Expand Down
21 changes: 21 additions & 0 deletions caluma/caluma_workflow/tests/test_set_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
def test_set_paths(db, case, work_item_factory):

workitem = work_item_factory(case=case)
# workitem.save() # trigger the signal
assert workitem.path == [str(case.pk), str(workitem.pk)]


def test_row_document_path(db, case, form_and_document):
form, document, questions, answers = form_and_document(
use_table=True, use_subform=True
)

case.document = document
case.save()
document.save()
assert document.path == [str(case.pk), str(document.pk)]

table_ans = answers["table"]
row_doc = table_ans.documents.first()
row_doc.save()
assert row_doc.path == [str(case.pk), str(document.pk), str(row_doc.pk)]
5 changes: 5 additions & 0 deletions caluma/tests/__snapshots__/test_schema.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@
modifiedByUser: String
modifiedByGroup: String
id: ID!
path: [String]!
closedAt: DateTime
closedByUser: String
closedByGroup: String
Expand Down Expand Up @@ -493,6 +494,7 @@
modifiedByUser: String
modifiedByGroup: String
id: ID!
path: [String]!
form: Form!
source: Document
meta: GenericScalar
Expand Down Expand Up @@ -947,6 +949,7 @@
modifiedByUser: String
modifiedByGroup: String
id: ID!
path: [String]!
meta: GenericScalar
historyUserId: String
form: Form
Expand Down Expand Up @@ -2126,6 +2129,7 @@
CREATED_BY_GROUP
MODIFIED_BY_USER
MODIFIED_BY_GROUP
PATH
FORM
SOURCE
}
Expand Down Expand Up @@ -2480,6 +2484,7 @@
modifiedByUser: String
modifiedByGroup: String
id: ID!
path: [String]!
name: String!
description: String
closedAt: DateTime
Expand Down