diff --git a/api/sync.py b/api/sync.py index 3c9f394..51b5319 100644 --- a/api/sync.py +++ b/api/sync.py @@ -7,6 +7,7 @@ from rest_framework import status from django.apps import apps from django.utils import timezone from django.db.models import Q +from django.core.exceptions import ObjectDoesNotExist from collections import defaultdict @@ -28,17 +29,14 @@ class DataApi(APIView): print(f"DataApi post > {model_operation} {model_name}") serializer_class = build_serializer_class(model_name) + data['last_updated_by'] = request.user.id # always refresh the user performing the operation model = apps.get_model(app_label='tournaments', model_name=model_name) now = timezone.localtime(timezone.now()) try: data_id = data.get('id') - # instance = model.objects.get(id=data_id) - instance = get_data('tournaments', model_name, data_id) - # update the possible data access objects with the current date - if model_operation == 'DELETE': parent_model, parent_id = instance.get_parent_reference() return self.delete_and_save_log(request, data_id, model_operation, model_name, store_id, now) @@ -62,9 +60,10 @@ class DataApi(APIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def save_and_create_log(self, request, serializer, model_operation, model_name, store_id, date): + instance = serializer.save() - self.create_and_save_model_log( + self.save_model_log( user=request.user, model_operation=model_operation, model_name=model_name, @@ -84,10 +83,10 @@ class DataApi(APIView): instance.delete() - # we delete all previous logs linked to the instance because they won't be needed anymore + # We delete all previous logs linked to the instance because they won't be needed anymore ModelLog.objects.filter(model_id=data_id).delete() - self.create_and_save_model_log( + self.save_model_log( user=request.user, model_operation=model_operation, model_name=model_name, @@ -108,15 +107,21 @@ class DataApi(APIView): data_access.last_hierarchy_update = date data_access.save() - def create_and_save_model_log(self, user, model_operation, model_name, model_id, store_id, date): - model_log = ModelLog() - model_log.user = user - model_log.operation = model_operation - model_log.date = date - model_log.model_name = model_name - model_log.model_id = model_id - model_log.store_id = store_id - model_log.save() + def save_model_log(self, user, model_operation, model_name, model_id, store_id, date): + existing_log = ModelLog.objects.filter(user=user, model_id=model_id, operation=model_operation).first() + if existing_log: + existing_log.date = date + existing_log.model_operation = model_operation + existing_log.save() + else: + model_log = ModelLog() + model_log.user = user + model_log.operation = model_operation + model_log.date = date + model_log.model_name = model_name + model_log.model_id = model_id + model_log.store_id = store_id + model_log.save() def get(self, request, *args, **kwargs): last_update = request.query_params.get('last_update') @@ -130,29 +135,32 @@ class DataApi(APIView): updates = defaultdict(dict) deletions = defaultdict(list) - print(f'>>> log count = {len(logs)}') + # print(f'>>> log count = {len(logs)}') for log in logs: - if log.operation in ['POST', 'PUT']: - data = get_serialized_data('tournaments', log.model_name, log.model_id) - updates[log.model_name][log.model_id] = data - elif log.operation == 'DELETE': - deletions[log.model_name].append({'model_id': log.model_id, 'store_id': log.store_id}) - elif log.operation == 'GRANT_ACCESS': - - model = apps.get_model('tournaments', model_name=log.model_name) - instance = model.objects.get(id=log.model_id) - serializer_class = build_serializer_class(log.model_name) - serializer = serializer_class(instance) - - # data = get_serialized_data('tournaments', log.model_name, log.model_id) - updates[log.model_name][log.model_id] = serializer.data - # instance = model.objects.get(id=log.model_id) - self.add_children_recursively(instance, updates) - self.add_parents_recursively(instance, updates) - elif log.operation == 'delete data access signal': - print(f'revoke access {log.model_id} - {log.store_id}') - deletions[log.model_name].append({'model_id': log.model_id, 'store_id': log.store_id}) + try: + if log.operation in ['POST', 'PUT']: + data = get_serialized_data('tournaments', log.model_name, log.model_id) + updates[log.model_name][log.model_id] = data + elif log.operation == 'DELETE': + deletions[log.model_name].append({'model_id': log.model_id, 'store_id': log.store_id}) + elif log.operation == 'GRANT_ACCESS': + + model = apps.get_model('tournaments', model_name=log.model_name) + instance = model.objects.get(id=log.model_id) + serializer_class = build_serializer_class(log.model_name) + serializer = serializer_class(instance) + + # data = get_serialized_data('tournaments', log.model_name, log.model_id) + updates[log.model_name][log.model_id] = serializer.data + # instance = model.objects.get(id=log.model_id) + self.add_children_recursively(instance, updates) + self.add_parents_recursively(instance, updates) + elif log.operation == 'delete data access signal': + print(f'revoke access {log.model_id} - {log.store_id}') + deletions[log.model_name].append({'model_id': log.model_id, 'store_id': log.store_id}) + except ObjectDoesNotExist: + pass # Convert updates dict to list for each model for model_name in updates: @@ -179,16 +187,19 @@ class DataApi(APIView): data_access_query = Q(last_hierarchy_update__gt=last_update) & (Q(shared_with__in=[user]) | Q(owner=user)) data_access_list = DataAccess.objects.filter(data_access_query) #.values_list('model_id', flat=True) - print(f'>> da count = {len(data_access_list)}') + # print(f'>> da count = {len(data_access_list)}') # get ids of all recently updated related instances of each shared data model_ids = [] for data_access in data_access_list: - instance = get_data('tournaments', data_access.model_name, data_access.model_id) - related_instances = instance.related_instances() - related_ids = [ri.id for ri in instance.related_instances() if ri.last_update > last_update] - model_ids.extend(related_ids) - model_ids.append(instance.id) + model_ids.append(data_access.model_id) + try: + instance = get_data('tournaments', data_access.model_name, data_access.model_id) + related_instances = instance.related_instances() + related_ids = [ri.id for ri in instance.related_instances() if ri.last_update > last_update] + model_ids.extend(related_ids) + except ObjectDoesNotExist: + pass # get all ModelLog list since the last_update, from the user and from the data he has access to log_query = Q(date__gt=last_update) & (Q(user=user) | Q(model_id__in=model_ids)) diff --git a/api/utils.py b/api/utils.py index 0a2749e..5f60170 100644 --- a/api/utils.py +++ b/api/utils.py @@ -26,9 +26,6 @@ def build_serializer_class(source): def get_data(app_label, model_name, model_id): model = apps.get_model(app_label=app_label, model_name=model_name) return model.objects.get(id=model_id) - # serializer_class = build_serializer_class(model_name) - # serializer = serializer_class(instance) - # return serializer.data def get_serialized_data(app_label, model_name, model_id): model = apps.get_model(app_label=app_label, model_name=model_name) diff --git a/tournaments/migrations/0100_club_last_updated_by_court_last_updated_by_and_more.py b/tournaments/migrations/0100_club_last_updated_by_court_last_updated_by_and_more.py new file mode 100644 index 0000000..bee09ec --- /dev/null +++ b/tournaments/migrations/0100_club_last_updated_by_court_last_updated_by_and_more.py @@ -0,0 +1,100 @@ +# Generated by Django 5.1 on 2024-11-20 14:15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0099_dataaccess_last_hierarchy_update'), + ] + + operations = [ + migrations.AddField( + model_name='club', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='court', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='dataaccess', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='dateinterval', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='devicetoken', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='event', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='failedapicall', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='groupstage', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='log', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='match', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='playerregistration', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='purchase', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='round', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='teamregistration', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='teamscore', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='tournament', + name='last_updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='tournament', + name='event', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tournaments', to='tournaments.event'), + ), + ] diff --git a/tournaments/models/base.py b/tournaments/models/base.py index 474f962..2799bfb 100644 --- a/tournaments/models/base.py +++ b/tournaments/models/base.py @@ -5,14 +5,11 @@ from typing import List, Set class BaseModel(models.Model): creation_date = models.DateTimeField(default=now, editable=False) last_update = models.DateTimeField(default=now) - # last_updated_by = models.ForeignKey('CustomUser', blank=True, null=True, on_delete=models.SET_NULL) + last_updated_by = models.ForeignKey('CustomUser', blank=True, null=True, on_delete=models.SET_NULL) class Meta: abstract = True - def get_owner(self): - return None - def get_parent_reference(self): """Return a tuple: model_name, model_id""" return None, None diff --git a/tournaments/models/data_access.py b/tournaments/models/data_access.py index 1fd2800..e98489b 100644 --- a/tournaments/models/data_access.py +++ b/tournaments/models/data_access.py @@ -1,6 +1,8 @@ from django.db import models from django.utils import timezone from django.apps import apps +from django.core.exceptions import ObjectDoesNotExist + import uuid from . import ModelLog, SideStoreModel, BaseModel @@ -14,66 +16,28 @@ class DataAccess(BaseModel): granted_at = models.DateTimeField(auto_now_add=True) last_hierarchy_update = models.DateTimeField(default=timezone.now) - # def save(self, *args, **kwargs): - # is_new = self._state.adding - - # print('>>> save DA') - # if not is_new: - # # Store old shared_with users before save - # old_instance = DataAccess.objects.get(pk=self.pk) - # self._old_shared_with = set(old_instance.shared_with.all()) - - # super().save(*args, **kwargs) - - # if is_new: - # # For new instances, create logs for all shared users - # self.create_access_logs('GRANT_ACCESS') - # else: - # # For updates, handle differences - # new_shared_with = set(self.shared_with.all()) - - # # Users that were added - # added_users = new_shared_with - self._old_shared_with - # for user in added_users: - # self.create_access_log(user, 'GRANT_ACCESS') - - # # Users that were removed - # removed_users = self._old_shared_with - new_shared_with - # for user in removed_users: - # self.create_access_log(user, 'REVOKE_ACCESS') - - # def delete(self, *args, **kwargs): - # # Store users before deletion - # users_to_revoke = list(self.shared_with.all()) - - # # First delete the instance - # super().delete(*args, **kwargs) - - # # Then create revoke logs for all users - # for user in users_to_revoke: - # self.create_access_log(user, 'REVOKE_ACCESS') - - # def create_access_logs(self, operation): - # """Create logs for all shared users""" - # users = self.shared_with.all() - # print(f'>>> create logs for users = {len(users)}') - - # for user in self.shared_with.all(): - # self.create_access_log(user, operation) - def create_access_log(self, user, operation): """Create a single access log for a specific user""" model_class = apps.get_model('tournaments', self.model_name) - obj = model_class.objects.get(id=self.model_id) - store_id = None - if isinstance(obj, SideStoreModel): - store_id = obj.store_id - - ModelLog.objects.create( - user=user, - model_id=self.model_id, - model_name=self.model_name, - operation=operation, - date=timezone.now(), - store_id=store_id - ) + try: + obj = model_class.objects.get(id=self.model_id) + store_id = None + if isinstance(obj, SideStoreModel): + store_id = obj.store_id + + existing_log = ModelLog.objects.filter(user=user, model_id=self.model_id, operation=operation).first() + if existing_log: + existing_log.date = timezone.now() + existing_log.model_operation = operation + existing_log.save() + else: + ModelLog.objects.create( + user=user, + model_id=self.model_id, + model_name=self.model_name, + operation=operation, + date=timezone.now(), + store_id=store_id + ) + except ObjectDoesNotExist: + pass diff --git a/tournaments/models/date_interval.py b/tournaments/models/date_interval.py index bd3db82..56d4696 100644 --- a/tournaments/models/date_interval.py +++ b/tournaments/models/date_interval.py @@ -9,10 +9,6 @@ class DateInterval(BaseModel): start_date = models.DateTimeField() end_date = models.DateTimeField() - # Required for sync web sockets update - def get_owner(self): - return self.event.creator - # Data Access def get_parent_reference(self): return 'Event', self.event.id diff --git a/tournaments/models/event.py b/tournaments/models/event.py index 14ca6d7..da66662 100644 --- a/tournaments/models/event.py +++ b/tournaments/models/event.py @@ -15,10 +15,6 @@ class Event(BaseModel): def __str__(self): return self.display_name() - # Required for sync web sockets update - def get_owner(self): - return self.creator - def save(self, *args, **kwargs): if self.creator: self.creator_full_name = self.creator.full_name() diff --git a/tournaments/models/group_stage.py b/tournaments/models/group_stage.py index ec6b6c6..fa89df0 100644 --- a/tournaments/models/group_stage.py +++ b/tournaments/models/group_stage.py @@ -19,10 +19,6 @@ class GroupStage(SideStoreModel): def get_parent_reference(self): return 'Event', self.tournament.event.id - # Required for sync web sockets update - def get_owner(self): - return self.tournament.event.creator - def __str__(self): return self.display_name() # return f"{self.tournament.display_name()} - {self.display_name()}" diff --git a/tournaments/models/match.py b/tournaments/models/match.py index 055adbe..23bd996 100644 --- a/tournaments/models/match.py +++ b/tournaments/models/match.py @@ -30,10 +30,6 @@ class Match(SideStoreModel): def get_parent_reference(self): return 'Event', self.tournament().event.id - # Required for sync web sockets update - def get_owner(self): - return self.tournament().event.creator - def __str__(self): names = " / ".join(self.player_names()) return f"{self.stage_name()} #{self.index}: {names}" diff --git a/tournaments/models/player_registration.py b/tournaments/models/player_registration.py index fb04e19..e087425 100644 --- a/tournaments/models/player_registration.py +++ b/tournaments/models/player_registration.py @@ -38,10 +38,6 @@ class PlayerRegistration(SideStoreModel): def get_parent_reference(self): return 'Event', self.team_registration.tournament.event.id - # Required for sync web sockets update - def get_owner(self): - return self.team_registration.tournament.event.creator - def __str__(self): return self.name() diff --git a/tournaments/models/purchase.py b/tournaments/models/purchase.py index 7b9a4b7..c139ef4 100644 --- a/tournaments/models/purchase.py +++ b/tournaments/models/purchase.py @@ -13,7 +13,3 @@ class Purchase(BaseModel): def __str__(self): return f"{self.identifier} > {self.product_id} - {self.purchase_date} - {self.user.username}" - - # Required for sync web sockets update - def get_owner(self): - return self.user diff --git a/tournaments/models/round.py b/tournaments/models/round.py index 4db3e03..c94582b 100644 --- a/tournaments/models/round.py +++ b/tournaments/models/round.py @@ -14,10 +14,6 @@ class Round(SideStoreModel): def get_parent_reference(self): return 'Event', self.tournament.event.id - # Required for sync web sockets update - def get_owner(self): - return self.tournament.event.creator - def __str__(self): if self.parent: return f"LB: {self.name()}" diff --git a/tournaments/models/team_score.py b/tournaments/models/team_score.py index 1328236..49d3a69 100644 --- a/tournaments/models/team_score.py +++ b/tournaments/models/team_score.py @@ -17,10 +17,6 @@ class TeamScore(SideStoreModel): def get_parent_reference(self): return 'Event', self.tournament().event.id - # Required for sync web sockets update - def get_owner(self): - return self.tournament().event.creator - def tournament(self): if self.team_registration: return self.team_registration.tournament diff --git a/tournaments/models/tournament.py b/tournaments/models/tournament.py index e81e7ac..929dd26 100644 --- a/tournaments/models/tournament.py +++ b/tournaments/models/tournament.py @@ -17,7 +17,7 @@ class TeamSortingType(models.IntegerChoices): class Tournament(BaseModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) - event = models.ForeignKey(Event, blank=True, null=True, on_delete=models.CASCADE, related_name="events") + event = models.ForeignKey(Event, blank=True, null=True, on_delete=models.CASCADE, related_name="tournaments") name = models.CharField(max_length=200, null=True, blank=True) start_date = models.DateTimeField() end_date = models.DateTimeField(null=True, blank=True) diff --git a/tournaments/signals.py b/tournaments/signals.py index d4ce89d..ea5f87c 100644 --- a/tournaments/signals.py +++ b/tournaments/signals.py @@ -27,24 +27,30 @@ def synchronization_notifications(sender, instance, **kwargs): if sender in [FailedApiCall, Log, ModelLog]: return - channel_layer = get_channel_layer() + if kwargs.get('signal') == post_delete: + delete_data_access_if_necessary(instance.id) + + print(f'*** signals {sender}') user_ids = set() + # add impacted users if isinstance(instance, CustomUser): user_ids.add(instance.id) - elif isinstance(instance, DataAccess): - for shared_user in instance.shared_with.all(): - user_ids.add(shared_user.id) elif isinstance(instance, BaseModel): - owner = instance.get_owner() + owner = instance.last_updated_by if owner is not None: user_ids.add(owner.id) if isinstance(instance, BaseModel): - parent_model, data_access_reference_id = instance.get_parent_reference() data_access_query = Q(model_id=instance.id) - if data_access_reference_id is not None: - data_access_query |= Q(model_id=data_access_reference_id) + if kwargs.get('signal') != post_delete: + # when deleting objects, accessing reference generates DoesNotExist exceptions + + parent_model, data_access_reference_id = instance.get_parent_reference() + if data_access_reference_id is not None: + data_access_query |= Q(model_id=data_access_reference_id) + + # look for users through data access objects data_access_list = DataAccess.objects.filter(data_access_query) for data_access in data_access_list: user_ids.add(data_access.owner.id) @@ -52,13 +58,20 @@ def synchronization_notifications(sender, instance, **kwargs): user_ids.add(shared_user.id) for user_id in user_ids: - group_name = f"sync_{user_id}" - print(f">>> send to group {group_name}") + send_user_message(user_id) - # Send to all clients in the sync group - async_to_sync(channel_layer.group_send)( - group_name, {"type": "sync.update", "message": "hello"} - ) +def delete_data_access_if_necessary(model_id): + DataAccess.objects.filter(model_id=model_id).delete() + +def send_user_message(user_id): + group_name = f"sync_{user_id}" + print(f">>> send to group {group_name}") + + # Send to all clients in the sync group + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + group_name, {"type": "sync.update", "message": "hello"} + ) @receiver(m2m_changed, sender=DataAccess.shared_with.through) def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs): @@ -66,22 +79,27 @@ def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs): for user_id in pk_set: user = CustomUser.objects.get(id=user_id) instance.create_access_log(user, 'GRANT_ACCESS') + send_user_message(user_id) elif action == "post_remove": for user_id in pk_set: user = CustomUser.objects.get(id=user_id) instance.create_access_log(user, 'REVOKE_ACCESS') + send_user_message(user_id) @receiver(pre_delete, sender=DataAccess) -def store_users_before_delete(sender, instance, **kwargs): - # Store the users in a temporary attribute that we can access after deletion - instance._users_to_revoke = list(instance.shared_with.all()) - -@receiver(post_delete, sender=DataAccess) def revoke_access_after_delete(sender, instance, **kwargs): - # Create revoke logs for all previously stored users - if hasattr(instance, '_users_to_revoke'): - for user in instance._users_to_revoke: - instance.create_access_log(user, 'REVOKE_ACCESS') + for user in instance.shared_with.all(): + instance.create_access_log(user, 'REVOKE_ACCESS') + +# # Store the users in a temporary attribute that we can access after deletion +# instance._users_to_revoke = list(instance.shared_with.all()) + +# @receiver(post_delete, sender=DataAccess) +# def revoke_access_after_delete(sender, instance, **kwargs): +# # Create revoke logs for all previously stored users +# if hasattr(instance, '_users_to_revoke'): +# for user in instance._users_to_revoke: +# instance.create_access_log(user, 'REVOKE_ACCESS') # Others