"""Map item service for business logic.""" from typing import List, Optional from uuid import UUID from sqlalchemy.orm import Session from fastapi import HTTPException, status from geoalchemy2.shape import from_shape, to_shape from shapely.geometry import shape, Point, LineString import json import asyncio from app.models.map_item import MapItem from app.models.user import User from app.schemas.map_item import MapItemCreate, MapItemUpdate from app.services.map_service import get_map_by_id from app.websocket.connection_manager import manager def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]: """Get all items for a map.""" # Verify user has access to the map get_map_by_id(db, map_id, user) items = db.query(MapItem).filter(MapItem.map_id == map_id).all() return items def get_map_item_by_id(db: Session, item_id: UUID, user: Optional[User] = None) -> MapItem: """Get a map item by ID.""" item = db.query(MapItem).filter(MapItem.id == item_id).first() if not item: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Map item not found" ) # Verify user has access to the map get_map_by_id(db, item.map_id, user) return item def geojson_to_geography(geojson: dict) -> str: """Convert GeoJSON geometry to PostGIS geography WKT.""" geom = shape(geojson) # Ensure coordinates are in the correct format for PostGIS (lon, lat) if isinstance(geom, Point): return f'SRID=4326;POINT({geom.x} {geom.y})' elif isinstance(geom, LineString): coords = ', '.join([f'{x} {y}' for x, y in geom.coords]) return f'SRID=4326;LINESTRING({coords})' else: raise ValueError(f"Unsupported geometry type: {type(geom)}") def geography_to_geojson(geography) -> dict: """Convert PostGIS geography to GeoJSON.""" geom = to_shape(geography) return json.loads(json.dumps(geom.__geo_interface__)) def item_to_dict(item: MapItem) -> dict: """Convert MapItem to JSON-serializable dict for WebSocket broadcast.""" return { "id": str(item.id), "map_id": str(item.map_id), "type": item.type, "geometry": geography_to_geojson(item.geometry), "properties": item.properties, "created_at": item.created_at.isoformat(), "updated_at": item.updated_at.isoformat() } def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: User) -> MapItem: """Create a new map item.""" # Verify user has access to the map get_map_by_id(db, map_id, user) # Convert GeoJSON to PostGIS geography geometry_wkt = geojson_to_geography(item_data.geometry) item = MapItem( map_id=map_id, type=item_data.type, geometry=geometry_wkt, properties=item_data.properties, created_by=user.id, updated_by=user.id ) db.add(item) db.commit() db.refresh(item) # If this is a cable with device connections, update device port tracking if item.type == 'cable': start_device_id = item.properties.get('start_device_id') end_device_id = item.properties.get('end_device_id') print(f"Cable created: start_device_id={start_device_id}, end_device_id={end_device_id}") if start_device_id: print(f"Updating port connections for start device: {start_device_id}") update_device_connections(db, UUID(start_device_id), item.id) if end_device_id: print(f"Updating port connections for end device: {end_device_id}") update_device_connections(db, UUID(end_device_id), item.id) # Broadcast item creation to WebSocket clients try: loop = asyncio.get_event_loop() loop.create_task(manager.send_item_created(map_id, item_to_dict(item))) except RuntimeError: # No event loop running, skip WebSocket broadcast pass return item def update_device_connections(db: Session, device_id: UUID, cable_id: UUID) -> None: """Add cable connection to device's connections array.""" print(f"update_device_connections called: device_id={device_id}, cable_id={cable_id}") device = db.query(MapItem).filter(MapItem.id == device_id).first() if not device: print(f"Device not found: {device_id}") return # Create a mutable copy of properties properties = dict(device.properties) if device.properties else {} connections = properties.get('connections', []) port_count = properties.get('port_count', 0) print(f"Device {device_id}: port_count={port_count}, current_connections={len(connections)}") # Find next available port used_ports = {conn.get('port_number') for conn in connections if isinstance(conn, dict)} next_port = 1 while next_port in used_ports and next_port <= port_count: next_port += 1 # Only add if there's an available port if next_port <= port_count: connections.append({ 'cable_id': str(cable_id), 'port_number': next_port }) properties['connections'] = connections # Mark the column as modified so SQLAlchemy detects the change from sqlalchemy.orm.attributes import flag_modified device.properties = properties flag_modified(device, 'properties') db.commit() db.refresh(device) print(f"Added connection to port {next_port}. Total connections now: {len(connections)}") print(f"Device properties after update: {device.properties}") else: print(f"No available ports! Port count: {port_count}, used: {len(connections)}") def update_map_item(db: Session, item_id: UUID, item_data: MapItemUpdate, user: User) -> MapItem: """Update a map item.""" item = get_map_item_by_id(db, item_id, user) # Update fields if provided if item_data.type is not None: item.type = item_data.type if item_data.geometry is not None: item.geometry = geojson_to_geography(item_data.geometry) if item_data.properties is not None: item.properties = item_data.properties item.updated_by = user.id db.commit() db.refresh(item) # Broadcast item update to WebSocket clients try: loop = asyncio.get_event_loop() loop.create_task(manager.send_item_updated(item.map_id, item_to_dict(item))) except RuntimeError: # No event loop running, skip WebSocket broadcast pass return item def delete_map_item(db: Session, item_id: UUID, user: User) -> None: """Delete a map item.""" item = get_map_item_by_id(db, item_id, user) # Capture map_id and item_id before deletion for WebSocket broadcast map_id = item.map_id deleted_item_id = str(item.id) # If deleting a cable, remove it from device connections if item.type == 'cable': start_device_id = item.properties.get('start_device_id') end_device_id = item.properties.get('end_device_id') if start_device_id: remove_device_connection(db, UUID(start_device_id), item.id) if end_device_id: remove_device_connection(db, UUID(end_device_id), item.id) # If deleting an AP, delete all associated wireless mesh links if item.type in ['indoor_ap', 'outdoor_ap']: print(f"Deleting AP {item.id}, checking for wireless mesh links...") # Find all wireless mesh links connected to this AP wireless_meshes = db.query(MapItem).filter( MapItem.map_id == item.map_id, MapItem.type == 'wireless_mesh' ).all() for mesh in wireless_meshes: start_ap_id = mesh.properties.get('start_ap_id') end_ap_id = mesh.properties.get('end_ap_id') if start_ap_id == str(item.id) or end_ap_id == str(item.id): print(f"Deleting wireless mesh {mesh.id} connected to AP {item.id}") db.delete(mesh) db.delete(item) db.commit() # Broadcast item deletion to WebSocket clients try: loop = asyncio.get_event_loop() loop.create_task(manager.send_item_deleted(map_id, deleted_item_id)) except RuntimeError: # No event loop running, skip WebSocket broadcast pass def remove_device_connection(db: Session, device_id: UUID, cable_id: UUID) -> None: """Remove cable connection from device's connections array.""" print(f"remove_device_connection called: device_id={device_id}, cable_id={cable_id}") device = db.query(MapItem).filter(MapItem.id == device_id).first() if not device: print(f"Device not found: {device_id}") return # Create a mutable copy of properties properties = dict(device.properties) if device.properties else {} connections = properties.get('connections', []) print(f"Before removal: {len(connections)} connections") # Filter out the cable connection connections = [ conn for conn in connections if isinstance(conn, dict) and conn.get('cable_id') != str(cable_id) ] print(f"After removal: {len(connections)} connections") properties['connections'] = connections # Mark the column as modified so SQLAlchemy detects the change from sqlalchemy.orm.attributes import flag_modified device.properties = properties flag_modified(device, 'properties') db.commit() db.refresh(device) print(f"Removed cable connection. Device now has {len(connections)} connections")