diff --git a/api/sync.py b/api/sync.py new file mode 100644 index 0000000..3c9f394 --- /dev/null +++ b/api/sync.py @@ -0,0 +1,227 @@ +from django.db.models.fields.related_lookups import RelatedExact +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status + +from django.apps import apps +from django.utils import timezone +from django.db.models import Q + +from collections import defaultdict + +from .utils import build_serializer_class, get_data, get_serialized_data + +from tournaments.models import ModelLog, DataAccess, BaseModel + +class DataApi(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, *args, **kwargs): + + # unfold content + model_operation = request.data.get('operation') + model_name = request.data.get('model_name') + data = request.data.get('data') + store_id = request.data.get('store_id') + + print(f"DataApi post > {model_operation} {model_name}") + + serializer_class = build_serializer_class(model_name) + + 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) + else: # PUT + serializer = serializer_class(instance, data=data, context={'request': request}) + if serializer.is_valid(): + if instance.last_update <= serializer.validated_data.get('last_update'): + print('>>> update') + return self.save_and_create_log(request, serializer, model_operation, model_name, store_id, now) + else: + print('>>> return 203') + return Response(serializer.data, status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except model.DoesNotExist: # POST + print('>>> insert') + serializer = serializer_class(data=data, context={'request': request}) + if serializer.is_valid(): + return self.save_and_create_log(request, serializer, model_operation, model_name, store_id, now) + else: + 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( + user=request.user, + model_operation=model_operation, + model_name=model_name, + model_id=instance.id, + store_id=store_id, + date=date + ) + + self.update_linked_data_access(instance, date) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def delete_and_save_log(self, request, data_id, model_operation, model_name, store_id, date): + + instance = get_data('tournaments', model_name, data_id) + self.update_linked_data_access(instance, date) + + instance.delete() + + # 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( + user=request.user, + model_operation=model_operation, + model_name=model_name, + model_id=data_id, + store_id=store_id, + date=date + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def update_linked_data_access(self, instance, date): + related_instances = instance.related_instances() + related_ids = [ri.id for ri in instance.related_instances()] + related_ids.append(instance.id) + data_access_list = DataAccess.objects.filter(model_id__in=related_ids) + + for data_access in data_access_list: + 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 get(self, request, *args, **kwargs): + last_update = request.query_params.get('last_update') + if not last_update: + return Response({"error": "last_update parameter is required"}, status=status.HTTP_400_BAD_REQUEST) + + print(f'/data GET: {last_update}') + + logs = self.query_model_logs(last_update, request.user) + + updates = defaultdict(dict) + deletions = defaultdict(list) + + 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}) + + # Convert updates dict to list for each model + for model_name in updates: + updates[model_name] = list(updates[model_name].values()) + + # Convert deletions set to list for each model + for model_name in deletions: + deletions[model_name] = deletions[model_name] + + response_data = { + "updates": dict(updates), + "deletions": dict(deletions) + } + + return Response(response_data, status=status.HTTP_200_OK) + + def query_model_logs(self, last_update, user): + try: + last_update = timezone.datetime.fromisoformat(last_update) + except ValueError: + return Response({"error": f"Invalid date format for last_update: {last_update}"}, status=status.HTTP_400_BAD_REQUEST) + + # get recently modified DataAccess + 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)}') + + # 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) + + # 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)) + return ModelLog.objects.filter(log_query).order_by('date') + + def add_children_recursively(self, instance, updates): + """ + Recursively add all children of an instance to the updates dictionary. + """ + # print(f"Instance class: {instance.__class__}") + child_models = instance.get_children_by_model() + + for child_model_name, children in child_models.items(): + for child in children: + if isinstance(child, BaseModel): + serializer_class = build_serializer_class(child_model_name) + serializer = serializer_class(child) + updates[child_model_name][child.id] = serializer.data + self.add_children_recursively(child, updates) + + def add_parents_recursively(self, instance, updates): + parent_models = instance.get_parents_by_model() + + for parent_model_name, parent in parent_models.items(): + # print(f'parent = {parent_model_name}') + if isinstance(parent, BaseModel): + serializer_class = build_serializer_class(parent_model_name) + serializer = serializer_class(parent) + updates[parent_model_name][parent.id] = serializer.data + self.add_parents_recursively(parent, updates) + + # def get_data(self, model, log): + # instance = model.objects.get(id=log.model_id) + # serializer_class = build_serializer_class(log.model_name) + # serializer = serializer_class(instance) + # return serializer.data diff --git a/api/urls.py b/api/urls.py index 171aaa2..02fd7db 100644 --- a/api/urls.py +++ b/api/urls.py @@ -2,7 +2,7 @@ from django.urls import include, path from rest_framework import routers from rest_framework.authtoken.views import obtain_auth_token -from . import views +from . import views, sync router = routers.DefaultRouter() router.register(r'users', views.UserViewSet) @@ -26,7 +26,7 @@ router.register(r'data-access', views.DataAccessViewSet) urlpatterns = [ path('', include(router.urls)), - path('data/', views.DataApi.as_view(), name="data"), + path('data/', sync.DataApi.as_view(), name="data"), path('api-token-auth/', obtain_auth_token, name='api_token_auth'), path("user-by-token/", views.user_by_token, name="user_by_token"), diff --git a/api/utils.py b/api/utils.py index c8c571d..0a2749e 100644 --- a/api/utils.py +++ b/api/utils.py @@ -1,5 +1,6 @@ import re import importlib +from django.apps import apps def is_valid_email(email): email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' @@ -21,3 +22,17 @@ def build_serializer_class(source): module = importlib.import_module('api.serializers') return getattr(module, transformed_string) + +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) + instance = model.objects.get(id=model_id) + serializer_class = build_serializer_class(model_name) + serializer = serializer_class(instance) + return serializer.data diff --git a/api/views.py b/api/views.py index b14db3e..725d84f 100644 --- a/api/views.py +++ b/api/views.py @@ -1,5 +1,5 @@ from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, TournamentSerializer, UserSerializer, ChangePasswordSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, LiveMatchSerializer, PurchaseSerializer, CustomUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, DataAccessSerializer -from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, ModelLog, DataAccess, BaseModel +from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DataAccess from rest_framework import viewsets, permissions from rest_framework.authtoken.models import Token @@ -22,197 +22,6 @@ from collections import defaultdict from .permissions import IsClubOwner from .utils import is_valid_email, build_serializer_class -class DataApi(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request, *args, **kwargs): - - # unfold content - model_operation = request.data.get('operation') - model_name = request.data.get('model_name') - data = request.data.get('data') - store_id = request.data.get('store_id') - - print(f"DataApi post > {model_operation} {model_name}") - - serializer_class = build_serializer_class(model_name) - - model = apps.get_model(app_label='tournaments', model_name=model_name) - try: - data_id = data.get('id') - instance = model.objects.get(id=data_id) - - 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, parent_id, parent_model) - else: # PUT - serializer = serializer_class(instance, data=data, context={'request': request}) - if serializer.is_valid(): - if instance.last_update <= serializer.validated_data.get('last_update'): - print('>>> update') - return self.save_and_create_log(request, serializer, model_operation, model_name, store_id) - else: - print('>>> return 203') - return Response(serializer.data, status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - except model.DoesNotExist: # POST - print('>>> insert') - serializer = serializer_class(data=data, context={'request': request}) - if serializer.is_valid(): - return self.save_and_create_log(request, serializer, model_operation, model_name, store_id) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def save_and_create_log(self, request, serializer, model_operation, model_name, store_id): - instance = serializer.save() - parent_model, parent_id = instance.get_parent_reference() - - self.create_and_save_model_log( - user=request.user, - model_operation=model_operation, - model_name=model_name, - model_id=instance.id, - store_id=store_id, - parent_id=parent_id, - parent_model=parent_model - ) - - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def delete_and_save_log(self, request, data_id, model_operation, model_name, store_id, parent_id, parent_model): - - model = apps.get_model(app_label='tournaments', model_name=model_name) - print(model) - try: - instance = model.objects.get(id=data_id) - instance.delete() - except model.DoesNotExist: - return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) - - # we delete all 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( - user=request.user, - model_operation=model_operation, - model_name=model_name, - model_id=data_id, - store_id=store_id, - parent_id=parent_id, - parent_model=parent_model - ) - - return Response(status=status.HTTP_204_NO_CONTENT) - - def create_and_save_model_log(self, user, model_operation, model_name, model_id, store_id, parent_id, parent_model): - model_log = ModelLog() - model_log.user = user - model_log.operation = model_operation - model_log.date = timezone.localtime(timezone.now()) - model_log.model_name = model_name - model_log.model_id = model_id - model_log.store_id = store_id - model_log.parent_model_id = parent_id - model_log.parent_model_name = parent_model - model_log.save() - - def get(self, request, *args, **kwargs): - print('/data GET YEAH!') - - last_update = request.query_params.get('last_update') - if not last_update: - return Response({"error": "last_update parameter is required"}, status=status.HTTP_400_BAD_REQUEST) - - print(last_update) - - try: - last_update = timezone.datetime.fromisoformat(last_update) - except ValueError: - return Response({"error": f"Invalid date format for last_update: {last_update}"}, status=status.HTTP_400_BAD_REQUEST) - - data_access_query = Q(shared_with=request.user) | Q(owner=request.user) - data_access = DataAccess.objects.filter(data_access_query).values_list('model_id', flat=True) - log_query = Q(date__gt=last_update) & (Q(user=request.user) | Q(model_id__in=data_access) | Q(parent_model_id__in=data_access)) - logs = ModelLog.objects.filter(log_query).order_by('date') - - updates = defaultdict(dict) - deletions = defaultdict(list) - - print(f'>>> log count = {len(logs)}') - - for log in logs: - model = apps.get_model(app_label='tournaments', model_name=log.model_name) - - if log.operation in ['POST', 'PUT']: - try: - data = self.get_data(model, log) - updates[log.model_name][log.model_id] = data - except model.DoesNotExist: - # If the instance doesn't exist, it might have been deleted after this log was created - pass - elif log.operation == 'DELETE': - deletions[log.model_name].append({'model_id': log.model_id, 'store_id': log.store_id}) - elif log.operation == 'GRANT_ACCESS': - data = self.get_data(model, log) - updates[log.model_name][log.model_id] = data - instance = model.objects.get(id=log.model_id) - self.add_children_recursively(instance, updates) - self.add_parents_recursively(instance, updates) - elif log.operation == 'REVOKE_ACCESS': - print(f'revoke access {log.model_id} - {log.store_id}') - # data = self.get_data(model, log) - deletions[log.model_name].append({'model_id': log.model_id, 'store_id': log.store_id}) - - # Convert updates dict to list for each model - for model_name in updates: - updates[model_name] = list(updates[model_name].values()) - - # Convert deletions set to list for each model - for model_name in deletions: - deletions[model_name] = deletions[model_name] - - date = logs.last().date.astimezone().isoformat(timespec='seconds') if logs else None - - response_data = { - "updates": dict(updates), - "deletions": dict(deletions), - "date": date - } - - return Response(response_data, status=status.HTTP_200_OK) - - def add_children_recursively(self, instance, updates): - """ - Recursively add all children of an instance to the updates dictionary. - """ - child_models = instance.get_children_by_model() - - for child_model_name, children in child_models.items(): - for child in children: - if isinstance(child, BaseModel): - serializer_class = build_serializer_class(child_model_name) - serializer = serializer_class(child) - updates[child_model_name][child.id] = serializer.data - self.add_children_recursively(child, updates) - - def add_parents_recursively(self, instance, updates): - parent_models = instance.get_parents_by_model() - - for parent_model_name, parent in parent_models.items(): - # print(f'parent = {parent_model_name}') - if isinstance(parent, BaseModel): - serializer_class = build_serializer_class(parent_model_name) - serializer = serializer_class(parent) - updates[parent_model_name][parent.id] = serializer.data - self.add_parents_recursively(parent, updates) - - def get_data(self, model, log): - instance = model.objects.get(id=log.model_id) - serializer_class = build_serializer_class(log.model_name) - serializer = serializer_class(instance) - return serializer.data - class CustomAuthToken(APIView): permission_classes = [] @@ -261,6 +70,20 @@ class Logout(APIView): return Response(status=status.HTTP_200_OK) +class ChangePasswordView(UpdateAPIView): + serializer_class = ChangePasswordSerializer + + def update(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + # if using drf authtoken, create a new token + if hasattr(user, 'auth_token'): + user.auth_token.delete() + token, created = Token.objects.get_or_create(user=user) + # return new token + return Response({'token': token.key}, status=status.HTTP_200_OK) + @api_view(['GET']) def user_by_token(request): serializer = UserSerializer(request.user) @@ -283,8 +106,25 @@ class ClubViewSet(viewsets.ModelViewSet): permission_classes = [IsClubOwner] # Clubs are public whereas the other requests are only for logged users def perform_create(self, serializer): + super.perform_create() serializer.save(creator=self.request.user) +class EventViewSet(viewsets.ModelViewSet): + queryset = Event.objects.all() + serializer_class = EventSerializer + + def get_queryset(self): + if self.request.user.is_anonymous: + return [] + # return self.queryset.filter(creator=self.request.user) + return self.queryset.filter( + Q(creator=self.request.user) | + Q(id__in=DataAccess.objects.filter( + shared_with=self.request.user, + model_name=self.queryset.model.__name__ + ).values_list('model_id', flat=True)) + ) + class TournamentViewSet(viewsets.ModelViewSet): queryset = Tournament.objects.all() serializer_class = TournamentSerializer @@ -318,49 +158,12 @@ class PurchaseViewSet(viewsets.ModelViewSet): ).values_list('model_id', flat=True)) ) - def put(self, request, pk): - raise MethodNotAllowed('PUT') - def patch(self, request, pk): raise MethodNotAllowed('PATCH') def delete(self, request, pk): raise MethodNotAllowed('DELETE') -# class ExpandedTournamentViewSet(viewsets.ModelViewSet): -# queryset = Tournament.objects.all() -# serializer_class = ExpandedTournamentSerializer - -class ChangePasswordView(UpdateAPIView): - serializer_class = ChangePasswordSerializer - - def update(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.save() - # if using drf authtoken, create a new token - if hasattr(user, 'auth_token'): - user.auth_token.delete() - token, created = Token.objects.get_or_create(user=user) - # return new token - return Response({'token': token.key}, status=status.HTTP_200_OK) - -class EventViewSet(viewsets.ModelViewSet): - queryset = Event.objects.all() - serializer_class = EventSerializer - - def get_queryset(self): - if self.request.user.is_anonymous: - return [] - # return self.queryset.filter(creator=self.request.user) - return self.queryset.filter( - Q(creator=self.request.user) | - Q(id__in=DataAccess.objects.filter( - shared_with=self.request.user, - model_name=self.queryset.model.__name__ - ).values_list('model_id', flat=True)) - ) - class RoundViewSet(viewsets.ModelViewSet): queryset = Round.objects.all() serializer_class = RoundSerializer diff --git a/tournaments/admin.py b/tournaments/admin.py index 0be7cce..baec6fa 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -100,9 +100,13 @@ class ModelLogAdmin(admin.ModelAdmin): ordering = ['-date'] class DataAccessAdmin(admin.ModelAdmin): - list_display = ['owner', 'shared_with', 'model_name', 'model_id', 'granted_at'] + list_display = ['owner', 'get_shared_users', 'model_name', 'model_id', 'last_hierarchy_update'] list_filter = ['owner', 'shared_with'] - ordering = ['-granted_at'] + ordering = ['-last_hierarchy_update'] + + @admin.display(description='Shared with') + def get_shared_users(self, obj): + return ", ".join([str(item) for item in obj.shared_with.all()]) admin.site.register(CustomUser, CustomUserAdmin) admin.site.register(Club, ClubAdmin) diff --git a/tournaments/consumers.py b/tournaments/consumers.py index 6c4fd03..dc86e92 100644 --- a/tournaments/consumers.py +++ b/tournaments/consumers.py @@ -3,6 +3,44 @@ import json from asgiref.sync import async_to_sync from channels.generic.websocket import WebsocketConsumer +class UserConsumer(WebsocketConsumer): + def connect(self): + self.room_name = self.scope["url_route"]["kwargs"]["user_id"] + self.room_group_name = f"sync_{self.room_name}" + + # Join room group + async_to_sync(self.channel_layer.group_add)( + self.room_group_name, self.channel_name + ) + + self.accept() + + def disconnect(self, close_code): + # Leave room group + async_to_sync(self.channel_layer.group_discard)( + self.room_group_name, self.channel_name + ) + + # Receive message from WebSocket + def receive(self, data): + # text_data_json = json.loads(text_data) + # message = text_data_json["message"] + print(f'received {data}') + + # Send message to room group + # chat.message calls the chat_message method + async_to_sync(self.channel_layer.group_send)( + self.room_group_name, {"type": "sync.update", "message": "hello"} # sync.update calls the method below + ) + + # Receive message from room group + def sync_update(self, event): + message = event["message"] + + # Send message to WebSocket + self.send(text_data=message) + + class ChatConsumer(WebsocketConsumer): def connect(self): self.room_name = 'main' diff --git a/tournaments/migrations/0097_club_last_updated_by_court_last_updated_by_and_more.py b/tournaments/migrations/0097_club_last_updated_by_court_last_updated_by_and_more.py new file mode 100644 index 0000000..8b64c71 --- /dev/null +++ b/tournaments/migrations/0097_club_last_updated_by_court_last_updated_by_and_more.py @@ -0,0 +1,190 @@ +# Generated by Django 5.1 on 2024-11-12 16:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0096_alter_modellog_operation_and_more'), + ] + + 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='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='club', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='creator_clubs', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='court', + name='club', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courts', to='tournaments.club'), + ), + migrations.AlterField( + model_name='customuser', + name='clubs', + field=models.ManyToManyField(blank=True, related_name='creators', to='tournaments.club'), + ), + migrations.AlterField( + model_name='dateinterval', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='date_intervals', to='tournaments.event'), + ), + migrations.AlterField( + model_name='devicetoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_tokens', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='event', + name='club', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='tournaments.club'), + ), + migrations.AlterField( + model_name='event', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='failedapicall', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='failed_api_calls', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='groupstage', + name='tournament', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_stages', to='tournaments.tournament'), + ), + migrations.AlterField( + model_name='log', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='logs', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='match', + name='group_stage', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='tournaments.groupstage'), + ), + migrations.AlterField( + model_name='match', + name='round', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='tournaments.round'), + ), + migrations.AlterField( + model_name='modellog', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_logs', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='playerregistration', + name='team_registration', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='player_registrations', to='tournaments.teamregistration'), + ), + migrations.AlterField( + model_name='purchase', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchases', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='round', + name='tournament', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rounds', to='tournaments.tournament'), + ), + migrations.AlterField( + model_name='teamregistration', + name='group_stage', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='team_registrations', to='tournaments.groupstage'), + ), + migrations.AlterField( + model_name='teamregistration', + name='tournament', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_registrations', to='tournaments.tournament'), + ), + migrations.AlterField( + model_name='teamscore', + name='team_registration', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='team_scores', to='tournaments.teamregistration'), + ), + 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/migrations/0098_remove_club_last_updated_by_and_more.py b/tournaments/migrations/0098_remove_club_last_updated_by_and_more.py new file mode 100644 index 0000000..8f25238 --- /dev/null +++ b/tournaments/migrations/0098_remove_club_last_updated_by_and_more.py @@ -0,0 +1,108 @@ +# Generated by Django 5.1 on 2024-11-15 14:45 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0097_club_last_updated_by_court_last_updated_by_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='club', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='court', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='dateinterval', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='devicetoken', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='event', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='failedapicall', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='groupstage', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='log', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='match', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='modellog', + name='parent_model_id', + ), + migrations.RemoveField( + model_name='modellog', + name='parent_model_name', + ), + migrations.RemoveField( + model_name='playerregistration', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='purchase', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='round', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='teamregistration', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='teamscore', + name='last_updated_by', + ), + migrations.RemoveField( + model_name='tournament', + name='last_updated_by', + ), + migrations.AddField( + model_name='dataaccess', + name='creation_date', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + migrations.AddField( + model_name='dataaccess', + name='last_update', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.RemoveField( + model_name='dataaccess', + name='shared_with', + ), + migrations.AlterField( + model_name='tournament', + name='event', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tournaments.event'), + ), + migrations.AddField( + model_name='dataaccess', + name='shared_with', + field=models.ManyToManyField(related_name='shared_data', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/tournaments/migrations/0099_dataaccess_last_hierarchy_update.py b/tournaments/migrations/0099_dataaccess_last_hierarchy_update.py new file mode 100644 index 0000000..42876b9 --- /dev/null +++ b/tournaments/migrations/0099_dataaccess_last_hierarchy_update.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1 on 2024-11-18 12:54 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0098_remove_club_last_updated_by_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='dataaccess', + name='last_hierarchy_update', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/tournaments/models/base.py b/tournaments/models/base.py index 262e364..474f962 100644 --- a/tournaments/models/base.py +++ b/tournaments/models/base.py @@ -1,15 +1,20 @@ from django.db import models from django.utils.timezone import now +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) class Meta: abstract = True + def get_owner(self): + return None + def get_parent_reference(self): - """Override in child models to provide parent reference""" + """Return a tuple: model_name, model_id""" return None, None def get_children_by_model(self): @@ -24,6 +29,7 @@ class BaseModel(models.Model): for child in children: if (child.one_to_many or child.one_to_one) and child.auto_created: model_name = child.related_model.__name__ + # print(f'>>> add children for {model_name}') related_objects[model_name] = getattr(self, child.name).all() return related_objects @@ -57,6 +63,81 @@ class BaseModel(models.Model): return parents + + def get_recursive_children(self, processed_objects: Set = None) -> List: + """ + Recursively get all children objects through the hierarchy + """ + if processed_objects is None: + processed_objects = set() + + # Skip if we've already processed this object to avoid infinite recursion + if self.pk in processed_objects: + return [] + + processed_objects.add(self.pk) + children = [] + + # Get immediate children + children_by_model = self.get_children_by_model() + for queryset in children_by_model.values(): + for child in queryset: + children.append(child) + # Recursively get children of children + if isinstance(child, BaseModel): + children.extend(child.get_recursive_children(processed_objects)) + + return children + + def get_recursive_parents(self, processed_objects: Set = None) -> List: + """ + Recursively get all parent objects through the hierarchy + """ + if processed_objects is None: + processed_objects = set() + + # Skip if we've already processed this object to avoid infinite recursion + if self.pk in processed_objects: + return [] + + processed_objects.add(self.pk) + parents = [] + + # Get immediate parents + parents_by_model = self.get_parents_by_model() + for parent in parents_by_model.values(): + parents.append(parent) + # Recursively get parents of parents + if isinstance(parent, BaseModel): + parents.extend(parent.get_recursive_parents(processed_objects)) + + return parents + + def related_instances(self): + """ + Get all related instances (both children and parents) recursively + """ + instances = [] + processed_objects = set() + instances.extend(self.get_recursive_children(processed_objects)) + + processed_objects = set() + instances.extend(self.get_recursive_parents(processed_objects)) + + return instances + + # def related_instances(self): + # instances = [] + # children_by_model = self.get_children_by_model() + # all_children = [item for sublist in children_by_model.values() for item in sublist] + # instances.extend(all_children) + + # parents_by_model = self.get_parents_by_model() + # instances.extend(parents_by_model.values()) + + # return instances + + class SideStoreModel(BaseModel): store_id = models.CharField(max_length=100) diff --git a/tournaments/models/club.py b/tournaments/models/club.py index 0e1bc18..0f92361 100644 --- a/tournaments/models/club.py +++ b/tournaments/models/club.py @@ -4,7 +4,7 @@ from . import BaseModel class Club(BaseModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) - creator = models.ForeignKey('CustomUser', blank=True, null=True, on_delete=models.SET_NULL) + creator = models.ForeignKey('CustomUser', blank=True, null=True, on_delete=models.SET_NULL, related_name='creator_clubs') name = models.CharField(max_length=50) acronym = models.CharField(max_length=10) phone = models.CharField(max_length=15, null=True, blank=True) diff --git a/tournaments/models/data_access.py b/tournaments/models/data_access.py index ed265f5..1fd2800 100644 --- a/tournaments/models/data_access.py +++ b/tournaments/models/data_access.py @@ -3,45 +3,77 @@ from django.utils import timezone from django.apps import apps import uuid -from . import ModelLog, SideStoreModel +from . import ModelLog, SideStoreModel, BaseModel -class DataAccess(models.Model): +class DataAccess(BaseModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4) owner = models.ForeignKey('CustomUser', related_name='owned_data', on_delete=models.CASCADE) - shared_with = models.ForeignKey('CustomUser', related_name='shared_data', on_delete=models.CASCADE) + shared_with = models.ManyToManyField('CustomUser', related_name='shared_data') model_name = models.CharField(max_length=50) model_id = models.UUIDField() 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 # Check if this is a new DataAccess - super().save(*args, **kwargs) + # def save(self, *args, **kwargs): + # is_new = self._state.adding - if is_new: - self.create_initial_log() + # 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): - # print('>>> delete data access') - # # Create deletion sync logs before deleting the access - # self.create_deletion_log() - # return super().delete(*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)}') - def create_initial_log(self): + # 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) - parent_model, parent_id = obj.get_parent_reference() store_id = None if isinstance(obj, SideStoreModel): store_id = obj.store_id ModelLog.objects.create( - user=self.shared_with, # The user receiving access + user=user, model_id=self.model_id, model_name=self.model_name, - operation='GRANT_ACCESS', + operation=operation, date=timezone.now(), - store_id=store_id, - parent_model_id=parent_id, - parent_model_name=parent_model + store_id=store_id ) diff --git a/tournaments/models/date_interval.py b/tournaments/models/date_interval.py index 56d4696..bd3db82 100644 --- a/tournaments/models/date_interval.py +++ b/tournaments/models/date_interval.py @@ -9,6 +9,10 @@ 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/device_token.py b/tournaments/models/device_token.py index fc5a711..625fe8c 100644 --- a/tournaments/models/device_token.py +++ b/tournaments/models/device_token.py @@ -4,7 +4,7 @@ import uuid from . import BaseModel class DeviceToken(BaseModel): - user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name='device_tokens') value = models.TextField() def __str__(self): diff --git a/tournaments/models/event.py b/tournaments/models/event.py index da66662..14ca6d7 100644 --- a/tournaments/models/event.py +++ b/tournaments/models/event.py @@ -15,6 +15,10 @@ 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 3012c58..ec6b6c6 100644 --- a/tournaments/models/group_stage.py +++ b/tournaments/models/group_stage.py @@ -17,7 +17,11 @@ class GroupStage(SideStoreModel): # Data Access def get_parent_reference(self): - return 'Event', self.tournament.event.id + 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() diff --git a/tournaments/models/match.py b/tournaments/models/match.py index c058a53..055adbe 100644 --- a/tournaments/models/match.py +++ b/tournaments/models/match.py @@ -28,7 +28,11 @@ class Match(SideStoreModel): # Data Access def get_parent_reference(self): - return 'Event', self.tournament().event.id + 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()) diff --git a/tournaments/models/model_log.py b/tournaments/models/model_log.py index 77fcdd6..e338e62 100644 --- a/tournaments/models/model_log.py +++ b/tournaments/models/model_log.py @@ -10,5 +10,5 @@ class ModelLog(models.Model): date = models.DateTimeField() model_name = models.CharField(max_length=50) store_id = models.CharField(max_length=200, blank=True, null=True) - parent_model_id = models.UUIDField(blank=True, null=True) - parent_model_name = models.CharField(max_length=50, blank=True, null=True) + # parent_model_id = models.UUIDField(blank=True, null=True) + # parent_model_name = models.CharField(max_length=50, blank=True, null=True) diff --git a/tournaments/models/player_registration.py b/tournaments/models/player_registration.py index e087425..fb04e19 100644 --- a/tournaments/models/player_registration.py +++ b/tournaments/models/player_registration.py @@ -38,6 +38,10 @@ 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 c139ef4..7b9a4b7 100644 --- a/tournaments/models/purchase.py +++ b/tournaments/models/purchase.py @@ -13,3 +13,7 @@ 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 c94582b..4db3e03 100644 --- a/tournaments/models/round.py +++ b/tournaments/models/round.py @@ -14,6 +14,10 @@ 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 b032554..1328236 100644 --- a/tournaments/models/team_score.py +++ b/tournaments/models/team_score.py @@ -13,6 +13,14 @@ class TeamScore(SideStoreModel): def __str__(self): return f"{self.match.stage_name()} #{self.match.index}: {self.player_names()}" + # Data Access + 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 588d77d..e81e7ac 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='tournaments') + event = models.ForeignKey(Event, blank=True, null=True, on_delete=models.CASCADE, related_name="events") name = models.CharField(max_length=200, null=True, blank=True) start_date = models.DateTimeField() end_date = models.DateTimeField(null=True, blank=True) @@ -60,9 +60,17 @@ class Tournament(BaseModel): hide_points_earned = models.BooleanField(default=False) publish_rankings = models.BooleanField(default=False) + # Data Access def get_parent_reference(self): return 'Event', self.event.id + def get_child_models(self): + return { + 'Round': 'round_set', + 'GroupStage': 'groupstage_set', + 'TeamRegistration': 'teamregistration_set' + } + def __str__(self): if self.name: return self.name @@ -196,7 +204,7 @@ class Tournament(BaseModel): def team_summons(self): summons = [] - for team_registration in self.team_registrations.all(): + for team_registration in self.teamregistration_set.all(): if team_registration.is_valid_for_summon(): next_match = team_registration.next_match() if next_match and next_match.start_date is not None: @@ -211,7 +219,7 @@ class Tournament(BaseModel): def rankings(self): rankings = [] - for team_registration in self.team_registrations.all(): + for team_registration in self.teamregistration_set.all(): if team_registration.walk_out is False and team_registration.final_ranking is not None: names = team_registration.team_names() ranking = team_registration.final_ranking @@ -232,7 +240,7 @@ class Tournament(BaseModel): complete_teams = [] closed_registration_date = self.closed_registration_date - for team_registration in self.team_registrations.all(): + for team_registration in self.teamregistration_set.all(): is_valid = False if closed_registration_date is not None and team_registration.registration_date is not None and team_registration.registration_date <= closed_registration_date: is_valid = True @@ -329,10 +337,10 @@ class Tournament(BaseModel): match_groups = [] if group_stage_id: - group_stage = self.group_stages.filter(id=group_stage_id).first() + group_stage = self.groupstage_set.filter(id=group_stage_id).first() match_groups.append(self.group_stage_match_group(group_stage, broadcasted, hide_empty_matches=False)) elif round_id: - round = self.rounds.filter(id=round_id).first() + round = self.round_set.filter(id=round_id).first() if round: match_groups = self.round_match_groups(round, broadcasted, hide_empty_matches=False) else: @@ -342,11 +350,11 @@ class Tournament(BaseModel): def all_groups(self, broadcasted): groups = [] - for round in self.rounds.filter(parent=None).all().order_by('index'): + for round in self.round_set.filter(parent=None).all().order_by('index'): groups.extend(self.round_match_groups(round, broadcasted, hide_empty_matches=True)) if self.display_group_stages(): - for group_stage in self.group_stages.all().order_by('index'): + for group_stage in self.groupstage_set.all().order_by('index'): group = self.group_stage_match_group(group_stage, broadcasted, hide_empty_matches=True) if group: groups.append(group) @@ -354,7 +362,7 @@ class Tournament(BaseModel): return groups def group_stage_match_group(self, group_stage, broadcasted, hide_empty_matches): - matches = group_stage.matches.all() + matches = group_stage.match_set.all() if hide_empty_matches: matches = [m for m in matches if m.should_appear()] else: @@ -368,7 +376,7 @@ class Tournament(BaseModel): def round_match_groups(self, round, broadcasted, hide_empty_matches): groups = [] - matches = round.matches.order_by('index').all() + matches = round.match_set.order_by('index').all() if hide_empty_matches: matches = [m for m in matches if m.should_appear()] else: @@ -397,7 +405,7 @@ class Tournament(BaseModel): return MatchGroup(name, live_matches) def live_group_stages(self): - group_stages = list(self.group_stages.all()) + group_stages = list(self.groupstage_set.all()) group_stages.sort(key=lambda gs: gs.index) return [gs.live_group_stages() for gs in group_stages] @@ -435,7 +443,7 @@ class Tournament(BaseModel): matches = [] group_stages = [] - if len(self.group_stages.all()) > 0 and self.no_bracket_match_has_started(): + if len(self.groupstage_set.all()) > 0 and self.no_bracket_match_has_started(): group_stages = self.live_group_stages() matches = self.broadcasted_group_stages_matches() first_round = self.first_round() @@ -463,18 +471,18 @@ class Tournament(BaseModel): def no_bracket_match_has_started(self): matches = [] - for round in self.rounds.all(): - for match in round.matches.all(): + for round in self.round_set.all(): + for match in round.match_set.all(): if match.started(): return False return True def all_matches(self, hide_empty_matches): matches = [] - for round in self.rounds.all(): + for round in self.round_set.all(): matches.extend(round.all_matches(hide_empty_matches)) - for group_stage in self.group_stages.all(): - matches.extend(group_stage.matches.all()) + for group_stage in self.groupstage_set.all(): + matches.extend(group_stage.match_set.all()) matches = [m for m in matches if m.should_appear()] @@ -482,12 +490,12 @@ class Tournament(BaseModel): def group_stage_matches(self): matches = [] - for group_stage in self.group_stages.all(): - matches.extend(group_stage.matches.all()) + for group_stage in self.groupstage_set.all(): + matches.extend(group_stage.match_set.all()) return matches def group_stages_running(self): - if len(self.group_stages.all()) > 0: + if len(self.groupstage_set.all()) > 0: # check le debut des match de Round matches = self.group_stage_matches() running_group_stage_matches = [m for m in matches if m.end_date is None] @@ -509,7 +517,7 @@ class Tournament(BaseModel): current_round = last_started_match.round.root_round() if current_round: return current_round - main_rounds = list(self.rounds.filter(parent=None).all()) + main_rounds = list(self.round_set.filter(parent=None).all()) main_rounds.sort(key=lambda r: r.index) if main_rounds: return main_rounds[0] @@ -522,10 +530,10 @@ class Tournament(BaseModel): return matches[0] def round_for_index(self, index): - return self.rounds.filter(index=index, parent=None).first() + return self.round_set.filter(index=index, parent=None).first() def first_round(self): - main_rounds = list(self.rounds.filter(parent=None)) + main_rounds = list(self.round_set.filter(parent=None)) main_rounds.sort(key=lambda r: r.index, reverse=True) return main_rounds[0] @@ -534,13 +542,13 @@ class Tournament(BaseModel): group_stages = self.elected_broadcast_group_stages() group_stages.sort(key=lambda gs: (gs.index, gs.start_date is None, gs.start_date)) for group_stage in group_stages: - matches.extend(group_stage.matches.all()) + matches.extend(group_stage.match_set.all()) matches = [m for m in matches if m.should_appear()] matches.sort(key=lambda m: (m.start_date is None, m.end_date is not None, m.start_date, m.index)) return matches def elected_broadcast_group_stages(self): - group_stages = list(self.group_stages.all()) + group_stages = list(self.groupstage_set.all()) started = [gs for gs in group_stages if gs.starts_soon()] if len(started) > 0: return started @@ -588,7 +596,7 @@ class Tournament(BaseModel): def display_group_stages(self): if self.end_date is not None: return True - if len(self.group_stages.all()) == 0: + if len(self.groupstage_set.all()) == 0: return False if self.publish_group_stages: return True @@ -600,7 +608,7 @@ class Tournament(BaseModel): return timezone.now() >= first_group_stage_start_date def group_stage_start_date(self): - group_stages = [gs for gs in self.group_stages.all() if gs.start_date is not None] + group_stages = [gs for gs in self.groupstage_set.all() if gs.start_date is not None] if len(group_stages) == 0: return None @@ -630,7 +638,7 @@ class Tournament(BaseModel): def bracket_matches(self): matches = [] - for round in self.rounds.all(): + for round in self.round_set.all(): matches.extend(round.all_matches(False)) return matches @@ -655,7 +663,7 @@ class Tournament(BaseModel): return self.federal_level_category == FederalLevelCategory.UNLISTED def is_build_and_not_empty(self): - return (len(self.group_stages.all()) > 0 or len(self.rounds.all()) > 0) and len(self.team_registrations.all()) >= 4 + return (len(self.groupstage_set.all()) > 0 or len(self.round_set.all()) > 0) and len(self.teamregistration_set.all()) >= 4 def day_duration_formatted(self): return plural_format("jour", self.day_duration) @@ -667,7 +675,7 @@ class Tournament(BaseModel): return False def has_all_group_stages_started(self): - for group_stage in self.group_stages.all(): + for group_stage in self.groupstage_set.all(): if group_stage.has_at_least_one_started_match() == False: return False return True diff --git a/tournaments/routing.py b/tournaments/routing.py index b644e48..f07a0fc 100644 --- a/tournaments/routing.py +++ b/tournaments/routing.py @@ -4,6 +4,7 @@ from django.urls import re_path from . import consumers websocket_urlpatterns = [ + re_path(r"ws/user/(?P[\w-]+)/$", consumers.UserConsumer.as_asgi()), re_path(r"ws/chat/$", consumers.ChatConsumer.as_asgi()), # re_path(r"ws/chat/(?P\w+)/$", consumers.ChatConsumer.as_asgi()), ] diff --git a/tournaments/signals.py b/tournaments/signals.py index 100a5e9..d4ce89d 100644 --- a/tournaments/signals.py +++ b/tournaments/signals.py @@ -1,14 +1,90 @@ import random import string -from django.db.models.signals import post_save, pre_delete +from django.db.models.signals import post_save, pre_delete, post_delete, m2m_changed +from django.db.transaction import DatabaseError from django.dispatch import receiver from django.conf import settings from django.apps import apps from django.utils import timezone +from django.db.models import Q -from .models import Club, FailedApiCall, CustomUser, Log, DataAccess, ModelLog +from .models import Club, FailedApiCall, CustomUser, Log, DataAccess, ModelLog, BaseModel import requests +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync + +# Synchronization + +@receiver([post_save, post_delete]) +def synchronization_notifications(sender, instance, **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. + """ + + if sender in [FailedApiCall, Log, ModelLog]: + return + + channel_layer = get_channel_layer() + + user_ids = set() + 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() + 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) + data_access_list = DataAccess.objects.filter(data_access_query) + for data_access in data_access_list: + user_ids.add(data_access.owner.id) + for shared_user in data_access.shared_with.all(): + 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 to all clients in the sync group + 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): + if action == "post_add": + for user_id in pk_set: + user = CustomUser.objects.get(id=user_id) + instance.create_access_log(user, 'GRANT_ACCESS') + elif action == "post_remove": + for user_id in pk_set: + user = CustomUser.objects.get(id=user_id) + instance.create_access_log(user, 'REVOKE_ACCESS') + +@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') + +# Others + def generate_unique_code(): characters = string.ascii_letters + string.digits while True: @@ -29,10 +105,6 @@ DISCORD_LOGS_WEBHOOK_URL = 'https://discord.com/api/webhooks/1257987637449588736 def notify_discord_on_create(sender, instance, created, **kwargs): notify_object_creation_on_discord(created, instance, DISCORD_FAILED_CALLS_WEBHOOK_URL) -# @receiver(post_save, sender=CustomUser) -# def notify_user_creation_on_discord(sender, instance, created, **kwargs): -# notify_object_creation_on_discord(created, instance, DISCORD_LOGS_WEBHOOK_URL) - @receiver(post_save, sender=Log) def notify_log_creation_on_discord(sender, instance, created, **kwargs): notify_object_creation_on_discord(created, instance, DISCORD_LOGS_WEBHOOK_URL) @@ -42,7 +114,10 @@ def notify_object_creation_on_discord(created, instance, webhook_url): if created: default_db_engine = settings.DATABASES['default']['ENGINE'] if default_db_engine != 'django.db.backends.sqlite3': - message = f'New {instance.__class__.__name__} created: {instance.discord_string()}' + if hasattr(instance, 'discord_string'): + message = f'New {instance.__class__.__name__} created: {instance.discord_string()}' + else: + message = "no message. Please configure 'discord_string' on your instance" send_discord_message(webhook_url, message) def send_discord_message(webhook_url, content): @@ -54,27 +129,3 @@ def send_discord_message(webhook_url, content): raise ValueError( f'Error sending message to Discord webhook: {response.status_code}, {response.text}' ) - -@receiver(pre_delete, sender=DataAccess) -def data_access_pre_delete(sender, instance, **kwargs): - print('>>> delete data access signal') - try: - model_class = apps.get_model('tournaments', instance.model_name) - obj = model_class.objects.get(id=instance.model_id) - parent_model, parent_id = obj.get_parent_reference() - store_id = None - if hasattr(obj, 'store_id'): - store_id = obj.store_id - - ModelLog.objects.create( - user=instance.shared_with, - model_id=instance.model_id, - model_name=instance.model_name, - operation='REVOKE_ACCESS', - date=timezone.now(), - store_id=store_id, - parent_model_id=parent_id, - parent_model_name=parent_model - ) - except Exception as e: - print(f"Error in data_access_pre_delete signal: {e}")