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