898 lines
32 KiB
Python
898 lines
32 KiB
Python
"""
|
|
Location Module - Frontend Views (Jinja2 Rendering)
|
|
|
|
Phase 3 Implementation: Jinja2 Template Views
|
|
Views: 5 total
|
|
1. GET /app/locations - List view (HTML)
|
|
2. GET /app/locations/create - Create form (HTML)
|
|
3. GET /app/locations/{id} - Detail view (HTML)
|
|
4. GET /app/locations/{id}/edit - Edit form (HTML)
|
|
5. GET /app/locations/map - Map view (HTML)
|
|
|
|
Each view:
|
|
- Loads Jinja2 template from templates/ directory
|
|
- Calls backend API endpoints (/api/v1/locations/...)
|
|
- Passes context to template for rendering
|
|
- Handles errors (404, template not found)
|
|
- Supports dark mode and responsive design
|
|
"""
|
|
|
|
from fastapi import APIRouter, Query, HTTPException, Path, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
|
|
from pathlib import Path as PathlibPath
|
|
import logging
|
|
from typing import Optional
|
|
from app.core.database import execute_query, execute_update
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Initialize Jinja2 environment pointing to templates directory
|
|
# Jinja2 loaders use the root directory for template lookups
|
|
# Since templates reference shared/frontend/base.html, root should be /app/app
|
|
app_root = PathlibPath(__file__).parent.parent.parent.parent # /app/app
|
|
|
|
# Create a single FileSystemLoader rooted at app_root so that both relative paths work
|
|
loader = FileSystemLoader(str(app_root))
|
|
|
|
env = Environment(
|
|
loader=loader,
|
|
autoescape=True,
|
|
trim_blocks=True,
|
|
lstrip_blocks=True
|
|
)
|
|
|
|
# Use direct database access instead of API calls to avoid auth issues
|
|
|
|
# Location type options for dropdowns
|
|
LOCATION_TYPES = [
|
|
{"value": "kompleks", "label": "Kompleks"},
|
|
{"value": "bygning", "label": "Bygning"},
|
|
{"value": "etage", "label": "Etage"},
|
|
{"value": "customer_site", "label": "Kundesite"},
|
|
{"value": "rum", "label": "Rum"},
|
|
{"value": "kantine", "label": "Kantine"},
|
|
{"value": "moedelokale", "label": "Mødelokale"},
|
|
{"value": "vehicle", "label": "Køretøj"},
|
|
]
|
|
|
|
LOCATION_TYPE_LABELS = {
|
|
"kompleks": "Kompleks",
|
|
"bygning": "Bygning",
|
|
"etage": "Etage",
|
|
"customer_site": "Kundesite",
|
|
"rum": "Rum",
|
|
"kantine": "Kantine",
|
|
"moedelokale": "Mødelokale",
|
|
"vehicle": "Køretøj",
|
|
}
|
|
|
|
|
|
def get_location_type_label(location_type: Optional[str]) -> str:
|
|
return LOCATION_TYPE_LABELS.get(location_type or "", location_type or "Ukendt")
|
|
|
|
|
|
def get_parent_location_choices(exclude_id: Optional[int] = None) -> list[dict]:
|
|
exclude_ids = []
|
|
if exclude_id is not None:
|
|
exclude_tree = execute_query(
|
|
"""
|
|
WITH RECURSIVE descendants AS (
|
|
SELECT id
|
|
FROM locations_locations
|
|
WHERE id = %s
|
|
|
|
UNION ALL
|
|
|
|
SELECT l.id
|
|
FROM locations_locations l
|
|
JOIN descendants d ON l.parent_location_id = d.id
|
|
WHERE l.deleted_at IS NULL
|
|
)
|
|
SELECT id FROM descendants
|
|
""",
|
|
(exclude_id,),
|
|
)
|
|
exclude_ids = [row["id"] for row in (exclude_tree or []) if row.get("id") is not None]
|
|
|
|
parent_locations = execute_query(
|
|
"""
|
|
WITH RECURSIVE location_tree AS (
|
|
SELECT
|
|
id,
|
|
name,
|
|
location_type,
|
|
parent_location_id,
|
|
customer_id,
|
|
is_active,
|
|
name::text AS hierarchy_path,
|
|
0 AS depth
|
|
FROM locations_locations
|
|
WHERE deleted_at IS NULL AND parent_location_id IS NULL
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
l.id,
|
|
l.name,
|
|
l.location_type,
|
|
l.parent_location_id,
|
|
l.customer_id,
|
|
l.is_active,
|
|
(lt.hierarchy_path || ' > ' || l.name)::text AS hierarchy_path,
|
|
lt.depth + 1 AS depth
|
|
FROM locations_locations l
|
|
JOIN location_tree lt ON l.parent_location_id = lt.id
|
|
WHERE l.deleted_at IS NULL
|
|
)
|
|
SELECT
|
|
id,
|
|
name,
|
|
location_type,
|
|
parent_location_id,
|
|
customer_id,
|
|
is_active,
|
|
hierarchy_path,
|
|
depth
|
|
FROM location_tree
|
|
WHERE is_active = true
|
|
ORDER BY hierarchy_path
|
|
LIMIT 2000
|
|
"""
|
|
)
|
|
|
|
choices = []
|
|
for row in parent_locations or []:
|
|
if row.get("id") in exclude_ids:
|
|
continue
|
|
row["type_label"] = get_location_type_label(row.get("location_type"))
|
|
row["display_name"] = f"{row.get('hierarchy_path')} ({row['type_label']})"
|
|
choices.append(row)
|
|
return choices
|
|
|
|
|
|
def render_template(template_name: str, **context) -> str:
|
|
"""
|
|
Load and render a Jinja2 template with context.
|
|
|
|
Args:
|
|
template_name: Name of template file in templates/ directory
|
|
**context: Variables to pass to template
|
|
|
|
Returns:
|
|
Rendered HTML string
|
|
|
|
Raises:
|
|
HTTPException: If template not found
|
|
"""
|
|
try:
|
|
template = env.get_template(template_name)
|
|
return template.render(**context)
|
|
except TemplateNotFound as e:
|
|
logger.error(f"❌ Template not found: {template_name}")
|
|
raise HTTPException(status_code=500, detail=f"Template {template_name} not found")
|
|
except Exception as e:
|
|
logger.error(f"❌ Error rendering template {template_name}: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Error rendering template: {str(e)}")
|
|
|
|
|
|
|
|
def calculate_pagination(total: int, limit: int, skip: int) -> dict:
|
|
"""
|
|
Calculate pagination metadata.
|
|
|
|
Args:
|
|
total: Total number of records
|
|
limit: Records per page
|
|
skip: Number of records to skip
|
|
|
|
Returns:
|
|
Dict with pagination info
|
|
"""
|
|
total_pages = (total + limit - 1) // limit # Ceiling division
|
|
page_number = (skip // limit) + 1
|
|
|
|
return {
|
|
"total": total,
|
|
"limit": limit,
|
|
"skip": skip,
|
|
"page_number": page_number,
|
|
"total_pages": total_pages,
|
|
"has_prev": skip > 0,
|
|
"has_next": skip + limit < total,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# 1. GET /app/locations - List view (HTML)
|
|
# ============================================================================
|
|
|
|
@router.get("/app/locations", response_class=HTMLResponse)
|
|
def list_locations_view(
|
|
location_type: Optional[str] = Query(None, description="Filter by type"),
|
|
is_active: Optional[str] = Query(None, description="Filter by active status"),
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(50, ge=1, le=100)
|
|
):
|
|
"""
|
|
Render the locations list page.
|
|
|
|
Displays all locations in a table with:
|
|
- Columns: Name, Type (badge), City, Status, Actions
|
|
- Filters: by type, by active status
|
|
- Pagination controls
|
|
- Create button
|
|
- Bulk select & delete
|
|
|
|
Features:
|
|
- Dark mode support (CSS variables)
|
|
- Mobile responsive (table → cards at 768px)
|
|
- Real-time search (optional)
|
|
"""
|
|
try:
|
|
logger.info(f"🔍 Rendering locations list view (skip={skip}, limit={limit})")
|
|
|
|
# Convert is_active from string to boolean or None
|
|
is_active_bool = None
|
|
if is_active and is_active.lower() in ('true', '1', 'yes'):
|
|
is_active_bool = True
|
|
elif is_active and is_active.lower() in ('false', '0', 'no'):
|
|
is_active_bool = False
|
|
|
|
# Query locations directly from database
|
|
where_clauses = ["deleted_at IS NULL"]
|
|
query_params = []
|
|
|
|
if location_type:
|
|
where_clauses.append("location_type = %s")
|
|
query_params.append(location_type)
|
|
if is_active_bool is not None:
|
|
where_clauses.append("is_active = %s")
|
|
query_params.append(is_active_bool)
|
|
|
|
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
|
|
|
query = f"""
|
|
SELECT * FROM locations_locations
|
|
WHERE {where_sql}
|
|
ORDER BY name
|
|
LIMIT %s OFFSET %s
|
|
"""
|
|
query_params.extend([limit, skip])
|
|
|
|
locations = execute_query(query, tuple(query_params)) or []
|
|
|
|
def build_tree(items: list) -> list:
|
|
nodes = {}
|
|
roots = []
|
|
|
|
for loc in items or []:
|
|
if not isinstance(loc, dict):
|
|
continue
|
|
loc_id = loc.get("id")
|
|
if loc_id is None:
|
|
continue
|
|
nodes[loc_id] = {
|
|
"id": loc_id,
|
|
"name": loc.get("name"),
|
|
"location_type": loc.get("location_type"),
|
|
"parent_location_id": loc.get("parent_location_id"),
|
|
"address_city": loc.get("address_city"),
|
|
"is_active": loc.get("is_active", True)
|
|
}
|
|
|
|
for node in nodes.values():
|
|
parent_id = node.get("parent_location_id")
|
|
if parent_id and parent_id in nodes:
|
|
nodes[parent_id].setdefault("children", []).append(node)
|
|
else:
|
|
roots.append(node)
|
|
|
|
def sort_nodes(node_list: list) -> None:
|
|
node_list.sort(key=lambda n: (n.get("name") or "").lower())
|
|
for n in node_list:
|
|
if n.get("children"):
|
|
sort_nodes(n["children"])
|
|
|
|
sort_nodes(roots)
|
|
return roots
|
|
|
|
location_tree = build_tree(locations if isinstance(locations, list) else [])
|
|
|
|
# Get total count (API returns full list, so count locally)
|
|
# In production, the API should return {data: [...], total: N}
|
|
total = len(locations) if isinstance(locations, list) else locations.get("total", 0)
|
|
|
|
# Calculate pagination info
|
|
pagination = calculate_pagination(total, limit, skip)
|
|
|
|
# Render template with context
|
|
html = render_template(
|
|
"modules/locations/templates/list.html",
|
|
locations=locations,
|
|
total=total,
|
|
skip=skip,
|
|
limit=limit,
|
|
location_type=location_type,
|
|
is_active=is_active_bool, # Use boolean value for template
|
|
page_number=pagination["page_number"],
|
|
total_pages=pagination["total_pages"],
|
|
has_prev=pagination["has_prev"],
|
|
has_next=pagination["has_next"],
|
|
location_types=LOCATION_TYPES,
|
|
location_tree=location_tree,
|
|
create_url="/app/locations/create",
|
|
map_url="/app/locations/map",
|
|
)
|
|
|
|
logger.info(f"✅ Rendered locations list (showing {len(locations)} of {total})")
|
|
return HTMLResponse(content=html)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Error rendering locations list: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Error rendering list view: {str(e)}")
|
|
|
|
|
|
# ============================================================================
|
|
# 2. GET /app/locations/create - Create form (HTML)
|
|
# ============================================================================
|
|
|
|
@router.get("/app/locations/create", response_class=HTMLResponse)
|
|
def create_location_view(
|
|
parent_location_id: Optional[int] = Query(None, gt=0),
|
|
customer_id: Optional[int] = Query(None, gt=0),
|
|
):
|
|
"""
|
|
Render the location creation form.
|
|
|
|
Form fields:
|
|
- Name (required)
|
|
- Type (required, dropdown)
|
|
- Address (street, city, postal code, country)
|
|
- Contact info (phone, email)
|
|
- Coordinates (latitude, longitude)
|
|
- Notes
|
|
- Active toggle
|
|
|
|
Form submission:
|
|
- POST to /api/v1/locations
|
|
- Redirect to detail page on success
|
|
- Show errors inline on validation fail
|
|
"""
|
|
try:
|
|
logger.info("🆕 Rendering create location form")
|
|
|
|
parent_locations = get_parent_location_choices() or []
|
|
selected_parent = next((row for row in parent_locations if row.get("id") == parent_location_id), None)
|
|
|
|
if selected_parent and customer_id is None and selected_parent.get("customer_id") is not None:
|
|
customer_id = selected_parent.get("customer_id")
|
|
|
|
# Query customers
|
|
customers = execute_query("""
|
|
SELECT id, name, email, phone
|
|
FROM customers
|
|
WHERE deleted_at IS NULL AND is_active = true
|
|
ORDER BY name
|
|
LIMIT 1000
|
|
""")
|
|
|
|
# Render template with context
|
|
html = render_template(
|
|
"modules/locations/templates/create.html",
|
|
form_action="/api/v1/locations",
|
|
form_method="POST",
|
|
submit_text="Create Location",
|
|
cancel_url="/app/locations",
|
|
location_types=LOCATION_TYPES,
|
|
parent_locations=parent_locations,
|
|
customers=customers or [],
|
|
selected_parent_id=parent_location_id,
|
|
selected_customer_id=customer_id,
|
|
selected_parent=selected_parent,
|
|
location=None, # No location data for create form
|
|
)
|
|
|
|
logger.info("✅ Rendered create location form")
|
|
return HTMLResponse(content=html)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Error rendering create form: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Error rendering create form: {str(e)}")
|
|
|
|
|
|
# =========================================================================
|
|
# 2b. GET /app/locations/wizard - Wizard for floors and rooms
|
|
# =========================================================================
|
|
|
|
@router.get("/app/locations/wizard", response_class=HTMLResponse)
|
|
def location_wizard_view():
|
|
"""
|
|
Render the location wizard form.
|
|
"""
|
|
try:
|
|
logger.info("🧭 Rendering location wizard")
|
|
|
|
parent_locations = get_parent_location_choices()
|
|
|
|
customers = execute_query("""
|
|
SELECT id, name, email, phone
|
|
FROM customers
|
|
WHERE deleted_at IS NULL AND is_active = true
|
|
ORDER BY name
|
|
LIMIT 1000
|
|
""")
|
|
|
|
html = render_template(
|
|
"modules/locations/templates/wizard.html",
|
|
location_types=LOCATION_TYPES,
|
|
parent_locations=parent_locations,
|
|
customers=customers,
|
|
cancel_url="/app/locations",
|
|
)
|
|
|
|
logger.info("✅ Rendered location wizard")
|
|
return HTMLResponse(content=html)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Error rendering wizard: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Error rendering wizard: {str(e)}")
|
|
|
|
|
|
# ============================================================================
|
|
# 3. GET /app/locations/outlets - Wall outlet overview
|
|
# ============================================================================
|
|
|
|
@router.get("/app/locations/outlets", response_class=HTMLResponse)
|
|
def wall_outlets_view(q: Optional[str] = Query(None), status: Optional[str] = Query(None)):
|
|
try:
|
|
where = ["o.deleted_at IS NULL", "o.is_active = TRUE"]
|
|
params = []
|
|
if status:
|
|
where.append("o.status = %s")
|
|
params.append(status)
|
|
if q:
|
|
where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)")
|
|
params.extend([f"%{q.strip()}%"] * 7)
|
|
outlets = execute_query(f"""
|
|
WITH RECURSIVE tree AS (
|
|
SELECT id, name, parent_location_id, name::text AS hierarchy_path
|
|
FROM locations_locations WHERE parent_location_id IS NULL AND deleted_at IS NULL
|
|
UNION ALL
|
|
SELECT l.id, l.name, l.parent_location_id, tree.hierarchy_path || ' > ' || l.name
|
|
FROM locations_locations l JOIN tree ON l.parent_location_id = tree.id
|
|
WHERE l.deleted_at IS NULL
|
|
)
|
|
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name, tree.hierarchy_path
|
|
FROM locations_wall_outlets o
|
|
JOIN locations_locations l ON l.id = o.location_id
|
|
LEFT JOIN customers c ON c.id = l.customer_id
|
|
LEFT JOIN tree ON tree.id = l.id
|
|
WHERE {' AND '.join(where)}
|
|
ORDER BY tree.hierarchy_path, o.outlet_number
|
|
""", tuple(params)) or []
|
|
return HTMLResponse(render_template(
|
|
"modules/locations/templates/outlets.html", outlets=outlets, query=q or '', selected_status=status or ''
|
|
))
|
|
except Exception as exc:
|
|
logger.error("Error rendering wall outlets overview: %s", exc)
|
|
raise HTTPException(status_code=500, detail="Kunne ikke vise vægstik")
|
|
|
|
|
|
# ============================================================================
|
|
# 4. GET /app/locations/{id} - Detail view (HTML)
|
|
# ============================================================================
|
|
|
|
@router.get("/app/locations/{id}", response_class=HTMLResponse)
|
|
def detail_location_view(id: int = Path(..., gt=0)):
|
|
"""
|
|
Render the location detail page.
|
|
|
|
Displays:
|
|
- Location basic info (name, type, address, contact)
|
|
- Contact persons (list + add form)
|
|
- Operating hours (table + add form)
|
|
- Services (list + add form)
|
|
- Capacity tracking (list + add form)
|
|
- Map (if lat/long available)
|
|
- Audit trail (collapsible)
|
|
- Action buttons (Edit, Delete, Back)
|
|
"""
|
|
try:
|
|
logger.info(f"📍 Rendering detail view for location {id}")
|
|
|
|
# Query location details directly
|
|
location = execute_query(
|
|
"SELECT * FROM locations_locations WHERE id = %s",
|
|
(id,)
|
|
)
|
|
|
|
if not location:
|
|
logger.warning(f"⚠️ Location {id} not found")
|
|
raise HTTPException(status_code=404, detail=f"Location {id} not found")
|
|
|
|
location = location[0] # Get first result
|
|
|
|
hierarchy = []
|
|
current_parent_id = location.get("parent_location_id")
|
|
while current_parent_id:
|
|
parent = execute_query(
|
|
"SELECT id, name, location_type, parent_location_id FROM locations_locations WHERE id = %s",
|
|
(current_parent_id,)
|
|
)
|
|
if not parent:
|
|
break
|
|
parent_row = parent[0]
|
|
hierarchy.insert(0, parent_row)
|
|
current_parent_id = parent_row.get("parent_location_id")
|
|
|
|
children = execute_query(
|
|
"""
|
|
SELECT id, name, location_type
|
|
FROM locations_locations
|
|
WHERE parent_location_id = %s AND deleted_at IS NULL
|
|
ORDER BY name
|
|
""",
|
|
(id,)
|
|
)
|
|
|
|
contacts = execute_query(
|
|
"""
|
|
SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone,
|
|
role, is_primary, created_at
|
|
FROM locations_contacts
|
|
WHERE location_id = %s AND deleted_at IS NULL
|
|
ORDER BY is_primary DESC, contact_name ASC
|
|
""",
|
|
(id,)
|
|
)
|
|
|
|
operating_hours = execute_query(
|
|
"""
|
|
SELECT id, location_id, day_of_week,
|
|
CASE day_of_week
|
|
WHEN 0 THEN 'Mandag'
|
|
WHEN 1 THEN 'Tirsdag'
|
|
WHEN 2 THEN 'Onsdag'
|
|
WHEN 3 THEN 'Torsdag'
|
|
WHEN 4 THEN 'Fredag'
|
|
WHEN 5 THEN 'Lørdag'
|
|
WHEN 6 THEN 'Søndag'
|
|
END AS day_name,
|
|
open_time, close_time, is_open, notes
|
|
FROM locations_hours
|
|
WHERE location_id = %s
|
|
ORDER BY day_of_week ASC
|
|
""",
|
|
(id,)
|
|
)
|
|
|
|
services = execute_query(
|
|
"""
|
|
SELECT id, location_id, service_name, is_available, created_at
|
|
FROM locations_services
|
|
WHERE location_id = %s AND deleted_at IS NULL
|
|
ORDER BY service_name ASC
|
|
""",
|
|
(id,)
|
|
)
|
|
|
|
capacity = execute_query(
|
|
"""
|
|
SELECT id, location_id, capacity_type, total_capacity, used_capacity, last_updated
|
|
FROM locations_capacity
|
|
WHERE location_id = %s
|
|
ORDER BY capacity_type ASC
|
|
""",
|
|
(id,)
|
|
)
|
|
|
|
hardware = execute_query(
|
|
"""
|
|
SELECT id, asset_type, brand, model, serial_number, status
|
|
FROM hardware_assets
|
|
WHERE current_location_id = %s AND deleted_at IS NULL
|
|
ORDER BY brand ASC, model ASC, serial_number ASC
|
|
""",
|
|
(id,)
|
|
)
|
|
|
|
wall_outlets = execute_query(
|
|
"""
|
|
SELECT id, outlet_number, category, patch_panel, patch_port, switch_name, switch_port, status, notes, is_active
|
|
FROM locations_wall_outlets
|
|
WHERE location_id = %s AND deleted_at IS NULL
|
|
ORDER BY outlet_number
|
|
""",
|
|
(id,),
|
|
)
|
|
|
|
cross_fields = execute_query(
|
|
"""SELECT id, name, port_count, notes, is_active
|
|
FROM locations_cross_fields
|
|
WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE
|
|
ORDER BY name""",
|
|
(id,),
|
|
)
|
|
for cross_field in cross_fields or []:
|
|
cross_field["ports"] = execute_query(
|
|
"""SELECT p.id, p.port_number, p.is_active,
|
|
o.id AS outlet_id, o.outlet_number, o.status AS outlet_status,
|
|
l.name AS outlet_location_name
|
|
FROM locations_cross_field_ports p
|
|
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
|
|
LEFT JOIN locations_locations l ON l.id = o.location_id
|
|
WHERE p.cross_field_id = %s ORDER BY p.port_number""",
|
|
(cross_field["id"],),
|
|
) or []
|
|
|
|
audit_log = execute_query(
|
|
"""
|
|
SELECT id, location_id, event_type, user_id, changes, created_at
|
|
FROM locations_audit_log
|
|
WHERE location_id = %s
|
|
ORDER BY created_at DESC
|
|
""",
|
|
(id,)
|
|
)
|
|
|
|
location["hierarchy"] = hierarchy
|
|
location["children"] = children
|
|
location["contacts"] = contacts or []
|
|
location["operating_hours"] = operating_hours or []
|
|
location["services"] = services or []
|
|
location["capacity"] = capacity or []
|
|
location["hardware"] = hardware or []
|
|
location["wall_outlets"] = wall_outlets or []
|
|
location["cross_fields"] = cross_fields or []
|
|
location["audit_log"] = audit_log or []
|
|
|
|
# Query customers
|
|
customers = execute_query("""
|
|
SELECT id, name, email, phone
|
|
FROM customers
|
|
WHERE deleted_at IS NULL AND is_active = true
|
|
ORDER BY name
|
|
LIMIT 1000
|
|
""")
|
|
|
|
# Optionally fetch related data if available from API
|
|
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
|
|
# hours = call_api("GET", f"/api/v1/locations/{id}/hours")
|
|
|
|
customers = customers or []
|
|
|
|
# Render template with context
|
|
html = render_template(
|
|
"modules/locations/templates/detail.html",
|
|
location=location,
|
|
edit_url=f"/app/locations/{id}/edit",
|
|
list_url="/app/locations",
|
|
map_url="/app/locations/map",
|
|
location_types=LOCATION_TYPES,
|
|
customers=customers,
|
|
)
|
|
|
|
logger.info(f"✅ Rendered detail view for location {id}: {location.get('name', 'Unknown')}")
|
|
return HTMLResponse(content=html)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Error rendering detail view for location {id}: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Error rendering detail view: {str(e)}")
|
|
|
|
|
|
# ============================================================================
|
|
# 4. GET /app/locations/{id}/edit - Edit form (HTML)
|
|
# ============================================================================
|
|
|
|
@router.get("/app/locations/{id}/edit", response_class=HTMLResponse)
|
|
def edit_location_view(id: int = Path(..., gt=0)):
|
|
"""
|
|
Render the location edit form.
|
|
|
|
Pre-filled with current data.
|
|
Form submission:
|
|
- PATCH to /api/v1/locations/{id}
|
|
- Redirect to detail page on success
|
|
"""
|
|
try:
|
|
logger.info(f"✏️ Rendering edit form for location {id}")
|
|
|
|
# Query location details
|
|
location = execute_query(
|
|
"SELECT * FROM locations_locations WHERE id = %s",
|
|
(id,)
|
|
)
|
|
|
|
if not location:
|
|
logger.warning(f"⚠️ Location {id} not found for edit")
|
|
raise HTTPException(status_code=404, detail=f"Location {id} not found")
|
|
|
|
location = location[0] # Get first result
|
|
|
|
parent_locations = get_parent_location_choices(exclude_id=id) or []
|
|
selected_parent = next(
|
|
(row for row in parent_locations if row.get("id") == location.get("parent_location_id")),
|
|
None,
|
|
)
|
|
|
|
# Query customers
|
|
customers = execute_query("""
|
|
SELECT id, name, email, phone
|
|
FROM customers
|
|
WHERE deleted_at IS NULL AND is_active = true
|
|
ORDER BY name
|
|
LIMIT 1000
|
|
""")
|
|
|
|
# Render template with context
|
|
# Note: HTML forms don't support PATCH, so we use POST with a hidden _method field
|
|
html = render_template(
|
|
"modules/locations/templates/edit.html",
|
|
location=location,
|
|
form_action=f"/app/locations/{id}/edit",
|
|
form_method="POST", # HTML forms only support GET and POST
|
|
submit_text="Update Location",
|
|
cancel_url=f"/app/locations/{id}",
|
|
location_types=LOCATION_TYPES,
|
|
parent_locations=parent_locations,
|
|
customers=customers or [],
|
|
selected_parent=selected_parent,
|
|
http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field
|
|
)
|
|
|
|
logger.info(f"✅ Rendered edit form for location {id}: {location.get('name', 'Unknown')}")
|
|
return HTMLResponse(content=html)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Error rendering edit form for location {id}: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Error rendering edit form: {str(e)}")
|
|
|
|
|
|
# ============================================================================
|
|
# 4b. POST /app/locations/{id}/edit - Handle form submission (fallback)
|
|
# ============================================================================
|
|
|
|
@router.post("/app/locations/{id}/edit")
|
|
async def update_location_view(request: Request, id: int = Path(..., gt=0)):
|
|
"""Handle edit form submission and redirect to detail page."""
|
|
try:
|
|
form = await request.form()
|
|
|
|
# Update location directly in database
|
|
execute_update("""
|
|
UPDATE locations_locations SET
|
|
name = %s,
|
|
location_type = %s,
|
|
parent_location_id = %s,
|
|
customer_id = %s,
|
|
is_active = %s,
|
|
address_street = %s,
|
|
address_city = %s,
|
|
address_postal_code = %s,
|
|
address_country = %s,
|
|
phone = %s,
|
|
email = %s,
|
|
latitude = %s,
|
|
longitude = %s,
|
|
notes = %s,
|
|
has_cross_field = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
""", (
|
|
form.get("name"),
|
|
form.get("location_type"),
|
|
int(form.get("parent_location_id")) if form.get("parent_location_id") else None,
|
|
int(form.get("customer_id")) if form.get("customer_id") else None,
|
|
form.get("is_active") == "on",
|
|
form.get("address_street"),
|
|
form.get("address_city"),
|
|
form.get("address_postal_code"),
|
|
form.get("address_country"),
|
|
form.get("phone"),
|
|
form.get("email"),
|
|
float(form.get("latitude")) if form.get("latitude") else None,
|
|
float(form.get("longitude")) if form.get("longitude") else None,
|
|
form.get("notes"),
|
|
form.get("has_cross_field") == "on" and form.get("location_type") == "rum",
|
|
id
|
|
))
|
|
|
|
return RedirectResponse(url=f"/app/locations/{id}", status_code=303)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Error updating location {id}: {str(e)}")
|
|
raise HTTPException(status_code=500, detail="Failed to update location")
|
|
|
|
|
|
# ============================================================================
|
|
# 5. GET /app/locations/map - Map view (HTML) [Optional]
|
|
# ============================================================================
|
|
|
|
@router.get("/app/locations/map", response_class=HTMLResponse)
|
|
def map_locations_view(
|
|
location_type: Optional[str] = Query(None, description="Filter by type")
|
|
):
|
|
"""
|
|
Render interactive map showing all locations.
|
|
|
|
Features:
|
|
- Leaflet.js map
|
|
- Location markers with popups
|
|
- Filter by type dropdown
|
|
- Click marker to go to detail page
|
|
- Center on first location or default coordinates
|
|
"""
|
|
try:
|
|
logger.info("🗺️ Rendering map view")
|
|
|
|
# Query all locations with filters
|
|
where_clauses = []
|
|
query_params = []
|
|
|
|
if location_type:
|
|
where_clauses.append("location_type = %s")
|
|
query_params.append(location_type)
|
|
|
|
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
|
|
|
query = f"""
|
|
SELECT * FROM locations_locations
|
|
WHERE {where_sql}
|
|
ORDER BY name
|
|
LIMIT 1000
|
|
"""
|
|
|
|
locations = execute_query(query, tuple(query_params) if query_params else None)
|
|
|
|
# Filter to locations with coordinates
|
|
locations_with_coords = [
|
|
loc for loc in locations
|
|
if isinstance(loc, dict) and loc.get("latitude") and loc.get("longitude")
|
|
]
|
|
|
|
logger.info(f"📍 Found {len(locations_with_coords)} locations with coordinates")
|
|
|
|
# Determine center coordinates (first location or default Copenhagen)
|
|
if locations_with_coords:
|
|
center_lat = locations_with_coords[0].get("latitude", 55.6761)
|
|
center_lng = locations_with_coords[0].get("longitude", 12.5683)
|
|
else:
|
|
# Default to Copenhagen
|
|
center_lat = 55.6761
|
|
center_lng = 12.5683
|
|
|
|
# Render template with context
|
|
html = render_template(
|
|
"modules/locations/templates/map.html",
|
|
locations=locations_with_coords,
|
|
center_lat=center_lat,
|
|
center_lng=center_lng,
|
|
zoom_level=6, # Denmark zoom level
|
|
location_type=location_type,
|
|
location_types=LOCATION_TYPES,
|
|
list_url="/app/locations",
|
|
)
|
|
|
|
logger.info(f"✅ Rendered map view with {len(locations_with_coords)} locations")
|
|
return HTMLResponse(content=html)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Error rendering map view: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Error rendering map view: {str(e)}")
|