You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
padelclub_backend/sync/signals.py

341 lines
13 KiB

from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed
from django.db import models, transaction
from django.dispatch import receiver
from .models import DataAccess, ModelLog, ModelOperation, BaseModel, SideStoreModel
from authentication.models import Device
from django.contrib.auth import get_user_model
from django.utils import timezone
from .ws_sender import websocket_sender
from .registry import device_registry, related_users_registry
User = get_user_model()
### Device
@receiver(post_save, sender=Device)
def device_created(sender, instance, **kwargs):
if not instance.user:
return
evaluate_if_user_should_sync(instance.user)
@receiver(pre_delete, sender=Device)
def device_pre_delete(sender, instance, **kwargs):
instance._user = instance.user if instance.user else None
@receiver(post_delete, sender=Device)
def device_post_delete(sender, instance, **kwargs):
if not hasattr(instance, '_user') or not instance._user:
return
evaluate_if_user_should_sync(instance._user)
### Sync
@receiver([pre_save, pre_delete])
def presave_handler(sender, instance, **kwargs):
synchronization_prepare(sender, instance, **kwargs)
def synchronization_prepare(sender, instance, **kwargs):
# print(f'*** synchronization_prepare for instance: {instance}')
signal = kwargs.get('signal')
# avoid crash in manage.py createsuperuser + delete user in the admin
if isinstance(instance, User) and (instance._state.db is None or signal == pre_delete):
return
# some other classes are excluded in settings_app.py: SYNC_APPS
if not isinstance(instance, BaseModel) and not isinstance(instance, User):
return
users = related_users(instance)
# print(f'* impacted users = {users}')
related_users_registry.register(instance.id, users)
# user_ids = [user.id for user in users]
if signal == pre_save:
detect_foreign_key_changes(sender, instance)
@receiver([post_save, post_delete])
def synchronization_notifications(sender, instance, created=False, **kwargs):
"""
Signal handler that sends notifications through WebSocket channels when model instances are saved or deleted.
The function creates a WebSocket group name for each affected user and sends a sync update message
to all clients connected to that group.
"""
# some classes are excluded in settings_app.py: SYNC_APPS
if not isinstance(instance, BaseModel) and not isinstance(instance, User):
return
process_foreign_key_changes(sender, instance, **kwargs)
signal = kwargs.get('signal')
save_model_log_if_possible(instance, signal, created)
notify_impacted_users(instance)
related_users_registry.unregister(instance.id)
def notify_impacted_users(instance):
# print(f'*** notify_impacted_users for instance: {instance}')
# user_ids = set()
# # add impacted users
# if isinstance(instance, User):
# user_ids.add(instance.id)
# elif isinstance(instance, BaseModel):
# owner = instance.last_updated_by
# if owner:
# user_ids.add(owner.id)
# if isinstance(instance, BaseModel):
# if hasattr(instance, '_users_to_notify'):
# user_ids.update(instance._users_to_notify)
# else:
# print('no users to notify')
device_id = device_registry.get_device_id(instance.id)
users = related_users_registry.get_users(instance.id)
if users:
user_ids = [user.id for user in users]
# print(f'notify device: {device_id}, users = {user_ids}')
for user_id in user_ids:
websocket_sender.send_user_message(user_id, device_id)
device_registry.unregister(instance.id)
def save_model_log_if_possible(instance, signal, created):
users = related_users_registry.get_users(instance.id)
# print(f'*** save_model_log >>> users = {users}, instance = {instance}')
if users:
if signal == post_save or signal == pre_save:
if created:
operation = ModelOperation.POST
else:
operation = ModelOperation.PUT
else:
operation = ModelOperation.DELETE
model_name = instance.__class__.__name__
store_id = None
if isinstance(instance, SideStoreModel):
store_id = instance.store_id
if operation == ModelOperation.DELETE: # delete now unnecessary logs
ModelLog.objects.filter(model_id=instance.id).delete()
# user_ids = [user.id for user in users]
# # print(f'users to notify: {user_ids}')
# instance._users_to_notify = user_ids # save this for the post_save signal
save_model_log(users, operation, model_name, instance.id, store_id)
else:
print(f'>>> Model Log could not be created because no linked user could be found: {instance.__class__.__name__} {instance}, {signal}')
def save_model_log(users, model_operation, model_name, model_id, store_id):
device_id = device_registry.get_device_id(model_id)
with transaction.atomic():
for user in users:
# print(f'>>> create log for {user.username} : {model_operation} {model_name}')
# if user.should_synchronize:
model_log = ModelLog()
model_log.user = user
model_log.operation = model_operation
model_log.model_name = model_name
model_log.model_id = model_id
model_log.store_id = store_id
model_log.device_id = device_id
model_log.save()
# print(f'ML users = {len(users)}')
# existing_log = ModelLog.objects.filter(users__in=users, model_id=model_id, operation=model_operation).first()
# if existing_log:
# # print(f'update existing log {existing_log.users} ')
# existing_log.date = timezone.now()
# existing_log.device_id = device_id
# # existing_log.operation = model_operation
# existing_log.save()
# existing_log.users.set(users)
# else:
# model_log = ModelLog()
# model_log.operation = model_operation
# model_log.date = timezone.now()
# model_log.model_name = model_name
# model_log.model_id = model_id
# model_log.store_id = store_id
# model_log.device_id = device_id
# model_log.save()
# model_log.users.set(users)
def detect_foreign_key_changes(sender, instance):
if not hasattr(instance, 'pk') or not instance.pk:
return
if not isinstance(instance, BaseModel):
return
data_access_list = related_data_access(instance)
if data_access_list:
try:
old_instance = sender.objects.get(pk=instance.pk)
except sender.DoesNotExist:
return
# Check foreign key fields
for field in sender._meta.get_fields():
if isinstance(field, models.ForeignKey) and not field.related_model == User:
# print(f'field.related_model = {field.related_model}')
old_value = getattr(old_instance, field.name, None)
new_value = getattr(instance, field.name, None)
if old_value != new_value:
if not hasattr(instance, '_fk_changes'):
instance._fk_changes = []
instance._fk_changes.append({
'data_access_list': data_access_list,
'old_value': old_value,
'new_value': new_value
})
def process_foreign_key_changes(sender, instance, **kwargs):
### TODO : we want to avoid creating ModelLog for the user making the change, but how?
if hasattr(instance, '_fk_changes'):
for change in instance._fk_changes:
for data_access in change['data_access_list']:
shared = data_access.shared_with.all()
owner = {data_access.related_user}
## exclude last_updated_by from extra notifications
if instance.last_updated_by:
shared = shared.exclude(id=instance.last_updated_by.id)
owner = owner.discard(instance.last_updated_by)
if change['old_value']:
model_name = change['old_value'].__class__.__name__
if shared:
print(f"SHARED_RELATIONSHIP_REMOVED: shared={shared}, model_name={model_name}")
save_model_log(shared, 'SHARED_RELATIONSHIP_REMOVED',
model_name, change['old_value'].id,
change['old_value'].get_store_id())
# if owner:
# print(f"RELATIONSHIP_REMOVED: owner={owner}, model_name={model_name}")
# save_model_log(owner, 'RELATIONSHIP_REMOVED',
# model_name, change['old_value'].id,
# change['old_value'].get_store_id())
if change['new_value']:
model_name = change['new_value'].__class__.__name__
if shared:
print(f"SHARED_RELATIONSHIP_SET: shared={shared}, model_name={model_name}")
save_model_log(shared, 'SHARED_RELATIONSHIP_SET',
model_name, change['new_value'].id,
change['new_value'].get_store_id())
# if owner:
# print(f"RELATIONSHIP_SET: owner={owner}, model_name={model_name}")
# save_model_log(owner, 'RELATIONSHIP_SET',
# model_name, change['old_value'].id,
# change['old_value'].get_store_id())
### Data Access
@receiver(post_delete)
def delete_data_access_if_necessary(sender, instance, **kwargs):
if not isinstance(instance, BaseModel):
return
if hasattr(instance, 'id'):
DataAccess.objects.filter(model_id=instance.id).delete()
@receiver(m2m_changed, sender=DataAccess.shared_with.through)
def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs):
# print(f'm2m changed = {pk_set}')
users = User.objects.filter(id__in=pk_set)
if action == "post_add":
instance.create_access_log(users, 'GRANT_ACCESS')
elif action == "post_remove":
instance.create_access_log(users, 'REVOKE_ACCESS')
device_id = device_registry.get_device_id(instance.id)
for user_id in pk_set:
websocket_sender.send_user_message(user_id, device_id)
device_registry.unregister(instance.id)
for user in users:
evaluate_if_user_should_sync(user)
@receiver(post_save, sender=DataAccess)
def data_access_post_save(sender, instance, **kwargs):
if instance.related_user:
evaluate_if_user_should_sync(instance.related_user)
@receiver(pre_delete, sender=DataAccess)
def revoke_access_after_delete(sender, instance, **kwargs):
instance.create_revoke_access_log()
related_users_registry.register(instance.id, instance.shared_with.all())
instance._user = instance.related_user
@receiver(post_delete, sender=DataAccess)
def data_access_post_delete(sender, instance, **kwargs):
notify_impacted_users(instance)
if not hasattr(instance, '_user') or not instance._user:
return
evaluate_if_user_should_sync(instance._user)
def related_users(instance):
users = set()
if isinstance(instance, User):
users.add(instance)
elif isinstance(instance, BaseModel):
users.add(instance.related_user)
# users.add(instance.last_updated_by)
# look in hierarchy
related_instances = instance.related_instances()
# print(f'related_instances = {related_instances}')
# related_users = [ri.related_user for ri in related_instances if isinstance(ri, BaseModel)]
# users.update(related_users)
# look in related DataAccess
data_access_list = instances_related_data_access(instance, related_instances)
for data_access in data_access_list:
users.add(data_access.related_user)
users.update(data_access.shared_with.all())
if isinstance(instance, DataAccess):
users.update(instance.shared_with.all())
return {user for user in users if user is not None}
def related_data_access(instance):
related_instances = instance.related_instances()
return instances_related_data_access(instance, related_instances)
def instances_related_data_access(instance, related_instances):
related_ids = [ri.id for ri in related_instances]
related_ids.append(instance.id)
return DataAccess.objects.filter(model_id__in=related_ids)
def evaluate_if_user_should_sync(user):
should_synchronize = False
if user.devices.count() > 1:
should_synchronize = True
elif DataAccess.objects.filter(
models.Q(shared_with=user) |
models.Q(related_user=user)
).count() > 0:
should_synchronize = True
with transaction.atomic():
user.should_synchronize = should_synchronize
# if we go from True to False we might want to delete ModelLog once the last device has synchronized
user.save()