{{ case.titel }}
{{ case.created_at }}
diff --git a/app/modules/locations/backend/router.py b/app/modules/locations/backend/router.py
index 36d4fc3..7208a6e 100644
--- a/app/modules/locations/backend/router.py
+++ b/app/modules/locations/backend/router.py
@@ -38,7 +38,8 @@ from app.modules.locations.models.schemas import (
OperatingHours, OperatingHoursCreate, OperatingHoursUpdate,
Service, ServiceCreate, ServiceUpdate,
Capacity, CapacityCreate, CapacityUpdate,
- BulkUpdateRequest, BulkDeleteRequest, LocationStats
+ BulkUpdateRequest, BulkDeleteRequest, LocationStats,
+ LocationWizardCreateRequest, LocationWizardCreateResponse
)
router = APIRouter()
@@ -71,7 +72,7 @@ def _normalize_form_data(form_data: Any) -> dict:
@router.get("/locations", response_model=List[Location])
async def list_locations(
- location_type: Optional[str] = Query(None, description="Filter by location type (kompleks, bygning, etage, customer_site, rum, vehicle)"),
+ location_type: Optional[str] = Query(None, description="Filter by location type (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=1000)
@@ -80,7 +81,7 @@ async def list_locations(
List all locations with optional filters and pagination.
Query Parameters:
- - location_type: Filter by type (kompleks, bygning, etage, customer_site, rum, vehicle)
+ - location_type: Filter by type (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)
- is_active: Filter by active status (true/false)
- skip: Pagination offset (default 0)
- limit: Results per page (default 50, max 1000)
@@ -255,6 +256,306 @@ async def create_location(request: Request):
)
+# ============================================================================
+# 11b. GET /api/v1/locations/by-customer/{customer_id} - Filter by customer
+# ============================================================================
+
+@router.get("/locations/by-customer/{customer_id}", response_model=List[Location])
+async def get_locations_by_customer(customer_id: int):
+ """
+ Get all locations linked to a customer.
+
+ Path parameter: customer_id
+ Returns: List of Location objects ordered by name
+ """
+ try:
+ query = """
+ SELECT l.*, p.name AS parent_location_name, c.name AS customer_name
+ FROM locations_locations l
+ LEFT JOIN locations_locations p ON l.parent_location_id = p.id
+ LEFT JOIN customers c ON l.customer_id = c.id
+ WHERE l.customer_id = %s AND l.deleted_at IS NULL
+ ORDER BY l.name ASC
+ """
+ results = execute_query(query, (customer_id,))
+ return [Location(**row) for row in results]
+
+ except Exception as e:
+ logger.error(f"❌ Error getting customer locations: {str(e)}")
+ raise HTTPException(
+ status_code=500,
+ detail="Failed to get locations by customer"
+ )
+
+
+# ============================================================================
+# 11c. GET /api/v1/locations/by-ids - Fetch by IDs
+# ============================================================================
+
+@router.get("/locations/by-ids", response_model=List[Location])
+async def get_locations_by_ids(ids: str = Query(..., description="Comma-separated location IDs")):
+ """
+ Get locations by a comma-separated list of IDs.
+ """
+ try:
+ id_values = [int(value) for value in ids.split(',') if value.strip().isdigit()]
+ if not id_values:
+ return []
+
+ placeholders = ",".join(["%s"] * len(id_values))
+ query = f"""
+ SELECT l.*, p.name AS parent_location_name, c.name AS customer_name
+ FROM locations_locations l
+ LEFT JOIN locations_locations p ON l.parent_location_id = p.id
+ LEFT JOIN customers c ON l.customer_id = c.id
+ WHERE l.id IN ({placeholders}) AND l.deleted_at IS NULL
+ ORDER BY l.name ASC
+ """
+ results = execute_query(query, tuple(id_values))
+ return [Location(**row) for row in results]
+
+ except Exception as e:
+ logger.error(f"❌ Error getting locations by ids: {str(e)}")
+ raise HTTPException(
+ status_code=500,
+ detail="Failed to get locations by ids"
+ )
+
+
+# =========================================================================
+# 2b. POST /api/v1/locations/bulk-create - Create location with floors/rooms
+# =========================================================================
+
+@router.post("/locations/bulk-create", response_model=LocationWizardCreateResponse)
+async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
+ """
+ Create a root location with floors and rooms in a single request.
+
+ Request body: LocationWizardCreateRequest
+ Returns: IDs of created locations
+ """
+ try:
+ root = data.root
+ auto_suffix = data.auto_suffix
+
+ payload_names = [root.name]
+ for floor in data.floors:
+ payload_names.append(floor.name)
+ for room in floor.rooms:
+ payload_names.append(room.name)
+
+ normalized_names = [name.strip().lower() for name in payload_names if name]
+ if not auto_suffix and len(normalized_names) != len(set(normalized_names)):
+ raise HTTPException(
+ status_code=400,
+ detail="Duplicate names found in wizard payload"
+ )
+
+ if not auto_suffix:
+ placeholders = ",".join(["%s"] * len(payload_names))
+ existing_query = f"""
+ SELECT name FROM locations_locations
+ WHERE name IN ({placeholders}) AND deleted_at IS NULL
+ """
+ existing = execute_query(existing_query, tuple(payload_names))
+ if existing:
+ existing_names = ", ".join(sorted({row.get("name") for row in existing if row.get("name")}))
+ raise HTTPException(
+ status_code=400,
+ detail=f"Locations already exist with names: {existing_names}"
+ )
+
+ if root.customer_id is not None:
+ customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
+ customer = execute_query(customer_query, (root.customer_id,))
+ if not customer:
+ raise HTTPException(status_code=400, detail="customer_id does not exist")
+
+ if root.parent_location_id is not None:
+ parent_query = "SELECT id FROM locations_locations WHERE id = %s AND deleted_at IS NULL"
+ parent = execute_query(parent_query, (root.parent_location_id,))
+ if not parent:
+ raise HTTPException(status_code=400, detail="parent_location_id does not exist")
+
+ insert_query = """
+ INSERT INTO locations_locations (
+ name, location_type, parent_location_id, customer_id, address_street, address_city,
+ address_postal_code, address_country, latitude, longitude,
+ phone, email, notes, is_active, created_at, updated_at
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
+ RETURNING *
+ """
+
+ reserved_names = set()
+
+ def _normalize_name(value: str) -> str:
+ return (value or "").strip().lower()
+
+ def _name_exists(value: str) -> bool:
+ normalized = _normalize_name(value)
+ if normalized in reserved_names:
+ return True
+ check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
+ existing = execute_query(check_query, (value,))
+ return bool(existing)
+
+ def _reserve_name(value: str) -> None:
+ normalized = _normalize_name(value)
+ if normalized:
+ reserved_names.add(normalized)
+
+ def _resolve_unique_name(base_name: str) -> str:
+ if not auto_suffix:
+ _reserve_name(base_name)
+ return base_name
+ base_name = base_name.strip()
+ if not _name_exists(base_name):
+ _reserve_name(base_name)
+ return base_name
+ suffix = 2
+ while True:
+ candidate = f"{base_name} ({suffix})"
+ if not _name_exists(candidate):
+ _reserve_name(candidate)
+ return candidate
+ suffix += 1
+
+ def insert_location_record(
+ name: str,
+ location_type: str,
+ parent_location_id: Optional[int],
+ customer_id: Optional[int],
+ address_street: Optional[str],
+ address_city: Optional[str],
+ address_postal_code: Optional[str],
+ address_country: Optional[str],
+ latitude: Optional[float],
+ longitude: Optional[float],
+ phone: Optional[str],
+ email: Optional[str],
+ notes: Optional[str],
+ is_active: bool
+ ) -> Location:
+ params = (
+ name,
+ location_type,
+ parent_location_id,
+ customer_id,
+ address_street,
+ address_city,
+ address_postal_code,
+ address_country,
+ latitude,
+ longitude,
+ phone,
+ email,
+ notes,
+ is_active
+ )
+ result = execute_query(insert_query, params)
+ if not result:
+ raise HTTPException(status_code=500, detail="Failed to create location")
+ return Location(**result[0])
+
+ resolved_root_name = _resolve_unique_name(root.name)
+ root_location = insert_location_record(
+ name=resolved_root_name,
+ location_type=root.location_type,
+ parent_location_id=root.parent_location_id,
+ customer_id=root.customer_id,
+ address_street=root.address_street,
+ address_city=root.address_city,
+ address_postal_code=root.address_postal_code,
+ address_country=root.address_country,
+ latitude=root.latitude,
+ longitude=root.longitude,
+ phone=root.phone,
+ email=root.email,
+ notes=root.notes,
+ is_active=root.is_active
+ )
+
+ audit_query = """
+ INSERT INTO locations_audit_log (location_id, event_type, user_id, changes, created_at)
+ VALUES (%s, %s, %s, %s, NOW())
+ """
+ root_changes = root.model_dump()
+ root_changes["name"] = resolved_root_name
+ execute_query(audit_query, (root_location.id, 'created', None, json.dumps({"after": root_changes})))
+
+ floor_ids: List[int] = []
+ room_ids: List[int] = []
+
+ for floor in data.floors:
+ resolved_floor_name = _resolve_unique_name(floor.name)
+ floor_location = insert_location_record(
+ name=resolved_floor_name,
+ location_type=floor.location_type,
+ parent_location_id=root_location.id,
+ customer_id=root.customer_id,
+ address_street=root.address_street,
+ address_city=root.address_city,
+ address_postal_code=root.address_postal_code,
+ address_country=root.address_country,
+ latitude=root.latitude,
+ longitude=root.longitude,
+ phone=root.phone,
+ email=root.email,
+ notes=None,
+ is_active=floor.is_active
+ )
+ floor_ids.append(floor_location.id)
+ execute_query(audit_query, (
+ floor_location.id,
+ 'created',
+ None,
+ json.dumps({"after": {"name": resolved_floor_name, "location_type": floor.location_type, "parent_location_id": root_location.id}})
+ ))
+
+ for room in floor.rooms:
+ resolved_room_name = _resolve_unique_name(room.name)
+ room_location = insert_location_record(
+ name=resolved_room_name,
+ location_type=room.location_type,
+ parent_location_id=floor_location.id,
+ customer_id=root.customer_id,
+ address_street=root.address_street,
+ address_city=root.address_city,
+ address_postal_code=root.address_postal_code,
+ address_country=root.address_country,
+ latitude=root.latitude,
+ longitude=root.longitude,
+ phone=root.phone,
+ email=root.email,
+ notes=None,
+ is_active=room.is_active
+ )
+ room_ids.append(room_location.id)
+ execute_query(audit_query, (
+ room_location.id,
+ 'created',
+ None,
+ json.dumps({"after": {"name": resolved_room_name, "location_type": room.location_type, "parent_location_id": floor_location.id}})
+ ))
+
+ created_total = 1 + len(floor_ids) + len(room_ids)
+ logger.info("✅ Wizard created %s locations (root=%s)", created_total, root_location.id)
+
+ return LocationWizardCreateResponse(
+ root_id=root_location.id,
+ floor_ids=floor_ids,
+ room_ids=room_ids,
+ created_total=created_total
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error creating location hierarchy: {str(e)}")
+ raise HTTPException(status_code=500, detail="Failed to create location hierarchy")
+
+
# ============================================================================
# 3. GET /api/v1/locations/{id} - Get single location with all relationships
# ============================================================================
@@ -467,7 +768,7 @@ async def update_location(id: int, data: LocationUpdate):
detail="customer_id does not exist"
)
if key == 'location_type':
- allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
+ allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if value not in allowed_types:
logger.warning(f"⚠️ Invalid location_type: {value}")
raise HTTPException(
@@ -2587,7 +2888,7 @@ async def bulk_update_locations(data: BulkUpdateRequest):
# Validate location_type if provided
if 'location_type' in data.updates:
- allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
+ allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if data.updates['location_type'] not in allowed_types:
logger.warning(f"⚠️ Invalid location_type: {data.updates['location_type']}")
raise HTTPException(
@@ -2804,7 +3105,7 @@ async def get_locations_by_type(
"""
Get all locations of a specific type with pagination.
- Path parameter: location_type - one of (kompleks, bygning, etage, customer_site, rum, vehicle)
+ Path parameter: location_type - one of (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)
Query parameters: skip, limit for pagination
Returns: Paginated list of Location objects ordered by name
@@ -2815,7 +3116,7 @@ async def get_locations_by_type(
"""
try:
# Validate location_type is one of allowed values
- allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
+ allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if location_type not in allowed_types:
logger.warning(f"⚠️ Invalid location_type: {location_type}")
raise HTTPException(
diff --git a/app/modules/locations/frontend/views.py b/app/modules/locations/frontend/views.py
index 3128551..c190eb7 100644
--- a/app/modules/locations/frontend/views.py
+++ b/app/modules/locations/frontend/views.py
@@ -52,6 +52,8 @@ LOCATION_TYPES = [
{"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"},
]
@@ -307,6 +309,52 @@ def create_location_view():
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 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)
# ============================================================================
@@ -341,6 +389,32 @@ def detail_location_view(id: int = Path(..., gt=0)):
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
diff --git a/app/modules/locations/models/schemas.py b/app/modules/locations/models/schemas.py
index a1b6c8c..fb88ef5 100644
--- a/app/modules/locations/models/schemas.py
+++ b/app/modules/locations/models/schemas.py
@@ -20,7 +20,7 @@ class LocationBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)")
location_type: str = Field(
...,
- description="Type: kompleks | bygning | etage | customer_site | rum | vehicle"
+ description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
)
parent_location_id: Optional[int] = Field(
None,
@@ -45,7 +45,7 @@ class LocationBase(BaseModel):
@classmethod
def validate_location_type(cls, v):
"""Validate location_type is one of allowed values"""
- allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
+ allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if v not in allowed:
raise ValueError(f'location_type must be one of {allowed}')
return v
@@ -61,7 +61,7 @@ class LocationUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
location_type: Optional[str] = Field(
None,
- description="Type: kompleks | bygning | etage | customer_site | rum | vehicle"
+ description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
)
parent_location_id: Optional[int] = None
customer_id: Optional[int] = None
@@ -81,7 +81,7 @@ class LocationUpdate(BaseModel):
def validate_location_type(cls, v):
if v is None:
return v
- allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
+ allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if v not in allowed:
raise ValueError(f'location_type must be one of {allowed}')
return v
@@ -291,6 +291,51 @@ class BulkDeleteRequest(BaseModel):
ids: List[int] = Field(..., min_items=1, description="Location IDs to soft-delete")
+class LocationWizardRoom(BaseModel):
+ """Room definition for location wizard"""
+ name: str = Field(..., min_length=1, max_length=255)
+ location_type: str = Field("rum", description="Type: rum | kantine | moedelokale")
+ is_active: bool = Field(True, description="Whether room is active")
+
+ @field_validator('location_type')
+ @classmethod
+ def validate_room_type(cls, v):
+ allowed = ['rum', 'kantine', 'moedelokale']
+ if v not in allowed:
+ raise ValueError(f'location_type must be one of {allowed}')
+ return v
+
+
+class LocationWizardFloor(BaseModel):
+ """Floor definition for location wizard"""
+ name: str = Field(..., min_length=1, max_length=255)
+ location_type: str = Field("etage", description="Type: etage")
+ rooms: List[LocationWizardRoom] = Field(default_factory=list)
+ is_active: bool = Field(True, description="Whether floor is active")
+
+ @field_validator('location_type')
+ @classmethod
+ def validate_floor_type(cls, v):
+ if v != 'etage':
+ raise ValueError('location_type must be etage for floors')
+ return v
+
+
+class LocationWizardCreateRequest(BaseModel):
+ """Request for creating a location with floors and rooms"""
+ root: LocationCreate
+ floors: List[LocationWizardFloor] = Field(..., min_items=1)
+ auto_suffix: bool = Field(True, description="Auto-suffix names if duplicates exist")
+
+
+class LocationWizardCreateResponse(BaseModel):
+ """Response for location wizard creation"""
+ root_id: int
+ floor_ids: List[int] = Field(default_factory=list)
+ room_ids: List[int] = Field(default_factory=list)
+ created_total: int
+
+
# ============================================================================
# 7. RESPONSE MODELS
# ============================================================================
diff --git a/app/modules/locations/templates/create.html b/app/modules/locations/templates/create.html
index 462b7d5..7cfdb38 100644
--- a/app/modules/locations/templates/create.html
+++ b/app/modules/locations/templates/create.html
@@ -50,7 +50,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
{% endfor %}
{% endif %}
diff --git a/app/modules/locations/templates/detail.html b/app/modules/locations/templates/detail.html
index 7c7f478..00878a2 100644
--- a/app/modules/locations/templates/detail.html
+++ b/app/modules/locations/templates/detail.html
@@ -39,6 +39,8 @@
'bygning': 'Bygning',
'etage': 'Etage',
'rum': 'Rum',
+ 'kantine': 'Kantine',
+ 'moedelokale': 'Mødelokale',
'customer_site': 'Kundesite',
'vehicle': 'Køretøj'
}.get(location.location_type, location.location_type) %}
@@ -48,6 +50,8 @@
'bygning': '#1abc9c',
'etage': '#3498db',
'rum': '#e67e22',
+ 'kantine': '#d35400',
+ 'moedelokale': '#16a085',
'customer_site': '#9b59b6',
'vehicle': '#8e44ad'
}.get(location.location_type, '#6c757d') %}
@@ -512,7 +516,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
{% endfor %}
{% endif %}
diff --git a/app/modules/locations/templates/edit.html b/app/modules/locations/templates/edit.html
index 5a50f35..d24da69 100644
--- a/app/modules/locations/templates/edit.html
+++ b/app/modules/locations/templates/edit.html
@@ -51,7 +51,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
{% endfor %}
{% endif %}
diff --git a/app/modules/locations/templates/list.html b/app/modules/locations/templates/list.html
index e3bdfd0..394106a 100644
--- a/app/modules/locations/templates/list.html
+++ b/app/modules/locations/templates/list.html
@@ -41,7 +41,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
{% endfor %}
{% endif %}
@@ -76,6 +76,9 @@
Opret lokation
+
+ Wizard
+
@@ -115,6 +118,8 @@
'etage': 'Etage',
'customer_site': 'Kundesite',
'rum': 'Rum',
+ 'kantine': 'Kantine',
+ 'moedelokale': 'Mødelokale',
'vehicle': 'Køretøj'
}.get(node.location_type, node.location_type) %}
@@ -124,6 +129,8 @@
'etage': '#3498db',
'customer_site': '#9b59b6',
'rum': '#e67e22',
+ 'kantine': '#d35400',
+ 'moedelokale': '#16a085',
'vehicle': '#8e44ad'
}.get(node.location_type, '#6c757d') %}
diff --git a/app/modules/locations/templates/map.html b/app/modules/locations/templates/map.html
index 6e4755e..6260a6c 100644
--- a/app/modules/locations/templates/map.html
+++ b/app/modules/locations/templates/map.html
@@ -32,7 +32,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
{% endfor %}
{% endif %}
diff --git a/app/modules/locations/templates/wizard.html b/app/modules/locations/templates/wizard.html
new file mode 100644
index 0000000..6910d38
--- /dev/null
+++ b/app/modules/locations/templates/wizard.html
@@ -0,0 +1,393 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Wizard: Lokationer - BMC Hub{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
Wizard: Opret lokation
+
Opret en adresse med etager og rum i en samlet arbejdsgang
+
+
+
+
+ Fejl!
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/app/modules/nextcloud/backend/router.py b/app/modules/nextcloud/backend/router.py
index 7ac7dd6..4b67867 100644
--- a/app/modules/nextcloud/backend/router.py
+++ b/app/modules/nextcloud/backend/router.py
@@ -181,6 +181,52 @@ async def list_groups(instance_id: int, customer_id: Optional[int] = Query(None)
return response
+@router.get("/instances/{instance_id}/users")
+async def list_users(
+ instance_id: int,
+ customer_id: Optional[int] = Query(None),
+ search: Optional[str] = Query(None),
+ include_details: bool = Query(False),
+ limit: int = Query(200, ge=1, le=500),
+):
+ if include_details:
+ response = await service.list_users_details(instance_id, customer_id, search, limit)
+ else:
+ response = await service.list_users(instance_id, customer_id, search)
+ if customer_id is not None:
+ _audit(
+ customer_id,
+ instance_id,
+ "users",
+ {
+ "instance_id": instance_id,
+ "search": search,
+ "include_details": include_details,
+ "limit": limit,
+ },
+ response,
+ )
+ return response
+
+
+@router.get("/instances/{instance_id}/users/{uid}")
+async def get_user_details(
+ instance_id: int,
+ uid: str,
+ customer_id: Optional[int] = Query(None),
+):
+ response = await service.get_user_details(instance_id, uid, customer_id)
+ if customer_id is not None:
+ _audit(
+ customer_id,
+ instance_id,
+ "user_details",
+ {"instance_id": instance_id, "uid": uid},
+ response,
+ )
+ return response
+
+
@router.get("/instances/{instance_id}/shares")
async def list_shares(instance_id: int, customer_id: Optional[int] = Query(None)):
response = await service.list_public_shares(instance_id, customer_id)
diff --git a/app/modules/nextcloud/backend/service.py b/app/modules/nextcloud/backend/service.py
index 25b62aa..4a2a309 100644
--- a/app/modules/nextcloud/backend/service.py
+++ b/app/modules/nextcloud/backend/service.py
@@ -179,6 +179,67 @@ class NextcloudService:
use_cache=True,
)
+ async def list_users(
+ self,
+ instance_id: int,
+ customer_id: Optional[int] = None,
+ search: Optional[str] = None,
+ ) -> dict:
+ instance = self._get_instance(instance_id, customer_id)
+ if not instance or not instance["is_enabled"]:
+ return {"users": []}
+
+ params = {"search": search} if search else None
+ return await self._ocs_request(
+ instance,
+ "/ocs/v1.php/cloud/users",
+ method="GET",
+ params=params,
+ use_cache=False,
+ )
+
+ async def get_user_details(
+ self,
+ instance_id: int,
+ uid: str,
+ customer_id: Optional[int] = None,
+ ) -> dict:
+ instance = self._get_instance(instance_id, customer_id)
+ if not instance or not instance["is_enabled"]:
+ return {"user": None}
+
+ return await self._ocs_request(
+ instance,
+ f"/ocs/v1.php/cloud/users/{uid}",
+ method="GET",
+ use_cache=False,
+ )
+
+ async def list_users_details(
+ self,
+ instance_id: int,
+ customer_id: Optional[int] = None,
+ search: Optional[str] = None,
+ limit: int = 200,
+ ) -> dict:
+ response = await self.list_users(instance_id, customer_id, search)
+ users = response.get("payload", {}).get("ocs", {}).get("data", {}).get("users", [])
+ if not isinstance(users, list):
+ users = []
+
+ users = users[: max(1, min(limit, 500))]
+ detailed = []
+ for uid in users:
+ detail_resp = await self.get_user_details(instance_id, uid, customer_id)
+ data = detail_resp.get("payload", {}).get("ocs", {}).get("data", {}) if isinstance(detail_resp, dict) else {}
+ detailed.append({
+ "uid": uid,
+ "display_name": data.get("displayname") if isinstance(data, dict) else None,
+ "email": data.get("email") if isinstance(data, dict) else None,
+ })
+
+ return {"users": detailed}
+
async def list_public_shares(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py
index 0a8d533..d0d2967 100644
--- a/app/modules/sag/backend/router.py
+++ b/app/modules/sag/backend/router.py
@@ -32,11 +32,15 @@ async def list_sager(
tag: Optional[str] = Query(None),
customer_id: Optional[int] = Query(None),
ansvarlig_bruger_id: Optional[int] = Query(None),
+ include_deferred: bool = Query(False),
):
"""List all cases with optional filtering."""
try:
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL"
params = []
+
+ if not include_deferred:
+ query += " AND (deferred_until IS NULL OR deferred_until <= NOW())"
if status:
query += " AND status = %s"
diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py
index 7b5df52..e35699f 100644
--- a/app/modules/sag/frontend/views.py
+++ b/app/modules/sag/frontend/views.py
@@ -41,8 +41,11 @@ async def sager_liste(
params = []
if not include_deferred:
- query += " AND (s.deferred_until IS NULL OR s.deferred_until <= NOW())"
- query += " AND (s.deferred_until_case_id IS NULL OR s.deferred_until_status IS NULL OR ds.status = s.deferred_until_status)"
+ query += " AND ("
+ query += "s.deferred_until IS NULL"
+ query += " OR s.deferred_until <= NOW()"
+ query += " OR (s.deferred_until_case_id IS NOT NULL AND s.deferred_until_status IS NOT NULL AND ds.status = s.deferred_until_status)"
+ query += ")"
if status:
query += " AND s.status = %s"
diff --git a/app/products/backend/router.py b/app/products/backend/router.py
index 229aae0..0441b1d 100644
--- a/app/products/backend/router.py
+++ b/app/products/backend/router.py
@@ -1,13 +1,15 @@
"""
Products API
"""
-from fastapi import APIRouter, HTTPException, Query
+from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Dict, Any, Optional, Tuple
from app.core.database import execute_query, execute_query_single
from app.core.config import settings
+from app.core.auth_dependencies import require_permission
import logging
import os
import aiohttp
+import json
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -20,6 +22,122 @@ def _apigw_headers() -> Dict[str, str]:
return {"Authorization": f"Bearer {token}"}
+def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: str = "manual") -> Dict[str, Any]:
+ supplier_name = payload.get("supplier_name")
+ supplier_code = payload.get("supplier_code")
+ supplier_sku = payload.get("supplier_sku") or payload.get("sku")
+ supplier_price = payload.get("supplier_price") or payload.get("price")
+ supplier_currency = payload.get("supplier_currency") or payload.get("currency") or "DKK"
+ supplier_stock = payload.get("supplier_stock") or payload.get("stock_qty")
+ supplier_url = payload.get("supplier_url") or payload.get("supplier_link")
+ supplier_product_url = (
+ payload.get("supplier_product_url")
+ or payload.get("product_url")
+ or payload.get("product_link")
+ or payload.get("url")
+ )
+
+ match_query = None
+ match_params = None
+ if supplier_code and supplier_sku:
+ match_query = """
+ SELECT * FROM product_suppliers
+ WHERE product_id = %s AND supplier_code = %s AND supplier_sku = %s
+ LIMIT 1
+ """
+ match_params = (product_id, supplier_code, supplier_sku)
+ elif supplier_url:
+ match_query = """
+ SELECT * FROM product_suppliers
+ WHERE product_id = %s AND supplier_url = %s
+ LIMIT 1
+ """
+ match_params = (product_id, supplier_url)
+ elif supplier_name and supplier_sku:
+ match_query = """
+ SELECT * FROM product_suppliers
+ WHERE product_id = %s AND supplier_name = %s AND supplier_sku = %s
+ LIMIT 1
+ """
+ match_params = (product_id, supplier_name, supplier_sku)
+
+ existing = execute_query_single(match_query, match_params) if match_query else None
+
+ if existing:
+ update_query = """
+ UPDATE product_suppliers
+ SET supplier_name = %s,
+ supplier_code = %s,
+ supplier_sku = %s,
+ supplier_price = %s,
+ supplier_currency = %s,
+ supplier_stock = %s,
+ supplier_url = %s,
+ supplier_product_url = %s,
+ source = %s,
+ last_updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ RETURNING *
+ """
+ result = execute_query(
+ update_query,
+ (
+ supplier_name,
+ supplier_code,
+ supplier_sku,
+ supplier_price,
+ supplier_currency,
+ supplier_stock,
+ supplier_url,
+ supplier_product_url,
+ source,
+ existing.get("id"),
+ )
+ )
+ return result[0] if result else existing
+
+ insert_query = """
+ INSERT INTO product_suppliers (
+ product_id,
+ supplier_name,
+ supplier_code,
+ supplier_sku,
+ supplier_price,
+ supplier_currency,
+ supplier_stock,
+ supplier_url,
+ supplier_product_url,
+ source,
+ last_updated_at
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
+ RETURNING *
+ """
+ result = execute_query(
+ insert_query,
+ (
+ product_id,
+ supplier_name,
+ supplier_code,
+ supplier_sku,
+ supplier_price,
+ supplier_currency,
+ supplier_stock,
+ supplier_url,
+ supplier_product_url,
+ source,
+ )
+ )
+ return result[0] if result else {}
+
+
+def _log_product_audit(product_id: int, event_type: str, user_id: Optional[int], changes: Dict[str, Any]) -> None:
+ audit_query = """
+ INSERT INTO product_audit_log (product_id, event_type, user_id, changes)
+ VALUES (%s, %s, %s, %s)
+ """
+ execute_query(audit_query, (product_id, event_type, user_id, json.dumps(changes)))
+
+
def _normalize_query(raw_query: str) -> Tuple[str, List[str]]:
normalized = " ".join(
"".join(ch.lower() if ch.isalnum() else " " for ch in raw_query).split()
@@ -149,6 +267,7 @@ async def import_apigw_product(payload: Dict[str, Any]):
(sku_internal,)
)
if existing:
+ _upsert_product_supplier(existing["id"], product, source="gateway")
return existing
sales_price = product.get("price")
@@ -194,7 +313,10 @@ async def import_apigw_product(payload: Dict[str, Any]):
True,
)
result = execute_query(insert_query, params)
- return result[0] if result else {}
+ created = result[0] if result else {}
+ if created:
+ _upsert_product_supplier(created["id"], product, source="gateway")
+ return created
except HTTPException:
raise
except Exception as e:
@@ -446,6 +568,191 @@ async def get_product(product_id: int):
raise HTTPException(status_code=500, detail=str(e))
+@router.patch("/products/{product_id}", response_model=Dict[str, Any])
+async def update_product(
+ product_id: int,
+ payload: Dict[str, Any],
+ current_user: dict = Depends(require_permission("products.update"))
+):
+ """Update product fields like name."""
+ try:
+ name = payload.get("name")
+ if name is not None:
+ name = name.strip()
+ if not name:
+ raise HTTPException(status_code=400, detail="name cannot be empty")
+
+ existing = execute_query_single(
+ "SELECT name FROM products WHERE id = %s AND deleted_at IS NULL",
+ (product_id,)
+ )
+ if not existing:
+ raise HTTPException(status_code=404, detail="Product not found")
+
+ query = """
+ UPDATE products
+ SET name = COALESCE(%s, name),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s AND deleted_at IS NULL
+ RETURNING *
+ """
+ result = execute_query(query, (name, product_id))
+ if not result:
+ raise HTTPException(status_code=404, detail="Product not found")
+ if name is not None and name != existing.get("name"):
+ _log_product_audit(
+ product_id,
+ "name_updated",
+ current_user.get("id") if current_user else None,
+ {"old": existing.get("name"), "new": name}
+ )
+ return result[0]
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("❌ Error updating product: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/products/{product_id}", response_model=Dict[str, Any])
+async def delete_product(
+ product_id: int,
+ current_user: dict = Depends(require_permission("products.delete"))
+):
+ """Soft-delete a product."""
+ try:
+ query = """
+ UPDATE products
+ SET deleted_at = CURRENT_TIMESTAMP,
+ status = 'inactive',
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s AND deleted_at IS NULL
+ RETURNING id
+ """
+ result = execute_query(query, (product_id,))
+ if not result:
+ raise HTTPException(status_code=404, detail="Product not found")
+ _log_product_audit(
+ product_id,
+ "deleted",
+ current_user.get("id") if current_user else None,
+ {"status": "inactive"}
+ )
+ return {"status": "deleted", "id": result[0].get("id")}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("❌ Error deleting product: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/products/{product_id}/suppliers", response_model=List[Dict[str, Any]])
+async def list_product_suppliers(product_id: int):
+ """List suppliers for a product."""
+ try:
+ query = """
+ SELECT
+ id,
+ product_id,
+ supplier_name,
+ supplier_code,
+ supplier_sku,
+ supplier_price,
+ supplier_currency,
+ supplier_stock,
+ supplier_url,
+ supplier_product_url,
+ source,
+ last_updated_at
+ FROM product_suppliers
+ WHERE product_id = %s
+ ORDER BY supplier_name NULLS LAST, supplier_price NULLS LAST
+ """
+ return execute_query(query, (product_id,)) or []
+ except Exception as e:
+ logger.error("❌ Error loading product suppliers: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/products/{product_id}/suppliers", response_model=Dict[str, Any])
+async def upsert_product_supplier(product_id: int, payload: Dict[str, Any]):
+ """Create or update a product supplier."""
+ try:
+ return _upsert_product_supplier(product_id, payload, source=payload.get("source") or "manual")
+ except Exception as e:
+ logger.error("❌ Error saving product supplier: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/products/{product_id}/suppliers/{supplier_id}", response_model=Dict[str, Any])
+async def delete_product_supplier(product_id: int, supplier_id: int):
+ """Delete a product supplier."""
+ try:
+ query = "DELETE FROM product_suppliers WHERE id = %s AND product_id = %s"
+ execute_query(query, (supplier_id, product_id))
+ return {"status": "deleted"}
+ except Exception as e:
+ logger.error("❌ Error deleting product supplier: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/products/{product_id}/suppliers/refresh", response_model=Dict[str, Any])
+async def refresh_product_suppliers(product_id: int):
+ """Refresh suppliers from API Gateway using EAN only."""
+ try:
+ product = execute_query_single(
+ "SELECT ean FROM products WHERE id = %s AND deleted_at IS NULL",
+ (product_id,),
+ )
+ if not product:
+ raise HTTPException(status_code=404, detail="Product not found")
+
+ ean = (product.get("ean") or "").strip()
+ if not ean:
+ raise HTTPException(status_code=400, detail="Product has no EAN to search")
+
+ base_url = settings.APIGW_BASE_URL or settings.APIGATEWAY_URL
+ url = f"{base_url.rstrip('/')}/api/v1/products/search"
+ params = {"q": ean, "per_page": 25}
+ timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)
+
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.get(url, headers=_apigw_headers(), params=params) as response:
+ if response.status >= 400:
+ detail = await response.text()
+ raise HTTPException(status_code=response.status, detail=detail)
+ data = await response.json()
+
+ results = data.get("products") if isinstance(data, dict) else []
+ if not isinstance(results, list):
+ results = []
+
+ saved = 0
+ seen_keys = set()
+ for item in results:
+ key = (
+ item.get("supplier_code"),
+ item.get("sku"),
+ item.get("product_url") or item.get("url")
+ )
+ if key in seen_keys:
+ continue
+ seen_keys.add(key)
+ _upsert_product_supplier(product_id, item, source="gateway")
+ saved += 1
+
+ return {
+ "status": "refreshed",
+ "saved": saved,
+ "queries": [{"q": ean}]
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("❌ Error refreshing product suppliers: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.get("/products/{product_id}/price-history", response_model=List[Dict[str, Any]])
async def list_product_price_history(product_id: int, limit: int = Query(100)):
"""List price history entries for a product."""
diff --git a/app/products/frontend/detail.html b/app/products/frontend/detail.html
index bbe8460..5d04109 100644
--- a/app/products/frontend/detail.html
+++ b/app/products/frontend/detail.html
@@ -83,12 +83,23 @@
-
-
-
+