test another snap-to-edge and level clone

This commit is contained in:
Degra02 2025-08-02 07:54:26 +02:00
parent f301c424ba
commit 2f12b901ac
12 changed files with 774 additions and 29 deletions

View file

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

View file

@ -124,4 +124,28 @@ class EditorBeaconsLookup(BaseSchema):
EditorBeacon
]
]
]
]
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

View file

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

View file

@ -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('<i class="glyphicon glyphicon-remove"></i> 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('<i class="glyphicon glyphicon-copy"></i> 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('<i class="glyphicon glyphicon-refresh"></i> 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('<i class="glyphicon glyphicon-ok"></i> Clone Items');
});
}
};
if ($('#sidebar').length) {
editor.init();
// Initialize clone floor functionality after editor is ready
setTimeout(function() {
editor.cloneFloor.init();
}, 1000);
}

View file

@ -16,6 +16,9 @@
{% trans 'Level' as model_title %}
<i class="glyphicon glyphicon-pencil"></i> {% blocktrans %}Edit {{ model_title }}{% endblocktrans %}
</a>
<button id="clone-floor-btn" class="btn btn-info btn-xs" style="margin-left: 5px;" title="Clone selected items to another floor">
<i class="glyphicon glyphicon-copy"></i> Clone to Floor
</button>
</p>
<p>
{% if level.on_top_of == None %}
@ -25,6 +28,39 @@
{% endif %}
</p>
<!-- Clone Floor Interface -->
<div id="clone-floor-selector" style="display: none; margin: 10px 0; padding: 10px; border: 1px solid #ddd; border-radius: 4px; background-color: #f9f9f9;">
<h4><i class="glyphicon glyphicon-copy"></i> Clone Selected Items</h4>
<div class="form-group">
<label for="target-level-select">Target Level:</label>
<select id="target-level-select" class="form-control">
<option value="">Select target level...</option>
{% for l in levels %}
{% if l.pk != level.pk %}
<option value="{{ l.pk }}">{{ l.title }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="keep-sync-checkbox"> Keep items synchronized across levels
</label>
</div>
<div class="form-group">
<button id="execute-clone-btn" class="btn btn-primary btn-sm">
<i class="glyphicon glyphicon-ok"></i> Clone Items
</button>
<button id="cancel-clone-btn" class="btn btn-default btn-sm">
<i class="glyphicon glyphicon-remove"></i> Cancel
</button>
</div>
<div id="selected-items-info" class="alert alert-info" style="margin-bottom: 0;">
<strong>Selected Items: <span id="selected-count">0</span></strong>
<p style="margin: 5px 0 0 0; font-size: 12px;">Click on map items to select them for cloning. Selected items will be highlighted in red.</p>
</div>
</div>
{% 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 %}

View file

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

View file

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

View file

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

View file

@ -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
from c3nav.mapdata.models.overlay import DataOverlay, DataOverlayFeature # noqa
from c3nav.mapdata.models.sync import ClonedItemSync # noqa

View file

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

View file

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

View file

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