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 django.contrib.auth import get_user_model from django.utils import timezone from .ws_sender import websocket_sender User = get_user_model() @receiver(pre_save) def presave_handler(sender, instance, **kwargs): try: sender.objects.get(pk=instance.pk) created = False except sender.DoesNotExist: created = True synchronization_prepare(sender, instance, created, **kwargs) @receiver(pre_delete) def predelete_handler(sender, instance, **kwargs): synchronization_prepare(sender, instance, False, **kwargs) def synchronization_prepare(sender, instance, created, **kwargs): 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 device_id = None if hasattr(instance, '_device_id'): device_id = instance._device_id # print(f'kwargs = {kwargs}') save_model_log_if_possible(instance, signal, created, device_id) if signal == pre_save: # print('yes') detect_foreign_key_changes(sender, instance, device_id) @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 # print(f'*** instance._state.db: {instance._state.db}') notify_impacted_users(instance) def notify_impacted_users(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 = None if hasattr(instance, '_device_id'): device_id = instance._device_id # print(f'notify: {user_ids}') for user_id in user_ids: websocket_sender.send_user_message(user_id, device_id) # send_user_message(user_id) def save_model_log_if_possible(instance, signal, created, device_id): users = related_users(instance) # print(f'users = {len(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, device_id) else: print(f'>>> Model Log could not be created because no linked user could be found: {instance}, {signal}') def save_model_log(users, model_operation, model_name, model_id, store_id, device_id): now = timezone.now() # 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 = 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 = 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, device_id): 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: for data_access in data_access_list: if old_value: model_name = old_value.__class__.__name__ save_model_log(data_access.shared_with.all(), 'REVOKE_ACCESS', model_name, old_value.id, old_value.get_store_id(), device_id) if new_value: model_name = new_value.__class__.__name__ save_model_log(data_access.shared_with.all(), 'GRANT_ACCESS', model_name, new_value.id, new_value.get_store_id(), device_id) # REVOKE access for old_value and GRANT new_value print(f"Foreign key changed in {sender.__name__}: " f"{field.name} from {old_value} to {new_value}") @receiver(post_delete) def delete_data_access_if_necessary(sender, instance, **kwargs): 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): 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 = None if hasattr(instance, '_device_id'): device_id = instance._device_id for user_id in pk_set: websocket_sender.send_user_message(user_id, device_id) @receiver(pre_delete, sender=DataAccess) def revoke_access_after_delete(sender, instance, **kwargs): instance.create_revoke_access_log() 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() 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_instances = 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)