bmc_hub/app/modules/locations/frontend/views.py

586 lines
20 KiB
Python
Raw Normal View History

"""
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)}")