""" 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": "vehicle", "label": "Køretøj"}, ] 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 = [] 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)) 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(): """ 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") # Query parent locations parent_locations = execute_query(""" SELECT id, name, location_type FROM locations_locations WHERE is_active = true ORDER BY name LIMIT 1000 """) # 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, 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)}") # ============================================================================ # 3. 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 # 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") # 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 # Query parent locations (exclude self) parent_locations = execute_query(""" SELECT id, name, location_type FROM locations_locations WHERE is_active = true AND id != %s ORDER BY name LIMIT 1000 """, (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 # 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, 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, 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"), 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)}")