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.
- django-pulse/__init__.py +1 -0
- django-pulse/apps.py +8 -0
- django-pulse/consumers.py +155 -0
- django-pulse/init.py +1 -0
- django-pulse/models.py +97 -0
- django-pulse/routing.py +6 -0
- django-pulse/signals.py +61 -0
- django_pulse-1.0.0.dist-info/METADATA +160 -0
- django_pulse-1.0.0.dist-info/RECORD +12 -0
- django_pulse-1.0.0.dist-info/WHEEL +5 -0
- django_pulse-1.0.0.dist-info/licenses/LICENSE +0 -0
- django_pulse-1.0.0.dist-info/top_level.txt +1 -0
django-pulse/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
default_app_config = 'django_pulse.apps.DjangoPulseConfig'
|
django-pulse/apps.py
ADDED
|
@@ -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
|
+
|
django-pulse/routing.py
ADDED
django-pulse/signals.py
ADDED
|
@@ -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,,
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django-pulse
|