""" 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 from jinja2 import Environment, FileSystemLoader, TemplateNotFound from pathlib import Path as PathlibPath import requests import logging from typing import Optional 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 ) # Backend API base URL # Inside container: localhost:8000, externally: localhost:8001 API_BASE_URL = "http://localhost:8000" # 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 call_api(method: str, endpoint: str, **kwargs) -> dict: """ Call backend API endpoint using requests (synchronous). Args: method: HTTP method (GET, POST, PATCH, DELETE) endpoint: API endpoint path (e.g., "/api/v1/locations") **kwargs: Additional arguments for requests call (params, json, etc.) Returns: Response JSON or dict Raises: HTTPException: If API call fails """ try: url = f"{API_BASE_URL}{endpoint}" if not endpoint.startswith("http") else endpoint response = requests.request(method, url, timeout=30, **kwargs) response.raise_for_status() return response.json() except requests.exceptions.HTTPError as e: if e.response.status_code == 404: logger.warning(f"⚠️ API 404: {method} {endpoint}") raise HTTPException(status_code=404, detail="Resource not found") logger.error(f"❌ API error {e.response.status_code}: {method} {endpoint}") raise HTTPException(status_code=500, detail=f"API error: {e.response.status_code}") except requests.exceptions.RequestException as e: logger.error(f"❌ API call failed {method} {endpoint}: {str(e)}") raise HTTPException(status_code=500, detail=f"API connection error: {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[bool] = 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})") # Build API call parameters params = { "skip": skip, "limit": limit, } if location_type: params["location_type"] = location_type if is_active is not None: params["is_active"] = is_active # Call backend API to get locations locations = call_api("GET", "/api/v1/locations", params=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, 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") parent_locations = call_api( "GET", "/api/v1/locations", params={"skip": 0, "limit": 1000} ) customers = call_api( "GET", "/api/v1/customers", params={"offset": 0, "limit": 1000} ) customers = call_api( "GET", "/api/v1/customers", params={"offset": 0, "limit": 1000} ) customers = call_api( "GET", "/api/v1/customers", params={"offset": 0, "limit": 1000} ) customers = call_api( "GET", "/api/v1/customers", params={"offset": 0, "limit": 1000} ) customers = call_api( "GET", "/api/v1/customers", params={"offset": 0, "limit": 1000} ) customers = call_api( "GET", "/api/v1/customers", params={"offset": 0, "limit": 1000} ) customers = call_api( "GET", "/api/v1/customers", params={"offset": 0, "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}") # Call backend API to get location details location = call_api("GET", f"/api/v1/locations/{id}") customers = call_api( "GET", "/api/v1/customers", params={"offset": 0, "limit": 1000} ) if not location: logger.warning(f"⚠️ Location {id} not found") raise HTTPException(status_code=404, detail=f"Location {id} not found") # 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}") # Call backend API to get current location data location = call_api("GET", f"/api/v1/locations/{id}") parent_locations = call_api( "GET", "/api/v1/locations", params={"skip": 0, "limit": 1000} ) parent_locations = [ loc for loc in parent_locations if isinstance(loc, dict) and loc.get("id") != id ] customers = call_api( "GET", "/api/v1/customers", params={"offset": 0, "limit": 1000} ) if not location: logger.warning(f"⚠️ Location {id} not found for edit") raise HTTPException(status_code=404, detail=f"Location {id} not found") # 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() payload = { "name": form.get("name"), "location_type": form.get("location_type"), "parent_location_id": int(form.get("parent_location_id")) if form.get("parent_location_id") else None, "customer_id": int(form.get("customer_id")) if form.get("customer_id") else None, "is_active": form.get("is_active") == "on", "address_street": form.get("address_street"), "address_city": form.get("address_city"), "address_postal_code": form.get("address_postal_code"), "address_country": form.get("address_country"), "phone": form.get("phone"), "email": form.get("email"), "latitude": float(form.get("latitude")) if form.get("latitude") else None, "longitude": float(form.get("longitude")) if form.get("longitude") else None, "notes": form.get("notes"), } call_api("PATCH", f"/api/v1/locations/{id}", json=payload) 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") # Build API call parameters params = { "skip": 0, "limit": 1000, # Get all locations for map } if location_type: params["location_type"] = location_type # Call backend API to get all locations locations = call_api("GET", "/api/v1/locations", params=params) # 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)}")