Compare commits
1 commit
main
...
stairs-wip
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2c840e5e20 |
49 changed files with 976 additions and 14139 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -73,4 +73,3 @@ target/
|
||||||
|
|
||||||
#Ipython Notebook
|
#Ipython Notebook
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
local_run/data
|
|
||||||
|
|
|
@ -15,32 +15,32 @@ ENV DEBIAN_FRONTEND noninteractive
|
||||||
RUN --mount=type=cache,target=/var/cache/apt,id=apt_$TARGETARCH --mount=type=tmpfs,target=/var/lib/apt/lists \
|
RUN --mount=type=cache,target=/var/cache/apt,id=apt_$TARGETARCH --mount=type=tmpfs,target=/var/lib/apt/lists \
|
||||||
rm /etc/apt/apt.conf.d/docker-clean && \
|
rm /etc/apt/apt.conf.d/docker-clean && \
|
||||||
apt-get update && apt-get install -y --no-install-recommends \
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3.12 \
|
python3.12=3.12.3-1ubuntu0.5 \
|
||||||
# renovate: srcname=python3.12
|
# renovate: srcname=python3.12
|
||||||
libpython3.12 \
|
libpython3.12=3.12.3-1ubuntu0.5 \
|
||||||
# renovate: srcname=python3.12
|
# renovate: srcname=python3.12
|
||||||
python3.12-venv \
|
python3.12-venv=3.12.3-1ubuntu0.5 \
|
||||||
# renovate: srcname=python-pip
|
# renovate: srcname=python-pip
|
||||||
python3-pip \
|
python3-pip=24.0+dfsg-1ubuntu1.1 \
|
||||||
# renovate: srcname=postgresql-16
|
# renovate: srcname=postgresql-16
|
||||||
libpq5 \
|
libpq5=16.8-0ubuntu0.24.04.1 \
|
||||||
# renovate: srcname=postgresql-16
|
# renovate: srcname=postgresql-16
|
||||||
postgresql-client-16 \
|
postgresql-client-16=16.8-0ubuntu0.24.04.1 \
|
||||||
curl \
|
curl=8.5.0-2ubuntu10.6 \
|
||||||
# renovate: srcname=pcre3
|
# renovate: srcname=pcre3
|
||||||
libpcre3 \
|
libpcre3=2:8.39-15build1 \
|
||||||
# renovate: srcname=librsvg
|
# renovate: srcname=librsvg
|
||||||
librsvg2-2 \
|
librsvg2-2=2.58.0+dfsg-1build1 \
|
||||||
# renovate: srcname=librsvg
|
# renovate: srcname=librsvg
|
||||||
gir1.2-rsvg-2.0 \
|
gir1.2-rsvg-2.0=2.58.0+dfsg-1build1 \
|
||||||
# renovate: srcname=cairo
|
# renovate: srcname=cairo
|
||||||
libcairo2 \
|
libcairo2=1.18.0-3build1 \
|
||||||
libgirepository-1.0-1 \
|
libgirepository-1.0-1=1.80.1-1 \
|
||||||
# renovate: srcname=libmemcached
|
# renovate: srcname=libmemcached
|
||||||
libmemcached11t64 \
|
libmemcached11t64=1.1.4-1.1build3 \
|
||||||
gettext \
|
gettext \
|
||||||
tzdata-legacy \
|
tzdata=2025b-0ubuntu0.24.04 \
|
||||||
ca-certificates \
|
ca-certificates=20240203 \
|
||||||
# renovate: srcname=libzstd
|
# renovate: srcname=libzstd
|
||||||
zstd=1.5.5+dfsg2-2build1.1
|
zstd=1.5.5+dfsg2-2build1.1
|
||||||
ENV PATH="/usr/lib/postgresql/16/bin/:$PATH"
|
ENV PATH="/usr/lib/postgresql/16/bin/:$PATH"
|
||||||
|
@ -49,17 +49,17 @@ ENV PATH="/usr/lib/postgresql/16/bin/:$PATH"
|
||||||
FROM base as builder
|
FROM base as builder
|
||||||
RUN --mount=type=cache,target=/var/cache/apt,id=apt_$TARGETARCH --mount=type=tmpfs,target=/var/lib/apt/lists \
|
RUN --mount=type=cache,target=/var/cache/apt,id=apt_$TARGETARCH --mount=type=tmpfs,target=/var/lib/apt/lists \
|
||||||
apt-get update && apt-get install -y --no-install-recommends \
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential=12.10ubuntu1 \
|
||||||
# renovate: srcname=python3.12
|
# renovate: srcname=python3.12
|
||||||
python3.12-dev \
|
python3.12-dev=3.12.3-1ubuntu0.5 \
|
||||||
libpcre3-dev \
|
libpcre3-dev=2:8.39-15build1 \
|
||||||
# renovate: srcname=postgresql-16
|
# renovate: srcname=postgresql-16
|
||||||
libpq-dev \
|
libpq-dev=16.8-0ubuntu0.24.04.1 \
|
||||||
# renovate: srcname=cairo
|
# renovate: srcname=cairo
|
||||||
libcairo2-dev \
|
libcairo2-dev=1.18.0-3build1 \
|
||||||
libgirepository1.0-dev \
|
libgirepository1.0-dev=1.80.1-1 \
|
||||||
# renovate: srcname=libmemcached
|
# renovate: srcname=libmemcached
|
||||||
libmemcached-dev
|
libmemcached-dev=1.1.4-1.1build3
|
||||||
|
|
||||||
|
|
||||||
RUN mkdir /app
|
RUN mkdir /app
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
C3NAV_TAG=latest
|
|
||||||
C3NAV_DOMAIN=c3nav.docker.localhost
|
|
||||||
# to change the default database name
|
|
||||||
#C3NAV_DATABASE_NAME=c3nav-37c3
|
|
||||||
# if you want to change the default path to the data dir, which by default is a folder called data in this directory
|
|
||||||
C3NAV_DOCKER_DATA_DIR=./data
|
|
|
@ -1,152 +0,0 @@
|
||||||
# NOI Sensor Management System
|
|
||||||
|
|
||||||
This system allows you to manage environmental sensors from the NOI Open Data Hub in c3nav, displaying them as overlay features on different levels/floors.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The system supports:
|
|
||||||
- Multiple sensors on the same overlay but on different levels
|
|
||||||
- Dynamic addition of new sensors through Django management commands
|
|
||||||
- Automatic data scraping from NOI Open Data Hub APIs
|
|
||||||
- Real-time display of CO2, temperature, humidity and other environmental data
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **Single Overlay**: All NOI environmental sensors are managed under one `DataOverlay`
|
|
||||||
- **Multiple Levels**: Sensors can be placed on different floors (floor0, floor1, etc.)
|
|
||||||
- **Flexible Configuration**: Sensor locations and properties are configurable via the overlay's `sensor_config` field
|
|
||||||
- **Dynamic Discovery**: The system can automatically discover and display any sensor data from the NOI API
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
The main setup is handled by the `up.sh` script, which:
|
|
||||||
|
|
||||||
1. Creates a single "NOI Environmental Sensors" overlay
|
|
||||||
2. Configures initial sensors with their coordinates and levels
|
|
||||||
3. Scrapes initial data from the NOI Open Data Hub
|
|
||||||
4. Applies necessary database migrations
|
|
||||||
|
|
||||||
## Managing Sensors
|
|
||||||
|
|
||||||
### 1. List All Sensors
|
|
||||||
```bash
|
|
||||||
# Using the helper script
|
|
||||||
./manage_noi_sensors.sh list
|
|
||||||
|
|
||||||
# Or directly
|
|
||||||
docker compose exec -T c3nav-core python manage.py list_sensors --overlay-id 1
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add a New Sensor
|
|
||||||
```bash
|
|
||||||
# Using the helper script
|
|
||||||
./manage_noi_sensors.sh add 'NOI:YourSensorID' 'Sensor Display Name' 300.0 250.0 floor1
|
|
||||||
|
|
||||||
# Or directly
|
|
||||||
docker compose exec -T c3nav-core python manage.py add_sensor \
|
|
||||||
--overlay-id 1 \
|
|
||||||
--sensor-id 'NOI:YourSensorID' \
|
|
||||||
--name 'Sensor Display Name' \
|
|
||||||
--x 300.0 \
|
|
||||||
--y 250.0 \
|
|
||||||
--level floor1
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Scrape Data for All Sensors
|
|
||||||
```bash
|
|
||||||
# Using the helper script
|
|
||||||
./manage_noi_sensors.sh scrape
|
|
||||||
|
|
||||||
# Or directly
|
|
||||||
docker compose exec -T c3nav-core python manage.py manage_sensors --scrape-data --overlay-id 1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Structure
|
|
||||||
|
|
||||||
The overlay's `sensor_config` field contains:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data_path": "data",
|
|
||||||
"mappings": {
|
|
||||||
"id_field": "scode",
|
|
||||||
"name_field": "sname",
|
|
||||||
"x_field": "scoordinate.x",
|
|
||||||
"y_field": "scoordinate.y"
|
|
||||||
},
|
|
||||||
"sensors": [
|
|
||||||
{
|
|
||||||
"id": "NOI:FreeSoftwareLab-Temperature",
|
|
||||||
"coordinates": {"x": 291.0, "y": 241.0},
|
|
||||||
"level": "floor1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "NOI:NOI-A1-Floor1-CO2",
|
|
||||||
"coordinates": {"x": 270.0, "y": 241.0},
|
|
||||||
"level": "floor1"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
### DataOverlay fields:
|
|
||||||
- `data_source_url`: URL to scrape sensor data from
|
|
||||||
- `sensor_config`: JSON configuration for sensor mapping and processing
|
|
||||||
|
|
||||||
### DataOverlayFeature fields:
|
|
||||||
- `sensor_id`: Unique identifier for the sensor
|
|
||||||
- `sensor_type`: Type of sensor (e.g., 'environmental')
|
|
||||||
- `sensor_value`: Single sensor value (nullable for multi-measurement sensors)
|
|
||||||
- `sensor_unit`: Unit of measurement (nullable for multi-measurement sensors)
|
|
||||||
- `coordinates_x`, `coordinates_y`: Position in c3nav coordinate system
|
|
||||||
- `last_updated`: Timestamp of last data update
|
|
||||||
- `sensor_data`: Raw sensor data for debugging
|
|
||||||
- `extra_data`: Processed sensor readings for display
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
1. **Configuration**: Sensors are configured in the overlay's `sensor_config`
|
|
||||||
2. **Scraping**: The `manage_sensors` command fetches data from NOI Open Data Hub
|
|
||||||
3. **Processing**: Data is processed according to sensor configuration
|
|
||||||
4. **Storage**: Sensor features are created/updated in the database
|
|
||||||
5. **Display**: Sensors appear as interactive points on the map
|
|
||||||
|
|
||||||
## Adding New Sensor Types
|
|
||||||
|
|
||||||
To add a new sensor from the NOI Open Data Hub:
|
|
||||||
|
|
||||||
1. Find the sensor ID in the NOI API (usually starts with "NOI:")
|
|
||||||
2. Determine the coordinates where it should appear on the map
|
|
||||||
3. Choose the appropriate level/floor
|
|
||||||
4. Add it using the `add_sensor` command
|
|
||||||
5. Run the scrape command to fetch initial data
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Sensor not appearing on map
|
|
||||||
- Check if the level exists: `docker compose exec -T c3nav-core python manage.py shell -c "from c3nav.mapdata.models import Level; print([l.short_label for l in Level.objects.all()])"`
|
|
||||||
- Verify coordinates are within the map bounds
|
|
||||||
- Check if the overlay is enabled and visible
|
|
||||||
|
|
||||||
### No data being scraped
|
|
||||||
- Verify the sensor ID exists in the NOI Open Data Hub API
|
|
||||||
- Check the API URL is accessible: https://mobility.api.opendatahub.com/v2/flat/IndoorStation/*/latest
|
|
||||||
- Review logs during scraping for errors
|
|
||||||
|
|
||||||
### Data not updating
|
|
||||||
- Check the `last_updated` field in the sensor feature
|
|
||||||
- Verify the scraping command completed successfully
|
|
||||||
- Consider running the scrape command more frequently
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- `up.sh`: Main setup script
|
|
||||||
- `manage_noi_sensors.sh`: Helper script for sensor management
|
|
||||||
- `src/c3nav/mapdata/management/commands/manage_sensors.py`: Core sensor management command
|
|
||||||
- `src/c3nav/mapdata/management/commands/add_sensor.py`: Command to add new sensors
|
|
||||||
- `src/c3nav/mapdata/management/commands/list_sensors.py`: Command to list sensors
|
|
||||||
- `src/c3nav/mapdata/models/overlay.py`: Database models
|
|
||||||
- `src/c3nav/mapdata/migrations/0140_add_temperature_fields.py`: Migration for sensor fields
|
|
||||||
- `src/c3nav/mapdata/migrations/0141_add_sensor_data_field.py`: Migration for sensor_data field
|
|
|
@ -1,109 +0,0 @@
|
||||||
# SPDX-FileCopyrightText: 2024 NOI Techpark
|
|
||||||
# SPDX-FileCopyrightText: 2024 https://github.com/c3nav/c3nav
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
# Initially Copied from https://github.com/c3nav/c3nav/blob/034f8fe4633be3a982e94fd9e2f1fbc6d3dfafe1/deployment/docker/compose.yaml
|
|
||||||
|
|
||||||
x-restart-policy: &restart-policy
|
|
||||||
restart: unless-stopped
|
|
||||||
x-depends_on-default: &depends_on
|
|
||||||
condition: service_started
|
|
||||||
x-depends_on-healthy: &depends_on-healthy
|
|
||||||
condition: service_healthy
|
|
||||||
x-healthcheck_defaults: &healthcheck_defaults
|
|
||||||
interval: 10s
|
|
||||||
timeout: 2s
|
|
||||||
retries: 5
|
|
||||||
start_period: 10s
|
|
||||||
x-c3nav-defaults: &c3nav-defaults
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile
|
|
||||||
args:
|
|
||||||
- COMMIT=${COMMIT}
|
|
||||||
depends_on:
|
|
||||||
redis:
|
|
||||||
<<: *depends_on-healthy
|
|
||||||
postgres:
|
|
||||||
<<: *depends_on-healthy
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: ${C3NAV_DOCKER_DATA_DIR:-./data}
|
|
||||||
target: /data
|
|
||||||
bind:
|
|
||||||
create_host_path: true
|
|
||||||
x-c3nav-environment: &c3nav-environment
|
|
||||||
C3NAV_DEBUG: false
|
|
||||||
C3NAV_LOGLEVEL: info
|
|
||||||
C3NAV_CONFIG: /data/c3nav.cfg
|
|
||||||
C3NAV_DATA_DIR: /data
|
|
||||||
C3NAV_DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1,c3nav-core,c3nav-tiles,${C3NAV_DJANGO_ALLOWED_HOSTS:-""},${C3NAV_DOMAIN}
|
|
||||||
C3NAV_DJANGO_REVERSE_PROXY: "true"
|
|
||||||
UWSGI_WORKERS: ${C3NAV_CORE_WORKERS:-2}
|
|
||||||
C3NAV_DATABASE_BACKEND: postgresql
|
|
||||||
C3NAV_DATABASE_NAME: ${C3NAV_DATABASE_NAME:-c3nav}
|
|
||||||
C3NAV_DATABASE_USER: ${C3NAV_DATABASE_USER:-postgres}
|
|
||||||
C3NAV_DATABASE_HOST: postgres
|
|
||||||
C3NAV_REDIS: "redis://redis:6379/0"
|
|
||||||
C3NAV_CELERY_BROKER: "redis://redis:6379/1"
|
|
||||||
C3NAV_CELERY_BACKEND: "redis://redis:6379/2"
|
|
||||||
|
|
||||||
|
|
||||||
services:
|
|
||||||
c3nav-core:
|
|
||||||
<<: [*restart-policy, *c3nav-defaults]
|
|
||||||
command: webstatic
|
|
||||||
environment:
|
|
||||||
<<: *c3nav-environment
|
|
||||||
C3NAV_AUTOMIGRATE: yes
|
|
||||||
healthcheck:
|
|
||||||
<<: *healthcheck_defaults
|
|
||||||
test: curl -f http://localhost:8000/
|
|
||||||
ports:
|
|
||||||
- "${SERVER_PORT:-8000}:8000"
|
|
||||||
|
|
||||||
c3nav-workers:
|
|
||||||
<<: [*restart-policy, *c3nav-defaults]
|
|
||||||
command: worker
|
|
||||||
environment:
|
|
||||||
<<: *c3nav-environment
|
|
||||||
C3NAV_AUTOMIGRATE: no
|
|
||||||
healthcheck:
|
|
||||||
interval: 30s
|
|
||||||
timeout: 15s
|
|
||||||
retries: 2
|
|
||||||
start_period: 5s
|
|
||||||
test: entrypoint worker_healthcheck
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
<<: *restart-policy
|
|
||||||
image: postgres:16
|
|
||||||
healthcheck:
|
|
||||||
<<: *healthcheck_defaults
|
|
||||||
test: pg_isready -U postgres && psql -U postgres -d ${C3NAV_DATABASE_NAME:-c3nav} -c "SELECT 1;"
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${C3NAV_DATABASE_NAME:-c3nav}
|
|
||||||
POSTGRES_HOST_AUTH_METHOD: "trust"
|
|
||||||
volumes:
|
|
||||||
- "c3nav-postgres:/var/lib/postgresql/data"
|
|
||||||
|
|
||||||
redis:
|
|
||||||
<<: *restart-policy
|
|
||||||
image: redis:7.2
|
|
||||||
command: redis-server --save 60 1 --loglevel warning
|
|
||||||
healthcheck:
|
|
||||||
<<: *healthcheck_defaults
|
|
||||||
test: redis-cli ping
|
|
||||||
volumes:
|
|
||||||
- "c3nav-redis:/data"
|
|
||||||
ulimits:
|
|
||||||
nofile:
|
|
||||||
soft: 10032
|
|
||||||
hard: 10032
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
c3nav-postgres:
|
|
||||||
external: true
|
|
||||||
c3nav-redis:
|
|
||||||
external: true
|
|
|
@ -1,44 +0,0 @@
|
||||||
# SPDX-FileCopyrightText: 2024 NOI Techpark <digital@noi.bz.it>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: CC0-1.0
|
|
||||||
|
|
||||||
[c3nav]
|
|
||||||
svg_renderer=rsvg
|
|
||||||
editor=False
|
|
||||||
user_registration=false
|
|
||||||
initial_level=2
|
|
||||||
header_logo=/data/logo/sfscon.svg
|
|
||||||
imprint_link=https://www.sfscon.it/impressum/
|
|
||||||
branding=SFSCON map
|
|
||||||
app_enabled=False
|
|
||||||
|
|
||||||
[locale]
|
|
||||||
languages=en
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
#is_dark=False
|
|
||||||
#randomize_primary_color=False
|
|
||||||
map_background=#f7f8f8
|
|
||||||
map_wall_fill=#e5e4e5
|
|
||||||
map_wall_border=#c1bfbe
|
|
||||||
#map_door_fill=#ffffff
|
|
||||||
map_ground_fill=#f7f8f8
|
|
||||||
#map_obstacles_default_fill=#b7b7b7
|
|
||||||
#map_obstacles_default_border=#888888
|
|
||||||
#css_primary=#9b4dca
|
|
||||||
#css_initial=#ffffff
|
|
||||||
#css_primary=#9b4dca
|
|
||||||
#css_logo=None
|
|
||||||
#css_secondary=#525862
|
|
||||||
#css_tertiary=#f0f0f0
|
|
||||||
#css_quaternary=#767676
|
|
||||||
#css_quinary=#cccccc
|
|
||||||
css_header_text=#000000
|
|
||||||
#css_header_text_hover=#eeeeee
|
|
||||||
css_header_background=#ffffff
|
|
||||||
#css_shadow=#000000
|
|
||||||
#css_overlay_background=#ffffff
|
|
||||||
#css_grid=#000000
|
|
||||||
#css_modal_backdrop=#000000
|
|
||||||
#css_route_dots_shadow=#ffffff
|
|
||||||
#map_background=#dcdcdc
|
|
|
@ -1,6 +0,0 @@
|
||||||
<!--
|
|
||||||
SPDX-FileCopyrightText: 2024 NOI Techpark <digital@noi.bz.it>
|
|
||||||
|
|
||||||
SPDX-License-Identifier: CC0-1.0
|
|
||||||
-->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 186.5 37.18"><defs><style>.a{isolation:isolate;}.b{fill:#1c1c1c;}</style></defs><title>logo</title><g class="a"><g class="a"><path class="b" d="M15.85,54.66a20.05,20.05,0,0,1-7-1.29,18.42,18.42,0,0,1-6.25-3.88l4.62-5.56a17.43,17.43,0,0,0,4.27,2.7,11.07,11.07,0,0,0,4.59,1,6.29,6.29,0,0,0,3.77-.93,3,3,0,0,0,1.23-2.53,2.83,2.83,0,0,0-.35-1.43,3.44,3.44,0,0,0-1-1.07A9.71,9.71,0,0,0,18,40.83l-2.12-.91-4.67-2a13.8,13.8,0,0,1-2.67-1.43,11.92,11.92,0,0,1-2.31-2.06A9.29,9.29,0,0,1,4.63,31.7,9.68,9.68,0,0,1,4,28.15,9.38,9.38,0,0,1,5,24,10.22,10.22,0,0,1,7.6,20.59a12.74,12.74,0,0,1,4-2.29,15.35,15.35,0,0,1,5.14-.82A16.59,16.59,0,0,1,23,18.69a15.66,15.66,0,0,1,5.36,3.52l-4.12,5.12a15.54,15.54,0,0,0-3.52-2.12,9.91,9.91,0,0,0-3.91-.75,5.73,5.73,0,0,0-3.32.86,2.73,2.73,0,0,0-1.24,2.39,2.36,2.36,0,0,0,.41,1.4,4,4,0,0,0,1.16,1.05,12.08,12.08,0,0,0,1.73.88c.66.27,1.37.58,2.14.91l4.62,1.87a12.6,12.6,0,0,1,5.2,3.63,9.27,9.27,0,0,1,1.9,6.1,10.19,10.19,0,0,1-3.55,7.78,13,13,0,0,1-4.26,2.42A16.79,16.79,0,0,1,15.85,54.66Z" transform="translate(-2.59 -17.48)"/><path class="b" d="M35.48,54V18.14H58.09V25H43.57v8.3H56v6.82H43.57V54Z" transform="translate(-2.59 -17.48)"/><path class="b" d="M73.76,54.66a20.13,20.13,0,0,1-7-1.29,18.29,18.29,0,0,1-6.24-3.88l4.62-5.56a17.18,17.18,0,0,0,4.26,2.7,11.14,11.14,0,0,0,4.59,1,6.27,6.27,0,0,0,3.77-.93A3,3,0,0,0,79,44.21a2.74,2.74,0,0,0-.36-1.43,3.44,3.44,0,0,0-1-1.07,9.22,9.22,0,0,0-1.64-.88l-2.12-.91-4.68-2a14,14,0,0,1-2.66-1.43,11.36,11.36,0,0,1-2.31-2.06,9.53,9.53,0,0,1-1.63-2.75,9.9,9.9,0,0,1-.6-3.55A9.51,9.51,0,0,1,62.87,24a10.49,10.49,0,0,1,2.64-3.41,12.9,12.9,0,0,1,4-2.29,15.31,15.31,0,0,1,5.14-.82,16.63,16.63,0,0,1,6.19,1.21,15.76,15.76,0,0,1,5.36,3.52l-4.13,5.12a15.12,15.12,0,0,0-3.52-2.12,9.83,9.83,0,0,0-3.9-.75,5.74,5.74,0,0,0-3.33.86,2.73,2.73,0,0,0-1.24,2.39,2.36,2.36,0,0,0,.41,1.4,4.1,4.1,0,0,0,1.16,1.05,12.08,12.08,0,0,0,1.73.88l2.15.91,4.62,1.87a12.66,12.66,0,0,1,5.2,3.63,9.33,9.33,0,0,1,1.89,6.1,10.14,10.14,0,0,1-3.54,7.78,13.09,13.09,0,0,1-4.27,2.42A16.71,16.71,0,0,1,73.76,54.66Z" transform="translate(-2.59 -17.48)"/><path class="b" d="M108.36,54.66a17.49,17.49,0,0,1-6.44-1.18A15.15,15.15,0,0,1,96.62,50,16.35,16.35,0,0,1,93,44.24a22.08,22.08,0,0,1-1.32-8,21.51,21.51,0,0,1,1.38-7.95,17.81,17.81,0,0,1,3.68-5.91,15.55,15.55,0,0,1,5.39-3.68,16.83,16.83,0,0,1,6.49-1.27,13.59,13.59,0,0,1,6.22,1.4,17.22,17.22,0,0,1,4.56,3.28l-4.29,5.17a13.28,13.28,0,0,0-2.89-2.07,7.23,7.23,0,0,0-3.43-.8,7.72,7.72,0,0,0-3.44.8,8.18,8.18,0,0,0-2.81,2.28,11.5,11.5,0,0,0-1.86,3.63A15.85,15.85,0,0,0,100,36q0,5.57,2.36,8.61a7.59,7.59,0,0,0,6.33,3.05,8,8,0,0,0,3.93-1,12.09,12.09,0,0,0,3-2.39L120,49.38A14.55,14.55,0,0,1,108.36,54.66Z" transform="translate(-2.59 -17.48)"/><path class="b" d="M138,54.66a16.61,16.61,0,0,1-6.63-1.29,14.4,14.4,0,0,1-5.14-3.71,17.17,17.17,0,0,1-3.33-5.89,24.22,24.22,0,0,1-1.18-7.86,24,24,0,0,1,1.18-7.84,16.29,16.29,0,0,1,3.33-5.78,14.17,14.17,0,0,1,5.14-3.57,18.34,18.34,0,0,1,13.26,0,14.09,14.09,0,0,1,5.14,3.6,16.39,16.39,0,0,1,3.32,5.8,23.82,23.82,0,0,1,1.19,7.79A24.22,24.22,0,0,1,153,43.77a17,17,0,0,1-3.32,5.89,14.49,14.49,0,0,1-5.14,3.71A16.61,16.61,0,0,1,138,54.66Zm0-7a6.7,6.7,0,0,0,5.83-3.16c1.43-2.11,2.14-5,2.14-8.6s-.71-6.4-2.14-8.42a7.12,7.12,0,0,0-11.66,0c-1.43,2-2.15,4.82-2.15,8.42s.72,6.49,2.15,8.6A6.69,6.69,0,0,0,138,47.67Z" transform="translate(-2.59 -17.48)"/><path class="b" d="M161,54V18.14h8.31L178.65,36l3.52,7.92h.22c-.19-1.91-.4-4-.64-6.38s-.35-4.58-.35-6.71V18.14h7.7V54h-8.31l-9.35-17.87-3.52-7.81h-.22c.18,2,.4,4.1.63,6.38a62.75,62.75,0,0,1,.36,6.59V54Z" transform="translate(-2.59 -17.48)"/></g></g></svg>
|
|
Before Width: | Height: | Size: 3.7 KiB |
|
@ -1,85 +0,0 @@
|
||||||
--
|
|
||||||
-- PostgreSQL database dump
|
|
||||||
--
|
|
||||||
|
|
||||||
-- Dumped from database version 17.5 (Debian 17.5-1.pgdg120+1)
|
|
||||||
-- Dumped by pg_dump version 17.5
|
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
|
||||||
SET lock_timeout = 0;
|
|
||||||
SET idle_in_transaction_session_timeout = 0;
|
|
||||||
SET transaction_timeout = 0;
|
|
||||||
SET client_encoding = 'UTF8';
|
|
||||||
SET standard_conforming_strings = on;
|
|
||||||
SELECT pg_catalog.set_config('search_path', '', false);
|
|
||||||
SET check_function_bodies = false;
|
|
||||||
SET xmloption = content;
|
|
||||||
SET client_min_messages = warning;
|
|
||||||
SET row_security = off;
|
|
||||||
|
|
||||||
SET default_tablespace = '';
|
|
||||||
|
|
||||||
SET default_table_access_method = heap;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: auth_user; Type: TABLE; Schema: public; Owner: mindshub
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.auth_user (
|
|
||||||
id integer NOT NULL,
|
|
||||||
password character varying(128) NOT NULL,
|
|
||||||
last_login timestamp with time zone,
|
|
||||||
is_superuser boolean NOT NULL,
|
|
||||||
username character varying(150) NOT NULL,
|
|
||||||
first_name character varying(150) NOT NULL,
|
|
||||||
last_name character varying(150) NOT NULL,
|
|
||||||
email character varying(254) NOT NULL,
|
|
||||||
is_staff boolean NOT NULL,
|
|
||||||
is_active boolean NOT NULL,
|
|
||||||
date_joined timestamp with time zone NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.auth_user OWNER TO mindshub;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: auth_user_id_seq; Type: SEQUENCE; Schema: public; Owner: mindshub
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE public.auth_user ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
|
|
||||||
SEQUENCE NAME public.auth_user_id_seq
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: auth_user auth_user_pkey; Type: CONSTRAINT; Schema: public; Owner: mindshub
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.auth_user
|
|
||||||
ADD CONSTRAINT auth_user_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: auth_user auth_user_username_key; Type: CONSTRAINT; Schema: public; Owner: mindshub
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.auth_user
|
|
||||||
ADD CONSTRAINT auth_user_username_key UNIQUE (username);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: auth_user_username_6821ab7c_like; Type: INDEX; Schema: public; Owner: mindshub
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE INDEX auth_user_username_6821ab7c_like ON public.auth_user USING btree (username varchar_pattern_ops);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- PostgreSQL database dump complete
|
|
||||||
--
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
# SPDX-FileCopyrightText: 2024 NOI Techpark <digital@noi.bz.it>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: CC0-1.0
|
|
||||||
|
|
||||||
#/bin/bash
|
|
||||||
docker exec -i c3nav-postgres-1 su - postgres -c 'pg_dump --clean --if-exists --no-owner --exclude-table=public.auth_user c3nav' > dump.sql
|
|
10780
local_run/db/dump.sql
10780
local_run/db/dump.sql
File diff suppressed because one or more lines are too long
|
@ -1,6 +0,0 @@
|
||||||
# SPDX-FileCopyrightText: 2024 NOI Techpark <digital@noi.bz.it>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: CC0-1.0
|
|
||||||
|
|
||||||
#/bin/bash
|
|
||||||
cat dump.sql | docker exec -i c3nav-postgres-1 su - postgres -c 'psql c3nav'
|
|
|
@ -1,46 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Helper script to manage NOI sensors
|
|
||||||
# Usage: ./manage_noi_sensors.sh [add|list|scrape] [args...]
|
|
||||||
|
|
||||||
COMPOSE_EXEC="docker compose exec -T c3nav-core python manage.py"
|
|
||||||
|
|
||||||
case "$1" in
|
|
||||||
"add")
|
|
||||||
if [ $# -lt 6 ]; then
|
|
||||||
echo "Usage: $0 add <sensor-id> <name> <x> <y> <level>"
|
|
||||||
echo "Example: $0 add 'NOI:MyNewSensor' 'My New Sensor' 300.0 250.0 floor1"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
SENSOR_ID="$2"
|
|
||||||
NAME="$3"
|
|
||||||
X="$4"
|
|
||||||
Y="$5"
|
|
||||||
LEVEL="$6"
|
|
||||||
echo "Adding sensor: $NAME ($SENSOR_ID) at ($X, $Y) on $LEVEL"
|
|
||||||
$COMPOSE_EXEC add_sensor --overlay-id 1 --sensor-id "$SENSOR_ID" --name "$NAME" --x "$X" --y "$Y" --level "$LEVEL"
|
|
||||||
;;
|
|
||||||
"list")
|
|
||||||
echo "Listing all sensors in overlay 1:"
|
|
||||||
$COMPOSE_EXEC list_sensors --overlay-id 1
|
|
||||||
;;
|
|
||||||
"scrape")
|
|
||||||
echo "Scraping data for all sensors in overlay 1:"
|
|
||||||
$COMPOSE_EXEC manage_sensors --scrape-data --overlay-id 1
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "NOI Sensor Management Helper"
|
|
||||||
echo "Usage: $0 [add|list|scrape] [args...]"
|
|
||||||
echo ""
|
|
||||||
echo "Commands:"
|
|
||||||
echo " add <sensor-id> <name> <x> <y> <level> - Add a new sensor"
|
|
||||||
echo " list - List all sensors"
|
|
||||||
echo " scrape - Scrape data for all sensors"
|
|
||||||
echo ""
|
|
||||||
echo "Examples:"
|
|
||||||
echo " $0 add 'NOI:NewSensor' 'My Sensor' 300.0 250.0 floor1"
|
|
||||||
echo " $0 list"
|
|
||||||
echo " $0 scrape"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
117
local_run/up.sh
117
local_run/up.sh
|
@ -1,117 +0,0 @@
|
||||||
|
|
||||||
docker compose down --remove-orphans
|
|
||||||
rm -rf data || true
|
|
||||||
cp -r data-copy data
|
|
||||||
chmod 777 data
|
|
||||||
docker volume rm c3nav-postgres c3nav-redis || true
|
|
||||||
docker volume create c3nav-postgres
|
|
||||||
docker volume create c3nav-redis
|
|
||||||
|
|
||||||
# Start only postgres and redis first (no build since we pre-built)
|
|
||||||
docker compose up -d postgres redis
|
|
||||||
|
|
||||||
sleep 10
|
|
||||||
cat ./db/auth_user.sql | docker exec -i local_run-postgres-1 su - postgres -c 'psql c3nav'
|
|
||||||
|
|
||||||
|
|
||||||
# Load database dump before starting the main app
|
|
||||||
cat ./db/dump.sql | docker exec -i local_run-postgres-1 su - postgres -c 'psql c3nav' > /dev/null
|
|
||||||
|
|
||||||
# Fix geometry access permissions for anonymous users
|
|
||||||
docker exec -i local_run-postgres-1 psql -U postgres -d c3nav -c "UPDATE mapdata_space SET base_mapdata_accessible = true;"
|
|
||||||
|
|
||||||
# Now start the main services (no build since we pre-built)
|
|
||||||
docker compose up --build -d
|
|
||||||
echo "Creating Django superuser"
|
|
||||||
# docker exec -i local_run-c3nav-core-1 sh -c "echo \"from django.contrib.auth.models import User; [0].set_password('admin'); User.objects.get(username='admin').save()\" | /app/env/bin/python manage.py shell"
|
|
||||||
docker exec -i local_run-c3nav-core-1 sh -c "echo \"
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
user = User.objects.get_or_create(username='admin', defaults={'email': 'admin@example.com', 'is_superuser': True, 'is_staff': True})[0]
|
|
||||||
user.set_password('admin')
|
|
||||||
user.save()
|
|
||||||
print('Password set successfully for user:', user.username)
|
|
||||||
\" | /app/env/bin/python manage.py shell"
|
|
||||||
|
|
||||||
|
|
||||||
# Apply migrations after loading dump
|
|
||||||
echo "Applying migrations..."
|
|
||||||
docker exec -i local_run-c3nav-core-1 sh -c '/app/env/bin/python manage.py migrate --fake'
|
|
||||||
echo "Creating new migrations for sensor_data field..."
|
|
||||||
docker exec -i local_run-c3nav-core-1 sh -c '/app/env/bin/python manage.py makemigrations mapdata'
|
|
||||||
echo "Applying new migrations..."
|
|
||||||
docker exec -i local_run-c3nav-core-1 sh -c '/app/env/bin/python manage.py migrate mapdata'
|
|
||||||
|
|
||||||
docker compose ps -a
|
|
||||||
docker exec -i local_run-c3nav-core-1 sh -c '/app/env/bin/python manage.py clearmapcache --include-history --include-geometries && /app/env/bin/python manage.py collectstatic -l --no-input'
|
|
||||||
|
|
||||||
# Fix NumPy compatibility issue
|
|
||||||
echo "Applying NumPy compatibility fix..."
|
|
||||||
docker exec -i local_run-c3nav-core-1 sed -i 's/np\.fromstring(/np.frombuffer(/g' /app/c3nav/mapdata/utils/cache/indexed.py
|
|
||||||
|
|
||||||
# Process map updates to rebuild cache with NumPy fix
|
|
||||||
echo "Processing map updates to rebuild cache..."
|
|
||||||
docker exec -i local_run-c3nav-core-1 sh -c '/app/env/bin/python manage.py processupdates'
|
|
||||||
|
|
||||||
# Setup sensor overlays
|
|
||||||
echo "Setting up sensor overlays..."
|
|
||||||
docker compose exec -T c3nav-core python manage.py shell << 'EOF'
|
|
||||||
from c3nav.mapdata.models import DataOverlay, DataOverlayFeature
|
|
||||||
|
|
||||||
# Clear old overlays to avoid conflicts
|
|
||||||
DataOverlay.objects.filter(titles__en__icontains='Environmental').delete()
|
|
||||||
DataOverlay.objects.filter(titles__en__icontains='Temperature').delete()
|
|
||||||
|
|
||||||
# Create single NOI environmental sensor overlay with multiple sensors configuration
|
|
||||||
overlay = DataOverlay.objects.create(
|
|
||||||
titles={'en': 'NOI Environmental Sensors'},
|
|
||||||
description='Real-time CO2 and temperature sensors from NOI Open Data Hub - displays current readings with values and units',
|
|
||||||
default_geomtype='point',
|
|
||||||
data_source_url='https://mobility.api.opendatahub.com/v2/flat/IndoorStation/*/latest',
|
|
||||||
sensor_config={
|
|
||||||
'data_path': 'data',
|
|
||||||
'mappings': {
|
|
||||||
'id_field': 'scode',
|
|
||||||
'name_field': 'sname',
|
|
||||||
'x_field': 'scoordinate.x',
|
|
||||||
'y_field': 'scoordinate.y'
|
|
||||||
},
|
|
||||||
'sensors': [
|
|
||||||
{
|
|
||||||
'id': 'NOI:FreeSoftwareLab-Temperature',
|
|
||||||
'coordinates': {'x': 291.0, 'y': 241.0},
|
|
||||||
'level': 'floor1'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'NOI:NOI-A1-Floor1-CO2',
|
|
||||||
'coordinates': {'x': 270.0, 'y': 241.0},
|
|
||||||
'level': 'floor1'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
update_interval=120
|
|
||||||
)
|
|
||||||
print(f"NOI sensor overlay created with ID {overlay.id}")
|
|
||||||
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Scrape real NOI sensor data
|
|
||||||
echo "Scraping NOI sensor data..."
|
|
||||||
# Give the database a moment to settle after overlay creation
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# Scrape the overlay data (should automatically discover all configured sensors)
|
|
||||||
echo "Scraping overlay data (ID: 1)..."
|
|
||||||
docker compose exec -T c3nav-core python manage.py manage_sensors --scrape-data --overlay-id 1
|
|
||||||
|
|
||||||
# List all sensors to verify setup
|
|
||||||
echo "Listing all sensors in the overlay..."
|
|
||||||
docker compose exec -T c3nav-core python manage.py list_sensors --overlay-id 1
|
|
||||||
|
|
||||||
echo "Sensor setup completed!"
|
|
||||||
echo ""
|
|
||||||
echo "To add a new sensor to the overlay, use:"
|
|
||||||
echo "docker compose exec -T c3nav-core python manage.py add_sensor --overlay-id 1 --sensor-id 'NOI:YourSensorID' --name 'Your Sensor Name' --x 300.0 --y 250.0 --level floor1"
|
|
||||||
echo ""
|
|
||||||
echo "To scrape data for all sensors:"
|
|
||||||
echo "docker compose exec -T c3nav-core python manage.py manage_sensors --scrape-data --overlay-id 1"
|
|
||||||
|
|
|
@ -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.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.geometries import get_level_geometries_result, get_space_geometries_result
|
||||||
from c3nav.editor.api.schemas import EditorGeometriesElemSchema, EditorID, GeometryStylesSchema, UpdateCacheKey, \
|
from c3nav.editor.api.schemas import EditorGeometriesElemSchema, EditorID, GeometryStylesSchema, UpdateCacheKey, \
|
||||||
EditorBeaconsLookup, CloneFloorRequestSchema, CloneFloorResponseSchema
|
EditorBeaconsLookup
|
||||||
from c3nav.editor.views.base import editor_etag_func, accesses_mapdata
|
from c3nav.editor.views.base import editor_etag_func, accesses_mapdata
|
||||||
from c3nav.mapdata.api.base import api_etag
|
from c3nav.mapdata.api.base import api_etag
|
||||||
from c3nav.mapdata.models import Source
|
from c3nav.mapdata.models import Source
|
||||||
|
@ -42,6 +42,7 @@ def geometrystyles(request):
|
||||||
'door': '#ffffff',
|
'door': '#ffffff',
|
||||||
'area': '#55aaff',
|
'area': '#55aaff',
|
||||||
'stair': '#a000a0',
|
'stair': '#a000a0',
|
||||||
|
'stairway': '#b000b0',
|
||||||
'ramp': 'rgba(160, 0, 160, 0.2)',
|
'ramp': 'rgba(160, 0, 160, 0.2)',
|
||||||
'obstacle': '#999999',
|
'obstacle': '#999999',
|
||||||
'lineobstacle': '#999999',
|
'lineobstacle': '#999999',
|
||||||
|
@ -146,28 +147,3 @@ def beacons_lookup(request):
|
||||||
wifi_beacons=wifi_beacons,
|
wifi_beacons=wifi_beacons,
|
||||||
ibeacons=ibeacons,
|
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")
|
|
|
@ -125,27 +125,3 @@ class EditorBeaconsLookup(BaseSchema):
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
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
|
|
|
@ -402,7 +402,7 @@ def create_editor_form(editor_model):
|
||||||
'stroke_opacity', 'fill_color', 'fill_opacity', 'interactive', 'point_icon', 'extra_data', 'show_label',
|
'stroke_opacity', 'fill_color', 'fill_opacity', 'interactive', 'point_icon', 'extra_data', 'show_label',
|
||||||
'show_geometry', 'show_label', 'show_geometry', 'default_geomtype', 'cluster_points', 'update_interval',
|
'show_geometry', 'show_label', 'show_geometry', 'default_geomtype', 'cluster_points', 'update_interval',
|
||||||
'load_group_display', 'load_group_contribute',
|
'load_group_display', 'load_group_contribute',
|
||||||
'altitude_quest', 'fill_quest',
|
'altitude_quest', 'fill_quest', 'stair_count', 'stair_width',
|
||||||
]
|
]
|
||||||
field_names = [field.name for field in editor_model._meta.get_fields()
|
field_names = [field.name for field in editor_model._meta.get_fields()
|
||||||
if not field.one_to_many and not isinstance(field, ManyToManyRel)]
|
if not field.one_to_many and not isinstance(field, ManyToManyRel)]
|
||||||
|
|
|
@ -567,83 +567,210 @@ label.theme-color-label {
|
||||||
.leaflet-control-snap {
|
.leaflet-control-snap {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
background-clip: padding-box;
|
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;
|
display: block;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
|
line-height: 26px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 18px;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
color: #666;
|
color: #666;
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #a7a7a7;
|
background-color: #f4f4f4;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background-color: #b0ecb2;
|
background-color: #4CAF50;
|
||||||
border: 2px solid green;
|
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #7ac27d;
|
background-color: #45a049;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 */
|
/* Snap indicator styles */
|
||||||
.snap-indicator {
|
.snap-indicator {
|
||||||
transform: scale(11.6);
|
|
||||||
transform-box: fill-box;
|
|
||||||
transform-origin: center;
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
animation: snap-pulse 1s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 */
|
/* Stairway Creator control styles */
|
||||||
.edge-highlight {
|
.leaflet-control-stairway {
|
||||||
z-index: 999;
|
background-color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
margin-top: 45px; // Position below snap control
|
||||||
|
|
||||||
|
.stairway-toggle {
|
||||||
|
display: block;
|
||||||
|
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;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: #FF9800;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #F57C00;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stairway Creator configuration panel */
|
||||||
|
#stairway-config {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #FF9800;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #FF9800;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(255, 152, 0, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
margin-top: 15px;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #FF9800;
|
||||||
|
border-color: #FF9800;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #F57C00;
|
||||||
|
border-color: #F57C00;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: #6c757d;
|
||||||
|
border-color: #6c757d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stairway Creator instructions */
|
||||||
|
#stairway-instructions {
|
||||||
|
border-left: 4px solid #FF9800;
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border-color: #ffeaa7;
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #FF9800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stairway preview elements */
|
||||||
|
.stairway-point-label {
|
||||||
|
background-color: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stairway-step-label {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
border: 1px solid white;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stairway step preview lines */
|
||||||
|
.stairway-step-preview {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.original-edge-highlight {
|
/* Alert styles for stairway creator */
|
||||||
z-index: 1000;
|
.alert {
|
||||||
pointer-events: none;
|
padding: 12px;
|
||||||
}
|
margin-bottom: 15px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
/* Right-angle snap indicators */
|
&.alert-info {
|
||||||
.right-angle-reference {
|
color: #0c5460;
|
||||||
z-index: 998;
|
background-color: #d1ecf1;
|
||||||
pointer-events: none;
|
border-color: #bee5eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-angle-line {
|
|
||||||
z-index: 1001;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-angle-square {
|
|
||||||
z-index: 1002;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
File diff suppressed because it is too large
Load diff
|
@ -1,40 +0,0 @@
|
||||||
{% 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,9 +16,6 @@
|
||||||
{% trans 'Level' as model_title %}
|
{% trans 'Level' as model_title %}
|
||||||
<i class="glyphicon glyphicon-pencil"></i> {% blocktrans %}Edit {{ model_title }}{% endblocktrans %}
|
<i class="glyphicon glyphicon-pencil"></i> {% blocktrans %}Edit {{ model_title }}{% endblocktrans %}
|
||||||
</a>
|
</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>
|
||||||
<p>
|
<p>
|
||||||
{% if level.on_top_of == None %}
|
{% if level.on_top_of == None %}
|
||||||
|
@ -28,45 +25,6 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</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.graph' level=level.pk as graph_url %}
|
||||||
{% url 'editor.levels.overlays' level=level.pk as overlays_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 %}
|
{% include 'editor/fragment_child_models.html' with graph_url=graph_url overlays_url=overlays_url %}
|
||||||
|
|
|
@ -20,11 +20,6 @@
|
||||||
<a class="btn btn-default btn-xs" accesskey="n" href="{{ create_url }}">
|
<a class="btn btn-default btn-xs" accesskey="n" href="{{ create_url }}">
|
||||||
<i class="glyphicon glyphicon-plus"></i> {% blocktrans %}New {{ model_title }}{% endblocktrans %}
|
<i class="glyphicon glyphicon-plus"></i> {% blocktrans %}New {{ model_title }}{% endblocktrans %}
|
||||||
</a>
|
</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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if explicit_edit %}
|
{% 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.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.changes import changeset_detail, changeset_edit, changeset_redirect
|
||||||
from c3nav.editor.views.edit import edit, graph_edit, level_detail, list_objects, main_index, staircase_edit, sourceimage, space_detail
|
from c3nav.editor.views.edit import edit, graph_edit, level_detail, list_objects, main_index, sourceimage, space_detail
|
||||||
from c3nav.editor.views.overlays import overlays_list, overlay_features, overlay_feature_edit
|
from c3nav.editor.views.overlays import overlays_list, overlay_features, overlay_feature_edit
|
||||||
from c3nav.editor.views.quest import QuestFormView
|
from c3nav.editor.views.quest import QuestFormView
|
||||||
from c3nav.editor.views.users import user_detail, user_redirect
|
from c3nav.editor.views.users import user_detail, user_redirect
|
||||||
|
@ -33,6 +33,7 @@ def add_editor_urls(model_name, parent_model_name=None, with_list=True, explicit
|
||||||
])
|
])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# todo: custom path converters
|
# todo: custom path converters
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('levels/<int:pk>/', level_detail, name='editor.levels.detail'),
|
path('levels/<int:pk>/', level_detail, name='editor.levels.detail'),
|
||||||
|
@ -90,4 +91,3 @@ urlpatterns.extend(add_editor_urls('LeaveDescription', 'Space'))
|
||||||
urlpatterns.extend(add_editor_urls('CrossDescription', 'Space'))
|
urlpatterns.extend(add_editor_urls('CrossDescription', 'Space'))
|
||||||
urlpatterns.extend(add_editor_urls('BeaconMeasurement', 'Space'))
|
urlpatterns.extend(add_editor_urls('BeaconMeasurement', 'Space'))
|
||||||
urlpatterns.extend(add_editor_urls('RangingBeacon', '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,274 +55,3 @@ class SpaceChildEditUtils(DefaultEditUtils):
|
||||||
@property
|
@property
|
||||||
def _geometry_url(self):
|
def _geometry_url(self):
|
||||||
return '/api/v2/editor/geometries/space/'+str(self.space.pk) # todo: resolve correctly
|
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,12 +70,6 @@ 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)
|
@etag(editor_etag_func)
|
||||||
@accesses_mapdata
|
@accesses_mapdata
|
||||||
@sidebar_view
|
@sidebar_view
|
||||||
|
@ -411,11 +405,7 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e
|
||||||
"access_restriction_select": True,
|
"access_restriction_select": True,
|
||||||
})
|
})
|
||||||
|
|
||||||
if request.path.endswith("staircase"):
|
return render(request, 'editor/edit.html', ctx)
|
||||||
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):
|
def get_visible_spaces(request):
|
||||||
|
|
|
@ -125,7 +125,7 @@ def fetch_updates(request, response: HttpResponse):
|
||||||
}
|
}
|
||||||
if cross_origin is None:
|
if cross_origin is None:
|
||||||
result.update({
|
result.update({
|
||||||
'user_data': dict(request.user_data),
|
'user_data': request.user_data,
|
||||||
})
|
})
|
||||||
|
|
||||||
if cross_origin is not None:
|
if cross_origin is not None:
|
||||||
|
|
|
@ -8,4 +8,3 @@ class MapdataConfig(AppConfig):
|
||||||
from c3nav.mapdata.utils.cache.changes import register_signals
|
from c3nav.mapdata.utils.cache.changes import register_signals
|
||||||
register_signals()
|
register_signals()
|
||||||
import c3nav.mapdata.metrics # noqa
|
import c3nav.mapdata.metrics # noqa
|
||||||
import c3nav.mapdata.signals.sync # noqa
|
|
||||||
|
|
|
@ -1,141 +0,0 @@
|
||||||
import json
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.utils import timezone
|
|
||||||
from shapely.geometry import Point
|
|
||||||
|
|
||||||
from c3nav.mapdata.models import DataOverlay, DataOverlayFeature, Level
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = 'Add a new sensor to an existing overlay'
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
'--overlay-id',
|
|
||||||
type=int,
|
|
||||||
required=True,
|
|
||||||
help='ID of the overlay to add the sensor to',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--sensor-id',
|
|
||||||
type=str,
|
|
||||||
required=True,
|
|
||||||
help='Unique ID for the sensor (e.g., NOI:Sensor-ID)',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--name',
|
|
||||||
type=str,
|
|
||||||
required=True,
|
|
||||||
help='Display name for the sensor',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--x',
|
|
||||||
type=float,
|
|
||||||
required=True,
|
|
||||||
help='X coordinate in c3nav coordinate system',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--y',
|
|
||||||
type=float,
|
|
||||||
required=True,
|
|
||||||
help='Y coordinate in c3nav coordinate system',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--level',
|
|
||||||
type=str,
|
|
||||||
default='floor0',
|
|
||||||
help='Level/floor where the sensor is located (default: floor0)',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--sensor-type',
|
|
||||||
type=str,
|
|
||||||
default='environmental',
|
|
||||||
help='Type of sensor (default: environmental)',
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
try:
|
|
||||||
overlay = DataOverlay.objects.get(id=options['overlay_id'])
|
|
||||||
except DataOverlay.DoesNotExist:
|
|
||||||
self.stderr.write(f'Overlay with ID {options["overlay_id"]} not found')
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
level = Level.objects.get(short_label=options['level'])
|
|
||||||
except Level.DoesNotExist:
|
|
||||||
self.stderr.write(f'Level "{options["level"]}" not found')
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update overlay configuration to include the new sensor
|
|
||||||
sensor_config = overlay.sensor_config or {}
|
|
||||||
if 'sensors' not in sensor_config:
|
|
||||||
sensor_config['sensors'] = []
|
|
||||||
|
|
||||||
# Check if sensor already exists in config
|
|
||||||
existing_sensor = None
|
|
||||||
for i, sensor in enumerate(sensor_config['sensors']):
|
|
||||||
if sensor['id'] == options['sensor_id']:
|
|
||||||
existing_sensor = i
|
|
||||||
break
|
|
||||||
|
|
||||||
new_sensor_config = {
|
|
||||||
'id': options['sensor_id'],
|
|
||||||
'coordinates': {'x': options['x'], 'y': options['y']},
|
|
||||||
'level': options['level']
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing_sensor is not None:
|
|
||||||
sensor_config['sensors'][existing_sensor] = new_sensor_config
|
|
||||||
self.stdout.write(f'Updated sensor configuration for {options["sensor_id"]}')
|
|
||||||
else:
|
|
||||||
sensor_config['sensors'].append(new_sensor_config)
|
|
||||||
self.stdout.write(f'Added sensor configuration for {options["sensor_id"]}')
|
|
||||||
|
|
||||||
overlay.sensor_config = sensor_config
|
|
||||||
overlay.save()
|
|
||||||
|
|
||||||
# Create the sensor feature (or update if it exists)
|
|
||||||
point = Point(options['x'], options['y'])
|
|
||||||
|
|
||||||
feature, created = DataOverlayFeature.objects.update_or_create(
|
|
||||||
overlay=overlay,
|
|
||||||
sensor_id=options['sensor_id'],
|
|
||||||
defaults={
|
|
||||||
'titles': {'en': options['name']},
|
|
||||||
'geometry': point,
|
|
||||||
'level': level,
|
|
||||||
'sensor_type': options['sensor_type'],
|
|
||||||
'coordinates_x': options['x'],
|
|
||||||
'coordinates_y': options['y'],
|
|
||||||
'fill_color': '#95A5A6', # Default gray
|
|
||||||
'stroke_color': '#95A5A6',
|
|
||||||
'stroke_width': 2,
|
|
||||||
'fill_opacity': 0.8,
|
|
||||||
'show_label': True,
|
|
||||||
'show_geometry': True,
|
|
||||||
'interactive': True,
|
|
||||||
'point_icon': 'sensors',
|
|
||||||
'last_updated': timezone.now(),
|
|
||||||
'extra_data': {
|
|
||||||
'Status': 'No data yet',
|
|
||||||
'Last Updated': timezone.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
||||||
'Data Source': 'Manual configuration',
|
|
||||||
'Station ID': options['sensor_id']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
action = 'Created' if created else 'Updated'
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
f'{action} sensor "{options["name"]}" (ID: {options["sensor_id"]}) '
|
|
||||||
f'at coordinates ({options["x"]}, {options["y"]}) on level {options["level"]}'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
'You can now run the scrape command to fetch data for this sensor:'
|
|
||||||
)
|
|
||||||
self.stdout.write(
|
|
||||||
f'python manage.py manage_sensors --scrape-data --overlay-id {options["overlay_id"]}'
|
|
||||||
)
|
|
|
@ -1,60 +0,0 @@
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from c3nav.mapdata.models import DataOverlay, DataOverlayFeature
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = 'List all sensors in overlays'
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
'--overlay-id',
|
|
||||||
type=int,
|
|
||||||
help='ID of a specific overlay to list sensors for',
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
if options['overlay_id']:
|
|
||||||
try:
|
|
||||||
overlay = DataOverlay.objects.get(id=options['overlay_id'])
|
|
||||||
overlays = [overlay]
|
|
||||||
except DataOverlay.DoesNotExist:
|
|
||||||
self.stderr.write(f'Overlay with ID {options["overlay_id"]} not found')
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
overlays = DataOverlay.objects.all()
|
|
||||||
|
|
||||||
for overlay in overlays:
|
|
||||||
self.stdout.write(f'\n=== Overlay {overlay.id}: {overlay.titles.get("en", "Unknown")} ===')
|
|
||||||
|
|
||||||
# Show overlay configuration
|
|
||||||
sensor_config = overlay.sensor_config or {}
|
|
||||||
configured_sensors = sensor_config.get('sensors', [])
|
|
||||||
if configured_sensors:
|
|
||||||
self.stdout.write('Configured sensors:')
|
|
||||||
for sensor in configured_sensors:
|
|
||||||
self.stdout.write(f' - {sensor["id"]} at ({sensor["coordinates"]["x"]}, {sensor["coordinates"]["y"]}) on level {sensor.get("level", "default")}')
|
|
||||||
|
|
||||||
# Show actual sensor features in database
|
|
||||||
features = DataOverlayFeature.objects.filter(overlay=overlay)
|
|
||||||
if features:
|
|
||||||
self.stdout.write(f'\nSensor features in database ({features.count()}):')
|
|
||||||
for feature in features:
|
|
||||||
title = feature.titles.get('en', 'Unknown') if feature.titles else 'Unknown'
|
|
||||||
level_name = feature.level.short_label if feature.level else 'No level'
|
|
||||||
coords = f'({feature.coordinates_x}, {feature.coordinates_y})' if feature.coordinates_x is not None else 'No coords'
|
|
||||||
last_updated = feature.last_updated.strftime('%Y-%m-%d %H:%M:%S') if feature.last_updated else 'Never'
|
|
||||||
|
|
||||||
self.stdout.write(f' - {feature.sensor_id}: {title}')
|
|
||||||
self.stdout.write(f' Level: {level_name}, Coords: {coords}')
|
|
||||||
self.stdout.write(f' Type: {feature.sensor_type or "Unknown"}, Last updated: {last_updated}')
|
|
||||||
|
|
||||||
if feature.extra_data:
|
|
||||||
readings = [f'{k}: {v}' for k, v in feature.extra_data.items()
|
|
||||||
if k not in ['Last Updated', 'Data Source', 'Station ID']]
|
|
||||||
if readings:
|
|
||||||
self.stdout.write(f' Readings: {", ".join(readings)}')
|
|
||||||
else:
|
|
||||||
self.stdout.write('No sensor features found in database')
|
|
||||||
|
|
||||||
if not overlays:
|
|
||||||
self.stdout.write('No overlays found')
|
|
|
@ -1,461 +0,0 @@
|
||||||
import json
|
|
||||||
import requests
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.utils import timezone
|
|
||||||
from shapely.geometry import Point
|
|
||||||
|
|
||||||
from c3nav.mapdata.models import DataOverlay, DataOverlayFeature, Level
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = 'Setup and manage sensor overlays with generic sensor data'
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
'--create-overlay',
|
|
||||||
type=str,
|
|
||||||
help='Create a new sensor overlay with given name',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--data-source-url',
|
|
||||||
type=str,
|
|
||||||
help='URL to scrape sensor data from',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--sensor-config',
|
|
||||||
type=str,
|
|
||||||
help='JSON configuration for sensor data mapping',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--add-sensor',
|
|
||||||
action='store_true',
|
|
||||||
help='Add sensors manually with provided coordinates',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--scrape-data',
|
|
||||||
action='store_true',
|
|
||||||
help='Scrape data from configured data sources',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--overlay-id',
|
|
||||||
type=int,
|
|
||||||
help='ID of the overlay to work with',
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
if options['create_overlay']:
|
|
||||||
self.create_overlay(options)
|
|
||||||
elif options['add_sensor']:
|
|
||||||
self.add_sensors_manually(options)
|
|
||||||
elif options['scrape_data']:
|
|
||||||
self.scrape_sensor_data(options)
|
|
||||||
else:
|
|
||||||
self.stdout.write('Please specify an action: --create-overlay, --add-sensor, or --scrape-data')
|
|
||||||
|
|
||||||
def create_overlay(self, options):
|
|
||||||
"""Create a new sensor overlay"""
|
|
||||||
name = options['create_overlay']
|
|
||||||
|
|
||||||
# Parse sensor configuration
|
|
||||||
sensor_config = {}
|
|
||||||
if options['sensor_config']:
|
|
||||||
try:
|
|
||||||
sensor_config = json.loads(options['sensor_config'])
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.stderr.write('Invalid JSON in sensor_config')
|
|
||||||
return
|
|
||||||
|
|
||||||
overlay = DataOverlay.objects.create(
|
|
||||||
titles={'en': name},
|
|
||||||
description=f'Sensor overlay for {name}',
|
|
||||||
default_geomtype=DataOverlay.GeometryType.POINT,
|
|
||||||
data_source_url=options['data_source_url'],
|
|
||||||
sensor_config=sensor_config,
|
|
||||||
update_interval=30, # Update every 30 seconds
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f'Created overlay "{name}" with ID {overlay.id}')
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_sensors_manually(self, options):
|
|
||||||
"""Add sensors manually with coordinates"""
|
|
||||||
if not options['overlay_id']:
|
|
||||||
self.stderr.write('--overlay-id required when adding sensors manually')
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
overlay = DataOverlay.objects.get(id=options['overlay_id'])
|
|
||||||
except DataOverlay.DoesNotExist:
|
|
||||||
self.stderr.write(f'Overlay with ID {options["overlay_id"]} not found')
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get the ground floor level (floor0)
|
|
||||||
try:
|
|
||||||
level = Level.objects.get(short_label='floor0')
|
|
||||||
except Level.DoesNotExist:
|
|
||||||
level = Level.objects.first() # Fallback to first level
|
|
||||||
if not level:
|
|
||||||
self.stderr.write('No levels found in database')
|
|
||||||
return
|
|
||||||
|
|
||||||
# Example sensors - this should be configurable via command line or config file
|
|
||||||
sensors = [
|
|
||||||
{
|
|
||||||
'sensor_id': 'temp_001',
|
|
||||||
'sensor_type': 'temperature',
|
|
||||||
'name': 'Meeting Room A1 - Temperature',
|
|
||||||
'coordinates_x': 500,
|
|
||||||
'coordinates_y': 300,
|
|
||||||
'value': 22.5,
|
|
||||||
'unit': '°C'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'sensor_id': 'hum_001',
|
|
||||||
'sensor_type': 'humidity',
|
|
||||||
'name': 'Meeting Room A1 - Humidity',
|
|
||||||
'coordinates_x': 500,
|
|
||||||
'coordinates_y': 300,
|
|
||||||
'value': 55.0,
|
|
||||||
'unit': '%'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'sensor_id': 'temp_002',
|
|
||||||
'sensor_type': 'temperature',
|
|
||||||
'name': 'Server Room - Temperature',
|
|
||||||
'coordinates_x': 750,
|
|
||||||
'coordinates_y': 400,
|
|
||||||
'value': 18.2,
|
|
||||||
'unit': '°C'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'sensor_id': 'co2_001',
|
|
||||||
'sensor_type': 'co2',
|
|
||||||
'name': 'Office Space - CO2',
|
|
||||||
'coordinates_x': 300,
|
|
||||||
'coordinates_y': 600,
|
|
||||||
'value': 450,
|
|
||||||
'unit': 'ppm'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
for sensor_item in sensors:
|
|
||||||
# Create geometry from c3nav coordinates
|
|
||||||
point = Point(sensor_item['coordinates_x'], sensor_item['coordinates_y'])
|
|
||||||
|
|
||||||
# Get color based on sensor type and value
|
|
||||||
color = self.get_sensor_color(sensor_item['sensor_type'], sensor_item['value'])
|
|
||||||
|
|
||||||
feature = DataOverlayFeature.objects.create(
|
|
||||||
overlay=overlay,
|
|
||||||
titles={'en': sensor_item['name']},
|
|
||||||
geometry=point,
|
|
||||||
level=level,
|
|
||||||
sensor_id=sensor_item['sensor_id'],
|
|
||||||
sensor_type=sensor_item['sensor_type'],
|
|
||||||
sensor_value=sensor_item['value'],
|
|
||||||
sensor_unit=sensor_item['unit'],
|
|
||||||
coordinates_x=sensor_item['coordinates_x'],
|
|
||||||
coordinates_y=sensor_item['coordinates_y'],
|
|
||||||
fill_color=color,
|
|
||||||
stroke_color=color,
|
|
||||||
stroke_width=2,
|
|
||||||
fill_opacity=0.8,
|
|
||||||
show_label=True,
|
|
||||||
show_geometry=True,
|
|
||||||
interactive=True,
|
|
||||||
point_icon=self.get_sensor_icon(sensor_item['sensor_type']),
|
|
||||||
last_updated=timezone.now(),
|
|
||||||
extra_data={
|
|
||||||
'value': str(sensor_item['value']),
|
|
||||||
'unit': sensor_item['unit'],
|
|
||||||
'sensor_type': sensor_item['sensor_type']
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
f'Created sensor {sensor_item["sensor_id"]}: {sensor_item["name"]} '
|
|
||||||
f'({sensor_item["value"]}{sensor_item["unit"]}) at ({sensor_item["coordinates_x"]}, {sensor_item["coordinates_y"]})'
|
|
||||||
)
|
|
||||||
|
|
||||||
def scrape_sensor_data(self, options):
|
|
||||||
"""Scrape sensor data from configured data sources"""
|
|
||||||
overlays = DataOverlay.objects.filter(data_source_url__isnull=False)
|
|
||||||
|
|
||||||
if options['overlay_id']:
|
|
||||||
overlays = overlays.filter(id=options['overlay_id'])
|
|
||||||
|
|
||||||
for overlay in overlays:
|
|
||||||
self.stdout.write(f'Scraping data for overlay: {overlay.titles.get("en", "Unknown")}')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Fetch data from the source URL
|
|
||||||
response = requests.get(overlay.data_source_url, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Process data using sensor configuration
|
|
||||||
self.process_scraped_data(overlay, data)
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
self.stderr.write(f'Error fetching data from {overlay.data_source_url}: {e}')
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
self.stderr.write(f'Error parsing JSON from {overlay.data_source_url}: {e}')
|
|
||||||
except Exception as e:
|
|
||||||
self.stderr.write(f'Error processing data for overlay {overlay.id}: {e}')
|
|
||||||
|
|
||||||
def process_scraped_data(self, overlay, data):
|
|
||||||
"""Process scraped data according to overlay configuration"""
|
|
||||||
sensor_config = overlay.sensor_config or {}
|
|
||||||
|
|
||||||
# Default configuration for NOI Open Data Hub
|
|
||||||
default_config = {
|
|
||||||
"data_path": "data",
|
|
||||||
"mappings": {
|
|
||||||
"id_field": "scode",
|
|
||||||
"name_field": "sname",
|
|
||||||
"x_field": "scoordinate.x",
|
|
||||||
"y_field": "scoordinate.y"
|
|
||||||
},
|
|
||||||
"sensors": [] # List of specific sensors to process
|
|
||||||
}
|
|
||||||
|
|
||||||
config = {**default_config, **sensor_config}
|
|
||||||
|
|
||||||
# Extract sensor data array
|
|
||||||
api_data = data
|
|
||||||
if config.get("data_path"):
|
|
||||||
for path_part in config["data_path"].split("."):
|
|
||||||
api_data = api_data.get(path_part, [])
|
|
||||||
|
|
||||||
updated_count = 0
|
|
||||||
created_count = 0
|
|
||||||
|
|
||||||
# Group measurements by station (scode) first and discover sensor types dynamically
|
|
||||||
stations = {}
|
|
||||||
for item in api_data:
|
|
||||||
station_id = self.get_nested_field(item, config["mappings"]["id_field"])
|
|
||||||
station_name = self.get_nested_field(item, config["mappings"]["name_field"])
|
|
||||||
measurement_type = self.get_nested_field(item, "tname")
|
|
||||||
|
|
||||||
if not station_id or not measurement_type:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if station_id not in stations:
|
|
||||||
stations[station_id] = {
|
|
||||||
'name': station_name,
|
|
||||||
'measurements': {}
|
|
||||||
}
|
|
||||||
|
|
||||||
stations[station_id]['measurements'][measurement_type] = item
|
|
||||||
|
|
||||||
# If specific sensors are configured, only process those
|
|
||||||
configured_sensors = config.get('sensors', [])
|
|
||||||
sensor_configs = {s['id']: s for s in configured_sensors} if configured_sensors else {}
|
|
||||||
|
|
||||||
# Process each station and its measurements
|
|
||||||
for station_id, station_data in stations.items():
|
|
||||||
# Skip if we have specific sensors configured and this isn't one of them
|
|
||||||
if sensor_configs and station_id not in sensor_configs:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get sensor-specific configuration
|
|
||||||
sensor_specific_config = sensor_configs.get(station_id, {})
|
|
||||||
|
|
||||||
# Determine coordinates and level for this sensor
|
|
||||||
if sensor_specific_config.get('coordinates'):
|
|
||||||
# Use sensor-specific coordinates
|
|
||||||
x_coord = sensor_specific_config['coordinates']['x']
|
|
||||||
y_coord = sensor_specific_config['coordinates']['y']
|
|
||||||
else:
|
|
||||||
# Get coordinates from measurement data
|
|
||||||
first_measurement = next(iter(station_data['measurements'].values()))
|
|
||||||
x_coord = self.get_nested_field(first_measurement, config["mappings"]["x_field"])
|
|
||||||
y_coord = self.get_nested_field(first_measurement, config["mappings"]["y_field"])
|
|
||||||
|
|
||||||
if x_coord is None or y_coord is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Convert coordinates if needed
|
|
||||||
x_coord = float(x_coord)
|
|
||||||
y_coord = float(y_coord)
|
|
||||||
|
|
||||||
# Determine level for this sensor
|
|
||||||
level_name = sensor_specific_config.get('level', config.get('level', 'floor0'))
|
|
||||||
try:
|
|
||||||
level = Level.objects.get(short_label=level_name)
|
|
||||||
except Level.DoesNotExist:
|
|
||||||
self.stderr.write(f'Level "{level_name}" not found for sensor {station_id}, using ground floor')
|
|
||||||
try:
|
|
||||||
level = Level.objects.get(short_label='floor0')
|
|
||||||
except Level.DoesNotExist:
|
|
||||||
level = Level.objects.first() # Final fallback
|
|
||||||
if not level:
|
|
||||||
self.stderr.write(f'No levels found in database for sensor {station_id}')
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Collect all sensor data for this station in one feature
|
|
||||||
sensor_readings = {}
|
|
||||||
raw_measurements = {}
|
|
||||||
|
|
||||||
# Process ALL measurements found in the API response (dynamically discovered)
|
|
||||||
for measurement_type, measurement in station_data['measurements'].items():
|
|
||||||
# Extract values dynamically from the API response
|
|
||||||
sensor_value = self.get_nested_field(measurement, "mvalue")
|
|
||||||
sensor_unit = self.get_nested_field(measurement, "tunit")
|
|
||||||
sensor_name = self.get_nested_field(measurement, "tname")
|
|
||||||
sensor_description = self.get_nested_field(measurement, "tdescription")
|
|
||||||
|
|
||||||
if sensor_value is None:
|
|
||||||
continue # Skip if no value
|
|
||||||
|
|
||||||
# Convert sensor value to float
|
|
||||||
try:
|
|
||||||
sensor_value = float(sensor_value)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Store this measurement for the combined sensor
|
|
||||||
display_name = sensor_description or sensor_name or measurement_type
|
|
||||||
unit_str = f" {sensor_unit}" if sensor_unit else ""
|
|
||||||
sensor_readings[display_name] = f"{sensor_value}{unit_str}"
|
|
||||||
raw_measurements[measurement_type] = measurement
|
|
||||||
|
|
||||||
if not sensor_readings:
|
|
||||||
continue # Skip if no valid measurements
|
|
||||||
|
|
||||||
# Create a single sensor feature with all measurements
|
|
||||||
sensor_id = station_id # Use station ID as sensor ID
|
|
||||||
display_name = f"{station_data['name']} - Environmental Sensor"
|
|
||||||
|
|
||||||
# Determine primary color based on the most critical measurement
|
|
||||||
# Priority: CO2 > Temperature > Humidity
|
|
||||||
primary_color = '#95A5A6' # Default gray
|
|
||||||
|
|
||||||
# Look for CO2 measurements (various naming conventions)
|
|
||||||
co2_measurement = None
|
|
||||||
for mtype, measurement in raw_measurements.items():
|
|
||||||
if any(keyword in mtype.lower() for keyword in ['co2', 'carbon']):
|
|
||||||
co2_measurement = measurement
|
|
||||||
break
|
|
||||||
|
|
||||||
if co2_measurement:
|
|
||||||
co2_value = self.get_nested_field(co2_measurement, "mvalue")
|
|
||||||
if co2_value:
|
|
||||||
primary_color = self.get_sensor_color('co2', float(co2_value))
|
|
||||||
else:
|
|
||||||
# Look for temperature measurements
|
|
||||||
temp_measurement = None
|
|
||||||
for mtype, measurement in raw_measurements.items():
|
|
||||||
if any(keyword in mtype.lower() for keyword in ['temperature', 'temp']):
|
|
||||||
temp_measurement = measurement
|
|
||||||
break
|
|
||||||
|
|
||||||
if temp_measurement:
|
|
||||||
temp_value = self.get_nested_field(temp_measurement, "mvalue")
|
|
||||||
if temp_value:
|
|
||||||
primary_color = self.get_sensor_color('temperature', float(temp_value))
|
|
||||||
|
|
||||||
# Create geometry
|
|
||||||
point = Point(x_coord, y_coord)
|
|
||||||
|
|
||||||
feature, created = DataOverlayFeature.objects.update_or_create(
|
|
||||||
overlay=overlay,
|
|
||||||
sensor_id=sensor_id,
|
|
||||||
defaults={
|
|
||||||
'titles': {'en': display_name},
|
|
||||||
'geometry': point,
|
|
||||||
'level': level,
|
|
||||||
'sensor_type': 'environmental', # Combined sensor type
|
|
||||||
'sensor_value': None, # No single value for combined sensor
|
|
||||||
'sensor_unit': None, # No single unit for combined sensor
|
|
||||||
'coordinates_x': x_coord,
|
|
||||||
'coordinates_y': y_coord,
|
|
||||||
'fill_color': primary_color,
|
|
||||||
'stroke_color': primary_color,
|
|
||||||
'stroke_width': 2,
|
|
||||||
'fill_opacity': 0.8,
|
|
||||||
'show_label': True,
|
|
||||||
'show_geometry': True,
|
|
||||||
'interactive': True,
|
|
||||||
'point_icon': 'sensors', # Generic sensor icon
|
|
||||||
'last_updated': timezone.now(),
|
|
||||||
'sensor_data': raw_measurements, # Store all raw measurements
|
|
||||||
'extra_data': {
|
|
||||||
**sensor_readings, # All sensor readings as separate entries
|
|
||||||
'Last Updated': timezone.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
||||||
'Data Source': 'NOI Open Data Hub',
|
|
||||||
'Station ID': station_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if created:
|
|
||||||
created_count += 1
|
|
||||||
readings_str = ', '.join([f"{k}: {v}" for k, v in sensor_readings.items()])
|
|
||||||
self.stdout.write(f'Created sensor {sensor_id} on level {level.short_label}: {readings_str}')
|
|
||||||
else:
|
|
||||||
updated_count += 1
|
|
||||||
readings_str = ', '.join([f"{k}: {v}" for k, v in sensor_readings.items()])
|
|
||||||
self.stdout.write(f'Updated sensor {sensor_id} on level {level.short_label}: {readings_str}')
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
f'Processed overlay {overlay.id}: {created_count} created, {updated_count} updated'
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_nested_field(self, data, field_path):
|
|
||||||
"""Get value from nested field path like 'scoordinate.x'"""
|
|
||||||
try:
|
|
||||||
value = data
|
|
||||||
for part in field_path.split('.'):
|
|
||||||
value = value[part]
|
|
||||||
return value
|
|
||||||
except (KeyError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_sensor_color(self, sensor_type, value):
|
|
||||||
"""Get color based on sensor type and value"""
|
|
||||||
sensor_type_lower = sensor_type.lower()
|
|
||||||
|
|
||||||
if any(keyword in sensor_type_lower for keyword in ['temperature', 'temp']):
|
|
||||||
if value < 15:
|
|
||||||
return '#0066CC' # Cold - blue
|
|
||||||
elif value < 18:
|
|
||||||
return '#00AAFF' # Cool - light blue
|
|
||||||
elif value < 22:
|
|
||||||
return '#00CC66' # Comfortable - green
|
|
||||||
elif value < 25:
|
|
||||||
return '#FFAA00' # Warm - orange
|
|
||||||
else:
|
|
||||||
return '#FF3333' # Hot - red
|
|
||||||
elif any(keyword in sensor_type_lower for keyword in ['humidity', 'humid']):
|
|
||||||
if value < 30:
|
|
||||||
return '#FF6B6B' # Too dry - red
|
|
||||||
elif value < 60:
|
|
||||||
return '#4ECDC4' # Good - teal
|
|
||||||
else:
|
|
||||||
return '#45B7D1' # Too humid - blue
|
|
||||||
elif any(keyword in sensor_type_lower for keyword in ['co2', 'carbon']):
|
|
||||||
if value < 400:
|
|
||||||
return '#2ECC71' # Excellent - green
|
|
||||||
elif value < 800:
|
|
||||||
return '#F39C12' # Good - orange
|
|
||||||
elif value < 1200:
|
|
||||||
return '#E74C3C' # Poor - red
|
|
||||||
else:
|
|
||||||
return '#8E44AD' # Very poor - purple
|
|
||||||
else:
|
|
||||||
return '#95A5A6' # Default - gray
|
|
||||||
|
|
||||||
def get_sensor_icon(self, sensor_type):
|
|
||||||
"""Get icon based on sensor type"""
|
|
||||||
icons = {
|
|
||||||
'air-temperature': 'thermostat',
|
|
||||||
'air-humidity': 'water_drop',
|
|
||||||
'co2-ppm': 'air',
|
|
||||||
'temperature': 'thermostat',
|
|
||||||
'humidity': 'water_drop',
|
|
||||||
'co2': 'air',
|
|
||||||
'pressure': 'compress',
|
|
||||||
'light': 'light_mode'
|
|
||||||
}
|
|
||||||
return icons.get(sensor_type, 'sensors')
|
|
|
@ -1,187 +0,0 @@
|
||||||
import logging
|
|
||||||
import requests
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from c3nav.mapdata.models import DataOverlay, DataOverlayFeature, MapUpdate, Level
|
|
||||||
from shapely.geometry import Point
|
|
||||||
import json
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = 'Pull overlay data from external APIs'
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
'--force',
|
|
||||||
action='store_true',
|
|
||||||
help='Force update even if not enough time has passed',
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
overlays = DataOverlay.objects.all()
|
|
||||||
for overlay in overlays:
|
|
||||||
self.stdout.write(f"Processing overlay: {overlay.title}")
|
|
||||||
try:
|
|
||||||
self.update_overlay(overlay, force=options['force'])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating overlay {overlay.id}: {e}")
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
self.stderr.write(f"Error updating overlay {overlay.id}: {e}")
|
|
||||||
|
|
||||||
def update_overlay(self, overlay, force=False):
|
|
||||||
"""Update a single overlay from its API URL"""
|
|
||||||
|
|
||||||
if not overlay.pull_url:
|
|
||||||
self.stdout.write(f"No API URL configured for overlay {overlay.id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.stdout.write(f"Fetching data from: {overlay.pull_url}")
|
|
||||||
response = requests.get(overlay.pull_url, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
self.stdout.write(f"Received {len(data)} items from API")
|
|
||||||
|
|
||||||
# Process the data based on overlay type
|
|
||||||
if hasattr(overlay, 'description') and overlay.description and 'temperature' in overlay.description.lower():
|
|
||||||
self.process_temperature_data(overlay, data)
|
|
||||||
else:
|
|
||||||
self.stdout.write(f"Unknown overlay type for overlay {overlay.id}")
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
logger.error(f"HTTP error fetching {overlay.pull_url}: {e}")
|
|
||||||
self.stderr.write(f"HTTP error: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing overlay {overlay.id}: {e}")
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
self.stderr.write(f"Error: {e}")
|
|
||||||
|
|
||||||
def process_temperature_data(self, overlay, data):
|
|
||||||
"""Process temperature sensor data from NOI Open Data Hub"""
|
|
||||||
|
|
||||||
# Clear existing features for this overlay
|
|
||||||
DataOverlayFeature.objects.filter(overlay=overlay).delete()
|
|
||||||
self.stdout.write(f"Cleared existing features for overlay {overlay.id}")
|
|
||||||
|
|
||||||
# Group measurements by station
|
|
||||||
stations = {}
|
|
||||||
for item in data:
|
|
||||||
scode = item.get('scode')
|
|
||||||
if scode:
|
|
||||||
if scode not in stations:
|
|
||||||
stations[scode] = {
|
|
||||||
'sname': item.get('sname'),
|
|
||||||
'scoordinate': item.get('scoordinate'),
|
|
||||||
'measurements': []
|
|
||||||
}
|
|
||||||
stations[scode]['measurements'].append(item)
|
|
||||||
|
|
||||||
self.stdout.write(f"Found {len(stations)} stations")
|
|
||||||
|
|
||||||
for scode, station_data in stations.items():
|
|
||||||
try:
|
|
||||||
self.create_temperature_feature(overlay, scode, station_data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating feature for station {scode}: {e}")
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
self.stderr.write(f"Error creating feature for {scode}: {e}")
|
|
||||||
|
|
||||||
def create_temperature_feature(self, overlay, scode, station_data):
|
|
||||||
"""Create a DataOverlayFeature for a temperature station"""
|
|
||||||
|
|
||||||
# Extract coordinates from scoordinate object
|
|
||||||
scoordinate = station_data.get('scoordinate')
|
|
||||||
if not scoordinate:
|
|
||||||
self.stdout.write(f"No coordinates for station {scode}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# scoordinate is a dict with x, y coordinates
|
|
||||||
x = scoordinate.get('x')
|
|
||||||
y = scoordinate.get('y')
|
|
||||||
|
|
||||||
if x is None or y is None:
|
|
||||||
self.stdout.write(f"Invalid coordinates for station {scode}: {scoordinate}")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.stdout.write(f"Station {scode} coordinates: x={x}, y={y}")
|
|
||||||
|
|
||||||
# Create point geometry (assuming WGS84/EPSG:4326)
|
|
||||||
point = Point(x, y)
|
|
||||||
|
|
||||||
# Find temperature measurement
|
|
||||||
temperature = None
|
|
||||||
humidity = None
|
|
||||||
|
|
||||||
for measurement in station_data['measurements']:
|
|
||||||
tname = measurement.get('tname', '').lower()
|
|
||||||
if 'temperature' in tname:
|
|
||||||
temperature = measurement.get('mvalue')
|
|
||||||
elif 'humidity' in tname:
|
|
||||||
humidity = measurement.get('mvalue')
|
|
||||||
|
|
||||||
self.stdout.write(f"Station {scode}: temp={temperature}, humidity={humidity}")
|
|
||||||
|
|
||||||
if temperature is None:
|
|
||||||
self.stdout.write(f"No temperature data for station {scode}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Determine color based on temperature
|
|
||||||
color = self.get_temperature_color(temperature)
|
|
||||||
|
|
||||||
# Try to find appropriate level (ground floor by default)
|
|
||||||
level = None
|
|
||||||
try:
|
|
||||||
# Look for ground floor or level 0
|
|
||||||
level = Level.objects.filter(
|
|
||||||
short_label__in=['0', 'EG', 'Ground', 'G']
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not level:
|
|
||||||
# Fallback to any level
|
|
||||||
level = Level.objects.first()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not determine level: {e}")
|
|
||||||
|
|
||||||
if not level:
|
|
||||||
self.stdout.write(f"No level found for station {scode}")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.stdout.write(f"Using level: {level.short_label} for station {scode}")
|
|
||||||
|
|
||||||
# Create the feature
|
|
||||||
title = f"{station_data.get('sname', scode)} ({temperature}°C)"
|
|
||||||
|
|
||||||
feature = DataOverlayFeature.objects.create(
|
|
||||||
overlay=overlay,
|
|
||||||
level=level,
|
|
||||||
geometry=point.wkt,
|
|
||||||
title=title,
|
|
||||||
color=color,
|
|
||||||
opacity=0.8,
|
|
||||||
icon='thermometer'
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write(f"Created feature {feature.id} for station {scode}")
|
|
||||||
|
|
||||||
def get_temperature_color(self, temperature):
|
|
||||||
"""Get color based on temperature value"""
|
|
||||||
try:
|
|
||||||
temp = float(temperature)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return '#808080' # Gray for invalid values
|
|
||||||
|
|
||||||
if temp < 10:
|
|
||||||
return '#0000FF' # Blue for very cold
|
|
||||||
elif temp < 15:
|
|
||||||
return '#00BFFF' # Light blue for cold
|
|
||||||
elif temp < 20:
|
|
||||||
return '#00FF00' # Green for cool
|
|
||||||
elif temp < 25:
|
|
||||||
return '#FFFF00' # Yellow for comfortable
|
|
||||||
elif temp < 30:
|
|
||||||
return '#FFA500' # Orange for warm
|
|
||||||
else:
|
|
||||||
return '#FF0000' # Red for hot
|
|
|
@ -1,18 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-08-02 06:06
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mapdata', '0138_rangingbeacon_max_observed_num_clients_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataoverlay',
|
|
||||||
name='last_pull_time',
|
|
||||||
field=models.DateTimeField(blank=True, null=True, verbose_name='last pull time'),
|
|
||||||
),
|
|
||||||
]
|
|
33
src/c3nav/mapdata/migrations/0139_stairway.py
Normal file
33
src/c3nav/mapdata/migrations/0139_stairway.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 5.1.5 on 2025-08-01 21:38
|
||||||
|
|
||||||
|
import c3nav.mapdata.fields
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mapdata', '0138_rangingbeacon_max_observed_num_clients_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Stairway',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('import_tag', models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag')),
|
||||||
|
('geometry', c3nav.mapdata.fields.GeometryField(default=None, geomtype='polygon', help_text=None)),
|
||||||
|
('stair_count', models.PositiveIntegerField(default=5, validators=[django.core.validators.MinValueValidator(1)], verbose_name='number of stairs')),
|
||||||
|
('stair_width', models.DecimalField(decimal_places=2, default=0.3, max_digits=4, validators=[django.core.validators.MinValueValidator(Decimal('0.1'))], verbose_name='stair width')),
|
||||||
|
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mapdata.space', verbose_name='space')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Stairway',
|
||||||
|
'verbose_name_plural': 'Stairways',
|
||||||
|
'default_related_name': 'stairways',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,73 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-08-02 07:18
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django_pydantic_field
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mapdata', '0139_add_last_pull_time'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
# Add data source URL to DataOverlay
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataoverlay',
|
|
||||||
name='data_source_url',
|
|
||||||
field=models.URLField(blank=True, null=True, verbose_name='Data Source URL',
|
|
||||||
help_text='URL to scrape sensor data from'),
|
|
||||||
),
|
|
||||||
|
|
||||||
# Add sensor configuration to DataOverlay
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataoverlay',
|
|
||||||
name='sensor_config',
|
|
||||||
field=django_pydantic_field.SchemaField(
|
|
||||||
schema=dict, blank=True, null=True,
|
|
||||||
verbose_name='Sensor Configuration',
|
|
||||||
help_text='JSON configuration for sensor data mapping and processing'
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
# Add generic sensor fields to DataOverlayFeature
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataoverlayfeature',
|
|
||||||
name='sensor_id',
|
|
||||||
field=models.CharField(max_length=100, blank=True, null=True, verbose_name='Sensor ID'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataoverlayfeature',
|
|
||||||
name='sensor_type',
|
|
||||||
field=models.CharField(max_length=50, blank=True, null=True, verbose_name='Sensor Type',
|
|
||||||
help_text='Type of sensor: temperature, humidity, co2, etc.'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataoverlayfeature',
|
|
||||||
name='sensor_value',
|
|
||||||
field=models.FloatField(blank=True, null=True, verbose_name='Sensor Value'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataoverlayfeature',
|
|
||||||
name='sensor_unit',
|
|
||||||
field=models.CharField(max_length=20, blank=True, null=True, verbose_name='Sensor Unit',
|
|
||||||
help_text='Unit of measurement: °C, %, ppm, etc.'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataoverlayfeature',
|
|
||||||
name='coordinates_x',
|
|
||||||
field=models.FloatField(blank=True, null=True, verbose_name='X Coordinate',
|
|
||||||
help_text='X coordinate in c3nav coordinate system'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataoverlayfeature',
|
|
||||||
name='coordinates_y',
|
|
||||||
field=models.FloatField(blank=True, null=True, verbose_name='Y Coordinate',
|
|
||||||
help_text='Y coordinate in c3nav coordinate system'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataoverlayfeature',
|
|
||||||
name='last_updated',
|
|
||||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Last Updated'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,23 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-08-02 12:00
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
import django_pydantic_field
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('mapdata', '0140_add_temperature_fields'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataoverlayfeature',
|
|
||||||
name='sensor_data',
|
|
||||||
field=django_pydantic_field.SchemaField(
|
|
||||||
schema=dict, blank=True, null=True,
|
|
||||||
verbose_name='Raw Sensor Data',
|
|
||||||
help_text='Raw data from sensor for debugging and additional info'
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,35 +0,0 @@
|
||||||
# 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'),
|
|
||||||
('mapdata', '0141_add_sensor_data_field')
|
|
||||||
]
|
|
||||||
|
|
||||||
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')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -3,10 +3,9 @@ from c3nav.mapdata.models.access import AccessRestriction # noqa
|
||||||
from c3nav.mapdata.models.altitudes import GroundAltitude # noqa
|
from c3nav.mapdata.models.altitudes import GroundAltitude # noqa
|
||||||
from c3nav.mapdata.models.level import Level # noqa
|
from c3nav.mapdata.models.level import Level # noqa
|
||||||
from c3nav.mapdata.models.geometry.level import Building, Space, Door, AltitudeArea # noqa
|
from c3nav.mapdata.models.geometry.level import Building, Space, Door, AltitudeArea # noqa
|
||||||
from c3nav.mapdata.models.geometry.space import Area, Stair, Obstacle, LineObstacle, Hole, AltitudeMarker # noqa
|
from c3nav.mapdata.models.geometry.space import Area, Stair, Stairway, Obstacle, LineObstacle, Hole, AltitudeMarker # noqa
|
||||||
from c3nav.mapdata.models.locations import Location, LocationSlug, LocationGroup, LocationGroupCategory # noqa
|
from c3nav.mapdata.models.locations import Location, LocationSlug, LocationGroup, LocationGroupCategory # noqa
|
||||||
from c3nav.mapdata.models.source import Source # noqa
|
from c3nav.mapdata.models.source import Source # noqa
|
||||||
from c3nav.mapdata.models.graph import GraphNode, WayType, GraphEdge # noqa
|
from c3nav.mapdata.models.graph import GraphNode, WayType, GraphEdge # noqa
|
||||||
from c3nav.mapdata.models.theme import Theme # 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
|
|
|
@ -172,6 +172,22 @@ class Stair(SpaceGeometryMixin, models.Model):
|
||||||
default_related_name = 'stairs'
|
default_related_name = 'stairs'
|
||||||
|
|
||||||
|
|
||||||
|
class Stairway(SpaceGeometryMixin, models.Model):
|
||||||
|
"""
|
||||||
|
A stairway area that can generate individual stairs
|
||||||
|
"""
|
||||||
|
geometry = GeometryField('polygon')
|
||||||
|
stair_count = models.PositiveIntegerField(_('number of stairs'), default=5,
|
||||||
|
validators=[MinValueValidator(1)])
|
||||||
|
stair_width = models.DecimalField(_('stair width'), max_digits=4, decimal_places=2, default=0.3,
|
||||||
|
validators=[MinValueValidator(Decimal('0.1'))])
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Stairway')
|
||||||
|
verbose_name_plural = _('Stairways')
|
||||||
|
default_related_name = 'stairways'
|
||||||
|
|
||||||
|
|
||||||
class Ramp(SpaceGeometryMixin, models.Model):
|
class Ramp(SpaceGeometryMixin, models.Model):
|
||||||
"""
|
"""
|
||||||
A ramp
|
A ramp
|
||||||
|
|
|
@ -44,13 +44,6 @@ class DataOverlay(TitledMixin, AccessRestrictionMixin, models.Model):
|
||||||
verbose_name=_('Editor Access Restriction'),
|
verbose_name=_('Editor Access Restriction'),
|
||||||
on_delete=models.PROTECT)
|
on_delete=models.PROTECT)
|
||||||
|
|
||||||
# Generic sensor data configuration
|
|
||||||
data_source_url = models.URLField(blank=True, null=True, verbose_name=_('Data Source URL'),
|
|
||||||
help_text=_('URL to scrape sensor data from'))
|
|
||||||
sensor_config: Optional[dict] = SchemaField(schema=dict, blank=True, null=True,
|
|
||||||
verbose_name=_('Sensor Configuration'),
|
|
||||||
help_text=_('JSON configuration for sensor data mapping and processing'))
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _('Data Overlay')
|
verbose_name = _('Data Overlay')
|
||||||
verbose_name_plural = _('Data Overlays')
|
verbose_name_plural = _('Data Overlays')
|
||||||
|
@ -80,22 +73,6 @@ class DataOverlayFeature(TitledMixin, LevelGeometryMixin, models.Model):
|
||||||
default=None,
|
default=None,
|
||||||
verbose_name=_('extra data (JSON object)'))
|
verbose_name=_('extra data (JSON object)'))
|
||||||
|
|
||||||
# Generic sensor fields
|
|
||||||
sensor_id = models.CharField(max_length=100, blank=True, null=True, verbose_name=_('Sensor ID'))
|
|
||||||
sensor_type = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Sensor Type'),
|
|
||||||
help_text=_('Type of sensor: temperature, humidity, co2, etc.'))
|
|
||||||
sensor_value = models.FloatField(blank=True, null=True, verbose_name=_('Sensor Value'))
|
|
||||||
sensor_unit = models.CharField(max_length=20, blank=True, null=True, verbose_name=_('Sensor Unit'),
|
|
||||||
help_text=_('Unit of measurement: °C, %, ppm, etc.'))
|
|
||||||
coordinates_x = models.FloatField(blank=True, null=True, verbose_name=_('X Coordinate'),
|
|
||||||
help_text=_('X coordinate in c3nav coordinate system'))
|
|
||||||
coordinates_y = models.FloatField(blank=True, null=True, verbose_name=_('Y Coordinate'),
|
|
||||||
help_text=_('Y coordinate in c3nav coordinate system'))
|
|
||||||
last_updated = models.DateTimeField(blank=True, null=True, verbose_name=_('Last Updated'))
|
|
||||||
sensor_data: Optional[dict] = SchemaField(schema=dict, blank=True, null=True,
|
|
||||||
verbose_name=_('Raw Sensor Data'),
|
|
||||||
help_text=_('Raw data from sensor for debugging and additional info'))
|
|
||||||
|
|
||||||
def to_geojson(self, instance=None) -> dict:
|
def to_geojson(self, instance=None) -> dict:
|
||||||
result = {
|
result = {
|
||||||
'type': 'Feature',
|
'type': 'Feature',
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
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}"
|
|
|
@ -1,95 +0,0 @@
|
||||||
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
|
|
|
@ -82,76 +82,3 @@ def update_ap_names_bssid_mapping(self, map_name, user_id):
|
||||||
with changeset.lock_to_edit() as locked_changeset:
|
with changeset.lock_to_edit() as locked_changeset:
|
||||||
locked_changeset.title = 'passive update bssids'
|
locked_changeset.title = 'passive update bssids'
|
||||||
locked_changeset.apply(user)
|
locked_changeset.apply(user)
|
||||||
|
|
||||||
|
|
||||||
@app.task(bind=True, max_retries=3)
|
|
||||||
def pull_overlay_data(self, overlay_id=None):
|
|
||||||
"""
|
|
||||||
Celery task to pull data overlay features from external URLs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
overlay_id (int, optional): Specific overlay ID to update.
|
|
||||||
If None, updates all overlays with pull_url.
|
|
||||||
"""
|
|
||||||
logger.info('Starting overlay data pull task...')
|
|
||||||
|
|
||||||
from django.core.management import call_command
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Capture output from the management command
|
|
||||||
output = StringIO()
|
|
||||||
|
|
||||||
if overlay_id:
|
|
||||||
call_command('pulloverlaydata', overlay=overlay_id, stdout=output)
|
|
||||||
else:
|
|
||||||
call_command('pulloverlaydata', stdout=output)
|
|
||||||
|
|
||||||
result = output.getvalue()
|
|
||||||
logger.info(f'Overlay data pull completed: {result}')
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Overlay data pull failed: {e}')
|
|
||||||
if self.request.retries < self.max_retries:
|
|
||||||
logger.info(f'Retrying in 60 seconds... (attempt {self.request.retries + 1}/{self.max_retries})')
|
|
||||||
raise self.retry(countdown=60, exc=e)
|
|
||||||
else:
|
|
||||||
logger.error('Max retries exceeded for overlay data pull')
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
@app.task(bind=True)
|
|
||||||
def schedule_overlay_data_pulls(self):
|
|
||||||
"""
|
|
||||||
Periodic task to schedule individual overlay data pulls based on their intervals.
|
|
||||||
This should be called every minute by a periodic task scheduler.
|
|
||||||
"""
|
|
||||||
from c3nav.mapdata.models import DataOverlay
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
logger.info('Checking overlays for scheduled pulls...')
|
|
||||||
|
|
||||||
overlays = DataOverlay.objects.exclude(pull_url__isnull=True).exclude(pull_url='').exclude(pull_interval__isnull=True)
|
|
||||||
|
|
||||||
scheduled_count = 0
|
|
||||||
for overlay in overlays:
|
|
||||||
# Check if it's time to update this overlay
|
|
||||||
should_pull = False
|
|
||||||
|
|
||||||
if overlay.last_pull_time is None:
|
|
||||||
# Never pulled before
|
|
||||||
should_pull = True
|
|
||||||
else:
|
|
||||||
# Check if enough time has passed since last pull
|
|
||||||
next_pull_time = overlay.last_pull_time + overlay.pull_interval
|
|
||||||
should_pull = timezone.now() >= next_pull_time
|
|
||||||
|
|
||||||
if should_pull:
|
|
||||||
# Schedule the pull
|
|
||||||
pull_overlay_data.delay(overlay.pk)
|
|
||||||
scheduled_count += 1
|
|
||||||
logger.info(f'Scheduled pull for overlay: {overlay.title}')
|
|
||||||
|
|
||||||
logger.info(f'Scheduled {scheduled_count} overlay pulls')
|
|
||||||
return scheduled_count
|
|
||||||
|
|
1
src/c3nav/mapdata/utils/cache/local.py
vendored
1
src/c3nav/mapdata/utils/cache/local.py
vendored
|
@ -32,6 +32,7 @@ class LocalCacheProxy:
|
||||||
# not in our cache
|
# not in our cache
|
||||||
result = cache.get(key, default=NoneFromCache)
|
result = cache.get(key, default=NoneFromCache)
|
||||||
if result is not NoneFromCache:
|
if result is not NoneFromCache:
|
||||||
|
print("result", result, result is NoneFromCache)
|
||||||
if self._items.get(None) is None:
|
if self._items.get(None) is None:
|
||||||
self._items.set(OrderedDict())
|
self._items.set(OrderedDict())
|
||||||
self._items.get()[key] = result
|
self._items.get()[key] = result
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated by Django 5.1.5 on 2025-08-02 00:51
|
# Generated by Django 5.1.5 on 2025-08-01 21:38
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
<?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>
|
|
Before Width: | Height: | Size: 2 KiB |
|
@ -1,3 +0,0 @@
|
||||||
<?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>
|
|
Before Width: | Height: | Size: 1 KiB |
|
@ -1,3 +0,0 @@
|
||||||
<?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>
|
|
Before Width: | Height: | Size: 1.7 KiB |
|
@ -8,7 +8,3 @@ if __name__ == "__main__":
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
94
stairway_creator_final.md
Normal file
94
stairway_creator_final.md
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
# ✅ Stairway Creator - Final Correct Implementation
|
||||||
|
|
||||||
|
## Understanding the Requirement
|
||||||
|
|
||||||
|
You wanted a tool that helps **draw individual step lines quickly** within the current editor workflow:
|
||||||
|
|
||||||
|
> "if I set a start and end points, stairs get generated in between as straight lines (like in the example). Then, the number of stairs on this staircase should be configurable and the changes should be seen immediately"
|
||||||
|
|
||||||
|
## ✅ Correct Implementation
|
||||||
|
|
||||||
|
### **What It Does**
|
||||||
|
- **Click 2 Points**: Define start and end of stairway path
|
||||||
|
- **Generate Step Lines**: Creates individual straight lines perpendicular to the path
|
||||||
|
- **Real-time Preview**: See changes immediately as you adjust step count/width
|
||||||
|
- **Individual Editing**: Each step line can be edited separately after generation
|
||||||
|
- **Integrated Workflow**: Works within the current geometry editing system
|
||||||
|
|
||||||
|
### **User Workflow**
|
||||||
|
1. **Start Geometry Creation**: Begin creating any geometry type
|
||||||
|
2. **Activate Stairway Creator**: Click 🏗️ button
|
||||||
|
3. **Place Points**: Click start point, then end point
|
||||||
|
4. **Configure**: Adjust number of steps (3-50) and step width (0.1-2.0m)
|
||||||
|
5. **Real-time Preview**: See step positions update immediately
|
||||||
|
6. **Generate**: Click "Generate Stairs" to create individual step lines
|
||||||
|
7. **Edit**: Each step line becomes individually editable
|
||||||
|
|
||||||
|
### **Technical Implementation**
|
||||||
|
|
||||||
|
**Frontend Only** (`editor.js`):
|
||||||
|
- **Point Collection**: Click-based point placement system
|
||||||
|
- **Real-time Calculation**: Live preview updates as parameters change
|
||||||
|
- **Step Generation**: Creates individual Leaflet polylines
|
||||||
|
- **Geometry Integration**: Updates form with MultiLineString geometry
|
||||||
|
- **Individual Editing**: Each step line is separately editable
|
||||||
|
|
||||||
|
**Key Functions**:
|
||||||
|
```javascript
|
||||||
|
_calculate_straight_stairs(start, end) {
|
||||||
|
// Calculate perpendicular step positions
|
||||||
|
// Between start and end points
|
||||||
|
// With configurable count and width
|
||||||
|
}
|
||||||
|
|
||||||
|
_draw_step_lines(steps) {
|
||||||
|
// Create individual editable polylines
|
||||||
|
// Group them for collective editing
|
||||||
|
// Update geometry form
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Visual Preview System**
|
||||||
|
- **Point Markers**: Red numbered circles show clicked points
|
||||||
|
- **Step Preview**: Green lines show where steps will be placed
|
||||||
|
- **Real-time Updates**: Preview updates as you change:
|
||||||
|
- Number of steps (slider/input)
|
||||||
|
- Step width (affects line length)
|
||||||
|
- Immediate visual feedback
|
||||||
|
|
||||||
|
### **Geometry Output**
|
||||||
|
- **MultiLineString**: Single geometry containing all step lines
|
||||||
|
- **Individual Components**: Each step is a separate line within the MultiLineString
|
||||||
|
- **Editable**: Each step line can be modified individually after creation
|
||||||
|
- **Properly Formatted**: Compatible with c3nav's geometry system
|
||||||
|
|
||||||
|
## 🎯 **Benefits**
|
||||||
|
|
||||||
|
✅ **Speed**: Generate 10+ step lines in seconds vs minutes of manual drawing
|
||||||
|
✅ **Precision**: Perfect perpendicular alignment and equal spacing
|
||||||
|
✅ **Flexibility**: Configurable step count and width
|
||||||
|
✅ **Real-time**: Immediate visual feedback as you adjust parameters
|
||||||
|
✅ **Integration**: Works within existing editor workflow
|
||||||
|
✅ **Editability**: Each generated step remains individually editable
|
||||||
|
|
||||||
|
## 🧪 **Testing Process**
|
||||||
|
|
||||||
|
1. **Start geometry creation** (any type)
|
||||||
|
2. **Click 🏗️ button** to activate stairway creator
|
||||||
|
3. **Click start point** on the map
|
||||||
|
4. **Click end point** to define the stairway path
|
||||||
|
5. **Adjust step count** (see preview update in real-time)
|
||||||
|
6. **Adjust step width** (see line lengths change)
|
||||||
|
7. **Click "Generate Stairs"** to create the step lines
|
||||||
|
8. **Verify individual editing** - each step line should be separately editable
|
||||||
|
|
||||||
|
## 🎉 **Result**
|
||||||
|
|
||||||
|
This implementation provides exactly what you requested:
|
||||||
|
- **Quick step generation** between two points
|
||||||
|
- **Individual step lines** that can be edited separately
|
||||||
|
- **Real-time configuration** with immediate visual feedback
|
||||||
|
- **Straight line steps** perpendicular to the defined path
|
||||||
|
- **Integrated workflow** within the existing editor
|
||||||
|
|
||||||
|
The tool transforms the tedious process of manually drawing each step line into a quick, configurable, visual workflow that generates properly aligned individual step geometries!
|
14
start_db.sh
14
start_db.sh
|
@ -12,8 +12,6 @@ if [[ $# == 1 ]] && [[ $1 == "stop" ]]; then
|
||||||
echo "Stopped the postgres container"
|
echo "Stopped the postgres container"
|
||||||
elif [[ $# == 1 ]] && [[ $1 == "db" ]]; then
|
elif [[ $# == 1 ]] && [[ $1 == "db" ]]; then
|
||||||
echo "Setting up database"
|
echo "Setting up database"
|
||||||
sudo docker stop postgres
|
|
||||||
sudo docker container rm -f postgres
|
|
||||||
sudo docker run -d --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=test -e POSTGRES_USER=mindshub postgres
|
sudo docker run -d --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=test -e POSTGRES_USER=mindshub postgres
|
||||||
until psql "postgres://mindshub:test@localhost:5432" <<< "CREATE DATABASE insignorocketdb;"; do
|
until psql "postgres://mindshub:test@localhost:5432" <<< "CREATE DATABASE insignorocketdb;"; do
|
||||||
sleep 0.5;
|
sleep 0.5;
|
||||||
|
@ -31,16 +29,6 @@ elif [[ $# == 1 ]] && [[ $1 == "run" ]]; then
|
||||||
python manage.py processupdates
|
python manage.py processupdates
|
||||||
python manage.py runserver
|
python manage.py runserver
|
||||||
popd
|
popd
|
||||||
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 -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
|
|
||||||
python manage.py "${@:2}"
|
|
||||||
popd
|
|
||||||
else
|
else
|
||||||
echo "Usage: $0 [stop|db|run|run_without_output|manage]"
|
echo "Usage: $0 [stop|db|run]"
|
||||||
fi
|
fi
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue