- Implement test script for new SAG module endpoints BE-003 (Tag State Management) and BE-004 (Bulk Operations). - Create test cases for creating, updating, and bulk operations on cases and tags. - Add a test for module deactivation to ensure data integrity is maintained. - Include setup and teardown for tests to clear database state before and after each test.
586 lines
20 KiB
Python
586 lines
20 KiB
Python
"""
|
|
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)}")
|