Bookmarks sync
authorRadek Czajka <rczajka@rczajka.pl>
Fri, 1 Aug 2025 14:53:48 +0000 (16:53 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Fri, 1 Aug 2025 14:53:48 +0000 (16:53 +0200)
src/bookmarks/api/views.py
src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py [new file with mode: 0644]
src/bookmarks/models.py
src/social/api/views.py
src/social/models.py
src/social/syncable.py [new file with mode: 0644]

index 87314c6..38408c1 100644 (file)
@@ -15,13 +15,17 @@ from api.fields import AbsoluteURLField
 
 
 class BookmarkSerializer(serializers.ModelSerializer):
-    book = serializers.SlugRelatedField(queryset=catalogue.models.Book.objects.all(), slug_field='slug')
+    book = serializers.SlugRelatedField(
+        queryset=catalogue.models.Book.objects.all(), slug_field='slug',
+        required=False
+    )
     href = AbsoluteURLField(view_name='api_bookmark', view_args=['uuid'])
-    timestamp = serializers.IntegerField()
+    timestamp = serializers.IntegerField(required=False)
+    location = serializers.CharField(required=False)
     
     class Meta:
         model = models.Bookmark
-        fields = ['book', 'anchor', 'note', 'href', 'uuid', 'location', 'timestamp']
+        fields = ['book', 'anchor', 'note', 'href', 'uuid', 'location', 'timestamp', 'deleted']
         read_only_fields = ['uuid']
 
 
diff --git a/src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py b/src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py
new file mode 100644 (file)
index 0000000..8e9f32c
--- /dev/null
@@ -0,0 +1,30 @@
+# Generated by Django 4.0.8 on 2025-08-01 14:35
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('bookmarks', '0002_quote'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='bookmark',
+            name='deleted',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='bookmark',
+            name='reported_timestamp',
+            field=models.DateTimeField(default=django.utils.timezone.now),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='bookmark',
+            name='updated_at',
+            field=models.DateTimeField(auto_now=True),
+        ),
+    ]
index 234d051..583cd55 100644 (file)
@@ -1,24 +1,65 @@
 import uuid
+from django.apps import apps
 from django.db import models
+from django.utils.timezone import now
+from social.syncable import Syncable
 
 
-class Bookmark(models.Model):
+class Bookmark(Syncable, models.Model):
     uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
     user = models.ForeignKey('auth.User', models.CASCADE)
     book = models.ForeignKey('catalogue.Book', models.CASCADE)
     anchor = models.CharField(max_length=100, blank=True)
     created_at = models.DateTimeField(auto_now_add=True)
     note = models.TextField(blank=True)
+    updated_at = models.DateTimeField(auto_now=True)
+    reported_timestamp = models.DateTimeField(default=now)
+    deleted = models.BooleanField(default=False)
+
+    syncable_fields = [
+        'deleted', 'note',
+    ]
 
     def __str__(self):
         return str(self.uuid)
 
+    @classmethod
+    def create_from_data(cls, user, data):
+        if data.get('location'):
+            return cls.get_by_location(user, data['location'], create=True)
+        elif data.get('book') and data.get('anchor'):
+            return cls.objects.create(user=user, book=data['book'], anchor=data['anchor'])
+    
     @property
     def timestamp(self):
-        return self.created_at.timestamp()
+        return self.updated_at.timestamp()
     
     def location(self):
         return f'{self.book.slug}/{self.anchor}'
+
+    @classmethod
+    def get_by_location(cls, user, location, create=False):
+        Book = apps.get_model('catalogue', 'Book')
+        try:
+            slug, anchor = location.split('/')
+        except:
+            return None
+        instance = cls.objects.filter(
+            user=user,
+            book__slug=slug,
+            anchor=anchor
+        ).first()
+        if instance is None and create:
+            try:
+                book = Book.objects.get(slug=slug)
+            except Book.DoesNotExist:
+                return None
+            instance = cls.objects.create(
+                user=user,
+                book=book,
+                anchor=anchor
+            )
+        return instance
     
     def get_for_json(self):
         return {
index ea8b128..cad07df 100644 (file)
@@ -464,8 +464,9 @@ class BookmarkSyncView(SyncView):
     sync_id_field = 'uuid'
     sync_id_serializer_field = 'uuid'
 
-    def get_queryset_for_ts(self, timestamp):
-        return self.model.objects.filter(
-            user=self.request.user,
-            created_at__gt=timestamp
-        ).order_by('created_at')
+    def get_instance(self, user, data):
+        ret = super().get_instance(user, data)
+        if ret is None:
+            if data.get('location'):
+                ret = self.model.get_by_location(user, data['location'])
+        return ret
index 0b366fc..2692538 100644 (file)
@@ -16,6 +16,7 @@ from django.utils.timezone import now
 from catalogue.models import Book
 from catalogue.utils import get_random_hash
 from wolnelektury.utils import cached_render, clear_cached_renders
+from .syncable import Syncable
 
 
 class BannerGroup(models.Model):
@@ -207,44 +208,6 @@ class UserConfirmation(models.Model):
         ).send()
 
 
-
-class Syncable:
-    @classmethod
-    def sync(cls, user, instance, data):
-        ts = data.get('timestamp')
-        if ts is None:
-            ts = now()
-        else:
-            ts = datetime.fromtimestamp(ts, tz=utc)
-
-        if instance is not None:
-            if ts and ts < instance.reported_timestamp:
-                return
-
-        if instance is None:
-            if data.get('deleted'):
-                return
-            instance = cls.create_from_data(user, data)
-            if instance is None:
-                return
-
-        instance.reported_timestamp = ts
-        for f in cls.syncable_fields:
-            if f in data:
-                setattr(instance, f, data[f])
-        
-        instance.save()
-        return instance
-
-    @property
-    def timestamp(self):
-        return self.updated_at.timestamp()
-    
-    @classmethod
-    def create_from_data(cls, user, data):
-        raise NotImplementedError
-
-
 class Progress(Syncable, models.Model):
     user = models.ForeignKey(User, models.CASCADE)
     book = models.ForeignKey('catalogue.Book', models.CASCADE)
diff --git a/src/social/syncable.py b/src/social/syncable.py
new file mode 100644 (file)
index 0000000..789cdf8
--- /dev/null
@@ -0,0 +1,41 @@
+from datetime import datetime
+from django.utils.timezone import now
+from pytz import utc
+
+
+
+class Syncable:
+    @classmethod
+    def sync(cls, user, instance, data):
+        ts = data.get('timestamp')
+        if ts is None:
+            ts = now()
+        else:
+            ts = datetime.fromtimestamp(ts, tz=utc)
+
+        if instance is not None:
+            if ts and ts < instance.reported_timestamp:
+                return
+
+        if instance is None:
+            if data.get('deleted'):
+                return
+            instance = cls.create_from_data(user, data)
+            if instance is None:
+                return
+
+        instance.reported_timestamp = ts
+        for f in cls.syncable_fields:
+            if f in data:
+                setattr(instance, f, data[f])
+
+        instance.save()
+        return instance
+
+    @property
+    def timestamp(self):
+        return self.updated_at.timestamp()
+    
+    @classmethod
+    def create_from_data(cls, user, data):
+        raise NotImplementedError