From 2f12b901acac8d4f82df7b8bd7d57df2a80906db Mon Sep 17 00:00:00 2001 From: Degra02 Date: Sat, 2 Aug 2025 07:54:26 +0200 Subject: [PATCH] test another snap-to-edge and level clone --- src/c3nav/editor/api/endpoints.py | 29 +- src/c3nav/editor/api/schemas.py | 26 +- .../editor/static/editor/css/editor.scss | 3 - src/c3nav/editor/static/editor/js/editor.js | 268 ++++++++++++++++-- src/c3nav/editor/templates/editor/level.html | 36 +++ src/c3nav/editor/utils.py | 243 ++++++++++++++++ src/c3nav/mapdata/apps.py | 1 + .../migrations/0139_add_cloned_item_sync.py | 34 +++ src/c3nav/mapdata/models/__init__.py | 3 +- src/c3nav/mapdata/models/sync.py | 47 +++ src/c3nav/mapdata/signals/sync.py | 95 +++++++ .../0015_alter_nodemessage_message_type.py | 18 ++ 12 files changed, 774 insertions(+), 29 deletions(-) create mode 100644 src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py create mode 100644 src/c3nav/mapdata/models/sync.py create mode 100644 src/c3nav/mapdata/signals/sync.py create mode 100644 src/c3nav/mesh/migrations/0015_alter_nodemessage_message_type.py diff --git a/src/c3nav/editor/api/endpoints.py b/src/c3nav/editor/api/endpoints.py index 10734aae..dcc1576e 100644 --- a/src/c3nav/editor/api/endpoints.py +++ b/src/c3nav/editor/api/endpoints.py @@ -8,7 +8,7 @@ from c3nav.api.exceptions import API404 from c3nav.editor.api.base import api_etag_with_update_cache_key from c3nav.editor.api.geometries import get_level_geometries_result, get_space_geometries_result from c3nav.editor.api.schemas import EditorGeometriesElemSchema, EditorID, GeometryStylesSchema, UpdateCacheKey, \ - EditorBeaconsLookup + EditorBeaconsLookup, CloneFloorRequestSchema, CloneFloorResponseSchema from c3nav.editor.views.base import editor_etag_func, accesses_mapdata from c3nav.mapdata.api.base import api_etag from c3nav.mapdata.models import Source @@ -145,4 +145,29 @@ def beacons_lookup(request): return EditorBeaconsLookup( wifi_beacons=wifi_beacons, ibeacons=ibeacons, - ).model_dump(mode="json") \ No newline at end of file + ).model_dump(mode="json") + + +@editor_api_router.post('/clone-floor/', summary="clone floor items", + description="clone selected map items from one floor to another", + response={200: CloneFloorResponseSchema, **API404.dict(), + **auth_permission_responses}, + openapi_extra={"security": [{"APIKeyAuth": ["editor_access", "write"]}]}) +def clone_floor(request, data: CloneFloorRequestSchema): + from c3nav.editor.utils import clone_level_items + + try: + result = clone_level_items( + request=request, + source_level_id=data.source_level_id, + target_level_id=data.target_level_id, + items=data.items, + keep_sync=data.keep_sync + ) + return result + except Exception as e: + return CloneFloorResponseSchema( + success=False, + cloned_items=[], + message=f"Error cloning items: {str(e)}" + ).model_dump(mode="json") \ No newline at end of file diff --git a/src/c3nav/editor/api/schemas.py b/src/c3nav/editor/api/schemas.py index e7abacc0..ace9bbcf 100644 --- a/src/c3nav/editor/api/schemas.py +++ b/src/c3nav/editor/api/schemas.py @@ -124,4 +124,28 @@ class EditorBeaconsLookup(BaseSchema): EditorBeacon ] ] - ] \ No newline at end of file + ] + + +class CloneItemSchema(BaseSchema): + item_type: Annotated[str, APIField(title="geometry type (e.g., 'area', 'obstacle', 'space')")] + item_id: EditorID + + +class CloneFloorRequestSchema(BaseSchema): + source_level_id: EditorID + target_level_id: EditorID + items: list[CloneItemSchema] + keep_sync: Annotated[bool, APIField(default=False, title="keep cloned items synchronized across levels")] + + +class ClonedItemResult(BaseSchema): + item_type: str + original_id: EditorID + cloned_id: EditorID + + +class CloneFloorResponseSchema(BaseSchema): + success: bool + cloned_items: list[ClonedItemResult] + message: str \ No newline at end of file diff --git a/src/c3nav/editor/static/editor/css/editor.scss b/src/c3nav/editor/static/editor/css/editor.scss index 858c2569..14c8cc3d 100644 --- a/src/c3nav/editor/static/editor/css/editor.scss +++ b/src/c3nav/editor/static/editor/css/editor.scss @@ -607,7 +607,6 @@ label.theme-color-label { z-index: 1000; pointer-events: none; } -<<<<<<< HEAD @keyframes snap-pulse { 0% { @@ -676,5 +675,3 @@ label.theme-color-label { opacity: 0.7; } } -======= ->>>>>>> 90d3c9b7f5f567b6bbe37f3432b49f65710bf26c diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index c6255c3a..0e3f5a59 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -1759,7 +1759,6 @@ editor = { } }); -<<<<<<< HEAD // check current editing shape with infinite extension enabled if (editor._current_editing_shape) { var currentShapeSnap = editor._find_closest_point_on_geometry( @@ -1771,9 +1770,6 @@ editor = { } // find closest candidate -======= - // Find the closest candidate ->>>>>>> 90d3c9b7f5f567b6bbe37f3432b49f65710bf26c if (candidates.length > 0) { candidates.sort(function(a, b) { return a.distance - b.distance; }); var best = candidates[0]; @@ -1904,13 +1900,8 @@ editor = { var p1 = coordinates[i]; var p2 = coordinates[(i + 1) % coordinates.length]; -<<<<<<< HEAD var snapPoint = editor._find_closest_point_on_edge(p1, p2, targetLatLng, targetMapPoint, allowInfiniteExtension); -======= - - var snapPoint = editor._find_closest_point_on_edge(p1, p2, targetLatLng, targetMapPoint); ->>>>>>> 90d3c9b7f5f567b6bbe37f3432b49f65710bf26c if (snapPoint && snapPoint.distance < closestDistance) { closestDistance = snapPoint.distance; closestPoint = snapPoint; @@ -2084,7 +2075,6 @@ editor = { _show_snap_indicator: function(latlng, snapInfo) { editor._clear_snap_indicators(); -<<<<<<< HEAD // snap point indicator var indicator = L.circleMarker(latlng, { @@ -2092,18 +2082,6 @@ editor = { color: '#ff6b6b', fillColor: '#ff6b6b', fillOpacity: 0.8, -======= - - var size = 0.001; // adjust this to control square size - - var bounds = [ - [latlng.lat - size, latlng.lng - size], - [latlng.lat + size, latlng.lng + size] - ]; - - var indicator = L.rectangle(bounds, { - color: '#666', ->>>>>>> 90d3c9b7f5f567b6bbe37f3432b49f65710bf26c weight: 2, lineCap: "square", fillOpacity: 1., @@ -2532,6 +2510,252 @@ OverlayControl = L.Control.extend({ }, }); +// Clone Floor Functionality +editor.cloneFloor = { + selectedItems: [], + isSelectionMode: false, + + init: function() { + // Check if clone floor elements exist in the template + if ($('#clone-floor-btn').length > 0) { + // Bind click event to the button that's already in the template + $('#clone-floor-btn').click(editor.cloneFloor.toggleSelectionMode); + + // Bind events to the selector elements that are already in the template + $('#execute-clone-btn').click(editor.cloneFloor.executeClone); + $('#cancel-clone-btn').click(editor.cloneFloor.cancelSelection); + + console.log('Clone floor functionality initialized'); + } else { + console.log('Clone floor button not found in template'); + } + }, + + toggleSelectionMode: function() { + if (editor.cloneFloor.isSelectionMode) { + editor.cloneFloor.cancelSelection(); + } else { + editor.cloneFloor.startSelection(); + } + }, + + startSelection: function() { + editor.cloneFloor.isSelectionMode = true; + editor.cloneFloor.selectedItems = []; + + console.log('Clone floor: Starting selection mode'); + + $('#clone-floor-btn').html(' Cancel Selection').removeClass('btn-info').addClass('btn-warning'); + $('#clone-floor-selector').show(); + editor.cloneFloor.updateSelectedCount(); + + // Add click handlers to geometry items + if (editor._geometries_layer) { + let layerCount = 0; + editor._geometries_layer.eachLayer(function(layer) { + if (layer.feature && layer.feature.properties) { + layer.on('click', editor.cloneFloor.onItemClick); + layer.setStyle({cursor: 'pointer'}); + layerCount++; + } + }); + console.log('Clone floor: Added click handlers to', layerCount, 'layers'); + } else { + console.log('Clone floor: No geometries layer found'); + } + + // Disable map editing + editor.map.doubleClickZoom.disable(); + }, + + cancelSelection: function() { + editor.cloneFloor.isSelectionMode = false; + editor.cloneFloor.selectedItems = []; + + $('#clone-floor-btn').html(' Clone to Floor').removeClass('btn-warning').addClass('btn-info'); + $('#clone-floor-selector').hide(); + + // Remove click handlers and reset styles + if (editor._geometries_layer) { + editor._geometries_layer.eachLayer(function(layer) { + if (layer.feature && layer.feature.properties) { + layer.off('click', editor.cloneFloor.onItemClick); + layer.setStyle({cursor: 'default'}); + } + }); + } + + // Re-enable map editing + editor.map.doubleClickZoom.enable(); + + // Reset visual selection + editor.cloneFloor.updateVisualSelection(); + }, + + onItemClick: function(e) { + if (!editor.cloneFloor.isSelectionMode) return; + + e.originalEvent.stopPropagation(); + e.originalEvent.preventDefault(); + + const layer = e.target; + const feature = layer.feature; + + console.log('Clone floor: Item clicked', feature); + + if (!feature || !feature.properties) { + console.log('Clone floor: No feature or properties found'); + return; + } + + const itemId = feature.properties.id; + const itemType = feature.properties.type; + + console.log('Clone floor: Item ID:', itemId, 'Type:', itemType); + + // Check if item is already selected + const existingIndex = editor.cloneFloor.selectedItems.findIndex( + item => item.item_id === itemId && item.item_type === itemType + ); + + if (existingIndex >= 0) { + // Deselect item + editor.cloneFloor.selectedItems.splice(existingIndex, 1); + console.log('Clone floor: Item deselected'); + } else { + // Select item + editor.cloneFloor.selectedItems.push({ + item_id: itemId, + item_type: itemType + }); + console.log('Clone floor: Item selected'); + } + + editor.cloneFloor.updateSelectedCount(); + editor.cloneFloor.updateVisualSelection(); + }, + + updateSelectedCount: function() { + $('#selected-count').text(editor.cloneFloor.selectedItems.length); + }, + + updateVisualSelection: function() { + if (!editor._geometries_layer) return; + + // Reset all styles first + editor._geometries_layer.eachLayer(function(layer) { + if (layer.feature && layer.feature.properties) { + layer.setStyle(editor._get_geometry_style(layer.feature)); + } + }); + + // Highlight selected items + editor._geometries_layer.eachLayer(function(layer) { + if (layer.feature && layer.feature.properties) { + const isSelected = editor.cloneFloor.selectedItems.some( + item => item.item_id === layer.feature.properties.id && + item.item_type === layer.feature.properties.type + ); + + if (isSelected) { + layer.setStyle({ + stroke: true, + color: '#ff0000', + weight: 3, + fillOpacity: 0.7 + }); + } + } + }); + }, + + + executeClone: function() { + const targetLevelId = $('#target-level-select').val(); + const keepSync = $('#keep-sync-checkbox').is(':checked'); + + if (!targetLevelId) { + alert('Please select a target level'); + return; + } + + if (editor.cloneFloor.selectedItems.length === 0) { + alert('Please select items to clone'); + return; + } + + // Get current level ID + const currentLevelId = editor._level_control.current_level_id; + + if (currentLevelId === parseInt(targetLevelId)) { + alert('Source and target levels cannot be the same'); + return; + } + + // Show loading state + $('#execute-clone-btn').prop('disabled', true).html(' Cloning...'); + + // Prepare request data + const requestData = { + source_level_id: currentLevelId, + target_level_id: parseInt(targetLevelId), + items: editor.cloneFloor.selectedItems, + keep_sync: keepSync + }; + + // Make API call + console.log('Clone floor: Making API call with data:', requestData); + + // Use the raw fetch API with better error handling + c3nav_api.authenticated().then(function() { + return fetch(c3nav_api.make_url('editor/clone-floor/'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': c3nav_api.key, + 'Accept': 'application/json' + }, + body: JSON.stringify(requestData) + }); + }) + .then(function(response) { + console.log('Clone floor: API response status:', response.status); + + if (!response.ok) { + // Log the actual response text for debugging + return response.text().then(function(text) { + console.error('Clone floor: API error response:', text); + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + }); + } + + return response.json(); + }) + .then(function(data) { + console.log('Clone floor: API response data:', data); + + if (data.success) { + alert(`Successfully cloned ${data.cloned_items.length} items: ${data.message}`); + editor.cloneFloor.cancelSelection(); + } else { + alert(`Clone failed: ${data.message}`); + } + }) + .catch(function(error) { + console.error('Clone floor: Error details:', error); + alert(`Clone failed: ${error.message}`); + }) + .finally(function() { + $('#execute-clone-btn').prop('disabled', false).html(' Clone Items'); + }); + } +}; + if ($('#sidebar').length) { editor.init(); + // Initialize clone floor functionality after editor is ready + setTimeout(function() { + editor.cloneFloor.init(); + }, 1000); } diff --git a/src/c3nav/editor/templates/editor/level.html b/src/c3nav/editor/templates/editor/level.html index fdd08ee3..91e85297 100644 --- a/src/c3nav/editor/templates/editor/level.html +++ b/src/c3nav/editor/templates/editor/level.html @@ -16,6 +16,9 @@ {% trans 'Level' as model_title %} {% blocktrans %}Edit {{ model_title }}{% endblocktrans %} +

{% if level.on_top_of == None %} @@ -25,6 +28,39 @@ {% endif %}

+ + + {% url 'editor.levels.graph' level=level.pk as graph_url %} {% url 'editor.levels.overlays' level=level.pk as overlays_url %} {% include 'editor/fragment_child_models.html' with graph_url=graph_url overlays_url=overlays_url %} diff --git a/src/c3nav/editor/utils.py b/src/c3nav/editor/utils.py index 7c846405..562f103a 100644 --- a/src/c3nav/editor/utils.py +++ b/src/c3nav/editor/utils.py @@ -55,3 +55,246 @@ class SpaceChildEditUtils(DefaultEditUtils): @property def _geometry_url(self): return '/api/v2/editor/geometries/space/'+str(self.space.pk) # todo: resolve correctly + + +def clone_level_items(request, source_level_id, target_level_id, items, keep_sync=False): + """ + Clone selected map items from one level to another. + + Args: + request: Django request object + source_level_id: ID of the source level + target_level_id: ID of the target level + items: List of items to clone (each with item_type and item_id) + keep_sync: Whether to keep cloned items synchronized + + Returns: + Dictionary with success status, cloned items list, and message + """ + from django.apps import apps + from django.contrib.contenttypes.models import ContentType + from c3nav.mapdata.models import Level + from c3nav.mapdata.models.sync import ClonedItemSync + from c3nav.editor.api.schemas import CloneFloorResponseSchema + + # Get the source and target levels + try: + source_level = Level.objects.get(pk=source_level_id) + target_level = Level.objects.get(pk=target_level_id) + except Level.DoesNotExist: + return CloneFloorResponseSchema( + success=False, + cloned_items=[], + message="Source or target level not found" + ).model_dump(mode="json") + + # Check if user has editor permissions (simplified check for API) + if not hasattr(request, 'user') or not request.user.is_authenticated: + return CloneFloorResponseSchema( + success=False, + cloned_items=[], + message="Authentication required" + ).model_dump(mode="json") + + cloned_items = [] + + # Define supported item types and their model mappings + SUPPORTED_TYPES = { + 'area': 'Area', + 'obstacle': 'Obstacle', + 'lineobstacle': 'LineObstacle', + 'stair': 'Stair', + 'ramp': 'Ramp', + 'hole': 'Hole', + 'column': 'Column', + 'poi': 'POI', + 'altitudemarker': 'AltitudeMarker', + 'space': 'Space', + 'building': 'Building', + 'door': 'Door' + } + + try: + print(f"Starting to process {len(items)} items") + + for i, item in enumerate(items): + item_type = item.item_type.lower() + item_id = item.item_id + + print(f"Processing item {i+1}/{len(items)}: {item_type} with ID {item_id}") + + if item_type not in SUPPORTED_TYPES: + print(f"Item type '{item_type}' not supported. Supported types: {list(SUPPORTED_TYPES.keys())}") + continue + + model_name = SUPPORTED_TYPES[item_type] + Model = apps.get_model('mapdata', model_name) + + print(f"Looking for {model_name} with ID {item_id}") + + # Get the original item + try: + original_item = Model.objects.get(pk=item_id) + print(f"Found original item: {original_item}") + except Model.DoesNotExist: + print(f"{model_name} with ID {item_id} not found") + continue + + # Prepare the clone data + clone_data = {} + print(f"Model fields: {[f.name for f in Model._meta.fields]}") + + # Handle different item types differently + if item_type == 'space': + # For spaces, we need level but no space reference + for field in Model._meta.fields: + if field.name in ['id', 'pk']: + continue + + # Skip auto fields and read-only fields + if (hasattr(field, 'auto_created') and field.auto_created) or \ + (hasattr(field, 'editable') and not field.editable): + continue + + try: + field_value = getattr(original_item, field.name) + except (AttributeError, ValueError): + continue + + # Handle level reference + if field.name == 'level': + clone_data[field.name] = target_level + else: + # Copy other fields + if field_value is not None: + clone_data[field.name] = field_value + + print(f"Space clone data: {clone_data}") + else: + # For space-related items (areas, obstacles, etc.) + space_found = False + for field in Model._meta.fields: + if field.name in ['id', 'pk']: + continue + + # Skip auto fields and read-only fields + if (hasattr(field, 'auto_created') and field.auto_created) or \ + (hasattr(field, 'editable') and not field.editable): + continue + + try: + field_value = getattr(original_item, field.name) + except (AttributeError, ValueError): + continue + + # Handle level reference + if field.name == 'level': + clone_data[field.name] = target_level + # Handle space reference - need to find equivalent space on target level + elif field.name == 'space': + if hasattr(original_item, 'space') and original_item.space: + original_space = original_item.space + # Try to find a space with the same slug/title on target level + try: + target_space = target_level.spaces.filter( + title=original_space.title + ).first() + if target_space: + clone_data[field.name] = target_space + space_found = True + print(f"Found target space: {target_space}") + else: + print(f"No equivalent space found for '{original_space.title}' on target level") + except Exception as e: + print(f"Error finding target space: {e}") + else: + # Copy other fields + if field_value is not None: + clone_data[field.name] = field_value + + # Skip space-related items if no equivalent space found + if 'space' in [f.name for f in Model._meta.fields] and not space_found: + print(f"Skipping {item_type} {item_id} because no equivalent space found") + continue + + print(f"Clone data for {item_type} {item_id}: {clone_data}") + + # Create the cloned item + try: + print(f"Attempting to clone {model_name} with data: {clone_data}") + cloned_item = Model(**clone_data) + cloned_item.save() + print(f"Successfully created cloned item with ID: {cloned_item.pk}") + except Exception as create_error: + print(f"Error creating {model_name}: {create_error}") + print(f"Clone data was: {clone_data}") + # Try a different approach - create empty object and set fields one by one + try: + cloned_item = Model() + for field_name, field_value in clone_data.items(): + try: + setattr(cloned_item, field_name, field_value) + except Exception as field_error: + print(f"Could not set {field_name}={field_value}: {field_error}") + cloned_item.save() + print(f"Successfully created item using setattr approach with ID: {cloned_item.pk}") + except Exception as setattr_error: + print(f"Setattr approach also failed: {setattr_error}") + continue # Skip this item + + # Create sync relationship if requested + if keep_sync: + try: + original_ct = ContentType.objects.get_for_model(Model) + cloned_ct = ContentType.objects.get_for_model(Model) + + # Define fields that should be synchronized for each model type + sync_field_map = { + 'Area': ['title', 'access_restriction', 'slow_down_factor'], + 'Obstacle': ['height', 'altitude'], + 'LineObstacle': ['width', 'height', 'altitude'], + 'Stair': [], # Geometry-only, no additional fields to sync + 'Ramp': [], + 'Hole': [], + 'Column': ['access_restriction'], + 'POI': ['title', 'access_restriction'], + 'AltitudeMarker': ['groundaltitude'], + 'Space': ['title', 'access_restriction', 'outside'], + 'Building': ['title'], + 'Door': ['access_restriction'] + } + + sync_fields = sync_field_map.get(model_name, []) + + ClonedItemSync.objects.create( + original_content_type=original_ct, + original_object_id=original_item.pk, + cloned_content_type=cloned_ct, + cloned_object_id=cloned_item.pk, + sync_fields=sync_fields + ) + except Exception as sync_error: + # Don't fail the entire operation if sync setup fails + print(f"Warning: Could not create sync relationship: {sync_error}") + + cloned_items.append({ + 'item_type': item_type, + 'original_id': item_id, + 'cloned_id': cloned_item.pk + }) + + print(f"Successfully added item {i+1} to cloned_items list") + + print(f"Finished processing. Total cloned items: {len(cloned_items)}") + return CloneFloorResponseSchema( + success=True, + cloned_items=cloned_items, + message=f"Successfully cloned {len(cloned_items)} items" + ).model_dump(mode="json") + + except Exception as e: + return CloneFloorResponseSchema( + success=False, + cloned_items=cloned_items, + message=f"Error during cloning: {str(e)}" + ).model_dump(mode="json") diff --git a/src/c3nav/mapdata/apps.py b/src/c3nav/mapdata/apps.py index 5d47afa1..6bb6e524 100644 --- a/src/c3nav/mapdata/apps.py +++ b/src/c3nav/mapdata/apps.py @@ -8,3 +8,4 @@ class MapdataConfig(AppConfig): from c3nav.mapdata.utils.cache.changes import register_signals register_signals() import c3nav.mapdata.metrics # noqa + import c3nav.mapdata.signals.sync # noqa diff --git a/src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py b/src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py new file mode 100644 index 00000000..68a07f23 --- /dev/null +++ b/src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.5 on 2025-08-02 00:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('mapdata', '0138_rangingbeacon_max_observed_num_clients_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ClonedItemSync', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('original_object_id', models.PositiveIntegerField()), + ('cloned_object_id', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('is_active', models.BooleanField(default=True)), + ('sync_fields', models.JSONField(default=list, help_text='List of field names to keep synchronized')), + ('cloned_content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cloned_synced_items', to='contenttypes.contenttype')), + ('original_content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='original_synced_items', to='contenttypes.contenttype')), + ], + options={ + 'verbose_name': 'Cloned Item Sync', + 'verbose_name_plural': 'Cloned Item Syncs', + 'indexes': [models.Index(fields=['original_content_type', 'original_object_id'], name='mapdata_clo_origina_62f4ee_idx'), models.Index(fields=['cloned_content_type', 'cloned_object_id'], name='mapdata_clo_cloned__027e07_idx')], + 'unique_together': {('original_content_type', 'original_object_id', 'cloned_content_type', 'cloned_object_id')}, + }, + ), + ] diff --git a/src/c3nav/mapdata/models/__init__.py b/src/c3nav/mapdata/models/__init__.py index 277bd505..34c5cf02 100644 --- a/src/c3nav/mapdata/models/__init__.py +++ b/src/c3nav/mapdata/models/__init__.py @@ -8,4 +8,5 @@ from c3nav.mapdata.models.locations import Location, LocationSlug, LocationGroup from c3nav.mapdata.models.source import Source # noqa from c3nav.mapdata.models.graph import GraphNode, WayType, GraphEdge # noqa from c3nav.mapdata.models.theme import Theme # noqa -from c3nav.mapdata.models.overlay import DataOverlay, DataOverlayFeature # noqa \ No newline at end of file +from c3nav.mapdata.models.overlay import DataOverlay, DataOverlayFeature # noqa +from c3nav.mapdata.models.sync import ClonedItemSync # noqa \ No newline at end of file diff --git a/src/c3nav/mapdata/models/sync.py b/src/c3nav/mapdata/models/sync.py new file mode 100644 index 00000000..01966cfa --- /dev/null +++ b/src/c3nav/mapdata/models/sync.py @@ -0,0 +1,47 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ClonedItemSync(models.Model): + """ + Tracks relationships between cloned items across different levels + to keep them synchronized when one is modified. + """ + # The original item + original_content_type = models.ForeignKey( + 'contenttypes.ContentType', + on_delete=models.CASCADE, + related_name='original_synced_items' + ) + original_object_id = models.PositiveIntegerField() + + # The cloned item + cloned_content_type = models.ForeignKey( + 'contenttypes.ContentType', + on_delete=models.CASCADE, + related_name='cloned_synced_items' + ) + cloned_object_id = models.PositiveIntegerField() + + # Metadata + created_at = models.DateTimeField(auto_now_add=True) + is_active = models.BooleanField(default=True) + + # Fields to sync (JSON field storing field names to keep in sync) + sync_fields = models.JSONField( + default=list, + help_text=_('List of field names to keep synchronized') + ) + + class Meta: + verbose_name = _('Cloned Item Sync') + verbose_name_plural = _('Cloned Item Syncs') + unique_together = ('original_content_type', 'original_object_id', + 'cloned_content_type', 'cloned_object_id') + indexes = [ + models.Index(fields=['original_content_type', 'original_object_id']), + models.Index(fields=['cloned_content_type', 'cloned_object_id']), + ] + + def __str__(self): + return f"Sync: {self.original_content_type.model}#{self.original_object_id} -> {self.cloned_content_type.model}#{self.cloned_object_id}" \ No newline at end of file diff --git a/src/c3nav/mapdata/signals/sync.py b/src/c3nav/mapdata/signals/sync.py new file mode 100644 index 00000000..80fa92c2 --- /dev/null +++ b/src/c3nav/mapdata/signals/sync.py @@ -0,0 +1,95 @@ +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver +from django.contrib.contenttypes.models import ContentType + +from c3nav.mapdata.models.sync import ClonedItemSync + + +@receiver(post_save) +def sync_cloned_items_on_save(sender, instance, created, **kwargs): + """ + When a model instance is saved, update any cloned items that should be synchronized. + """ + if created: + return # Only sync on updates, not creation + + # Check if ClonedItemSync table exists (avoid errors during migrations) + try: + from django.db import connection + with connection.cursor() as cursor: + cursor.execute("SELECT 1 FROM information_schema.tables WHERE table_name = 'mapdata_cloneditemsync' LIMIT 1") + if not cursor.fetchone(): + return # Table doesn't exist yet, skip sync + except Exception: + return # Any database error, skip sync + + content_type = ContentType.objects.get_for_model(sender) + + # Find all sync relationships where this item is the original + try: + sync_relationships = ClonedItemSync.objects.filter( + original_content_type=content_type, + original_object_id=instance.pk, + is_active=True + ) + except Exception: + return # ClonedItemSync model not available, skip sync + + for sync_rel in sync_relationships: + try: + # Get the cloned item + cloned_model = sync_rel.cloned_content_type.model_class() + cloned_item = cloned_model.objects.get(pk=sync_rel.cloned_object_id) + + # Update synchronized fields + updated = False + for field_name in sync_rel.sync_fields: + if hasattr(instance, field_name) and hasattr(cloned_item, field_name): + original_value = getattr(instance, field_name) + current_value = getattr(cloned_item, field_name) + + if original_value != current_value: + setattr(cloned_item, field_name, original_value) + updated = True + + if updated: + cloned_item.save() + + except Exception as e: + # Log error but don't break the original save operation + print(f"Error syncing cloned item: {e}") + # Optionally deactivate the sync relationship if it's broken + sync_rel.is_active = False + sync_rel.save() + + +@receiver(post_delete) +def cleanup_sync_on_delete(sender, instance, **kwargs): + """ + When a model instance is deleted, clean up any sync relationships. + """ + # Check if ClonedItemSync table exists (avoid errors during migrations) + try: + from django.db import connection + with connection.cursor() as cursor: + cursor.execute("SELECT 1 FROM information_schema.tables WHERE table_name = 'mapdata_cloneditemsync' LIMIT 1") + if not cursor.fetchone(): + return # Table doesn't exist yet, skip cleanup + except Exception: + return # Any database error, skip cleanup + + try: + content_type = ContentType.objects.get_for_model(sender) + + # Clean up sync relationships where this item is either original or cloned + ClonedItemSync.objects.filter( + original_content_type=content_type, + original_object_id=instance.pk + ).delete() + + ClonedItemSync.objects.filter( + cloned_content_type=content_type, + cloned_object_id=instance.pk + ).delete() + except Exception: + pass # ClonedItemSync model not available, skip cleanup \ No newline at end of file diff --git a/src/c3nav/mesh/migrations/0015_alter_nodemessage_message_type.py b/src/c3nav/mesh/migrations/0015_alter_nodemessage_message_type.py new file mode 100644 index 00000000..90726685 --- /dev/null +++ b/src/c3nav/mesh/migrations/0015_alter_nodemessage_message_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-08-02 00:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mesh', '0014_remove_meshnode_name'), + ] + + operations = [ + migrations.AlterField( + model_name='nodemessage', + name='message_type', + field=models.CharField(choices=[('NOOP', 'noop'), ('ECHO_REQUEST', 'echo request'), ('ECHO_RESPONSE', 'echo response'), ('MESH_SIGNIN', 'mesh signin'), ('MESH_LAYER_ANNOUNCE', 'mesh layer announce'), ('MESH_ADD_DESTINATION', 'mesh add destination'), ('MESH_REMOVE_DESTINATIONS', 'mesh remove destinations'), ('MESH_ROUTE_REQUEST', 'mesh route request'), ('MESH_ROUTE_RESPONSE', 'mesh route response'), ('MESH_ROUTE_TRACE', 'mesh route trace'), ('MESH_ROUTING_FAILED', 'mesh routing failed'), ('MESH_SIGNIN_CONFIRM', 'mesh signin confirm'), ('MESH_RESET', 'mesh reset'), ('CONFIG_DUMP', 'dump config'), ('CONFIG_HARDWARE', 'hardware config'), ('CONFIG_BOARD', 'board config'), ('CONFIG_FIRMWARE', 'firmware config'), ('CONFIG_UPLINK', 'uplink config'), ('CONFIG_POSITION', 'position config'), ('CONFIG_NODE', 'node config'), ('CONFIG_IBEACON', 'ibeacon config'), ('OTA_STATUS', 'ota status'), ('OTA_REQUEST_STATUS', 'ota request status'), ('OTA_START', 'ota start'), ('OTA_URL', 'ota url'), ('OTA_FRAGMENT', 'ota fragment'), ('OTA_REQUEST_FRAGMENTS', 'ota request fragments'), ('OTA_SETTING', 'ota setting'), ('OTA_APPLY', 'ota apply'), ('OTA_ABORT', 'ota abort'), ('LOCATE_REQUEST_RANGE', 'locate request range'), ('LOCATE_RANGE_RESULTS', 'locate range results'), ('LOCATE_RAW_FTM_RESULTS', 'locate raw ftm results'), ('REBOOT', 'reboot'), ('REPORT_ERROR', 'report error')], db_index=True, max_length=24, verbose_name='message type'), + ), + ]