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

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