diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 056e7a5..a928234 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -10,7 +10,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, 3.10.x] + # TODO: add 3.11.x when it's ready! Last checked Oct 31, 2022 + python-version: [3.9, 3.10.x] django-version: ['<4', '>=4'] steps: diff --git a/ckc/models.py b/ckc/models.py index d086ef6..8531972 100644 --- a/ckc/models.py +++ b/ckc/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils.timezone import now class SoftDeleteQuerySet(models.QuerySet): @@ -23,3 +24,35 @@ class Meta: def delete(self, *args, **kwargs): self.deleted = True self.save() + + +class JsonSnapshotModel(models.Model): + """This mixin is meant to be inherited by a model class. It creates a snapshot field for the class that it is a part of. + This field is used to solidify data at a given point in time. + + The create_json_snapshot() method must be overridden in the class inheriting this mixin. Inside this method you will build + a custom JSON object of your model state. Include the fields you wish to be solidified. + + Lastly, call take_snapshot() at the point in your code you want data to be saved. The time and date this occurs will + also be saved in a separate field called snapshot_date. + """ + snapshot = models.JSONField(null=True, blank=True, default=dict) + snapshot_date = models.DateTimeField(null=True, blank=True) + + class Meta: + abstract = True + + def _create_json_snapshot(self) -> dict: + """Override this method to take a "snapshot" of the relevant data on this model""" + raise NotImplementedError + + def take_snapshot(self, force=False): + if not force: + assert not self.snapshot, "Can not override an existing snapshot instance." + + self.snapshot = self._create_json_snapshot() + + # TODO: Do we want to test these edge cases? + # assert self.snapshot is not None, "" + + self.snapshot_date = now() diff --git a/setup.cfg b/setup.cfg index 1c98221..795ab5d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,7 @@ name = django-ckc author = Eric Carmichael author_email = eric@ckcollab.com description = tools, utilities, etc. we use across projects @ ckc -version = 0.0.8 +version = 0.0.9 url = https://github.com/ckcollab/django-ckc keywords = django diff --git a/testproject/testapp/models.py b/testproject/testapp/models.py index 0ea8978..b4459fe 100644 --- a/testproject/testapp/models.py +++ b/testproject/testapp/models.py @@ -2,20 +2,29 @@ from django.contrib.gis.db.models import PointField from django.db import models -from ckc.models import SoftDeletableModel +from ckc.models import SoftDeletableModel, JsonSnapshotModel User = get_user_model() +# ---------------------------------------------------------------------------- +# Testing soft deletable model +# ---------------------------------------------------------------------------- class AModel(SoftDeletableModel): title = models.CharField(max_length=255, default="I'm a test!") +# ---------------------------------------------------------------------------- +# PrimaryKeyWriteSerializerReadField related model +# ---------------------------------------------------------------------------- class BModel(models.Model): a = models.ForeignKey(AModel, on_delete=models.CASCADE) +# ---------------------------------------------------------------------------- +# DefaultCreatedByMixin models +# ---------------------------------------------------------------------------- class ModelWithACreator(models.Model): created_by = models.ForeignKey(User, on_delete=models.CASCADE) @@ -24,5 +33,25 @@ class ModelWithADifferentNamedCreator(models.Model): owner = models.ForeignKey(User, on_delete=models.CASCADE) +# ---------------------------------------------------------------------------- +# For testing geo points in factories +# ---------------------------------------------------------------------------- class Location(models.Model): geo_point = PointField() + + +# ---------------------------------------------------------------------------- +# For testing JSON snapshots +# ---------------------------------------------------------------------------- +class SnapshottedModel(JsonSnapshotModel, models.Model): + + def _create_json_snapshot(self) -> dict: + return { + "test": "snapshot" + } + + +class SnapshottedModelMissingOverride(JsonSnapshotModel, models.Model): + # No _create_json_snapshot here! This is for testing purposes, to confirm we raise + # an assertion when this method is missing + pass diff --git a/tests/integration/test_json_model_snapshots.py b/tests/integration/test_json_model_snapshots.py new file mode 100644 index 0000000..8713172 --- /dev/null +++ b/tests/integration/test_json_model_snapshots.py @@ -0,0 +1,42 @@ +import pytest +from rest_framework.test import APITestCase + +from testapp.models import SnapshottedModel, SnapshottedModelMissingOverride + + +class TestJsonSnapshottedModels(APITestCase): + def test_snapshot_model_asserts_method_must_be_implemented_if_it_is_missing(self): + instance = SnapshottedModelMissingOverride() + + # make sure proper error raised when we try to snapshot with a missing method + with pytest.raises(NotImplementedError): + instance.take_snapshot() + + def test_snapshot_model_save_actually_saves_to_databse(self): + instance = SnapshottedModel() + instance.take_snapshot() + + # Snapshot was written to model... + assert instance.snapshot == {"test": "snapshot"} + + instance.save() + + # Snapshot was saved to database, forreal + assert SnapshottedModel.objects.get(snapshot__test="snapshot") + + def test_snapshotting_an_already_snapshotted_model_raises_exception_unless_forced(self): + instance = SnapshottedModel() + instance.take_snapshot() + + # trying to snapshot already snapshotted model -> shit the bed + with pytest.raises(Exception): + instance.take_snapshot() + + # Clear snapshot to see if we can re-set it.. + instance.snapshot = None + + # No exception raised here! And data was properly written + instance.take_snapshot(force=True) + + # We were able to re-snapshot + assert instance.snapshot == {"test": "snapshot"}