Skip to content

Commit

Permalink
feat(analysis): add path attribute / mixin to structural models
Browse files Browse the repository at this point in the history
Part of a series of PRs that will lead to the analysis module.

Add a denormalisation step to the structural data models (case, workitem,
and document) that will allow faster queries across case structures down
the road.
  • Loading branch information
winged committed Sep 30, 2021
1 parent 6104cbd commit b3501a3
Show file tree
Hide file tree
Showing 12 changed files with 246 additions and 8 deletions.
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")
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 _seen_keys.intersection(set(self_pk_list)):
# 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()
21 changes: 21 additions & 0 deletions caluma/caluma_core/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)]
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
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

0 comments on commit b3501a3

Please sign in to comment.