Skip to content

Commit bd61ff5

Browse files
authored
Merge pull request #285 from bjester/synchronized-filtering-context
Allow passthrough of sync_filter in deserialization and model validation logic
2 parents b74f896 + 4c4bf4a commit bd61ff5

8 files changed

Lines changed: 254 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
List of the most important changes for each release.
44

5+
## 0.8.7
6+
- Adds flexibility for customizing deserialization behavior using sync filter to `SyncableModel` methods
7+
58
## 0.8.6
69
- Allows sync operations to modify the context, particularly the sync filter, as long as the original is a subset of the new filter
710
- Deprecates usage of `Filter` for parameter replacement. Use `Filter.from_template` instead

morango/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.8.6"
1+
__version__ = "0.8.7"

morango/models/core.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -453,14 +453,21 @@ class Meta:
453453
models.Index(fields=["profile", "model_name", "partition", "dirty_bit"], condition=models.Q(dirty_bit=True), name="idx_morango_deserialize"),
454454
]
455455

456-
def _deserialize_store_model(self, fk_cache, defer_fks=False): # noqa: C901
456+
def _deserialize_store_model(self, fk_cache, defer_fks=False, sync_filter=None): # noqa: C901
457457
"""
458458
When deserializing a store model, we look at the deleted flags to know if we should delete the app model.
459459
Upon loading the app model in memory we validate the app models fields, if any errors occurs we follow
460460
foreign key relationships to see if the related model has been deleted to propagate that deletion to the target app model.
461461
We return:
462462
None => if the model was deleted successfully
463463
model => if the model validates successfully
464+
465+
:param fk_cache: A cache for foreign key lookups
466+
:type fk_cache: dict
467+
:param defer_fks: Whether to defer foreign key lookups
468+
:type defer_fks: bool
469+
:param sync_filter: The current sync's filter, if any
470+
:type sync_filter: Filter|None
464471
"""
465472
deferred_fks = {}
466473
klass_model = syncable_models.get_model(self.profile, self.model_name)
@@ -476,18 +483,20 @@ def _deserialize_store_model(self, fk_cache, defer_fks=False): # noqa: C901
476483
klass_model.syncing_objects.filter(id=self.id).delete()
477484
return None, deferred_fks
478485
else:
486+
if sync_filter:
487+
print("Has filter", sync_filter)
479488
# load model into memory
480-
app_model = klass_model.deserialize(json.loads(self.serialized))
489+
app_model = klass_model.deserialize(json.loads(self.serialized), sync_filter=sync_filter)
481490
app_model._morango_source_id = self.source_id
482491
app_model._morango_partition = self.partition
483492
app_model._morango_dirty_bit = False
484493

485494
try:
486495
# validate and return the model
487496
if defer_fks:
488-
deferred_fks = app_model.deferred_clean_fields()
497+
deferred_fks = app_model.deferred_clean_fields(sync_filter=sync_filter)
489498
else:
490-
app_model.cached_clean_fields(fk_cache)
499+
app_model.cached_clean_fields(fk_cache, sync_filter=sync_filter)
491500
return app_model, deferred_fks
492501

493502
except (exceptions.ValidationError, exceptions.ObjectDoesNotExist) as e:
@@ -853,15 +862,31 @@ def delete(
853862
obj._update_hard_deleted_models()
854863
return collector.delete()
855864

856-
def cached_clean_fields(self, fk_lookup_cache):
865+
def clean_fields(self, exclude=None, sync_filter=None):
866+
"""
867+
Immediately validates all fields
868+
869+
:param exclude: A list of field names to exclude from validation
870+
:type exclude: list[str]
871+
:param sync_filter: The current sync's filter, if any
872+
:type sync_filter: Filter|None
873+
"""
874+
super(SyncableModel, self).clean_fields(exclude=exclude)
875+
876+
def cached_clean_fields(self, fk_lookup_cache, exclude=None, sync_filter=None):
857877
"""
858878
Immediately validates all fields, but uses a cache for foreign key (FK) lookups to reduce
859879
repeated queries for many records with the same FK
860880
861881
:param fk_lookup_cache: A dictionary to use as a cache to prevent querying the database if a
862882
FK exists in the cache, having already been validated
883+
:type fk_lookup_cache: dict
884+
:param exclude: A list of field names to exclude from validation
885+
:type exclude: list[str]
886+
:param sync_filter: The current sync's filter, if any
887+
:type sync_filter: Filter|None
863888
"""
864-
excluded_fields = []
889+
excluded_fields = exclude or []
865890
fk_fields = [
866891
field for field in self._meta.fields if isinstance(field, models.ForeignKey)
867892
]
@@ -883,7 +908,7 @@ def cached_clean_fields(self, fk_lookup_cache):
883908
fk_lookup_cache[key] = 1
884909
excluded_fields.append(f.name)
885910

886-
self.clean_fields(exclude=excluded_fields)
911+
self.clean_fields(exclude=excluded_fields, sync_filter=sync_filter)
887912

888913
# after cleaning, we can confidently set ourselves in the fk_lookup_cache
889914
self_key = "{id}_{db_table}".format(
@@ -892,15 +917,19 @@ def cached_clean_fields(self, fk_lookup_cache):
892917
)
893918
fk_lookup_cache[self_key] = 1
894919

895-
def deferred_clean_fields(self):
920+
def deferred_clean_fields(self, exclude=None, sync_filter=None):
896921
"""
897922
Calls `.clean_fields()` but excludes all foreign key fields and instead returns them as a
898923
dictionary for deferred batch processing
899924
925+
:param exclude: A list of field names to exclude from validation
926+
:type exclude: list[str]
927+
:param sync_filter: The current sync's filter, if any
928+
:type sync_filter: Filter|None
900929
:return: A dictionary containing lists of `ForeignKeyReference`s keyed by the name of the
901930
model being referenced by the FK
902931
"""
903-
excluded_fields = []
932+
excluded_fields = exclude or []
904933
deferred_fks = defaultdict(list)
905934
for field in self._meta.fields:
906935
if not isinstance(field, models.ForeignKey):
@@ -918,7 +947,7 @@ def deferred_clean_fields(self):
918947
)
919948
)
920949

921-
self.clean_fields(exclude=excluded_fields)
950+
self.clean_fields(exclude=excluded_fields, sync_filter=sync_filter)
922951
return deferred_fks
923952

924953
def serialize(self):
@@ -939,8 +968,14 @@ def serialize(self):
939968
return data
940969

941970
@classmethod
942-
def deserialize(cls, dict_model):
943-
"""Returns an unsaved class object based on the valid properties passed in."""
971+
def deserialize(cls, dict_model, sync_filter=None):
972+
"""Returns an unsaved class object based on the valid properties passed in.
973+
974+
:param dict_model: The model data to deserialize
975+
:type dict_model: dict
976+
:param sync_filter: The current sync's filter, if any
977+
:type sync_filter: Filter|None
978+
"""
944979
kwargs = {}
945980
for f in cls._meta.concrete_fields:
946981
if f.attname in dict_model:

morango/sync/operations.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ def _deserialize_from_store(profile, skip_erroring=False, filter=None):
465465
lambda x, y: x | y,
466466
[Q(partition__startswith=prefix) for prefix in filter],
467467
)
468+
print("prefix_condition: ", prefix_condition)
468469
store_models = store_models.filter(prefix_condition)
469470

470471
# if requested, skip any records that previously errored, to be faster
@@ -485,7 +486,7 @@ def _deserialize_from_store(profile, skip_erroring=False, filter=None):
485486
for store_model in dirty_children:
486487
try:
487488
app_model, _ = store_model._deserialize_store_model(
488-
fk_cache
489+
fk_cache, sync_filter=filter
489490
)
490491
if app_model:
491492
with mute_signals(signals.pre_save, signals.post_save):
@@ -538,7 +539,7 @@ def _deserialize_from_store(profile, skip_erroring=False, filter=None):
538539
app_model,
539540
model_deferred_fks,
540541
) = store_model._deserialize_store_model(
541-
fk_cache, defer_fks=True
542+
fk_cache, defer_fks=True, sync_filter=filter,
542543
)
543544
if app_model:
544545
app_models.append(app_model)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 3.2.25 on 2026-01-21 18:58
2+
import uuid
3+
4+
import django.db.models.deletion
5+
from django.conf import settings
6+
from django.db import migrations
7+
from django.db import models
8+
9+
import morango.models.fields.uuids
10+
11+
12+
class Migration(migrations.Migration):
13+
14+
dependencies = [
15+
('facility_profile', '0004_testmodel'),
16+
]
17+
18+
operations = [
19+
migrations.CreateModel(
20+
name='ConditionalLog',
21+
fields=[
22+
('id', morango.models.fields.uuids.UUIDField(editable=False, primary_key=True, serialize=False)),
23+
('_morango_dirty_bit', models.BooleanField(default=True, editable=False)),
24+
('_morango_source_id', models.CharField(editable=False, max_length=96)),
25+
('_morango_partition', models.CharField(editable=False, max_length=128)),
26+
('content_id', morango.models.fields.uuids.UUIDField(db_index=True, default=uuid.uuid4)),
27+
('facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='facility_profile.facility')),
28+
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
29+
],
30+
options={
31+
'abstract': False,
32+
},
33+
),
34+
]

tests/testapp/facility_profile/models.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ class SyncableUserModelManager(SyncableModelManager, UserManager):
2222

2323

2424
class Facility(FacilityDataSyncableModel):
25-
26-
# Morango syncing settings
2725
morango_model_name = "facility"
2826

2927
name = models.CharField(max_length=100)
@@ -34,7 +32,10 @@ def calculate_source_id(self, *args, **kwargs):
3432
return self.name
3533

3634
def calculate_partition(self, *args, **kwargs):
37-
return ''
35+
if self.id:
36+
return uuid.UUID(self.id).hex
37+
else:
38+
return '{id}'.format(id=self.ID_PLACEHOLDER)
3839

3940
def clean_fields(self, *args, **kwargs):
4041
# reference parent here just to trigger a non-validation error to make sure we handle it
@@ -43,7 +44,6 @@ def clean_fields(self, *args, **kwargs):
4344

4445

4546
class MyUser(AbstractBaseUser, FacilityDataSyncableModel):
46-
# Morango syncing settings
4747
morango_model_name = "user"
4848

4949
USERNAME_FIELD = "username"
@@ -73,21 +73,19 @@ def compute_namespaced_id(partition_value, source_id_value, model_name):
7373

7474

7575
class SummaryLog(FacilityDataSyncableModel):
76-
# Morango syncing settings
7776
morango_model_name = "contentsummarylog"
7877

7978
user = models.ForeignKey(MyUser, on_delete=models.CASCADE)
8079
content_id = UUIDField(db_index=True, default=uuid.uuid4)
8180

8281
def calculate_source_id(self, *args, **kwargs):
83-
return '{}:{}'.format(self.user.id, self.content_id)
82+
return '{}:{}'.format(self.user_id, self.content_id)
8483

8584
def calculate_partition(self, *args, **kwargs):
86-
return '{user_id}:user:summary'.format(user_id=self.user.id)
85+
return '{user_id}:user:summary'.format(user_id=self.user_id)
8786

8887

8988
class InteractionLog(FacilityDataSyncableModel):
90-
# Morango syncing settings
9189
morango_model_name = "contentinteractionlog"
9290

9391
user = models.ForeignKey(MyUser, blank=True, null=True, on_delete=models.CASCADE)
@@ -97,7 +95,33 @@ def calculate_source_id(self, *args, **kwargs):
9795
return None
9896

9997
def calculate_partition(self, *args, **kwargs):
100-
return '{user_id}:user:interaction'.format(user_id=self.user.id)
98+
return '{user_id}:user:interaction'.format(user_id=self.user_id)
99+
100+
101+
class ConditionalLog(FacilityDataSyncableModel):
102+
morango_model_name = "conditionallog"
103+
104+
facility = models.ForeignKey(Facility, blank=False, null=False, on_delete=models.CASCADE)
105+
user = models.ForeignKey(MyUser, blank=True, null=True, on_delete=models.CASCADE)
106+
content_id = UUIDField(db_index=True, default=uuid.uuid4)
107+
108+
def calculate_source_id(self, *args, **kwargs):
109+
return None
110+
111+
def calculate_partition(self, *args, **kwargs):
112+
return uuid.UUID(self.facility_id).hex
113+
114+
def clean_fields(self, exclude=None, sync_filter=None):
115+
exclude = exclude or []
116+
if sync_filter:
117+
exclude.append("user_id")
118+
super(ConditionalLog, self).clean_fields(exclude=exclude, sync_filter=sync_filter)
119+
120+
@classmethod
121+
def deserialize(cls, dict_model, sync_filter=None):
122+
if sync_filter:
123+
del dict_model["user_id"]
124+
return super().deserialize(dict_model, sync_filter)
101125

102126

103127
class FilteredModelManager(SyncableModelManager):
@@ -110,7 +134,6 @@ def get_queryset(self):
110134
class TestModel(FacilityDataSyncableModel):
111135
"""Test model with a custom manager to test syncing_objects behavior"""
112136

113-
# Morango syncing settings
114137
morango_model_name = "testmodel"
115138

116139
name = models.CharField(max_length=100)

tests/testapp/tests/models/test_core.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import uuid
22

33
import factory
4+
import mock
45
from django.test import override_settings
56
from django.test import TestCase
67
from django.utils import timezone
8+
from facility_profile.models import Facility
79
from facility_profile.models import MyUser
810

911
from ..helpers import RecordMaxCounterFactory
@@ -367,3 +369,26 @@ def test_get_touched_record_ids_for_model__string(self):
367369
)
368370
),
369371
)
372+
373+
374+
class SyncableModelTestCase(TestCase):
375+
@mock.patch("morango.models.core.UUIDModelMixin.clean_fields")
376+
def test_clean_fields(self, mock_super_clean_fields):
377+
f = Facility(name="test")
378+
sync_filter = Filter("test")
379+
f.clean_fields(exclude=["test1"], sync_filter=sync_filter)
380+
mock_super_clean_fields.assert_called_once_with(exclude=["test1"])
381+
382+
@mock.patch("morango.models.core.SyncableModel.clean_fields")
383+
def test_cached_clean_fields(self, mock_clean_fields):
384+
f = Facility(name="test")
385+
sync_filter = Filter("test")
386+
f.cached_clean_fields({}, exclude=["test1"], sync_filter=sync_filter)
387+
mock_clean_fields.assert_called_once_with(exclude=["test1", "parent"], sync_filter=sync_filter)
388+
389+
@mock.patch("morango.models.core.SyncableModel.clean_fields")
390+
def test_deferred_clean_fields(self, mock_clean_fields):
391+
f = Facility(name="test")
392+
sync_filter = Filter("test")
393+
f.deferred_clean_fields(exclude=["test1"], sync_filter=sync_filter)
394+
mock_clean_fields.assert_called_once_with(exclude=["test1"], sync_filter=sync_filter)

0 commit comments

Comments
 (0)