From 7bdf38b78dee60eacd73107b827b649dd5af39c5 Mon Sep 17 00:00:00 2001 From: Laurent Date: Mon, 2 Dec 2024 17:37:04 +0100 Subject: [PATCH] Separate the sync from tournaments by creating a new app --- api/serializers.py | 12 +- api/urls.py | 7 +- api/utils.py | 34 ---- api/views.py | 21 +- padelclub_backend/settings.py | 1 + padelclub_backend/settings_app.py | 5 + sync/__init__.py | 0 sync/admin.py | 33 ++++ sync/apps.py | 9 + sync/migrations/0001_initial.py | 48 +++++ sync/migrations/__init__.py | 0 sync/models/__init__.py | 3 + {tournaments => sync}/models/base.py | 3 +- {tournaments => sync}/models/data_access.py | 6 +- {tournaments => sync}/models/model_log.py | 13 +- sync/registry.py | 37 ++++ sync/serializers.py | 8 + sync/signals.py | 167 ++++++++++++++++ sync/tests.py | 3 + sync/utils.py | 42 ++++ api/sync.py => sync/views.py | 35 +++- tournaments/admin.py | 33 +--- ...dellog_users_delete_dataaccess_and_more.py | 23 +++ tournaments/models/__init__.py | 5 +- tournaments/signals.py | 182 +----------------- 25 files changed, 445 insertions(+), 285 deletions(-) create mode 100644 sync/__init__.py create mode 100644 sync/admin.py create mode 100644 sync/apps.py create mode 100644 sync/migrations/0001_initial.py create mode 100644 sync/migrations/__init__.py create mode 100644 sync/models/__init__.py rename {tournaments => sync}/models/base.py (97%) rename {tournaments => sync}/models/data_access.py (87%) rename {tournaments => sync}/models/model_log.py (70%) create mode 100644 sync/registry.py create mode 100644 sync/serializers.py create mode 100644 sync/signals.py create mode 100644 sync/tests.py create mode 100644 sync/utils.py rename api/sync.py => sync/views.py (91%) create mode 100644 tournaments/migrations/0103_remove_modellog_users_delete_dataaccess_and_more.py diff --git a/api/serializers.py b/api/serializers.py index 3f24200..d51473f 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -11,7 +11,7 @@ from django.core.mail import EmailMessage from django.contrib.sites.shortcuts import get_current_site from api.tokens import account_activation_token -from tournaments.models.data_access import DataAccess +# from tournaments.models.data_access import DataAccess class UserSerializer(serializers.ModelSerializer): @@ -227,8 +227,8 @@ class DeviceTokenSerializer(serializers.ModelSerializer): fields = '__all__' read_only_fields = ['user'] -class DataAccessSerializer(serializers.ModelSerializer): - class Meta: - model = DataAccess - fields = '__all__' - read_only_fields = ['user'] +# class DataAccessSerializer(serializers.ModelSerializer): +# class Meta: +# model = DataAccess +# fields = '__all__' +# read_only_fields = ['user'] diff --git a/api/urls.py b/api/urls.py index 02fd7db..eb5a65c 100644 --- a/api/urls.py +++ b/api/urls.py @@ -2,7 +2,8 @@ from django.urls import include, path from rest_framework import routers from rest_framework.authtoken.views import obtain_auth_token -from . import views, sync +from . import views +from sync.views import DataApi, DataAccessViewSet router = routers.DefaultRouter() router.register(r'users', views.UserViewSet) @@ -21,12 +22,12 @@ router.register(r'date-intervals', views.DateIntervalViewSet) router.register(r'failed-api-calls', views.FailedApiCallViewSet) router.register(r'logs', views.LogViewSet) router.register(r'device-token', views.DeviceTokenViewSet) -router.register(r'data-access', views.DataAccessViewSet) +router.register(r'data-access', DataAccessViewSet) urlpatterns = [ path('', include(router.urls)), - path('data/', sync.DataApi.as_view(), name="data"), + path('data/', 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 e73eea1..bbe5dc2 100644 --- a/api/utils.py +++ b/api/utils.py @@ -1,39 +1,5 @@ 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-.]+$' return re.match(email_regex, email) is not None - -def build_serializer_class(model_name): - - # Remove the 's' character at the end if present - if model_name.endswith('s') and not model_name.endswith('ss'): - model_name = model_name[:-1] - - # Capitalize words separated by a dash - words = model_name.split('-') - capitalized_words = [word[0].upper() + word[1:] for word in words] - transformed_string = ''.join(capitalized_words) - - # Add 'Serializer' at the end - transformed_string += 'Serializer' - - module = importlib.import_module('api.serializers') - return getattr(module, transformed_string) - -def get_serializer(instance, model_name): - serializer = build_serializer_class(model_name) - return serializer(instance) - -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) - -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 d2a57f4..a6907a4 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, DataAccess +from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, TournamentSerializer, UserSerializer, ChangePasswordSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, LiveMatchSerializer, PurchaseSerializer, CustomUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer +from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken from rest_framework import viewsets, permissions from rest_framework.authtoken.models import Token @@ -20,7 +20,7 @@ from django.apps import apps from collections import defaultdict from .permissions import IsClubOwner -from .utils import is_valid_email, build_serializer_class +from .utils import is_valid_email class CustomAuthToken(APIView): permission_classes = [] @@ -249,11 +249,7 @@ class DateIntervalViewSet(viewsets.ModelViewSet): return [] return self.queryset.filter( - Q(event__creator=self.request.user) | - Q(event__id__in=DataAccess.objects.filter( - shared_with=self.request.user, - model_name=self.queryset.model.__name__ - ).values_list('model_id', flat=True)) + Q(event__creator=self.request.user) ) class FailedApiCallViewSet(viewsets.ModelViewSet): @@ -306,12 +302,3 @@ class DeviceTokenViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): serializer.save(user=self.request.user) - -class DataAccessViewSet(viewsets.ModelViewSet): - queryset = DataAccess.objects.all() - serializer_class = DataAccessSerializer - - def get_queryset(self): - if self.request.user: - return self.queryset.filter(Q(owner=self.request.user) | Q(shared_with__in=[self.request.user])) - return [] diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py index 2b6f00b..0231ccf 100644 --- a/padelclub_backend/settings.py +++ b/padelclub_backend/settings.py @@ -33,6 +33,7 @@ ALLOWED_HOSTS = ['*'] INSTALLED_APPS = [ 'daphne', + 'sync', 'tournaments', 'django.contrib.admin', 'django.contrib.auth', diff --git a/padelclub_backend/settings_app.py b/padelclub_backend/settings_app.py index ab94757..6125ceb 100644 --- a/padelclub_backend/settings_app.py +++ b/padelclub_backend/settings_app.py @@ -48,3 +48,8 @@ CHANNEL_LAYERS = { "BACKEND": "channels.layers.InMemoryChannelLayer" } } + +SYNC_APPS = { + 'sync': {}, + 'tournaments': { 'exclude': ['Log', 'FailedApiCall'] } +} diff --git a/sync/__init__.py b/sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sync/admin.py b/sync/admin.py new file mode 100644 index 0000000..42c72f8 --- /dev/null +++ b/sync/admin.py @@ -0,0 +1,33 @@ +from django.contrib import admin +from .models import BaseModel, ModelLog, DataAccess + +from django.utils import timezone + +class AutoUpdateAdmin(admin.ModelAdmin): + def save_model(self, request, obj, form, change): + if isinstance(obj, BaseModel): + obj.last_updated_by = request.user + obj.last_update = timezone.now() + super().save_model(request, obj, form, change) + +class ModelLogAdmin(admin.ModelAdmin): + list_display = ['get_users', 'date', 'operation', 'model_id', 'model_name'] + list_filter = ['users'] + ordering = ['-date'] + + @admin.display(description='Users') + def get_users(self, obj): + return ", ".join([str(item) for item in obj.users.all()]) + +class DataAccessAdmin(AutoUpdateAdmin): + list_display = ['owner', 'get_shared_users', 'model_name', 'model_id'] + list_filter = ['owner', 'shared_with'] + ordering = ['-granted_at'] + + @admin.display(description='Shared with') + def get_shared_users(self, obj): + return ", ".join([str(item) for item in obj.shared_with.all()]) + +# Register your models here. +admin.site.register(ModelLog, ModelLogAdmin) +admin.site.register(DataAccess, DataAccessAdmin) diff --git a/sync/apps.py b/sync/apps.py new file mode 100644 index 0000000..8a8a669 --- /dev/null +++ b/sync/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + +class SyncConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'sync' + + def ready(self): + # Import signals when Django starts + import sync.signals diff --git a/sync/migrations/0001_initial.py b/sync/migrations/0001_initial.py new file mode 100644 index 0000000..4cef94e --- /dev/null +++ b/sync/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 5.1 on 2024-12-02 15:39 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='DataAccess', + fields=[ + ('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('last_update', models.DateTimeField(default=django.utils.timezone.now)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('model_name', models.CharField(max_length=50)), + ('model_id', models.UUIDField()), + ('granted_at', models.DateTimeField(auto_now_add=True)), + ('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_data', to=settings.AUTH_USER_MODEL)), + ('shared_with', models.ManyToManyField(related_name='shared_data', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ModelLog', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('model_id', models.UUIDField()), + ('operation', models.CharField(choices=[('POST', 'POST'), ('PUT', 'PUT'), ('DELETE', 'DELETE'), ('GRANT_ACCESS', 'GRANT_ACCESS'), ('REVOKE_ACCESS', 'REVOKE_ACCESS')], max_length=50)), + ('date', models.DateTimeField()), + ('model_name', models.CharField(max_length=50)), + ('store_id', models.CharField(blank=True, max_length=200, null=True)), + ('users', models.ManyToManyField(blank=True, related_name='model_logs', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/sync/migrations/__init__.py b/sync/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sync/models/__init__.py b/sync/models/__init__.py new file mode 100644 index 0000000..4b08618 --- /dev/null +++ b/sync/models/__init__.py @@ -0,0 +1,3 @@ +from .base import BaseModel, SideStoreModel +from .model_log import ModelLog, ModelOperation +from .data_access import DataAccess diff --git a/tournaments/models/base.py b/sync/models/base.py similarity index 97% rename from tournaments/models/base.py rename to sync/models/base.py index 2799bfb..0e6e857 100644 --- a/tournaments/models/base.py +++ b/sync/models/base.py @@ -1,11 +1,12 @@ from django.db import models from django.utils.timezone import now from typing import List, Set +from django.conf import settings 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(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL) class Meta: abstract = True diff --git a/tournaments/models/data_access.py b/sync/models/data_access.py similarity index 87% rename from tournaments/models/data_access.py rename to sync/models/data_access.py index 5b50f14..020f17d 100644 --- a/tournaments/models/data_access.py +++ b/sync/models/data_access.py @@ -2,6 +2,8 @@ from django.db import models from django.utils import timezone from django.apps import apps from django.core.exceptions import ObjectDoesNotExist +from django.conf import settings + import uuid @@ -9,8 +11,8 @@ from . import ModelLog, SideStoreModel, BaseModel 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.ManyToManyField('CustomUser', related_name='shared_data') + owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='owned_data', on_delete=models.CASCADE) + shared_with = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='shared_data') model_name = models.CharField(max_length=50) model_id = models.UUIDField() granted_at = models.DateTimeField(auto_now_add=True) diff --git a/tournaments/models/model_log.py b/sync/models/model_log.py similarity index 70% rename from tournaments/models/model_log.py rename to sync/models/model_log.py index ee77ff0..947d291 100644 --- a/tournaments/models/model_log.py +++ b/sync/models/model_log.py @@ -1,10 +1,19 @@ from django.db import models +from django.conf import settings + import uuid -from . import ModelOperation + + +class ModelOperation(models.TextChoices): + POST = 'POST', 'POST' + PUT = 'PUT', 'PUT' + DELETE = 'DELETE', 'DELETE' + GRANT_ACCESS = 'GRANT_ACCESS', 'GRANT_ACCESS' + REVOKE_ACCESS = 'REVOKE_ACCESS', 'REVOKE_ACCESS' class ModelLog(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) - users = models.ManyToManyField('CustomUser', related_name='model_logs', blank=True) + users = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='model_logs', blank=True) model_id = models.UUIDField() operation = models.CharField(choices=ModelOperation.choices, max_length=50) date = models.DateTimeField() diff --git a/sync/registry.py b/sync/registry.py new file mode 100644 index 0000000..1ff797b --- /dev/null +++ b/sync/registry.py @@ -0,0 +1,37 @@ +from django.conf import settings +from django.apps import apps +from .models import BaseModel + +class SyncRegistry: + def __init__(self): + self._registry = {} + self.load_sync_apps() + + def load_sync_apps(self): + sync_apps = getattr(settings, 'SYNC_APPS', {}) + for app_label, config in sync_apps.items(): + print(f'app_label = {app_label}') + app_models = apps.get_app_config(app_label).get_models() + for model in app_models: + if hasattr(model, '_meta') and not model._meta.abstract: + if issubclass(model, BaseModel): + model_name = model.__name__ + if self.should_sync_model(model_name, config): + self.register(model) + + def should_sync_model(self, model_name, config): + if 'exclude' in config and model_name in config['exclude']: + return False + if 'models' in config and config['models']: + return model_name in config['models'] + return True + + def register(self, model): + print(f'>>> Registers {model.__name__}') + self._registry[model.__name__] = model + + def get_model(self, model_name): + return self._registry.get(model_name) + +# Create singleton instance +sync_registry = SyncRegistry() diff --git a/sync/serializers.py b/sync/serializers.py new file mode 100644 index 0000000..a48fa58 --- /dev/null +++ b/sync/serializers.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from .models import DataAccess + +class DataAccessSerializer(serializers.ModelSerializer): + class Meta: + model = DataAccess + fields = '__all__' + read_only_fields = ['user'] diff --git a/sync/signals.py b/sync/signals.py new file mode 100644 index 0000000..41845ae --- /dev/null +++ b/sync/signals.py @@ -0,0 +1,167 @@ +from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed +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 channels.layers import get_channel_layer +from asgiref.sync import async_to_sync + +from threading import Timer +from functools import partial + +# Synchronization + +User = get_user_model() + +@receiver([pre_save, pre_delete]) +def synchronization_prepare(sender, instance, created=False, **kwargs): + + # some classes are excluded in settings_app.py: SYNC_APPS + if not isinstance(instance, BaseModel): + return + + save_model_log_if_possible(instance, kwargs.get('signal'), created) + +@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): + return + + # print(f'*** signals {sender}') + notify_impacted_users(instance, kwargs.get('signal')) + +def notify_impacted_users(instance, signal): + 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 instance._users_to_notify is not None: + user_ids.update(instance._users_to_notify) + else: + print('no users to notify') + + print(f'notify: {user_ids}') + for user_id in user_ids: + send_user_message(user_id) + +def save_model_log_if_possible(instance, signal, created): + user = instance.last_updated_by + if user: + 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() + + users = {user} + data_access_list = related_data_access(instance) + for data_access in data_access_list: + users.add(data_access.owner) + users.update(data_access.shared_with.all()) + if isinstance(instance, DataAccess): + users.add(instance.owner) + users.update(instance.shared_with.all()) + + user_ids = [user.id for user in users] + + print(f'users to notify: {user_ids}') + instance._users_to_notify = user_ids # save this for the post_save signal + save_model_log(users, operation, model_name, instance.id, store_id) + + else: + print('>>> Model Log could not be created because instance.last_updated_by is None') + +def save_model_log(users, model_operation, model_name, model_id, store_id): + now = timezone.now() + 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.model_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.save() + model_log.users.set(users) + +def related_data_access(instance): + related_instances = instance.related_instances() + related_ids = [ri.id for ri in instance.related_instances()] + related_ids.append(instance.id) + return DataAccess.objects.filter(model_id__in=related_ids) + +def delete_data_access_if_necessary(model_id): + DataAccess.objects.filter(model_id=model_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') + + for user_id in pk_set: + send_user_message(user_id) + +def send_user_message(user_id): + + if not hasattr(send_user_message, '_buffer'): + send_user_message._buffer = set() + send_user_message._timer = None + + send_user_message._buffer.add(user_id) + + if send_user_message._timer: + send_user_message._timer.cancel() + + def send_buffered_messages(): + channel_layer = get_channel_layer() + for buffered_id in send_user_message._buffer: + group_name = f"sync_{buffered_id}" + print(f">>> send to group {group_name}") + async_to_sync(channel_layer.group_send)( + group_name, {"type": "sync.update", "message": "hello"} + ) + send_user_message._buffer.clear() + send_user_message._timer = None + + send_user_message._timer = Timer(0.1, send_buffered_messages) + send_user_message._timer.start() + +@receiver(pre_delete, sender=DataAccess) +def revoke_access_after_delete(sender, instance, **kwargs): + instance.create_revoke_access_log() diff --git a/sync/tests.py b/sync/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/sync/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/sync/utils.py b/sync/utils.py new file mode 100644 index 0000000..69acbad --- /dev/null +++ b/sync/utils.py @@ -0,0 +1,42 @@ +import importlib +from django.apps import apps +from .registry import sync_registry + +def build_serializer_class(model_name): + + # Remove the 's' character at the end if present + if model_name.endswith('s') and not model_name.endswith('ss'): + model_name = model_name[:-1] + + # Capitalize words separated by a dash + words = model_name.split('-') + capitalized_words = [word[0].upper() + word[1:] for word in words] + transformed_string = ''.join(capitalized_words) + + # Add 'Serializer' at the end + transformed_string += 'Serializer' + + # Try to find serializer in current directory first + try: + module = importlib.import_module('api.serializers') + return getattr(module, transformed_string) + except (ImportError, AttributeError): + module = importlib.import_module('.serializers', package=__package__) + return getattr(module, transformed_string) + +def get_serializer(instance, model_name): + serializer = build_serializer_class(model_name) + return serializer(instance) + +def get_data(model_name, model_id): + model = sync_registry.get_model(model_name) + # model = apps.get_model(app_label=app_label, model_name=model_name) + return model.objects.get(id=model_id) + +def get_serialized_data(model_name, model_id): + print(f'model_name = {model_name}') + model = sync_registry.get_model(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/sync.py b/sync/views.py similarity index 91% rename from api/sync.py rename to sync/views.py index 3f6f339..a9b5535 100644 --- a/api/sync.py +++ b/sync/views.py @@ -1,3 +1,8 @@ +from django.shortcuts import render +from .serializers import DataAccessSerializer +from django.db.models import Q + +from rest_framework import viewsets from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -5,7 +10,6 @@ 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 @@ -13,7 +17,9 @@ from urllib.parse import unquote from .utils import get_serializer, build_serializer_class, get_data, get_serialized_data -from tournaments.models import ModelLog, BaseModel, SideStoreModel +from .models import ModelLog, BaseModel, SideStoreModel, DataAccess + +from .registry import sync_registry class DataApi(APIView): permission_classes = [IsAuthenticated] @@ -31,7 +37,8 @@ class DataApi(APIView): 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) + # model = apps.get_model(app_label='tournaments', model_name=model_name) + model = sync_registry.get_model(model_name) if model_operation == 'POST': serializer = serializer_class(data=data, context={'request': request}) @@ -42,7 +49,7 @@ class DataApi(APIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) elif model_operation == 'PUT': data_id = data.get('id') - instance = get_data('tournaments', model_name, data_id) + instance = get_data(model_name, data_id) serializer = serializer_class(instance, data=data, context={'request': request}) if serializer.is_valid(): if instance.last_update <= serializer.validated_data.get('last_update'): @@ -57,7 +64,7 @@ class DataApi(APIView): elif model_operation == 'DELETE': data_id = data.get('id') try: - instance = get_data('tournaments', model_name, data_id) + instance = get_data(model_name, data_id) instance.delete() return Response(status=status.HTTP_204_NO_CONTENT) except model.DoesNotExist: # POST @@ -161,19 +168,20 @@ class DataApi(APIView): last_log_date = log.date try: if log.operation in ['POST', 'PUT']: - data = get_serialized_data('tournaments', log.model_name, log.model_id) + data = get_serialized_data(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) + model = sync_registry.get_model(log.model_name) + + # 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) serializer = get_serializer(instance, log.model_name) - # data = get_serialized_data('tournaments', log.model_name, log.model_id) grants[log.model_name][log.model_id] = serializer.data # instance = model.objects.get(id=log.model_id) self.add_children_recursively(instance, grants) @@ -187,7 +195,7 @@ class DataApi(APIView): }) # Get the model instance and add its parents to revocation_parents - model = apps.get_model('tournaments', model_name=log.model_name) + model = sync_registry.get_model(log.model_name) try: instance = model.objects.get(id=log.model_id) self.add_parents_recursively(instance, revocation_parents, minimal=True) @@ -279,3 +287,12 @@ class DataApi(APIView): dictionary[parent_model_name][parent.id] = serializer.data self.add_parents_recursively(parent, dictionary, minimal) + +class DataAccessViewSet(viewsets.ModelViewSet): + queryset = DataAccess.objects.all() + serializer_class = DataAccessSerializer + + def get_queryset(self): + if self.request.user: + return self.queryset.filter(Q(owner=self.request.user) | Q(shared_with__in=[self.request.user])) + return [] diff --git a/tournaments/admin.py b/tournaments/admin.py index 77512c7..1fa4e29 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -1,24 +1,15 @@ from django.contrib import admin -from django.utils import timezone -from tournaments.models import team_registration -from tournaments.models.data_access import DataAccess from tournaments.models.device_token import DeviceToken -from .models import BaseModel, Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log, ModelLog +from .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log from django.contrib.auth.admin import UserAdmin -from django.contrib.auth.forms import UserCreationForm, UserChangeForm from .forms import CustomUserCreationForm, CustomUserChangeForm from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter -class AutoUpdateAdmin(admin.ModelAdmin): - def save_model(self, request, obj, form, change): - if isinstance(obj, BaseModel): - obj.last_updated_by = request.user - obj.last_update = timezone.now() - super().save_model(request, obj, form, change) +from sync.admin import AutoUpdateAdmin class CustomUserAdmin(UserAdmin): form = CustomUserChangeForm @@ -102,24 +93,6 @@ class LogAdmin(AutoUpdateAdmin): class DeviceTokenAdmin(AutoUpdateAdmin): list_display = ['user', 'value'] -class ModelLogAdmin(admin.ModelAdmin): - list_display = ['get_users', 'date', 'operation', 'model_id', 'model_name'] - list_filter = ['users'] - ordering = ['-date'] - - @admin.display(description='Users') - def get_users(self, obj): - return ", ".join([str(item) for item in obj.users.all()]) - -class DataAccessAdmin(AutoUpdateAdmin): - list_display = ['owner', 'get_shared_users', 'model_name', 'model_id'] - list_filter = ['owner', 'shared_with'] - ordering = ['-granted_at'] - - @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) admin.site.register(Event, EventAdmin) @@ -136,5 +109,3 @@ admin.site.register(DateInterval, DateIntervalAdmin) admin.site.register(FailedApiCall, FailedApiCallAdmin) admin.site.register(Log, LogAdmin) admin.site.register(DeviceToken, DeviceTokenAdmin) -admin.site.register(ModelLog, ModelLogAdmin) -admin.site.register(DataAccess, DataAccessAdmin) diff --git a/tournaments/migrations/0103_remove_modellog_users_delete_dataaccess_and_more.py b/tournaments/migrations/0103_remove_modellog_users_delete_dataaccess_and_more.py new file mode 100644 index 0000000..47524b3 --- /dev/null +++ b/tournaments/migrations/0103_remove_modellog_users_delete_dataaccess_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1 on 2024-12-02 15:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0102_remove_dataaccess_last_hierarchy_update'), + ] + + operations = [ + migrations.RemoveField( + model_name='modellog', + name='users', + ), + migrations.DeleteModel( + name='DataAccess', + ), + migrations.DeleteModel( + name='ModelLog', + ), + ] diff --git a/tournaments/models/__init__.py b/tournaments/models/__init__.py index 8d295c1..3a4746d 100644 --- a/tournaments/models/__init__.py +++ b/tournaments/models/__init__.py @@ -1,4 +1,5 @@ -from .base import BaseModel, SideStoreModel +from sync.models import BaseModel, SideStoreModel + from .custom_user import CustomUser from .club import Club from .court import Court @@ -17,5 +18,3 @@ from .purchase import Purchase from .failed_api_call import FailedApiCall from .log import Log from .device_token import DeviceToken -from .model_log import ModelLog -from .data_access import DataAccess diff --git a/tournaments/signals.py b/tournaments/signals.py index 64e5e66..83c8f55 100644 --- a/tournaments/signals.py +++ b/tournaments/signals.py @@ -1,187 +1,15 @@ import random import string -from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed -from django.db.transaction import DatabaseError +from django.db.models.signals import post_save +# 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 django.apps import apps +# from django.db.models import Q -from .models import Club, FailedApiCall, CustomUser, Log, DataAccess, ModelLog, ModelOperation, BaseModel, SideStoreModel +from .models import Club, FailedApiCall, Log import requests -from channels.layers import get_channel_layer -from asgiref.sync import async_to_sync - -from threading import Timer -from functools import partial - -# Synchronization - -@receiver([pre_save, pre_delete]) -def synchronization_prepare(sender, instance, created=False, **kwargs): - - if not isinstance(instance, BaseModel): - return - if sender in [FailedApiCall, Log, ModelLog]: - return - - save_model_log_if_possible(instance, kwargs.get('signal'), created) - # if not isinstance(instance, DataAccess): - # update_data_access(instance) - -@receiver([post_save, post_delete]) -def synchronization_notifications(sender, instance, created=False, **kwargs): - """ - Signal handler that sends notifications through WebSocket channels when model instances are saved or deleted. - The function creates a WebSocket group name for each affected user and sends a sync update message - to all clients connected to that group. - """ - - if not isinstance(instance, BaseModel): - return - if sender in [FailedApiCall, Log, ModelLog]: - return - - # print(f'*** signals {sender}') - notify_impacted_users(instance, kwargs.get('signal')) - -def notify_impacted_users(instance, signal): - user_ids = set() - # add impacted users - if isinstance(instance, CustomUser): - 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 instance._users_to_notify is not None: - user_ids.update(instance._users_to_notify) - else: - print('no users to notify') - - print(f'notify: {user_ids}') - for user_id in user_ids: - send_user_message(user_id) - -def save_model_log_if_possible(instance, signal, created): - user = instance.last_updated_by - if user: - 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() - - users = {user} - data_access_list = related_data_access(instance) - for data_access in data_access_list: - users.add(data_access.owner) - users.update(data_access.shared_with.all()) - if isinstance(instance, DataAccess): - users.add(instance.owner) - users.update(instance.shared_with.all()) - - user_ids = [user.id for user in users] - - print(f'users to notify: {user_ids}') - instance._users_to_notify = user_ids # save this for the post_save signal - save_model_log(users, operation, model_name, instance.id, store_id) - - else: - print('>>> Model Log could not be created because instance.last_updated_by is None') - -def save_model_log(users, model_operation, model_name, model_id, store_id): - now = timezone.now() - 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.model_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.save() - model_log.users.set(users) - -def related_data_access(instance): - related_instances = instance.related_instances() - related_ids = [ri.id for ri in instance.related_instances()] - related_ids.append(instance.id) - return DataAccess.objects.filter(model_id__in=related_ids) - -# def update_data_access(instance): -# data_access_list = related_data_access(instance) - -# for data_access in data_access_list: -# date = timezone.now() if instance.last_update is None else instance.last_update -# data_access.last_hierarchy_update = date -# data_access.save() - -def delete_data_access_if_necessary(model_id): - DataAccess.objects.filter(model_id=model_id).delete() - -@receiver(m2m_changed, sender=DataAccess.shared_with.through) -def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs): - - users = CustomUser.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') - - for user_id in pk_set: - send_user_message(user_id) - -def send_user_message(user_id): - - if not hasattr(send_user_message, '_buffer'): - send_user_message._buffer = set() - send_user_message._timer = None - - send_user_message._buffer.add(user_id) - - if send_user_message._timer: - send_user_message._timer.cancel() - - def send_buffered_messages(): - channel_layer = get_channel_layer() - for buffered_id in send_user_message._buffer: - group_name = f"sync_{buffered_id}" - print(f">>> send to group {group_name}") - async_to_sync(channel_layer.group_send)( - group_name, {"type": "sync.update", "message": "hello"} - ) - send_user_message._buffer.clear() - send_user_message._timer = None - - send_user_message._timer = Timer(0.1, send_buffered_messages) - send_user_message._timer.start() - -@receiver(pre_delete, sender=DataAccess) -def revoke_access_after_delete(sender, instance, **kwargs): - instance.create_revoke_access_log() - # Others def generate_unique_code():