2026-01-31 23:16:24 +01:00
"""
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
2026-02-08 01:45:00 +01:00
from fastapi . responses import HTMLResponse , RedirectResponse
2026-01-31 23:16:24 +01:00
from jinja2 import Environment , FileSystemLoader , TemplateNotFound
from pathlib import Path as PathlibPath
2026-07-18 10:10:31 +02:00
import json
2026-01-31 23:16:24 +01:00
import logging
from typing import Optional
2026-02-08 01:45:00 +01:00
from app . core . database import execute_query , execute_update
2026-01-31 23:16:24 +01:00
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
)
2026-02-08 01:45:00 +01:00
# Use direct database access instead of API calls to avoid auth issues
2026-01-31 23:16:24 +01:00
# 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 " } ,
2026-02-09 15:30:07 +01:00
{ " value " : " kantine " , " label " : " Kantine " } ,
{ " value " : " moedelokale " , " label " : " Mødelokale " } ,
2026-01-31 23:16:24 +01:00
{ " value " : " vehicle " , " label " : " Køretøj " } ,
]
2026-07-17 01:58:02 +02:00
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
2026-01-31 23:16:24 +01:00
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 " ) ,
2026-02-08 01:45:00 +01:00
is_active : Optional [ str ] = Query ( None , description = " Filter by active status " ) ,
2026-01-31 23:16:24 +01:00
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 768 px )
- Real - time search ( optional )
"""
try :
logger . info ( f " 🔍 Rendering locations list view (skip= { skip } , limit= { limit } ) " )
2026-02-08 01:45:00 +01:00
# 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
2026-02-17 08:29:05 +01:00
where_clauses = [ " deleted_at IS NULL " ]
2026-02-08 01:45:00 +01:00
query_params = [ ]
2026-01-31 23:16:24 +01:00
if location_type :
2026-02-08 01:45:00 +01:00
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 ] )
2026-01-31 23:16:24 +01:00
2026-07-17 10:23:57 +02:00
locations = execute_query ( query , tuple ( query_params ) ) or [ ]
2026-01-31 23:16:24 +01:00
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 ,
2026-02-08 01:45:00 +01:00
is_active = is_active_bool , # Use boolean value for template
2026-01-31 23:16:24 +01:00
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 )
2026-07-17 01:58:02 +02:00
def create_location_view (
parent_location_id : Optional [ int ] = Query ( None , gt = 0 ) ,
customer_id : Optional [ int ] = Query ( None , gt = 0 ) ,
) :
2026-01-31 23:16:24 +01:00
"""
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 " )
2026-07-17 10:23:57 +02:00
parent_locations = get_parent_location_choices ( ) or [ ]
2026-07-17 01:58:02 +02:00
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 " )
2026-02-08 01:45:00 +01:00
# 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
""" )
2026-01-31 23:16:24 +01:00
# 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 ,
2026-07-17 10:23:57 +02:00
customers = customers or [ ] ,
2026-07-17 01:58:02 +02:00
selected_parent_id = parent_location_id ,
selected_customer_id = customer_id ,
selected_parent = selected_parent ,
2026-01-31 23:16:24 +01:00
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 ) } " )
2026-02-09 15:30:07 +01:00
# =========================================================================
# 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 " )
2026-07-17 01:58:02 +02:00
parent_locations = get_parent_location_choices ( )
2026-02-09 15:30:07 +01:00
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 ) } " )
2026-01-31 23:16:24 +01:00
# ============================================================================
2026-07-17 10:23:57 +02:00
# 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)
2026-01-31 23:16:24 +01:00
# ============================================================================
@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 } " )
2026-02-08 01:45:00 +01:00
# Query location details directly
location = execute_query (
" SELECT * FROM locations_locations WHERE id = %s " ,
( id , )
2026-01-31 23:16:24 +01:00
)
if not location :
logger . warning ( f " ⚠️ Location { id } not found " )
raise HTTPException ( status_code = 404 , detail = f " Location { id } not found " )
2026-02-08 01:45:00 +01:00
location = location [ 0 ] # Get first result
2026-02-09 15:30:07 +01:00
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 , )
)
2026-05-06 07:01:43 +02:00
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 (
"""
2026-07-18 10:10:31 +02:00
SELECT id , asset_type , brand , model , serial_number , status , hardware_specs , location_display_order
2026-05-06 07:01:43 +02:00
FROM hardware_assets
WHERE current_location_id = % s AND deleted_at IS NULL
2026-07-18 10:10:31 +02:00
ORDER BY location_display_order NULLS LAST , brand ASC , model ASC , serial_number ASC
2026-05-06 07:01:43 +02:00
""" ,
( id , )
)
2026-07-17 10:23:57 +02:00
wall_outlets = execute_query (
"""
2026-07-18 10:10:31 +02:00
SELECT id , outlet_number , customer_id , category , patch_panel , patch_port , switch_hardware_id , switch_name , switch_port , status , notes , is_active
2026-07-17 10:23:57 +02:00
FROM locations_wall_outlets
WHERE location_id = % s AND deleted_at IS NULL
ORDER BY outlet_number
""" ,
( id , ) ,
)
2026-07-18 10:10:31 +02:00
# Render the same physical port map directly under each switch on the location page.
hardware_link_map = { }
hardware_ids = [ hw [ ' id ' ] for hw in ( hardware or [ ] ) ]
if hardware_ids :
hardware_link_rows = execute_query (
""" SELECT l.source_hardware_id, l.source_port, l.target_hardware_id, l.target_port,
target . brand AS target_brand , target . model AS target_model , target . serial_number AS target_serial ,
source . brand AS source_brand , source . model AS source_model , source . serial_number AS source_serial
FROM hardware_network_links l
JOIN hardware_assets target ON target . id = l . target_hardware_id
JOIN hardware_assets source ON source . id = l . source_hardware_id
WHERE ( l . source_hardware_id = ANY ( % s ) OR l . target_hardware_id = ANY ( % s ) ) AND l . deleted_at IS NULL
ORDER BY l . id """ ,
( hardware_ids , hardware_ids ) ,
) or [ ]
for row in hardware_link_rows :
if row . get ( ' source_port ' ) :
hardware_link_map [ ( row [ ' source_hardware_id ' ] , str ( row [ ' source_port ' ] ) ) ] = row
if row . get ( ' target_port ' ) :
reverse_row = dict ( row )
reverse_row . update ( {
' target_hardware_id ' : row . get ( ' source_hardware_id ' ) ,
' target_brand ' : row . get ( ' source_brand ' ) ,
' target_model ' : row . get ( ' source_model ' ) ,
' target_serial ' : row . get ( ' source_serial ' ) ,
' target_port ' : row . get ( ' source_port ' ) ,
} )
hardware_link_map [ ( row [ ' target_hardware_id ' ] , str ( row [ ' target_port ' ] ) ) ] = reverse_row
for hw in hardware or [ ] :
hw [ ' switch_ports ' ] = [ ]
if str ( hw . get ( ' asset_type ' ) or ' ' ) . lower ( ) != ' netværk ' :
continue
specs = hw . get ( ' hardware_specs ' ) or { }
if isinstance ( specs , str ) :
try :
specs = json . loads ( specs )
except ( TypeError , ValueError ) :
specs = { }
port_count = int ( ( specs or { } ) . get ( ' port_count ' ) or 0 )
linked = {
str ( outlet . get ( ' switch_port ' ) ) : outlet
for outlet in ( wall_outlets or [ ] )
if outlet . get ( ' switch_hardware_id ' ) == hw . get ( ' id ' ) and outlet . get ( ' switch_port ' )
}
hw [ ' switch_ports ' ] = [
{
' port_number ' : str ( port ) ,
' outlet ' : linked . get ( str ( port ) ) ,
' hardware_link ' : hardware_link_map . get ( ( hw [ ' id ' ] , str ( port ) ) ) ,
}
for port in range ( 1 , port_count + 1 )
]
2026-07-17 10:23:57 +02:00
cross_fields = execute_query (
2026-07-18 10:10:31 +02:00
""" SELECT id, name, port_count, port_label_format, start_port_number, panel_row_size, display_order, notes, is_active
2026-07-17 10:23:57 +02:00
FROM locations_cross_fields
WHERE location_id = % s AND deleted_at IS NULL AND is_active = TRUE
2026-07-18 10:10:31 +02:00
ORDER BY display_order , id """ ,
2026-07-17 10:23:57 +02:00
( id , ) ,
)
for cross_field in cross_fields or [ ] :
cross_field [ " ports " ] = execute_query (
2026-07-18 10:10:31 +02:00
""" SELECT p.id, p.port_number, p.port_order, p.is_active,
2026-07-17 10:23:57 +02:00
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
2026-07-18 10:10:31 +02:00
WHERE p . cross_field_id = % s ORDER BY p . port_order """ ,
2026-07-17 10:23:57 +02:00
( cross_field [ " id " ] , ) ,
) or [ ]
2026-05-06 07:01:43 +02:00
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 , )
)
2026-02-09 15:30:07 +01:00
location [ " hierarchy " ] = hierarchy
location [ " children " ] = children
2026-05-06 07:01:43 +02:00
location [ " contacts " ] = contacts or [ ]
location [ " operating_hours " ] = operating_hours or [ ]
location [ " services " ] = services or [ ]
location [ " capacity " ] = capacity or [ ]
location [ " hardware " ] = hardware or [ ]
2026-07-17 10:23:57 +02:00
location [ " wall_outlets " ] = wall_outlets or [ ]
location [ " cross_fields " ] = cross_fields or [ ]
2026-05-06 07:01:43 +02:00
location [ " audit_log " ] = audit_log or [ ]
2026-02-09 15:30:07 +01:00
2026-02-08 01:45:00 +01:00
# 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
""" )
2026-01-31 23:16:24 +01:00
# 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")
2026-07-17 10:23:57 +02:00
customers = customers or [ ]
2026-01-31 23:16:24 +01:00
# 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 } " )
2026-02-08 01:45:00 +01:00
# Query location details
location = execute_query (
" SELECT * FROM locations_locations WHERE id = %s " ,
( id , )
2026-01-31 23:16:24 +01:00
)
if not location :
logger . warning ( f " ⚠️ Location { id } not found for edit " )
raise HTTPException ( status_code = 404 , detail = f " Location { id } not found " )
2026-02-08 01:45:00 +01:00
location = location [ 0 ] # Get first result
2026-07-17 10:23:57 +02:00
parent_locations = get_parent_location_choices ( exclude_id = id ) or [ ]
2026-07-17 01:58:02 +02:00
selected_parent = next (
( row for row in parent_locations if row . get ( " id " ) == location . get ( " parent_location_id " ) ) ,
None ,
)
2026-02-08 01:45:00 +01:00
# 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
""" )
2026-01-31 23:16:24 +01:00
# 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 ,
2026-07-17 10:23:57 +02:00
customers = customers or [ ] ,
2026-07-17 01:58:02 +02:00
selected_parent = selected_parent ,
2026-01-31 23:16:24 +01:00
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 ( )
2026-02-08 01:45:00 +01:00
# 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 ,
2026-07-17 10:23:57 +02:00
has_cross_field = % s ,
2026-02-08 01:45:00 +01:00
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 " ) ,
2026-07-17 10:23:57 +02:00
form . get ( " has_cross_field " ) == " on " and form . get ( " location_type " ) == " rum " ,
2026-02-08 01:45:00 +01:00
id
) )
2026-01-31 23:16:24 +01:00
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 " )
2026-02-08 01:45:00 +01:00
# Query all locations with filters
where_clauses = [ ]
query_params = [ ]
2026-01-31 23:16:24 +01:00
if location_type :
2026-02-08 01:45:00 +01:00
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
"""
2026-01-31 23:16:24 +01:00
2026-02-08 01:45:00 +01:00
locations = execute_query ( query , tuple ( query_params ) if query_params else None )
2026-01-31 23:16:24 +01:00
# 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 ) } " )