# from django.shortcuts import render import re import json from rest_framework import viewsets from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status from django.conf import settings from django.utils import timezone from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from collections import defaultdict from urllib.parse import unquote from .serializers import DataAccessSerializer from .utils import generate_random_id, get_serializer, build_serializer_class, get_data, get_serialized_data_by_id, HierarchyOrganizer from .models import ModelLog, BaseModel, SideStoreModel, DataAccess from .registry import model_registry, device_registry from .ws_sender import websocket_sender import logging logger = logging.getLogger(__name__) # class HierarchyApiView(APIView): def add_children_hierarchy(instance, models_dict): sync_models = getattr(settings, 'SYNC_MODEL_CHILDREN_SHARING', {}) if instance.__class__.__name__ in sync_models: relationships = sync_models[instance.__class__.__name__] # 'Match': {'team_scores', 'team_registration', 'player_registrations'} print(f'relationships = {relationships}') current = [instance] for relationship in relationships: print(f'> relationship = {relationship}') values = [] for item in current: value = getattr(item, relationship) if hasattr(value, 'all') and callable(value.all): # This is a queryset from a reverse relationship for related_obj in value.all(): child_model_name = related_obj.__class__.__name__ serializer = get_serializer(related_obj, child_model_name) models_dict[child_model_name][related_obj.id] = serializer.data # print(f'>>> 1 Added child for {relationship}: {child_model_name}') values.extend(value.all()) else: # This is a single object child_model_name = value.__class__.__name__ serializer = get_serializer(value, child_model_name) models_dict[child_model_name][value.id] = serializer.data # print(f'>>> 2 Added child for {relationship}: {child_model_name}') values.append(value) current = values else: add_children_recursively(instance, models_dict) def add_children_recursively(instance, models_dict): """ Recursively add all children of an instance to the updates dictionary. """ child_models = instance.get_children_by_model() for child_model_name, children in child_models.items(): for child in children: if isinstance(child, BaseModel): # print(f'add_children_recursively: {child_model_name}') serializer = get_serializer(child, child_model_name) models_dict[child_model_name][child.id] = serializer.data add_children_recursively(child, models_dict) def add_parents_with_hierarchy_organizer(instance, hierarchy_organizer, current_level=0): """ Recursively add all parents of an instance to the hierarchy organizer. Parents are added at a higher level than their children. """ parent_models = instance.get_parents_by_model() for parent_model_name, parent in parent_models.items(): if isinstance(parent, BaseModel): store_id = None if isinstance(parent, SideStoreModel): store_id = parent.store_id parent_data = { 'model_id': parent.id, 'store_id': store_id } # Add parent at the next level print(f'*** add parent: {parent_model_name}: {parent.id}') hierarchy_organizer.add_item(parent_model_name, parent_data, current_level + 1) # Recursively process parent's parents add_parents_with_hierarchy_organizer(parent, hierarchy_organizer, current_level + 1) def add_parents_recursively(instance, dictionary): """ Recursively add all parents of an instance to the updates dictionary. """ parent_models = instance.get_parents_by_model() for parent_model_name, parent in parent_models.items(): if isinstance(parent, BaseModel): serializer = get_serializer(parent, parent_model_name) dictionary[parent_model_name][parent.id] = serializer.data add_parents_recursively(parent, dictionary) class SynchronizationApi(APIView): permission_classes = [IsAuthenticated] def post(self, request, *args, **kwargs): device_id = request.data.get('device_id') operations = request.data['operations'] results = [] # print(f"DataApi post, {len(operations)} operations / device: {device_id}") models = set() transaction_id = generate_random_id() websocket_sender.add_user_transaction(self.request.user.id, transaction_id, device_id) for op in operations: result = None message = None model_operation = op.get('operation') model_name = op.get('model_name') data = op.get('data') data_id = data.get('id') device_registry.register(data_id, device_id) # print(f'*** YEAH: {model_operation} : {model_name}') try: # print(f'{model_operation} : {model_name}, id = {data['id']}') models.add(model_name) 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 = model_registry.get_model(model_name) if model_operation == 'POST': if data['related_user'] is None: # affect related_user is necessary data['related_user'] = request.user.id serializer = serializer_class(data=data, context={'request': request}) if serializer.is_valid(): instance = serializer.save() result = serializer.data response_status = status.HTTP_201_CREATED else: print(f'{model_name} POST: Data invalid ! {serializer.errors}') message = json.dumps(serializer.errors) response_status = status.HTTP_400_BAD_REQUEST elif model_operation == 'PUT': 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'): serializer.save() result = serializer.data response_status = status.HTTP_200_OK else: result = serializer.data response_status = status.HTTP_203_NON_AUTHORITATIVE_INFORMATION else: print(f'{model_name} PUT: Data invalid ! {serializer.errors}') response_status = status.HTTP_400_BAD_REQUEST elif model_operation == 'DELETE': try: instance = get_data(model_name, data_id) try: instance.delete() response_status = status.HTTP_204_NO_CONTENT except model.DoesNotExist: # a relationship was not found, not good response_status = status.HTTP_400_BAD_REQUEST except model.DoesNotExist: # the data was not found, it's ok response_status = status.HTTP_208_ALREADY_REPORTED print(f'{model_name} DoesNotExist, id: {data_id}') except Exception as e: response_status = status.HTTP_400_BAD_REQUEST import traceback tb = traceback.extract_tb(e.__traceback__) file_name = tb[-1].filename line_number = tb[-1].lineno print(f'OTHER exception on {model_operation} {model_name} : {str(e)}, type: {type(e)}, id: {data.get('id')}, file: {file_name}, line: {line_number}') message = str(e) results.append({ 'api_call_id': op.get('api_call_id'), 'status': response_status, 'data': result, 'message': message }) # print(f"sync POST completed for models: {models}") websocket_sender.remove_user_transaction(self.request.user.id, transaction_id, device_id) return Response({ 'results': results }, status=207) # Multi-Status ## GET def get(self, request, *args, **kwargs): last_update_str = request.query_params.get('last_update') if not last_update_str: return Response({"error": "last_update parameter is required"}, status=status.HTTP_400_BAD_REQUEST) try: decoded_last_update = unquote(last_update_str) # Decodes %2B into + last_update = timezone.datetime.fromisoformat(decoded_last_update) except ValueError: return Response({"error": f"Invalid date format for last_update: {decoded_last_update}"}, status=status.HTTP_400_BAD_REQUEST) print(f'>>> GET last modifications since: {last_update_str} / converted = {last_update}') device_id = request.query_params.get('device_id') logs = self.query_model_logs(last_update, request.user, device_id) print(f'>>> user = {request.user.username} > log count = {logs.count()}, device_id = {device_id}') # Process all logs and get response data result = LogProcessingResult() result.process_logs(logs) response_data = result.get_response_data() return Response(response_data, status=status.HTTP_200_OK) def query_model_logs(self, last_update, user, device_id): log_query = Q(date__gt=last_update, user=user) if device_id: log_query &= ~Q(device_id=device_id) # exclude query return ModelLog.objects.filter(log_query).order_by('date') class LogProcessingResult: """Class to handle all log processing and organize the results.""" def __init__(self): # Initialize all the collections self.updates = defaultdict(dict) self.deletions = defaultdict(list) self.shared_instances = defaultdict(dict) # {model_name: {model_id: instance}} self.grant_instances = defaultdict(dict) # {model_name: {model_id: instance}} self.revoke_info = defaultdict(list) # {model_name: [{model_id, store_id}]} self.shared_relationship_sets = defaultdict(dict) self.shared_relationship_removals = defaultdict(list) self.last_log_date = None def process_logs(self, logs): """Process logs to collect basic operations and handle grant/revoke efficiently.""" for log in logs: self.last_log_date = log.date try: if log.operation in ['POST', 'PUT', 'RELATIONSHIP_SET']: data = get_serialized_data_by_id(log.model_name, log.model_id) self.updates[log.model_name][log.model_id] = data elif log.operation in ['DELETE', 'RELATIONSHIP_REMOVED']: self.deletions[log.model_name].append(log.data_identifier_dict()) elif log.operation == 'SHARED_ACCESS': # Remove any existing revocations for this model_id self._remove_revocation(log.model_name, log.model_id) # Add to grant instances if not already there if log.model_id not in self.shared_instances[log.model_name]: model = model_registry.get_model(log.model_name) try: instance = model.objects.get(id=log.model_id) self.shared_instances[log.model_name][log.model_id] = instance except model.DoesNotExist: pass elif log.operation == 'REVOKED_ACCESS': print(f'revoke access {log.model_id} - {log.store_id}') # Remove any existing grants for this model_id self._remove_grant(log.model_name, log.model_id) # Add to revocations self.revoke_info[log.model_name].append(log.data_identifier_dict()) # elif log.operation == 'RELATIONSHIP_SET': # data = get_serialized_data(log.model_name, log.model_id) # self.relationship_sets[log.model_name][log.model_id] = data # elif log.operation == 'RELATIONSHIP_REMOVED': # self.relationship_removals[log.model_name].append(log.data_identifier_dict()) elif log.operation == 'SHARED_RELATIONSHIP_SET': data = get_serialized_data_by_id(log.model_name, log.model_id) self.shared_relationship_sets[log.model_name][log.model_id] = data elif log.operation == 'SHARED_RELATIONSHIP_REMOVED': self.shared_relationship_removals[log.model_name].append(log.data_identifier_dict()) except ObjectDoesNotExist: pass # Convert updates dict to list for each model for model_name in self.updates: self.updates[model_name] = list(self.updates[model_name].values()) for model_name in self.shared_relationship_sets: self.shared_relationship_sets[model_name] = list(self.shared_relationship_sets[model_name].values()) # return self def _remove_revocation(self, model_name, model_id): """Remove any revocation entries for the specified model and ID.""" if model_name in self.revoke_info: self.revoke_info[model_name] = [ r for r in self.revoke_info[model_name] if r['model_id'] != model_id ] # Clean up empty lists if not self.revoke_info[model_name]: del self.revoke_info[model_name] def _remove_grant(self, model_name, model_id): """Remove any grant entries for the specified model and ID.""" if model_name in self.shared_instances and model_id in self.shared_instances[model_name]: del self.shared_instances[model_name][model_id] # Clean up empty dictionaries if not self.shared_instances[model_name]: del self.shared_instances[model_name] def process_shared(self): """Process grants and their hierarchies.""" shared = defaultdict(dict) grants = defaultdict(dict) try: # Process each grant instance for model_name, instances in self.shared_instances.items(): for model_id, instance in instances.items(): serializer = get_serializer(instance, model_name) shared[model_name][model_id] = serializer.data add_children_hierarchy(instance, grants) add_parents_recursively(instance, grants) except Exception as e: print(f'ERR = {e}') # Convert to lists for model_name in shared: shared[model_name] = list(shared[model_name].values()) for model_name in grants: grants[model_name] = list(grants[model_name].values()) return shared, grants def process_revocations(self): """Process revocations and their hierarchies.""" revocations = defaultdict(list) revocations_parents_organizer = HierarchyOrganizer() # logger.info(f'$$$ process_revocations: {len(self.revoke_info)}') # sync_models = getattr(settings, 'SYNC_MODEL_CHILDREN_SHARING', {}) # First, collect all revocations for model_name, items in self.revoke_info.items(): revocations[model_name].extend(items) logger.info(f'$$$ process_revocations for {model_name}, items = {len(items)}') # Process parent hierarchies for each revoked item model = model_registry.get_model(model_name) for item in items: logger.info(f'$$$ item revoked = {item}') try: instance = model.objects.get(id=item['model_id']) logger.info(f'$$$ process revoked item parents of {model_name} : {item['model_id']}') add_parents_with_hierarchy_organizer(instance, revocations_parents_organizer) # if instance.__class__.__name__ in sync_models: # sharing_related_instances = sharing_related_instances(instance, True) # logger.info(f'$$$ get shared instances: {len(sharing_related_instances)}') # revocations = merge_dicts_dedup(revocations, sharing_related_instances) # # revocations_parents_organizer.sharing_related_instances = instance.sharing_related_instances() except model.DoesNotExist: pass return revocations, revocations_parents_organizer def get_response_data(self): """Construct the complete response data structure.""" shared, grants = self.process_shared() revocations, revocations_parents_organizer = self.process_revocations() # print(f'self.deletions = {dict(self.deletions)}') # print(f'self.shared_relationship_sets = {self.shared_relationship_sets}') # print(f'self.shared_relationship_removals = {self.shared_relationship_removals}') logger.info('--------------------- SYNC') return { "updates": dict(self.updates), "deletions": dict(self.deletions), "shared": dict(shared), "grants": dict(grants), "revocations": dict(revocations), "revocation_parents": revocations_parents_organizer.get_organized_data(), "shared_relationship_sets": self.shared_relationship_sets, "shared_relationship_removals": self.shared_relationship_removals, "date": self.last_log_date } class UserDataAccessApi(APIView): permission_classes = [IsAuthenticated] def get(self, request, *args, **kwargs): try: # Get all DataAccess objects where the requesting user is in shared_with data_access_objects = DataAccess.objects.filter( shared_with=request.user ).prefetch_related('shared_with') # Use prefetch_related for better performance data_by_model = defaultdict(dict) print(f'>>> grants = {len(data_access_objects)}') for data_access in data_access_objects: try: model = model_registry.get_model(data_access.model_name) instance = model.objects.get(id=data_access.model_id) # Get the base data serializer = get_serializer(instance, data_access.model_name) data_by_model[data_access.model_name][data_access.model_id] = serializer.data # Add parents & children recursively add_children_recursively(instance, data_by_model) add_parents_recursively(instance, data_by_model) except ObjectDoesNotExist: continue response_data = { model_name: list(model_data.values()) for model_name, model_data in data_by_model.items() } print(f'response_data = {response_data}') return Response(response_data, status=status.HTTP_200_OK) except Exception as e: return Response({ 'status': 'error', 'message': str(e) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) class DataAccessViewSet(viewsets.ModelViewSet): queryset = DataAccess.objects.all() serializer_class = DataAccessSerializer def get_queryset(self): if self.request.user: return self.queryset.filter(Q(related_user=self.request.user) | Q(shared_with__in=[self.request.user])) return [] def merge_dicts_dedup(dict1, dict2): """Merge two dictionaries, combining arrays and removing duplicates""" all_keys = set(dict1.keys()) | set(dict2.keys()) merged = {} for key in all_keys: arr1 = dict1.get(key, []) arr2 = dict2.get(key, []) # Convert to sets, union them, then back to list to remove duplicates merged[key] = list(set(arr1) | set(arr2)) return merged def sharing_related_instances(instance, identifiers_only): sync_models = getattr(settings, 'SYNC_MODEL_CHILDREN_SHARING', {}) # if self.__class__.__name__ in sync_models: relationships = sync_models[instance.__class__.__name__] # 'Match': {'team_scores', 'team_registration', 'player_registrations'} models_dict = defaultdict(dict) print(f'relationships = {relationships}') current = [instance] for relationship in relationships: print(f'> relationship = {relationship}') values = [] for item in current: value = getattr(item, relationship) if hasattr(value, 'all') and callable(value.all): # This is a queryset from a reverse relationship for related_obj in value.all(): child_model_name = related_obj.__class__.__name__ if identifiers_only: models_dict[child_model_name].append(related_obj.data_identifier_dict()) else: serializer = get_serializer(related_obj, child_model_name) models_dict[child_model_name].append(serializer.data) # print(f'>>> 1 Added child for {relationship}: {child_model_name}') values.extend(value.all()) else: # This is a single object child_model_name = value.__class__.__name__ if identifiers_only: models_dict[child_model_name].append(value.data_identifier_dict()) else: serializer = get_serializer(value, child_model_name) models_dict[child_model_name].append(serializer.data) # serializer = get_serializer(value, child_model_name) # models_dict[child_model_name][value.id] = serializer.data # print(f'>>> 2 Added child for {relationship}: {child_model_name}') values.append(value) current = values return models_dict