- Introduced Technician Dashboard V1 (tech_v1_overview.html) with KPI cards and new cases overview. - Implemented Technician Dashboard V2 (tech_v2_workboard.html) featuring a workboard layout for daily tasks and opportunities. - Developed Technician Dashboard V3 (tech_v3_table_focus.html) with a power table for detailed case management. - Created a dashboard selector page (technician_dashboard_selector.html) for easy navigation between dashboard versions. - Added user dashboard preferences migration (130_user_dashboard_preferences.sql) to store default dashboard paths. - Enhanced sag_sager table with assigned group ID (131_sag_assignment_group.sql) for better case management. - Updated sag_subscriptions table to include cancellation rules and billing dates (132_subscription_cancellation.sql, 134_subscription_billing_dates.sql). - Implemented subscription staging for CRM integration (136_simply_subscription_staging.sql). - Added a script to move time tracking section in detail view (move_time_section.py). - Created a test script for subscription processing (test_subscription_processing.py).
657 lines
23 KiB
Python
657 lines
23 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, 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": "kantine", "label": "Kantine"},
|
|
{"value": "moedelokale", "label": "Mødelokale"},
|
|
{"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 = ["deleted_at IS NULL"]
|
|
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 deleted_at IS NULL AND 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)}")
|
|
|
|
|
|
# =========================================================================
|
|
# 2b. GET /app/locations/wizard - Wizard for floors and rooms
|
|
# =========================================================================
|
|
|
|
@router.get("/app/locations/wizard", response_class=HTMLResponse)
|
|
def location_wizard_view():
|
|
"""
|
|
Render the location wizard form.
|
|
"""
|
|
try:
|
|
logger.info("🧭 Rendering location wizard")
|
|
|
|
parent_locations = execute_query("""
|
|
SELECT id, name, location_type
|
|
FROM locations_locations
|
|
WHERE deleted_at IS NULL AND is_active = true
|
|
ORDER BY name
|
|
LIMIT 1000
|
|
""")
|
|
|
|
customers = execute_query("""
|
|
SELECT id, name, email, phone
|
|
FROM customers
|
|
WHERE deleted_at IS NULL AND is_active = true
|
|
ORDER BY name
|
|
LIMIT 1000
|
|
""")
|
|
|
|
html = render_template(
|
|
"modules/locations/templates/wizard.html",
|
|
location_types=LOCATION_TYPES,
|
|
parent_locations=parent_locations,
|
|
customers=customers,
|
|
cancel_url="/app/locations",
|
|
)
|
|
|
|
logger.info("✅ Rendered location wizard")
|
|
return HTMLResponse(content=html)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Error rendering wizard: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Error rendering wizard: {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
|
|
|
|
hierarchy = []
|
|
current_parent_id = location.get("parent_location_id")
|
|
while current_parent_id:
|
|
parent = execute_query(
|
|
"SELECT id, name, location_type, parent_location_id FROM locations_locations WHERE id = %s",
|
|
(current_parent_id,)
|
|
)
|
|
if not parent:
|
|
break
|
|
parent_row = parent[0]
|
|
hierarchy.insert(0, parent_row)
|
|
current_parent_id = parent_row.get("parent_location_id")
|
|
|
|
children = execute_query(
|
|
"""
|
|
SELECT id, name, location_type
|
|
FROM locations_locations
|
|
WHERE parent_location_id = %s AND deleted_at IS NULL
|
|
ORDER BY name
|
|
""",
|
|
(id,)
|
|
)
|
|
|
|
location["hierarchy"] = hierarchy
|
|
location["children"] = children
|
|
|
|
# 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)}")
|