Merge branch 'ours' of https://repos.hackathon.bz.it/2025-summer/team-3 into ours
This commit is contained in:
commit
bfd08978a1
21 changed files with 1416 additions and 247 deletions
|
@ -10,7 +10,7 @@ docker volume create c3nav-redis
|
|||
# Start only postgres and redis first (no build since we pre-built)
|
||||
docker compose up -d postgres redis
|
||||
|
||||
sleep 5
|
||||
sleep 10
|
||||
cat ./db/auth_user.sql | docker exec -i local_run-postgres-1 su - postgres -c 'psql c3nav'
|
||||
|
||||
|
||||
|
|
|
@ -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")
|
|
@ -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
|
|
@ -567,36 +567,48 @@ label.theme-color-label {
|
|||
.leaflet-control-snap {
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
|
||||
.snap-toggle {
|
||||
|
||||
|
||||
/* watchout for leaflet.css trying to override a:hover with a different height/width */
|
||||
a.snap-toggle, a.snap-to-original-toggle, a.snap-to-90-toggle {
|
||||
background-size: 30px 30px;
|
||||
display: block;
|
||||
width: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 26px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
font-size: 18px;
|
||||
background-color: white;
|
||||
color: #666;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f4f4f4;
|
||||
color: #333;
|
||||
background-color: #a7a7a7;
|
||||
}
|
||||
|
||||
|
||||
&.active {
|
||||
background-color: #4CAF50;
|
||||
background-color: #b0ecb2;
|
||||
border: 2px solid green;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: #45a049;
|
||||
background-color: #7ac27d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.snap-to-90-toggle {
|
||||
background-color: yellow !important;
|
||||
}
|
||||
|
||||
/* icons */
|
||||
a.snap-toggle {
|
||||
background-image: url("/static/img/snap-to-edges-icon.svg");
|
||||
}
|
||||
a.snap-to-original-toggle {
|
||||
background-image: url("/static/img/snap-to-original-icon.svg");
|
||||
}
|
||||
a.snap-to-90-toggle {
|
||||
background-image: url("/static/img/snap-to-90-icon.svg");
|
||||
}
|
||||
}
|
||||
|
||||
/* Snap indicator styles */
|
||||
|
@ -608,70 +620,30 @@ label.theme-color-label {
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes snap-pulse {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Edge highlight styles for snap-to-edges */
|
||||
.edge-highlight {
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
animation: edge-fade-in 0.2s ease-in;
|
||||
}
|
||||
|
||||
.original-edge-highlight {
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
animation: edge-fade-in 0.2s ease-in;
|
||||
}
|
||||
|
||||
@keyframes edge-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Right-angle snap indicators */
|
||||
.right-angle-reference {
|
||||
z-index: 998;
|
||||
pointer-events: none;
|
||||
animation: edge-fade-in 0.2s ease-in;
|
||||
}
|
||||
|
||||
.right-angle-line {
|
||||
z-index: 1001;
|
||||
pointer-events: none;
|
||||
animation: right-angle-pulse 2s infinite;
|
||||
}
|
||||
|
||||
.right-angle-square {
|
||||
z-index: 1002;
|
||||
pointer-events: none;
|
||||
animation: right-angle-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes right-angle-pulse {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
40
src/c3nav/editor/templates/editor/create_staircase.html
Normal file
40
src/c3nav/editor/templates/editor/create_staircase.html
Normal file
|
@ -0,0 +1,40 @@
|
|||
{% load bootstrap3 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% include 'editor/fragment_levels.html' %}
|
||||
|
||||
<h3>
|
||||
{% blocktrans %}Add staircase{% endblocktrans %}
|
||||
</h3>
|
||||
{% bootstrap_messages %}
|
||||
|
||||
<form space="{{ space }}" {% if nozoom %}data-nozoom {% endif %}data-onbeforeunload data-new="staircase" data-geomtype="polygon" {% if access_restriction_select %} data-access-restriction-select{% endif %}>
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
<div class="form-group">
|
||||
<label for="stairway-steps">Number of Steps:</label>
|
||||
<input type="number" id="stairway-steps" class="form-control" value="10">
|
||||
</div>
|
||||
{% buttons %}
|
||||
<button class="invisiblesubmit" type="submit"></button>
|
||||
<!-- <div class="btn-group">
|
||||
<button type="button" id="generate-staircase" accesskey="g" class="btn btn-primary pull-right">
|
||||
{% trans 'Generate stairs' %}
|
||||
</button>
|
||||
</div> -->
|
||||
{% if can_edit %}
|
||||
{% if not nosave %}
|
||||
<button type="submit" accesskey="m" class="btn btn-primary pull-right">
|
||||
{% trans 'Save' %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a class="btn {% if new %}btn-danger{% else %}btn-default {% if can_edit %}pull-right{% endif %}{% endif %} cancel-btn" href="{{ back_url }}">
|
||||
{% if can_edit %}
|
||||
{% trans 'Cancel' %}
|
||||
{% else %}
|
||||
{% trans 'Back' %}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endbuttons %}
|
||||
</form>
|
|
@ -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,45 @@
|
|||
{% 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="select-all-btn" class="btn btn-success btn-sm" style="margin-left: 5px;">
|
||||
<i class="glyphicon glyphicon-check"></i> Select All
|
||||
</button>
|
||||
<button id="clear-selection-btn" class="btn btn-warning btn-sm" style="margin-left: 5px;">
|
||||
<i class="glyphicon glyphicon-unchecked"></i> Clear Selection
|
||||
</button>
|
||||
<button id="cancel-clone-btn" class="btn btn-default btn-sm" style="margin-left: 5px;">
|
||||
<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 %}
|
||||
|
|
|
@ -20,6 +20,11 @@
|
|||
<a class="btn btn-default btn-xs" accesskey="n" href="{{ create_url }}">
|
||||
<i class="glyphicon glyphicon-plus"></i> {% blocktrans %}New {{ model_title }}{% endblocktrans %}
|
||||
</a>
|
||||
{% if model_title == "Stair" %}
|
||||
<a class="btn btn-default btn-xs" accesskey="n" href="/editor/spaces/{{ space.id }}/staircase">
|
||||
<i class="glyphicon glyphicon-plus"></i> {% blocktrans %}New staircase{% endblocktrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if explicit_edit %}
|
||||
|
|
|
@ -4,7 +4,7 @@ from django.views.generic import TemplateView
|
|||
|
||||
from c3nav.editor.views.account import change_password_view, login_view, logout_view, register_view
|
||||
from c3nav.editor.views.changes import changeset_detail, changeset_edit, changeset_redirect
|
||||
from c3nav.editor.views.edit import edit, graph_edit, level_detail, list_objects, main_index, sourceimage, space_detail
|
||||
from c3nav.editor.views.edit import edit, graph_edit, level_detail, list_objects, main_index, staircase_edit, sourceimage, space_detail
|
||||
from c3nav.editor.views.overlays import overlays_list, overlay_features, overlay_feature_edit
|
||||
from c3nav.editor.views.quest import QuestFormView
|
||||
from c3nav.editor.views.users import user_detail, user_redirect
|
||||
|
@ -33,7 +33,6 @@ def add_editor_urls(model_name, parent_model_name=None, with_list=True, explicit
|
|||
])
|
||||
return result
|
||||
|
||||
|
||||
# todo: custom path converters
|
||||
urlpatterns = [
|
||||
path('levels/<int:pk>/', level_detail, name='editor.levels.detail'),
|
||||
|
@ -91,3 +90,4 @@ urlpatterns.extend(add_editor_urls('LeaveDescription', 'Space'))
|
|||
urlpatterns.extend(add_editor_urls('CrossDescription', 'Space'))
|
||||
urlpatterns.extend(add_editor_urls('BeaconMeasurement', 'Space'))
|
||||
urlpatterns.extend(add_editor_urls('RangingBeacon', 'Space'))
|
||||
urlpatterns.append(path('spaces/<int:space>/staircase', edit, name='editor.stairs.staircase', kwargs={'model': apps.get_model('mapdata', 'Stair')}))
|
||||
|
|
|
@ -55,3 +55,274 @@ 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
|
||||
print(f"Processing space item with fields: {[f.name for f in Model._meta.fields]}")
|
||||
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):
|
||||
print(f"Skipping field {field.name}: auto_created={getattr(field, 'auto_created', False)}, editable={getattr(field, 'editable', True)}")
|
||||
continue
|
||||
|
||||
try:
|
||||
field_value = getattr(original_item, field.name)
|
||||
print(f"Field {field.name}: {field_value} (type: {type(field_value)})")
|
||||
except (AttributeError, ValueError) as e:
|
||||
print(f"Could not get field {field.name}: {e}")
|
||||
continue
|
||||
|
||||
# Handle level reference
|
||||
if field.name == 'level':
|
||||
clone_data[field.name] = target_level
|
||||
print(f"Set level to target_level: {target_level}")
|
||||
else:
|
||||
# Copy other fields - but check for special fields that shouldn't be copied
|
||||
if field.name in ['slug']:
|
||||
# Don't copy slug directly as it needs to be unique
|
||||
# Instead, create a new unique slug based on the original
|
||||
if field_value:
|
||||
import re
|
||||
base_slug = re.sub(r'-\d+$', '', field_value) # Remove trailing numbers
|
||||
new_slug = f"{base_slug}-clone"
|
||||
# Make sure the new slug is unique
|
||||
counter = 1
|
||||
test_slug = new_slug
|
||||
while Model.objects.filter(slug=test_slug).exists():
|
||||
test_slug = f"{new_slug}-{counter}"
|
||||
counter += 1
|
||||
clone_data[field.name] = test_slug
|
||||
print(f"Generated unique slug: {test_slug}")
|
||||
continue
|
||||
if field_value is not None:
|
||||
clone_data[field.name] = field_value
|
||||
print(f"Copied field {field.name}: {field_value}")
|
||||
|
||||
print(f"Final 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}")
|
||||
print(f"Creating {model_name} object...")
|
||||
cloned_item = Model(**clone_data)
|
||||
print(f"Created object, now saving...")
|
||||
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"Error type: {type(create_error)}")
|
||||
print(f"Clone data was: {clone_data}")
|
||||
|
||||
# Try a different approach - create empty object and set fields one by one
|
||||
try:
|
||||
print("Trying field-by-field approach...")
|
||||
cloned_item = Model()
|
||||
for field_name, field_value in clone_data.items():
|
||||
try:
|
||||
setattr(cloned_item, field_name, field_value)
|
||||
print(f"Set {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")
|
||||
|
|
|
@ -70,6 +70,12 @@ def main_index(request):
|
|||
})
|
||||
|
||||
|
||||
@etag(editor_etag_func)
|
||||
@accesses_mapdata
|
||||
@sidebar_view
|
||||
def staircase_edit(request, space):
|
||||
return render(request, "editor/create_staircase.html")
|
||||
|
||||
@etag(editor_etag_func)
|
||||
@accesses_mapdata
|
||||
@sidebar_view
|
||||
|
@ -405,7 +411,11 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e
|
|||
"access_restriction_select": True,
|
||||
})
|
||||
|
||||
return render(request, 'editor/edit.html', ctx)
|
||||
if request.path.endswith("staircase"):
|
||||
ctx["space"] = space_id
|
||||
return render(request, 'editor/create_staircase.html', ctx)
|
||||
else:
|
||||
return render(request, 'editor/edit.html', ctx)
|
||||
|
||||
|
||||
def get_visible_spaces(request):
|
||||
|
|
|
@ -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
|
||||
|
|
34
src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py
Normal file
34
src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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
|
47
src/c3nav/mapdata/models/sync.py
Normal file
47
src/c3nav/mapdata/models/sync.py
Normal 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}"
|
95
src/c3nav/mapdata/signals/sync.py
Normal file
95
src/c3nav/mapdata/signals/sync.py
Normal 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
|
|
@ -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'),
|
||||
),
|
||||
]
|
3
src/c3nav/static/img/snap-to-90-icon.svg
Normal file
3
src/c3nav/static/img/snap-to-90-icon.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="background: transparent; background-color: transparent; color-scheme: light;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="132px" height="154px" viewBox="-0.5 -0.5 132 154"><defs/><g><g data-cell-id="0"><g data-cell-id="1"><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-14"><g><rect x="110" y="0" width="20" height="20" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" pointer-events="all"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-16"><g><rect x="0" y="50" width="120" height="60" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" pointer-events="all"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-15"><g><path d="M 120 150 L 120 20" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="4" stroke-miterlimit="10" stroke-dasharray="12 12" pointer-events="stroke"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-20"><g><rect x="70" y="110" width="60" height="30" fill="none" stroke="none" pointer-events="all"/></g><g><g><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 58px; height: 1px; padding-top: 125px; margin-left: 71px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div><font style="font-size: 16px;">90°</font></div></div></div></div></foreignObject><text x="100" y="129" fill="light-dark(#000000, #ffffff)" font-family="Helvetica" font-size="12px" text-anchor="middle">90°</text></switch></g></g></g></g></g></g></svg>
|
After Width: | Height: | Size: 2 KiB |
3
src/c3nav/static/img/snap-to-edges-icon.svg
Normal file
3
src/c3nav/static/img/snap-to-edges-icon.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="background: transparent; background-color: transparent; color-scheme: light dark;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="101px" height="126px" viewBox="-0.5 -0.5 101 126"><defs/><g><g data-cell-id="0"><g data-cell-id="1"><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-4"><g><path d="M 60 22 L 100 52 L 60 82 Z" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-miterlimit="10" pointer-events="all"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-6"><g><rect x="0" y="32" width="60" height="60" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" pointer-events="all"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-3"><g><path d="M 60 2 L 60 122" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="5" stroke-miterlimit="10" stroke-dasharray="15 15" pointer-events="stroke"/></g></g></g></g></g></svg>
|
After Width: | Height: | Size: 1 KiB |
3
src/c3nav/static/img/snap-to-original-icon.svg
Normal file
3
src/c3nav/static/img/snap-to-original-icon.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="background: transparent; background-color: transparent; color-scheme: light;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="114px" height="124px" viewBox="-0.5 -0.5 114 124"><defs/><g><g data-cell-id="0"><g data-cell-id="1"><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-7"><g><path d="M 11 71 L 71 1" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="2" stroke-miterlimit="10" stroke-dasharray="6 6" pointer-events="stroke"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-8"><g><path d="M 81 101 L 11 71" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="2" stroke-miterlimit="10" stroke-dasharray="6 6" pointer-events="stroke"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-9"><g><path d="M 81 101 L 71 1" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="2" stroke-miterlimit="10" stroke-dasharray="6 6" pointer-events="stroke"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-10"><g><path d="M 1 71 L 31 1" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-11"><g><path d="M 111 121 L 1 71" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-12"><g><path d="M 111 121 L 31 1" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/></g></g></g></g></g></svg>
|
After Width: | Height: | Size: 1.7 KiB |
|
@ -32,8 +32,8 @@ elif [[ $# == 1 ]] && [[ $1 == "run" ]]; then
|
|||
elif [[ $# == 1 ]] && [[ $1 == "run_without_output" ]]; then
|
||||
echo "Processing updates and running server without output"
|
||||
pushd src 2>&1 > /dev/null
|
||||
python manage.py processupdates 2>&1 | (grep -e "^ERROR" -e "^WARNING" -e "^HTTP" || true)
|
||||
python manage.py runserver 2>&1 | (grep -e "^ERROR" -e "^WARNING" -e "^HTTP" || true)
|
||||
python manage.py processupdates 2>&1 | (grep -vE '^(INFO|DEBUG)|__debug__' || true)
|
||||
python manage.py runserver 2>&1 | (grep -vE '^(INFO|DEBUG)|__debug__' || true)
|
||||
popd 2>&1 > /dev/null
|
||||
elif [[ $# > 0 ]] && [[ $1 == "manage" ]]; then
|
||||
pushd src
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue