Skip to content

Commit 0e29835

Browse files
authored
Merge pull request #269 from rtibbles/syncable_model_manager
Allow default model manager to be overridden without affecting syncing behaviour
2 parents c862901 + e79483b commit 0e29835

File tree

8 files changed

+169
-18
lines changed

8 files changed

+169
-18
lines changed

.github/workflows/check_migrations_sqlite.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ jobs:
2929
apt-get -y -qq update
3030
apt-get install -y build-essential tcl git-lfs
3131
- uses: actions/checkout@v4
32+
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
3233
with:
3334
lfs: true
3435
- name: Build SQLite 3.25.3
@@ -45,12 +46,14 @@ jobs:
4546
# Once we have confirmed that this works, set it for subsequent steps
4647
echo "LD_PRELOAD=$(realpath .libs/libsqlite3.so)" >> $GITHUB_ENV
4748
- uses: actions/cache@v4
49+
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
4850
with:
4951
path: ~/.cache/pip
5052
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
5153
restore-keys: |
5254
${{ runner.os }}-pip-
5355
- name: Install dependencies
56+
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
5457
run: pip install .
5558
- name: Run migrations
5659
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}

morango/__init__.py

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

morango/models/core.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ def _deserialize_store_model(self, fk_cache, defer_fks=False): # noqa: C901
473473
# imports core models.
474474
from morango.sync.utils import mute_signals
475475
with mute_signals(signals.post_delete):
476-
klass_model.objects.filter(id=self.id).delete()
476+
klass_model.syncing_objects.filter(id=self.id).delete()
477477
return None, deferred_fks
478478
else:
479479
# load model into memory
@@ -806,6 +806,11 @@ class SyncableModel(UUIDModelMixin):
806806

807807
objects = SyncableModelManager()
808808

809+
# Add a special syncing_objects queryset to every SyncableModel for use in syncing operations.
810+
# This means that we still deal with the entire set of objects when syncing, even if the default
811+
# model manager has been overridden to filter the queryset.
812+
syncing_objects = SyncableModelManager()
813+
809814
class Meta:
810815
abstract = True
811816

morango/registry.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ def _multiple_self_ref_fk_check(class_model):
3535
return False
3636

3737

38+
def _check_manager(name, objects):
39+
from morango.models.manager import SyncableModelManager
40+
from morango.models.query import SyncableModelQuerySet
41+
# syncable model checks
42+
if not isinstance(objects, SyncableModelManager):
43+
raise InvalidMorangoModelConfiguration(
44+
"Manager for {} must inherit from SyncableModelManager.".format(
45+
name
46+
)
47+
)
48+
if not isinstance(objects.none(), SyncableModelQuerySet):
49+
raise InvalidMorangoModelConfiguration(
50+
"Queryset for {} model must inherit from SyncableModelQuerySet.".format(
51+
name
52+
)
53+
)
54+
55+
3856
class SyncableModelRegistry(object):
3957
def __init__(self):
4058
self.profile_models = {}
@@ -98,8 +116,6 @@ def populate(self): # noqa: C901
98116

99117
import django.apps
100118
from morango.models.core import SyncableModel
101-
from morango.models.manager import SyncableModelManager
102-
from morango.models.query import SyncableModelQuerySet
103119

104120
model_list = []
105121
for model in django.apps.apps.get_models():
@@ -110,19 +126,10 @@ def populate(self): # noqa: C901
110126
raise InvalidMorangoModelConfiguration(
111127
"Syncing models with more than 1 self referential ForeignKey is not supported."
112128
)
113-
# syncable model checks
114-
if not isinstance(model.objects, SyncableModelManager):
115-
raise InvalidMorangoModelConfiguration(
116-
"Manager for {} must inherit from SyncableModelManager.".format(
117-
name
118-
)
119-
)
120-
if not isinstance(model.objects.none(), SyncableModelQuerySet):
121-
raise InvalidMorangoModelConfiguration(
122-
"Queryset for {} model must inherit from SyncableModelQuerySet.".format(
123-
name
124-
)
125-
)
129+
# Check both the objects and the syncing_objects querysets.
130+
_check_manager(name, model.objects)
131+
_check_manager(name, model.syncing_objects)
132+
126133
if model._meta.many_to_many:
127134
raise UnsupportedFieldType(
128135
"{} model with a ManyToManyField is not supported in morango."

morango/sync/operations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ def _serialize_into_store(profile, filter=None):
145145
for model in syncable_models.get_models(profile):
146146
new_store_records = []
147147
new_rmc_records = []
148-
klass_queryset = model.objects.filter(_morango_dirty_bit=True)
148+
klass_queryset = model.syncing_objects.filter(_morango_dirty_bit=True)
149149
if prefix_condition:
150150
klass_queryset = klass_queryset.filter(prefix_condition)
151151
store_records_dict = Store.objects.in_bulk(
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Generated by Django 3.2.25 on 2025-09-22 23:23
2+
from django.db import migrations
3+
from django.db import models
4+
5+
import morango.models.fields.uuids
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('facility_profile', '0003_auto_20240129_2025'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='TestModel',
17+
fields=[
18+
('id', morango.models.fields.uuids.UUIDField(editable=False, primary_key=True, serialize=False)),
19+
('_morango_dirty_bit', models.BooleanField(default=True, editable=False)),
20+
('_morango_source_id', models.CharField(editable=False, max_length=96)),
21+
('_morango_partition', models.CharField(editable=False, max_length=128)),
22+
('name', models.CharField(max_length=100)),
23+
('hidden', models.BooleanField(default=False)),
24+
],
25+
options={
26+
'abstract': False,
27+
},
28+
),
29+
]

tests/testapp/facility_profile/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,29 @@ def calculate_source_id(self, *args, **kwargs):
9898

9999
def calculate_partition(self, *args, **kwargs):
100100
return '{user_id}:user:interaction'.format(user_id=self.user.id)
101+
102+
103+
class FilteredModelManager(SyncableModelManager):
104+
"""Custom manager that filters out models marked as 'hidden'"""
105+
106+
def get_queryset(self):
107+
return super().get_queryset().filter(hidden=False)
108+
109+
110+
class TestModel(FacilityDataSyncableModel):
111+
"""Test model with a custom manager to test syncing_objects behavior"""
112+
113+
# Morango syncing settings
114+
morango_model_name = "testmodel"
115+
116+
name = models.CharField(max_length=100)
117+
hidden = models.BooleanField(default=False)
118+
119+
# Override the default manager to filter hidden objects
120+
objects = FilteredModelManager()
121+
122+
def calculate_source_id(self, *args, **kwargs):
123+
return self.name
124+
125+
def calculate_partition(self, *args, **kwargs):
126+
return 'testmodel'

tests/testapp/tests/integration/test_syncing_models.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import json
2+
13
from django.test import TestCase
24
from facility_profile.models import MyUser
5+
from facility_profile.models import TestModel
36

7+
from morango.models.core import Store
48
from morango.models.manager import SyncableModelManager
59
from morango.models.query import SyncableModelQuerySet
10+
from morango.sync.controller import MorangoProfileController
611

712

813
class SyncingModelsTestCase(TestCase):
@@ -61,3 +66,79 @@ def test_syncable_save(self):
6166
self.assertTrue(MyUser.objects.first()._morango_dirty_bit)
6267
user.save(update_dirty_bit_to=None)
6368
self.assertTrue(MyUser.objects.first()._morango_dirty_bit)
69+
70+
def test_syncing_objects_manager_with_custom_default_manager(self):
71+
"""Test that syncing_objects manager includes all objects even when default manager filters them out"""
72+
# Create some test objects
73+
TestModel.objects.create(name="visible", hidden=False)
74+
hidden_obj = TestModel(name="hidden", hidden=True)
75+
# Use save() to bypass the manager filter during creation
76+
hidden_obj.save()
77+
78+
# Verify that the default objects manager filters out hidden objects
79+
self.assertEqual(TestModel.objects.count(), 1)
80+
self.assertEqual(TestModel.objects.first().name, "visible")
81+
82+
# Verify that syncing_objects manager includes all objects for syncing
83+
self.assertEqual(TestModel.syncing_objects.count(), 2)
84+
syncing_names = set(TestModel.syncing_objects.values_list('name', flat=True))
85+
self.assertEqual(syncing_names, {"visible", "hidden"})
86+
87+
def test_hidden_models_serialization_into_store(self):
88+
"""Test that hidden models (filtered by default manager) still get serialized into Store"""
89+
controller = MorangoProfileController(TestModel.morango_profile)
90+
91+
# Create both visible and hidden objects
92+
visible_obj = TestModel.objects.create(name="visible", hidden=False)
93+
hidden_obj = TestModel(name="hidden", hidden=True)
94+
# Use save() to bypass the manager filter during creation
95+
hidden_obj.save()
96+
97+
# Serialize into store
98+
controller.serialize_into_store()
99+
100+
# Verify both objects (visible and hidden) are serialized into Store
101+
store_records = Store.objects.filter(model_name=TestModel.morango_model_name)
102+
self.assertEqual(store_records.count(), 2)
103+
104+
# Verify both objects are present in store by checking serialized data
105+
serialized_names = set()
106+
for store_record in store_records:
107+
serialized_data = json.loads(store_record.serialized)
108+
serialized_names.add(serialized_data['name'])
109+
110+
self.assertEqual(serialized_names, {"visible", "hidden"})
111+
112+
# Verify the store records have the correct IDs
113+
store_ids = set(store_records.values_list('id', flat=True))
114+
expected_ids = {str(visible_obj.id), str(hidden_obj.id)}
115+
self.assertEqual(store_ids, expected_ids)
116+
117+
def test_hidden_models_deletion_during_deserialization(self):
118+
"""Test that hidden models can be deleted during deserialization using syncing_objects"""
119+
controller = MorangoProfileController(TestModel.morango_profile)
120+
121+
# Create and serialize a hidden object
122+
hidden_obj = TestModel(name="hidden", hidden=True)
123+
hidden_obj.save()
124+
controller.serialize_into_store()
125+
126+
# Verify object exists in Store
127+
store_record = Store.objects.get(id=hidden_obj.id)
128+
self.assertFalse(store_record.deleted)
129+
130+
# Mark the store record as deleted (simulating deletion from another device)
131+
# Also set dirty_bit=True so it gets processed during deserialization
132+
store_record.deleted = True
133+
store_record.dirty_bit = True
134+
store_record.save()
135+
136+
# Deserialize from store - this should delete the hidden object
137+
controller.deserialize_from_store()
138+
139+
# Verify the hidden object was deleted from the database
140+
self.assertFalse(TestModel.syncing_objects.filter(id=hidden_obj.id).exists())
141+
self.assertFalse(TestModel.objects.filter(id=hidden_obj.id).exists())
142+
143+
# The store record should still exist but marked as deleted
144+
self.assertTrue(Store.objects.filter(id=hidden_obj.id, deleted=True).exists())

0 commit comments

Comments
 (0)