parent
9b81dc49e8
commit
7bdf38b78d
@ -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.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 |
||||
@ -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() |
||||
@ -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 |
||||
@ -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', |
||||
), |
||||
] |
||||
Loading…
Reference in new issue