Separate the sync from tournaments by creating a new app

sync
Laurent 11 months ago
parent 9b81dc49e8
commit 7bdf38b78d
  1. 12
      api/serializers.py
  2. 7
      api/urls.py
  3. 34
      api/utils.py
  4. 21
      api/views.py
  5. 1
      padelclub_backend/settings.py
  6. 5
      padelclub_backend/settings_app.py
  7. 0
      sync/__init__.py
  8. 33
      sync/admin.py
  9. 9
      sync/apps.py
  10. 48
      sync/migrations/0001_initial.py
  11. 0
      sync/migrations/__init__.py
  12. 3
      sync/models/__init__.py
  13. 3
      sync/models/base.py
  14. 6
      sync/models/data_access.py
  15. 13
      sync/models/model_log.py
  16. 37
      sync/registry.py
  17. 8
      sync/serializers.py
  18. 167
      sync/signals.py
  19. 3
      sync/tests.py
  20. 42
      sync/utils.py
  21. 35
      sync/views.py
  22. 33
      tournaments/admin.py
  23. 23
      tournaments/migrations/0103_remove_modellog_users_delete_dataaccess_and_more.py
  24. 5
      tournaments/models/__init__.py
  25. 182
      tournaments/signals.py

@ -11,7 +11,7 @@ from django.core.mail import EmailMessage
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
from api.tokens import account_activation_token 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): class UserSerializer(serializers.ModelSerializer):
@ -227,8 +227,8 @@ class DeviceTokenSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
read_only_fields = ['user'] read_only_fields = ['user']
class DataAccessSerializer(serializers.ModelSerializer): # class DataAccessSerializer(serializers.ModelSerializer):
class Meta: # class Meta:
model = DataAccess # model = DataAccess
fields = '__all__' # fields = '__all__'
read_only_fields = ['user'] # read_only_fields = ['user']

@ -2,7 +2,8 @@ from django.urls import include, path
from rest_framework import routers from rest_framework import routers
from rest_framework.authtoken.views import obtain_auth_token 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 = routers.DefaultRouter()
router.register(r'users', views.UserViewSet) 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'failed-api-calls', views.FailedApiCallViewSet)
router.register(r'logs', views.LogViewSet) router.register(r'logs', views.LogViewSet)
router.register(r'device-token', views.DeviceTokenViewSet) router.register(r'device-token', views.DeviceTokenViewSet)
router.register(r'data-access', views.DataAccessViewSet) router.register(r'data-access', DataAccessViewSet)
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), 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('api-token-auth/', obtain_auth_token, name='api_token_auth'),
path("user-by-token/", views.user_by_token, name="user_by_token"), path("user-by-token/", views.user_by_token, name="user_by_token"),

@ -1,39 +1,5 @@
import re import re
import importlib
from django.apps import apps
def is_valid_email(email): def is_valid_email(email):
email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$'
return re.match(email_regex, email) is not None 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

@ -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 .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, DataAccess 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 import viewsets, permissions
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
@ -20,7 +20,7 @@ from django.apps import apps
from collections import defaultdict from collections import defaultdict
from .permissions import IsClubOwner from .permissions import IsClubOwner
from .utils import is_valid_email, build_serializer_class from .utils import is_valid_email
class CustomAuthToken(APIView): class CustomAuthToken(APIView):
permission_classes = [] permission_classes = []
@ -249,11 +249,7 @@ class DateIntervalViewSet(viewsets.ModelViewSet):
return [] return []
return self.queryset.filter( return self.queryset.filter(
Q(event__creator=self.request.user) | 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))
) )
class FailedApiCallViewSet(viewsets.ModelViewSet): class FailedApiCallViewSet(viewsets.ModelViewSet):
@ -306,12 +302,3 @@ class DeviceTokenViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(user=self.request.user) 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 []

@ -33,6 +33,7 @@ ALLOWED_HOSTS = ['*']
INSTALLED_APPS = [ INSTALLED_APPS = [
'daphne', 'daphne',
'sync',
'tournaments', 'tournaments',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',

@ -48,3 +48,8 @@ CHANNEL_LAYERS = {
"BACKEND": "channels.layers.InMemoryChannelLayer" "BACKEND": "channels.layers.InMemoryChannelLayer"
} }
} }
SYNC_APPS = {
'sync': {},
'tournaments': { 'exclude': ['Log', 'FailedApiCall'] }
}

@ -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)

@ -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

@ -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)),
],
),
]

@ -0,0 +1,3 @@
from .base import BaseModel, SideStoreModel
from .model_log import ModelLog, ModelOperation
from .data_access import DataAccess

@ -1,11 +1,12 @@
from django.db import models from django.db import models
from django.utils.timezone import now from django.utils.timezone import now
from typing import List, Set from typing import List, Set
from django.conf import settings
class BaseModel(models.Model): class BaseModel(models.Model):
creation_date = models.DateTimeField(default=now, editable=False) creation_date = models.DateTimeField(default=now, editable=False)
last_update = models.DateTimeField(default=now) 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: class Meta:
abstract = True abstract = True

@ -2,6 +2,8 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from django.apps import apps from django.apps import apps
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings
import uuid import uuid
@ -9,8 +11,8 @@ from . import ModelLog, SideStoreModel, BaseModel
class DataAccess(BaseModel): class DataAccess(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4) id = models.UUIDField(primary_key=True, default=uuid.uuid4)
owner = models.ForeignKey('CustomUser', related_name='owned_data', on_delete=models.CASCADE) owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='owned_data', on_delete=models.CASCADE)
shared_with = models.ManyToManyField('CustomUser', related_name='shared_data') shared_with = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='shared_data')
model_name = models.CharField(max_length=50) model_name = models.CharField(max_length=50)
model_id = models.UUIDField() model_id = models.UUIDField()
granted_at = models.DateTimeField(auto_now_add=True) granted_at = models.DateTimeField(auto_now_add=True)

@ -1,10 +1,19 @@
from django.db import models from django.db import models
from django.conf import settings
import uuid 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): class ModelLog(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) 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() model_id = models.UUIDField()
operation = models.CharField(choices=ModelOperation.choices, max_length=50) operation = models.CharField(choices=ModelOperation.choices, max_length=50)
date = models.DateTimeField() date = models.DateTimeField()

@ -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()

@ -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']

@ -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()

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

@ -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

@ -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.views import APIView
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
@ -5,7 +10,6 @@ from rest_framework import status
from django.apps import apps from django.apps import apps
from django.utils import timezone from django.utils import timezone
from django.db.models import Q
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from collections import defaultdict 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 .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): class DataApi(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@ -31,7 +37,8 @@ class DataApi(APIView):
serializer_class = build_serializer_class(model_name) serializer_class = build_serializer_class(model_name)
data['last_updated_by'] = request.user.id # always refresh the user performing the operation 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': if model_operation == 'POST':
serializer = serializer_class(data=data, context={'request': request}) 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) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
elif model_operation == 'PUT': elif model_operation == 'PUT':
data_id = data.get('id') 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}) serializer = serializer_class(instance, data=data, context={'request': request})
if serializer.is_valid(): if serializer.is_valid():
if instance.last_update <= serializer.validated_data.get('last_update'): if instance.last_update <= serializer.validated_data.get('last_update'):
@ -57,7 +64,7 @@ class DataApi(APIView):
elif model_operation == 'DELETE': elif model_operation == 'DELETE':
data_id = data.get('id') data_id = data.get('id')
try: try:
instance = get_data('tournaments', model_name, data_id) instance = get_data(model_name, data_id)
instance.delete() instance.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except model.DoesNotExist: # POST except model.DoesNotExist: # POST
@ -161,19 +168,20 @@ class DataApi(APIView):
last_log_date = log.date last_log_date = log.date
try: try:
if log.operation in ['POST', 'PUT']: 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 updates[log.model_name][log.model_id] = data
elif log.operation == 'DELETE': elif log.operation == 'DELETE':
deletions[log.model_name].append({'model_id': log.model_id, 'store_id': log.store_id}) deletions[log.model_name].append({'model_id': log.model_id, 'store_id': log.store_id})
elif log.operation == 'GRANT_ACCESS': 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) instance = model.objects.get(id=log.model_id)
# serializer_class = build_serializer_class(log.model_name) # serializer_class = build_serializer_class(log.model_name)
# serializer = serializer_class(instance) # serializer = serializer_class(instance)
serializer = get_serializer(instance, log.model_name) 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 grants[log.model_name][log.model_id] = serializer.data
# instance = model.objects.get(id=log.model_id) # instance = model.objects.get(id=log.model_id)
self.add_children_recursively(instance, grants) self.add_children_recursively(instance, grants)
@ -187,7 +195,7 @@ class DataApi(APIView):
}) })
# Get the model instance and add its parents to revocation_parents # 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: try:
instance = model.objects.get(id=log.model_id) instance = model.objects.get(id=log.model_id)
self.add_parents_recursively(instance, revocation_parents, minimal=True) self.add_parents_recursively(instance, revocation_parents, minimal=True)
@ -279,3 +287,12 @@ class DataApi(APIView):
dictionary[parent_model_name][parent.id] = serializer.data dictionary[parent_model_name][parent.id] = serializer.data
self.add_parents_recursively(parent, dictionary, minimal) 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 []

@ -1,24 +1,15 @@
from django.contrib import admin 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 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.admin import UserAdmin
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .forms import CustomUserCreationForm, CustomUserChangeForm from .forms import CustomUserCreationForm, CustomUserChangeForm
from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter
class AutoUpdateAdmin(admin.ModelAdmin): from sync.admin import AutoUpdateAdmin
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 CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
form = CustomUserChangeForm form = CustomUserChangeForm
@ -102,24 +93,6 @@ class LogAdmin(AutoUpdateAdmin):
class DeviceTokenAdmin(AutoUpdateAdmin): class DeviceTokenAdmin(AutoUpdateAdmin):
list_display = ['user', 'value'] 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(CustomUser, CustomUserAdmin)
admin.site.register(Club, ClubAdmin) admin.site.register(Club, ClubAdmin)
admin.site.register(Event, EventAdmin) admin.site.register(Event, EventAdmin)
@ -136,5 +109,3 @@ admin.site.register(DateInterval, DateIntervalAdmin)
admin.site.register(FailedApiCall, FailedApiCallAdmin) admin.site.register(FailedApiCall, FailedApiCallAdmin)
admin.site.register(Log, LogAdmin) admin.site.register(Log, LogAdmin)
admin.site.register(DeviceToken, DeviceTokenAdmin) admin.site.register(DeviceToken, DeviceTokenAdmin)
admin.site.register(ModelLog, ModelLogAdmin)
admin.site.register(DataAccess, DataAccessAdmin)

@ -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',
),
]

@ -1,4 +1,5 @@
from .base import BaseModel, SideStoreModel from sync.models import BaseModel, SideStoreModel
from .custom_user import CustomUser from .custom_user import CustomUser
from .club import Club from .club import Club
from .court import Court from .court import Court
@ -17,5 +18,3 @@ from .purchase import Purchase
from .failed_api_call import FailedApiCall from .failed_api_call import FailedApiCall
from .log import Log from .log import Log
from .device_token import DeviceToken from .device_token import DeviceToken
from .model_log import ModelLog
from .data_access import DataAccess

@ -1,187 +1,15 @@
import random import random
import string import string
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed from django.db.models.signals import post_save
from django.db.transaction import DatabaseError # from django.db.transaction import DatabaseError
from django.dispatch import receiver from django.dispatch import receiver
from django.conf import settings from django.conf import settings
from django.apps import apps # from django.apps import apps
from django.utils import timezone # from django.db.models import Q
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 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 # Others
def generate_unique_code(): def generate_unique_code():

Loading…
Cancel
Save