basic sync view
authorRadek Czajka <rczajka@rczajka.pl>
Tue, 8 Jul 2025 15:29:52 +0000 (17:29 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Tue, 8 Jul 2025 15:29:52 +0000 (17:29 +0200)
src/social/api/urls2.py
src/social/api/views.py
src/social/migrations/0019_progress_deleted.py [new file with mode: 0644]
src/social/models.py

index d2d6245..039a6fe 100644 (file)
@@ -21,6 +21,8 @@ urlpatterns = [
     path('progress/<slug:slug>/', views.ProgressView.as_view()),
     path('progress/<slug:slug>/text/', views.TextProgressView.as_view()),
     path('progress/<slug:slug>/audio/', views.AudioProgressView.as_view()),
     path('progress/<slug:slug>/', views.ProgressView.as_view()),
     path('progress/<slug:slug>/text/', views.TextProgressView.as_view()),
     path('progress/<slug:slug>/audio/', views.AudioProgressView.as_view()),
+
+    path('sync/', views.SyncView.as_view()),
 ]
 
 
 ]
 
 
index 867a05a..c58efd1 100644 (file)
@@ -1,6 +1,8 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
+from datetime import datetime
+from pytz import utc
 from django.http import Http404
 from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveAPIView, RetrieveUpdateAPIView, RetrieveUpdateDestroyAPIView, DestroyAPIView, get_object_or_404
 from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
 from django.http import Http404
 from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveAPIView, RetrieveUpdateAPIView, RetrieveUpdateDestroyAPIView, DestroyAPIView, get_object_or_404
 from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
@@ -276,3 +278,61 @@ class AudioProgressView(ProgressMixin, RetrieveUpdateAPIView):
         serializer.instance.last_mode = 'audio'
         serializer.save()
 
         serializer.instance.last_mode = 'audio'
         serializer.save()
 
+
+
+class SyncSerializer(serializers.Serializer):
+    timestamp = serializers.IntegerField()
+    type = serializers.CharField()
+    id = serializers.CharField()
+
+    def to_representation(self, instance):
+        rep = super().to_representation(instance)
+        rep['object'] = instance['object'].data
+        return rep
+
+    def to_internal_value(self, data):
+        ret = super().to_internal_value(data)
+        ret['object'] = data['object']
+        return ret
+
+
+class SyncView(ListAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = SyncSerializer
+
+    def get_queryset(self):
+        try:
+            timestamp = int(self.request.GET.get('ts'))
+        except:
+            timestamp = 0
+
+        timestamp = datetime.fromtimestamp(timestamp, tz=utc)
+        
+        data = []
+        for p in models.Progress.objects.filter(
+                user=self.request.user,
+                updated_at__gt=timestamp).order_by('updated_at'):
+            data.append({
+                'timestamp': p.updated_at.timestamp(),
+                'type': 'progress',
+                'id': p.book.slug,
+                'object': ProgressSerializer(
+                    p, context={'request': self.request}
+                ) if not p.deleted else None
+            })
+        return data
+
+    def post(self, request):
+        data = request.data
+        for item in data:
+            ser = SyncSerializer(data=item)
+            ser.is_valid(raise_exception=True)
+            d = ser.validated_data
+            if d['type'] == 'progress':
+                models.Progress.sync(
+                    user=request.user,
+                    slug=d['id'],
+                    ts=datetime.fromtimestamp(d['timestamp'], tz=utc),
+                    data=d['object']
+                )
+        return Response()
diff --git a/src/social/migrations/0019_progress_deleted.py b/src/social/migrations/0019_progress_deleted.py
new file mode 100644 (file)
index 0000000..71b1fab
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2025-07-08 14:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('social', '0018_progress'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='progress',
+            name='deleted',
+            field=models.BooleanField(default=False),
+        ),
+    ]
index c7096dd..c63879b 100644 (file)
@@ -208,6 +208,7 @@ class Progress(models.Model):
     book = models.ForeignKey('catalogue.Book', models.CASCADE)
     created_at = models.DateTimeField(auto_now_add=True)
     updated_at = models.DateTimeField(auto_now=True)
     book = models.ForeignKey('catalogue.Book', models.CASCADE)
     created_at = models.DateTimeField(auto_now_add=True)
     updated_at = models.DateTimeField(auto_now=True)
+    deleted = models.BooleanField(default=False)
     last_mode = models.CharField(max_length=64, choices=[
         ('text', 'text'),
         ('audio', 'audio'),
     last_mode = models.CharField(max_length=64, choices=[
         ('text', 'text'),
         ('audio', 'audio'),
@@ -224,6 +225,18 @@ class Progress(models.Model):
     class Meta:
         unique_together = [('user', 'book')]
 
     class Meta:
         unique_together = [('user', 'book')]
 
+    @classmethod
+    def sync(cls, user, slug, ts, data):
+        obj, _created = cls.objects.get_or_create(user=user, book__slug=slug)
+        if _created or obj.updated_at < ts:
+            if data is not None:
+                obj.deleted = False
+                for k, v in data.items():
+                    setattr(obj, k, v)
+            else:
+                obj.deleted = True
+            obj.save()
+        
     def save(self, *args, **kwargs):
         audio_l = self.book.get_audio_length()
         if self.text_anchor:
     def save(self, *args, **kwargs):
         audio_l = self.book.get_audio_length()
         if self.text_anchor: