django-pulse 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ default_app_config = 'django_pulse.apps.DjangoPulseConfig'
django-pulse/apps.py ADDED
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+ class DjangoPulseConfig(AppConfig):
4
+ default_auto_field = 'django.db.models.BigAutoField'
5
+ name = 'django_pulse'
6
+
7
+ def ready(self):
8
+ import django_pulse.signals
@@ -0,0 +1,155 @@
1
+ import json
2
+ from channels.generic.websocket import AsyncWebsocketConsumer
3
+ from asgiref.sync import sync_to_async
4
+ from django.apps import apps
5
+ from django.db import transaction
6
+ class SyncConsumer(AsyncWebsocketConsumer):
7
+
8
+ def get_model_by_name(self, table_name):
9
+ for config in apps.get_app_configs():
10
+ try:
11
+ return apps.get_model(config.label, table_name)
12
+ except LookupError:
13
+ continue
14
+ return None
15
+
16
+ async def connect(self):
17
+
18
+ self.user = self.scope["user"]
19
+
20
+ if self.user.is_authenticated:
21
+ self.group_name = f"user_{self.user.id}_sync"
22
+
23
+ # Unirse al grupo privado del usuario en Redis
24
+ await self.channel_layer.group_add(
25
+ self.group_name,
26
+ self.channel_name
27
+ )
28
+ await self.accept()
29
+ else:
30
+ await self.close()
31
+
32
+ async def disconnect(self, close_code):
33
+ if hasattr(self, 'group_name'):
34
+ await self.channel_layer.group_discard(
35
+ self.group_name,
36
+ self.channel_name
37
+ )
38
+
39
+ # Este método se activa cuando la Signal envía algo a Redis
40
+ async def sync_message(self, event):
41
+ # Enviar el paquete de datos al móvil
42
+
43
+ await self.send(text_data=json.dumps({
44
+ "type": "SYNC_UPDATE",
45
+ "data": event["payload"]
46
+ }))
47
+
48
+ async def receive(self, text_data):
49
+ data = json.loads(text_data)
50
+ msg_type = data.get('type')
51
+
52
+ if msg_type == 'SYNC_BATCH_UPLOAD':
53
+ await self.handle_batch_upload(data)
54
+ elif msg_type == 'SYNC_UPLOAD':
55
+ await self.handle_upload(data)
56
+ elif msg_type == 'SYNC_REQUEST_DELTA':
57
+ await self.handle_delta_request(data)
58
+
59
+ async def handle_delta_request(self, data):
60
+ table_name = data.get('table')
61
+ last_version = data.get('last_version', 0)
62
+ model = self.get_model_by_name(table_name)
63
+
64
+ if model:
65
+ @sync_to_async
66
+ def get_updates():
67
+ # Filtramos por versión y opcionalmente por usuario
68
+ qs = model.objects.filter(version__gt=last_version)
69
+ if hasattr(model, 'user_id'):
70
+ qs = qs.filter(user_id=self.user.id)
71
+
72
+ return [
73
+ {
74
+ "model": table_name,
75
+ "sync_id": str(obj.sync_id),
76
+ "version": obj.version,
77
+ "data": obj.to_dict()
78
+ } for obj in qs
79
+ ]
80
+
81
+ changes = await get_updates()
82
+ for change in changes:
83
+ await self.send(text_data=json.dumps({
84
+ "type": "SYNC_UPDATE",
85
+ "data": change
86
+ }))
87
+
88
+ async def handle_upload(self, data):
89
+ table_name = data.get('table')
90
+ payload = data.get('payload')
91
+ model = self.get_model_by_name(table_name)
92
+ if not model: return
93
+
94
+ @sync_to_async
95
+ def process_individual():
96
+ sync_id = payload.get('sync_id')
97
+ client_version = payload.get('version', 0)
98
+
99
+ current_obj = model.objects.filter(sync_id=sync_id).first()
100
+ if current_obj and client_version < current_obj.version:
101
+ return {"status": "CONFLICT", "sync_id": sync_id}
102
+
103
+ # manejo de errores (NACK)
104
+ try:
105
+ exclude = ['id', 'version', 'is_local_only']
106
+ clean_data = {k: v for k, v in payload.items() if k not in exclude}
107
+ if 'is_deleted' in clean_data:
108
+ clean_data['is_deleted'] = bool(clean_data['is_deleted'])
109
+
110
+ model.objects.update_or_create(sync_id=sync_id, defaults=clean_data)
111
+ return {"status": "SUCCESS", "sync_id": sync_id}
112
+ except Exception as e:
113
+ return {"status": "ERROR", "sync_id": sync_id, "error": str(e)}
114
+
115
+ result = await process_individual()
116
+ await self.send(text_data=json.dumps({
117
+ "type": "SYNC_ACK_INDIVIDUAL",
118
+ "table": table_name,
119
+ **result
120
+ }))
121
+
122
+ async def handle_batch_upload(self, data):
123
+ table_name = data.get('table')
124
+ payloads = data.get('payloads', [])
125
+ model = self.get_model_by_name(table_name)
126
+ if not model: return
127
+
128
+ @sync_to_async
129
+ def process_atomic_batch():
130
+ results = {"synced_ids": [], "conflicts": [], "errors": []}
131
+ with transaction.atomic():
132
+ for item in payloads:
133
+ s_id = item.get('sync_id')
134
+ c_ver = item.get('version', 0)
135
+
136
+ curr = model.objects.filter(sync_id=s_id).first()
137
+ if curr and c_ver < curr.version:
138
+ results["conflicts"].append(s_id)
139
+ continue
140
+
141
+ try:
142
+ exclude = ['id', 'version', 'is_local_only']
143
+ clean = {k: v for k, v in item.items() if k not in exclude}
144
+ model.objects.update_or_create(sync_id=s_id, defaults=clean)
145
+ results["synced_ids"].append(s_id)
146
+ except Exception as e:
147
+ results["errors"].append({"id": s_id, "msg": str(e)})
148
+ return results
149
+
150
+ res = await process_atomic_batch()
151
+ await self.send(text_data=json.dumps({
152
+ "type": "BATCH_ACK",
153
+ "table": table_name,
154
+ **res
155
+ }))
django-pulse/init.py ADDED
@@ -0,0 +1 @@
1
+ default_app_config = 'django_pulse.apps.DjangoPulseConfig'
django-pulse/models.py ADDED
@@ -0,0 +1,97 @@
1
+ from django.contrib.contenttypes.fields import GenericForeignKey
2
+ from django.contrib.contenttypes.models import ContentType
3
+ from django.db import models
4
+ import uuid
5
+
6
+ class GlobalSyncSequence(models.Model):
7
+ last_version = models.BigIntegerField(default=0)
8
+
9
+ @classmethod
10
+ def get_next_version(cls):
11
+ # Usamos select_for_update para bloquear la fila y evitar que
12
+ # dos procesos tomen el mismo número al mismo tiempo (Race Condition)
13
+ from django.db import transaction
14
+ with transaction.atomic():
15
+ seq, created = cls.objects.select_for_update().get_or_create(id=1)
16
+ seq.last_version += 1
17
+ seq.save()
18
+ return seq.last_version
19
+
20
+ class SyncModel(models.Model):
21
+ # uid para evitar colisiones entre movil y servidor.
22
+ sync_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
23
+ version = models.BigIntegerField(default=0)
24
+ updated_at = models.DateTimeField(auto_now=True)
25
+ is_deleted = models.BooleanField(default=False)
26
+ user_id = models.IntegerField(db_index=True, null=True, blank=True)
27
+
28
+ class Meta:
29
+ abstract = True
30
+
31
+ def get_sync_groups(self):
32
+ """
33
+ Lógica por defecto:
34
+ 1. Si el modelo tiene un campo 'user', enviar a ese usuario.
35
+ 2. Si no, enviar al grupo 'global'.
36
+ """
37
+ if hasattr(self, 'user_id') and self.user_id:
38
+ return [f"user_{self.user_id}"]
39
+ return ["global"]
40
+
41
+
42
+ def soft_delete(self):
43
+ self.is_deleted = True
44
+
45
+ next_v = GlobalSyncSequence.get_next_version()
46
+ self.version = next_v
47
+ self.save()
48
+
49
+ def to_dict(self):
50
+ from django.forms.models import model_to_dict
51
+ data = model_to_dict(self)
52
+ data['sync_id'] = str(self.sync_id)
53
+ if 'updated_at' in data:
54
+ data['updated_at'] = self.updated_at.isoformat()
55
+ return data
56
+
57
+ def serialize_model(self, obj):
58
+ if hasattr(obj, 'to_dict'):
59
+ return obj.to_dict()
60
+ from django.forms.models import model_to_dict
61
+ return model_to_dict(obj)
62
+
63
+ def __str__(self):
64
+ return f"{self.sync_id}"
65
+
66
+ class SyncLog(models.Model):
67
+ # Apuntador genérico a cualquier objeto
68
+ content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
69
+ object_id = models.UUIDField()
70
+ content_object = GenericForeignKey('content_type', 'object_id')
71
+
72
+ action = models.CharField(max_length=10, choices=[
73
+ ('CREATE', 'Create'),
74
+ ('UPDATE', 'Update'),
75
+ ('DELETE', 'Delete'),
76
+ ])
77
+ version = models.BigIntegerField()
78
+ timestamp = models.DateTimeField(auto_now_add=True)
79
+
80
+ # Para la "Sincronización Selectiva" que hablamos
81
+ user_id = models.IntegerField(db_index=True)
82
+
83
+
84
+ class Item(SyncModel):
85
+ name = models.CharField(max_length=255)
86
+ description = models.TextField(blank=True)
87
+ created_at = models.DateTimeField(auto_now_add=True)
88
+
89
+ class Meta:
90
+ ordering = ['-created_at']
91
+ indexes = [
92
+ models.Index(fields=['name']),
93
+ ]
94
+
95
+ def __str__(self):
96
+ return self.name
97
+
@@ -0,0 +1,6 @@
1
+ from django.urls import re_path
2
+ from .consumers import SyncConsumer
3
+
4
+ websocket_urlpatterns = [
5
+ re_path(r'ws/pulse/sync/$', SyncConsumer.as_asgi()),
6
+ ]
@@ -0,0 +1,61 @@
1
+ from django.db.models.signals import post_save, post_delete
2
+ from django.dispatch import receiver
3
+ from .models import SyncModel, GlobalSyncSequence, SyncLog
4
+ from channels.layers import get_channel_layer
5
+ from asgiref.sync import async_to_sync
6
+ from django.forms.models import model_to_dict
7
+
8
+ @receiver(post_save)
9
+ def auto_track_sync_models(sender, instance, created, **kwargs):
10
+ if not isinstance(instance, SyncModel):
11
+ return
12
+
13
+ next_v = GlobalSyncSequence.get_next_version()
14
+ sender.objects.filter(pk=instance.pk).update(version=next_v)
15
+ instance.version = next_v # Actualiza el objeto en memoria antes de serializar
16
+
17
+ SyncLog.objects.create(
18
+ content_object=instance,
19
+ action='CREATE' if created else 'UPDATE',
20
+ version=next_v,
21
+ user_id=getattr(instance, 'user_id', None) # Intenta obtener user_id si existe
22
+ )
23
+
24
+ channel_layer = get_channel_layer()
25
+
26
+ groups = instance.get_sync_groups()
27
+ for group in groups:
28
+ async_to_sync(channel_layer.group_send)(
29
+ f"{group}_sync",
30
+ {
31
+ "type": "sync.message",
32
+ "payload": {
33
+ "model": sender.__name__.lower(),
34
+ "sync_id": str(instance.sync_id),
35
+ "version": next_v,
36
+ "data": model_to_dict(instance)
37
+ }
38
+ }
39
+ )
40
+
41
+ @receiver(post_delete)
42
+ def handle_hard_delete(sender, instance, **kwargs):
43
+ if not isinstance(instance, SyncModel):
44
+ return
45
+
46
+ channel_layer = get_channel_layer()
47
+
48
+ groups = instance.get_sync_groups()
49
+
50
+ for group in groups:
51
+ async_to_sync(channel_layer.group_send)(
52
+ f"{group}_sync",
53
+ {
54
+ "type": "sync.message",
55
+ "payload": {
56
+ "type": "HARD_DELETE",
57
+ "model": sender.__name__.lower(),
58
+ "sync_id": str(instance.sync_id),
59
+ }
60
+ }
61
+ )
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-pulse
3
+ Version: 1.0.0
4
+ Summary: Backend sync engine for pulse-rn
5
+ Home-page: https://github.com/JesusM15/django-pulse
6
+ Author: Jesus M.
7
+ Classifier: Framework :: Django
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.7
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: django>=3.2
15
+ Requires-Dist: channels>=3.0
16
+ Requires-Dist: channels-redis>=3.0
17
+ Requires-Dist: asgiref>=3.3
18
+ Dynamic: author
19
+ Dynamic: classifier
20
+ Dynamic: description
21
+ Dynamic: description-content-type
22
+ Dynamic: home-page
23
+ Dynamic: license-file
24
+ Dynamic: requires-dist
25
+ Dynamic: requires-python
26
+ Dynamic: summary
27
+
28
+ # Django Pulse
29
+
30
+ Backend engine for the `pulse-rn` React Native library.
31
+
32
+ ## Quick Start
33
+
34
+ 1. Install: `pip install django-pulse`
35
+ 2. Add to `INSTALLED_APPS`:
36
+ ```python
37
+ INSTALLED_APPS = [
38
+ ...,
39
+ 'channels',
40
+ 'django_pulse',
41
+ ]
42
+ ```
43
+
44
+ ## Model Inheritance
45
+
46
+ To enable synchronization for your models, inherit from `SyncModel` instead of `models.Model`:
47
+
48
+ ```python
49
+ from django_pulse.models import SyncModel
50
+ from django.db import models
51
+
52
+ class Task(SyncModel):
53
+ title = models.CharField(max_length=200)
54
+ description = models.TextField(blank=True)
55
+ completed = models.BooleanField(default=False)
56
+ user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
57
+
58
+ class Item(SyncModel):
59
+ name = models.CharField(max_length=255)
60
+ description = models.TextField(blank=True)
61
+ price = models.DecimalField(max_digits=10, decimal_places=2)
62
+ ```
63
+
64
+ ### SyncModel Features
65
+
66
+ When you inherit from `SyncModel`, your models automatically get these fields:
67
+
68
+ | Field | Type | Description |
69
+ |--------|------|-------------|
70
+ | `sync_id` | UUIDField | Unique identifier for synchronization |
71
+ | `version` | PositiveIntegerField | Version number for conflict resolution |
72
+ | `is_local_only` | BooleanField | Marks records pending sync |
73
+ | `sync_error` | TextField | Stores sync error messages |
74
+ | `created_at` | DateTimeField | Auto-created timestamp |
75
+ | `updated_at` | DateTimeField | Auto-updated timestamp |
76
+
77
+ ### Automatic Synchronization
78
+
79
+ All models inheriting from `SyncModel` will:
80
+
81
+ - Automatically generate UUID `sync_id` on creation
82
+ - Increment `version` on each update
83
+ - Track synchronization status with `is_local_only`
84
+ - Log synchronization errors in `sync_error`
85
+ - Participate in real-time WebSocket updates
86
+
87
+ ### User-Specific Sync
88
+
89
+ If your model has a `user` field (ForeignKey to User), synchronization will be scoped to that user:
90
+
91
+ ```python
92
+ class Task(SyncModel):
93
+ title = models.CharField(max_length=200)
94
+ user = models.ForeignKey(User, on_delete=models.CASCADE) # User-specific sync
95
+ ```
96
+
97
+ Models without a `user` field will sync globally to all connected clients.
98
+
99
+ ## WebSocket Configuration
100
+
101
+ Add the WebSocket routing to your `asgi.py`:
102
+
103
+ ```python
104
+ from django_pulse.routing import websocket_urlpatterns
105
+
106
+ application = ProtocolTypeRouter({
107
+ "http": get_asgi_application(),
108
+ "websocket": URLRouter(
109
+ websocket_urlpatterns
110
+ ),
111
+ })
112
+ ```
113
+
114
+ ## Required Settings
115
+
116
+ Ensure these settings are configured in your `settings.py`:
117
+
118
+ ```python
119
+ # Channels configuration
120
+ ASGI_APPLICATION = 'your_project.asgi.application'
121
+
122
+ CHANNEL_LAYERS = {
123
+ 'default': {
124
+ 'BACKEND': 'channels_redis.core.RedisChannelLayer',
125
+ 'CONFIG': {
126
+ 'hosts': [('127.0.0.1', 6379)],
127
+ },
128
+ },
129
+ }
130
+ ```
131
+
132
+ ## API Endpoints
133
+
134
+ The library automatically creates these WebSocket endpoints:
135
+
136
+ - `ws://localhost:8000/ws/pulse/sync/` - Main synchronization endpoint
137
+
138
+ ## Synchronization Flow
139
+
140
+ 1. **Client Connects**: WebSocket connection established
141
+ 2. **Delta Sync Request**: Client requests changes since last version
142
+ 3. **Batch Upload**: Client uploads pending local changes
143
+ 4. **Real-time Updates**: Server pushes changes to connected clients
144
+ 5. **Conflict Resolution**: Server resolves version conflicts
145
+
146
+ ## Example Usage
147
+
148
+ ```python
149
+ # Create a synced task
150
+ task = Task.objects.create(
151
+ title="Complete project",
152
+ description="Finish Django Pulse library",
153
+ user=request.user
154
+ )
155
+
156
+ # Update with automatic version increment
157
+ task.title = "Updated project title"
158
+ task.save() # Automatically increments version
159
+
160
+ # All changes are automatically synced to connected mobile clients
@@ -0,0 +1,12 @@
1
+ django-pulse/__init__.py,sha256=Nu0arJdJ2C7IbTAX6D0_A-AIv64MdgzjS3mjT5KS_xE,58
2
+ django-pulse/apps.py,sha256=nM5HUN2yQQVO0foV2AfIr7sKpNBFF9HlWeJUD_tDNXc,218
3
+ django-pulse/consumers.py,sha256=mID9x74rRLCV7tDhJxCZOMpCAcxclqhcUKutJYVFxdc,5821
4
+ django-pulse/init.py,sha256=Nu0arJdJ2C7IbTAX6D0_A-AIv64MdgzjS3mjT5KS_xE,58
5
+ django-pulse/models.py,sha256=T4VVo9qmP1_4rcf7K09HE4wA-6vNLGJI2ea5O7rPy9A,3223
6
+ django-pulse/routing.py,sha256=FcDpU0c-vmhLF_IFfwtkGgD0v5VhnUCbgsyM_jMiZPw,158
7
+ django-pulse/signals.py,sha256=RC9MQoK1VuJRs2w6woULNkiil4EJLqBrCw3hOWmN3Yc,2039
8
+ django_pulse-1.0.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ django_pulse-1.0.0.dist-info/METADATA,sha256=AfKwML_CWTshBFOfcBUd0Ei6CjTKnNrbRukz6eqVu1o,4535
10
+ django_pulse-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ django_pulse-1.0.0.dist-info/top_level.txt,sha256=9lVNFVg9jMWxBHZa1eCGi5DQZCUQLNy4zAe_K9Pkoo0,13
12
+ django_pulse-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
File without changes
@@ -0,0 +1 @@
1
+ django-pulse