diff --git a/.env.example b/.env.example index d8025d1..d29325e 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ # POSTGRESQL DATABASE - Local Development # ===================================================== DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub +HUB_BASE_URL=https://hub.bmcnetworks.dk # Database credentials (bruges af docker-compose) POSTGRES_USER=bmc_hub @@ -171,4 +172,4 @@ EMAIL_PROCESS_INTERVAL_MINUTES=5 EMAIL_WORKFLOWS_ENABLED=true EMAIL_WORKFLOW_AUTORUN_ENABLED=false EMAIL_MAX_UPLOAD_SIZE_MB=50 -ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip \ No newline at end of file +ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip diff --git a/.env.prod.example b/.env.prod.example index cf3454e..0cbcb18 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -24,6 +24,7 @@ GITHUB_REPO=ct/bmc_hub # POSTGRESQL DATABASE - Production # ===================================================== DATABASE_URL=postgresql://bmc_hub_prod:CHANGE_THIS_PASSWORD@postgres:5432/bmc_hub_prod +HUB_BASE_URL=https://hub.bmcnetworks.dk # Database credentials (bruges af docker-compose/podman-compose) POSTGRES_USER=bmc_hub_prod diff --git a/app/backups/backend/notifications.py b/app/backups/backend/notifications.py index d15c995..8b713e6 100644 --- a/app/backups/backend/notifications.py +++ b/app/backups/backend/notifications.py @@ -5,6 +5,7 @@ Sends rich formatted notifications to Mattermost webhook import logging import aiohttp +import json from datetime import datetime from typing import Dict, Optional, List from app.core.config import settings @@ -346,7 +347,7 @@ class MattermostNotification: """ if not self.enabled or not self.webhook_url: logger.info("📢 Notification (disabled): %s - job_id=%s", event_type, job_id) - return + return False, "Mattermost is disabled or webhook URL is missing" try: async with aiohttp.ClientSession() as session: @@ -355,21 +356,63 @@ class MattermostNotification: logger.info("📢 Notification sent: %s - job_id=%s", event_type, job_id) # Log to database - execute_insert( - """INSERT INTO backup_notifications - (backup_job_id, event_type, message, mattermost_payload) - VALUES (%s, %s, %s, %s)""", - (job_id, event_type, payload.get('text', ''), str(payload)) - ) + if job_id is not None: + execute_insert( + """INSERT INTO backup_notifications + (backup_job_id, event_type, message, mattermost_payload) + VALUES (%s, %s, %s, %s)""", + (job_id, event_type, payload.get('text', ''), json.dumps(payload, ensure_ascii=False)) + ) + return True, "Mattermost notification sent" else: error_text = await response.text() - logger.error("❌ Notification failed: HTTP %s - %s", - response.status, error_text) + should_retry_form = ( + response.status in (400, 404, 415) + and ( + "media type application/json" in error_text.lower() + or "incoming_webhook.general.app_error" in error_text.lower() + ) + ) + if not should_retry_form: + logger.error("❌ Notification failed: HTTP %s - %s", + response.status, error_text) + return False, f"Mattermost returned HTTP {response.status}: {error_text[:300]}" + + form_data = aiohttp.FormData() + form_data.add_field("payload", json.dumps(payload, ensure_ascii=False)) + async with session.post(self.webhook_url, data=form_data, timeout=10) as fallback_response: + fallback_text = await fallback_response.text() + if fallback_response.status == 200: + logger.info("📢 Notification sent using Mattermost form compatibility mode: %s", event_type) + if job_id is not None: + execute_insert( + """INSERT INTO backup_notifications + (backup_job_id, event_type, message, mattermost_payload) + VALUES (%s, %s, %s, %s)""", + (job_id, event_type, payload.get('text', ''), json.dumps(payload, ensure_ascii=False)) + ) + return True, "Mattermost notification sent using compatibility mode" + logger.error( + "❌ Mattermost compatibility request failed: HTTP %s - %s", + fallback_response.status, + fallback_text, + ) + if "incoming_webhook.general.app_error" in fallback_text: + return False, ( + "Mattermost rejected the webhook ID. Create a new Incoming Webhook " + "in Mattermost and use its complete generated /hooks/... URL." + ) + return False, ( + f"Mattermost returned HTTP {fallback_response.status} " + f"in compatibility mode: {fallback_text[:300]}" + ) except aiohttp.ClientError as e: logger.error("❌ Notification connection error: %s", str(e)) + return False, f"Mattermost connection error: {e}" except Exception as e: logger.error("❌ Notification error: %s", str(e)) + return False, f"Mattermost notification error: {e}" def _should_send_notification(self, event_type: str) -> bool: """Check if notification should be sent based on settings""" @@ -386,8 +429,7 @@ class MattermostNotification: def _get_hub_url(self) -> str: """Get BMC Hub base URL for action buttons""" - # TODO: Add HUB_BASE_URL to config - return "http://localhost:8000" # Fallback + return str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").strip().rstrip("/") # Singleton instance diff --git a/app/billing/backend/supplier_invoices.py b/app/billing/backend/supplier_invoices.py index 0336632..501e739 100644 --- a/app/billing/backend/supplier_invoices.py +++ b/app/billing/backend/supplier_invoices.py @@ -25,6 +25,17 @@ logger = logging.getLogger(__name__) router = APIRouter() _PURCHASE_CASE_TYPE = "indkøb" +_INTERNET_CASE_RELEVANT_CHANGE_FIELDS = { + "address", + "service_address", + "monthly_cost", + "technology", + "connection_type", + "circuit_number", + "speed_mbps", + "download_mbps", + "upload_mbps", +} SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist") @@ -256,7 +267,15 @@ def _ensure_internet_change_case( owner_customer_id: Optional[int], changes: Dict[str, Dict[str, object]], ) -> Optional[int]: - if not changes: + # Customer ownership, initial activation and internal classification are + # bookkeeping outcomes of a successful import, not operational incidents. + # Only create cases for changes that can affect delivery or billing. + relevant_changes = { + field: change + for field, change in (changes or {}).items() + if field in _INTERNET_CASE_RELEVANT_CHANGE_FIELDS + } + if not relevant_changes: return None title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}" @@ -287,7 +306,7 @@ def _ensure_internet_change_case( return None change_lines = "\n".join( f"- {field}: {change.get('from')} -> {change.get('to')}" - for field, change in changes.items() + for field, change in relevant_changes.items() ) description = ( "Automatisk oprettet ved import af internetfaktura.\n" @@ -769,7 +788,7 @@ def _match_customer_for_globalconnect_line(line: Dict, customers: List[Dict]) -> normalized_target = _normalize_company_name(end_customer_name) address_parts = [part.strip().upper() for part in re.split(r"[, ]+", service_address) if part.strip()] - best_match = None + best_matches = [] best_score = 0 for customer in customers: customer_name = str(customer.get("name") or "").strip() @@ -806,9 +825,17 @@ def _match_customer_for_globalconnect_line(line: Dict, customers: List[Dict]) -> if score > best_score: best_score = score - best_match = customer + best_matches = [customer] + elif score == best_score and score > 0: + best_matches.append(customer) - return best_match if best_score >= 50 else None + if best_score < 50 or not best_matches: + return None + # An address shared by several tenants is not enough to select a customer. + # Require a unique winner unless the invoice also supplied a customer name. + if len(best_matches) > 1 and not normalized_target: + return None + return best_matches[0] def _looks_like_ip_range_line(line: Dict) -> bool: @@ -1166,7 +1193,6 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_ ) primary_line = sorted_lines[0] display_reference = str(primary_line.get("provider_reference") or primary_line.get("circuit_id") or reference).strip() - matched_customer = _match_customer_for_globalconnect_line(primary_line, customers) service_address = _build_service_address(primary_line) if not service_address: logger.warning( @@ -1175,9 +1201,40 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_ invoice_number, ) return None + existing_resolution = _resolve_existing_globalconnect_connection(reference, service_address) + existing = existing_resolution.get("row") + conflict_reason = existing_resolution.get("conflict_reason") + if conflict_reason: + logger.warning( + "Skipping GlobalConnect connection %s from invoice %s because %s", + display_reference or reference, + invoice_number, + conflict_reason, + ) + return None + merge_ids = [int(item) for item in (existing_resolution.get("merge_ids") or [])] + if existing and merge_ids: + _merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"])) + + matched_customer = _match_customer_for_globalconnect_line(primary_line, customers) + if not matched_customer and existing and existing.get("customer_id"): + # Preserve a previously reviewed owner when the new invoice has no + # unambiguous customer name/address instead of replacing it with BMC. + matched_customer = next( + (customer for customer in customers if int(customer.get("id") or 0) == int(existing["customer_id"])), + None, + ) description = str(primary_line.get("description") or reference) end_customer_name = str(primary_line.get("end_customer_name") or "").strip() - internal_owner = _resolve_internal_bmc_customer() if _should_assign_internal_bmc_owner(lines, matched_customer, service_address) else None + # Internal BMC ownership is only a default for a newly discovered + # connection. An existing connection with no customer may deliberately be + # unassigned and must not gain an owner merely because a later invoice is + # ambiguous. + internal_owner = ( + _resolve_internal_bmc_customer() + if not existing and _should_assign_internal_bmc_owner(lines, matched_customer, service_address) + else None + ) owner_customer = matched_customer or internal_owner is_confident = _has_confident_globalconnect_mapping(matched_customer, service_address) connection_name = ( @@ -1194,21 +1251,6 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_ else: note_text = base_note if is_confident else f"{base_note} {mapping_note}" - existing_resolution = _resolve_existing_globalconnect_connection(reference, service_address) - existing = existing_resolution.get("row") - conflict_reason = existing_resolution.get("conflict_reason") - if conflict_reason: - logger.warning( - "Skipping GlobalConnect connection %s from invoice %s because %s", - display_reference or reference, - invoice_number, - conflict_reason, - ) - return None - merge_ids = [int(item) for item in (existing_resolution.get("merge_ids") or [])] - if existing and merge_ids: - _merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"])) - download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description) target_status = "active" if (is_confident or internal_owner) else "pending" shared_value_type = _shared_connection_value_type(internal_owner, matched_customer) @@ -1540,13 +1582,76 @@ def _connection_can_host_ip_range(connection_id: Optional[int], service_address: ) if not row: return False - if str(row.get("allocation_model") or "").lower() == "shared": - return True if not service_address: return True + # A shared connection may serve several customers, but a supplier reference + # and its IP-range must still belong to the same physical service address. + # Bypassing the address check here caused ranges from another site to + # overwrite customer, address and price data on the wrong connection. return _normalize_service_address_for_match(row.get("address")) == _normalize_service_address_for_match(service_address) +def _resolve_existing_ip_range_connection(line: Dict) -> Dict[str, object]: + """Use an existing CIDR as the strongest key, but never cross service addresses.""" + cidr = str(line.get("ip_address") or "").strip() + if not cidr: + return {"connection_id": None, "conflict_reason": None} + reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) + service_address = _build_service_address(line) + rows = execute_query( + """ + SELECT range.connection_id, range.service_address, range.provider_reference, + connection.address AS connection_address, + connection.circuit_number AS connection_reference + FROM internet_connections_ip_ranges range + JOIN internet_connections_connections connection ON connection.id = range.connection_id + WHERE range.cidr = %s + AND range.deleted_at IS NULL + AND connection.deleted_at IS NULL + ORDER BY range.id + """, + (cidr,), + ) or [] + if reference: + matching_reference = [ + row for row in rows + if _normalize_provider_reference(row.get("provider_reference")) == reference + ] + if matching_reference: + rows = matching_reference + if not rows: + return {"connection_id": None, "conflict_reason": None} + + normalized_target = _normalize_service_address_for_match(service_address) + exact_address = [ + row for row in rows + if normalized_target + and _normalize_service_address_for_match(row.get("service_address") or row.get("connection_address")) == normalized_target + ] + if len(exact_address) == 1: + return { + "connection_id": int(exact_address[0]["connection_id"]), + "canonical_reference": exact_address[0].get("connection_reference"), + "conflict_reason": None, + } + if len(rows) == 1 and not normalized_target: + return { + "connection_id": int(rows[0]["connection_id"]), + "canonical_reference": rows[0].get("connection_reference"), + "conflict_reason": None, + } + + known_addresses = sorted({ + str(row.get("service_address") or row.get("connection_address") or "").strip() + for row in rows + if str(row.get("service_address") or row.get("connection_address") or "").strip() + }) + reason = f"CIDR {cidr} findes allerede" + if known_addresses: + reason += f" på anden adresse: {', '.join(known_addresses)}" + return {"connection_id": None, "conflict_reason": reason} + + def _connection_skip_reason(line: Dict) -> str: reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) if not reference: @@ -1575,6 +1680,48 @@ def _build_sync_audit_entry(line: Dict, classification: str, status: str, reason } +def _clear_inherited_addresses_for_addressless_eb_ranges(lines: List[Dict]) -> List[Dict]: + """ + GlobalConnect's IP overview does not repeat a service address for legacy + EB references. The extractor can incorrectly carry the preceding NKA + address forward. If the matching existing EB connection is deliberately + addressless, retain that unknown state instead of trusting the inherited + address. + """ + references_by_address: Dict[str, set] = {} + for line in lines: + reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) + address = _normalize_service_address_for_match(_build_service_address(line)) + if _looks_like_ip_range_line(line) and reference.startswith("EB") and address: + references_by_address.setdefault(address, set()).add(reference) + + # A repeated address across several unrelated EB circuits is the extractor + # carrying the preceding site's address forward. Explicit EB addresses are + # retained when they occur on a single circuit. + inherited_addresses = { + address for address, references in references_by_address.items() + if len(references) >= 2 + } + + sanitized = [] + for source_line in lines: + line = dict(source_line) + reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) + normalized_address = _normalize_service_address_for_match(_build_service_address(line)) + if ( + _looks_like_ip_range_line(line) + and reference.startswith("EB") + and normalized_address in inherited_addresses + ): + line["service_address"] = None + line["location_street"] = None + line["location_zip"] = None + line["location_city"] = None + line["address_source"] = "not_stated_on_invoice" + sanitized.append(line) + return sanitized + + def _summarize_sync_audit(line_audit: List[Dict], connection_groups: int, ip_range_candidates: int) -> Dict: actionable_lines = [entry for entry in line_audit if entry["classification"] in {"connection", "ip_range"}] synced_lines = [entry for entry in actionable_lines if entry["status"] == "synced"] @@ -1594,11 +1741,13 @@ def _summarize_sync_audit(line_audit: List[Dict], connection_groups: int, ip_ran } -def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: bool = False) -> Dict: +def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simulate: bool = False) -> Dict: if not _is_globalconnect_extraction(extraction_row): return {"skipped": True, "reason": "not_globalconnect"} - lines = _load_extraction_lines(extraction_row) + lines = _clear_inherited_addresses_for_addressless_eb_ranges( + _load_extraction_lines(extraction_row) + ) if not lines: return {"skipped": True, "reason": "no_lines"} @@ -1680,8 +1829,16 @@ def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: b for audit_index, line in ip_range_lines: reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) service_address = _build_service_address(line) - connection_id = connection_map.get(reference) + existing_range_resolution = _resolve_existing_ip_range_connection(line) + if existing_range_resolution.get("conflict_reason"): + skipped_orphan_ip_ranges += 1 + line_audit[audit_index]["status"] = "skipped" + line_audit[audit_index]["reason"] = existing_range_resolution["conflict_reason"] + continue + connection_id = existing_range_resolution.get("connection_id") or connection_map.get(reference) resolved_from_existing = False + if existing_range_resolution.get("connection_id"): + resolved_from_existing = True if connection_id and not _connection_can_host_ip_range(connection_id, service_address): connection_id = None if not connection_id and reference: @@ -1700,9 +1857,15 @@ def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: b line_audit[audit_index]["reason"] = connection_conflict_reason continue + sync_line = dict(line) + if existing_range_resolution.get("canonical_reference"): + sync_line["provider_reference"] = existing_range_resolution["canonical_reference"] + sync_line["circuit_id"] = existing_range_resolution["canonical_reference"] + line_audit[audit_index]["matched_by"] = "existing_cidr_and_address" + line_audit[audit_index]["canonical_reference"] = existing_range_resolution["canonical_reference"] if connection_id and ( simulate - or _upsert_globalconnect_ip_range(connection_id, line, invoice_number) + or _upsert_globalconnect_ip_range(connection_id, sync_line, invoice_number) ): created_or_updated_ranges += 1 if resolved_from_existing: @@ -1737,6 +1900,119 @@ def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: b } +def _record_internet_invoice_sync_run( + extraction_row: Dict, + *, + status: str, + result: Optional[Dict] = None, + error_message: Optional[str] = None, +) -> None: + """Persist the outcome without allowing audit logging to break invoice processing.""" + try: + extraction_id = extraction_row.get("extraction_id") + supplier_invoice = execute_query_single( + """ + SELECT id + FROM supplier_invoices + WHERE extraction_id = %s + ORDER BY id DESC + LIMIT 1 + """, + (extraction_id,), + ) if extraction_id else None + payload = result or {} + verification = payload.get("verification") or {} + execute_update( + """ + INSERT INTO internet_connections_invoice_sync_runs ( + file_id, extraction_id, supplier_invoice_id, invoice_number, vendor_name, + invoice_date, status, connections_synced, connections_created, + connections_updated, ip_ranges_synced, total_lines, actionable_lines, + skipped_lines, error_message, result_json + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb) + """, + ( + extraction_row.get("file_id"), + extraction_id, + supplier_invoice.get("id") if supplier_invoice else None, + extraction_row.get("document_id") or extraction_row.get("invoice_number"), + extraction_row.get("vendor_name"), + extraction_row.get("document_date"), + status, + int(payload.get("connections_synced") or 0), + int(payload.get("connections_created") or 0), + int(payload.get("connections_updated") or 0), + int(payload.get("ip_ranges_synced") or 0), + int(verification.get("total_lines") or 0), + int(verification.get("actionable_lines") or 0), + int(verification.get("skipped_actionable_lines") or 0), + error_message, + json.dumps(payload, default=str), + ), + ) + except Exception as audit_error: + logger.warning("Could not persist internet invoice sync audit: %s", audit_error) + + +def _sync_globalconnect_extraction_to_internet( + extraction_row: Dict, + simulate: bool = False, + force: bool = False, +) -> Dict: + """Run GlobalConnect sync and keep a permanent, queryable result for the overview.""" + try: + if not simulate and not force: + invoice_number = str( + extraction_row.get("document_id") or extraction_row.get("invoice_number") or "" + ).strip() + extraction_id = extraction_row.get("extraction_id") + previous_run = execute_query_single( + """ + SELECT id, status, processed_at + FROM internet_connections_invoice_sync_runs + WHERE status IN ('success', 'warning') + AND ( + (%s IS NOT NULL AND extraction_id = %s) + OR (NULLIF(%s, '') IS NOT NULL AND invoice_number = %s) + ) + ORDER BY processed_at DESC, id DESC + LIMIT 1 + """, + (extraction_id, extraction_id, invoice_number, invoice_number), + ) + if previous_run: + return { + "skipped": True, + "reason": "invoice_already_processed", + "invoice_number": invoice_number, + "previous_run_id": previous_run.get("id"), + "previous_status": previous_run.get("status"), + "previous_processed_at": previous_run.get("processed_at"), + } + + result = _sync_globalconnect_extraction_to_internet_impl(extraction_row, simulate=simulate) + if not simulate: + verification = result.get("verification") or {} + if result.get("skipped"): + status = "skipped" + elif verification.get("requires_manual_review"): + status = "warning" + else: + status = "success" + _record_internet_invoice_sync_run(extraction_row, status=status, result=result) + return result + except Exception as exc: + if not simulate: + _record_internet_invoice_sync_run( + extraction_row, + status="error", + error_message=str(exc), + result={"exception_type": type(exc).__name__}, + ) + raise + + def _find_existing_product_id(vendor_id: Optional[int], description: str, sku: Optional[str]) -> Optional[int]: sku_value = str(sku or "").strip() desc_value = str(description or "").strip() @@ -3078,7 +3354,7 @@ async def sync_extraction_to_internet(file_id: int): if not extraction: raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil") - result = _sync_globalconnect_extraction_to_internet(extraction) + result = _sync_globalconnect_extraction_to_internet(extraction, force=True) return { "status": "success", "file_id": file_id, @@ -4812,6 +5088,17 @@ async def reprocess_uploaded_file(file_id: int): (vendor_id,)) if vendor: result["warning"] = f"⚠️ Ingen template fundet for {vendor['name']} - brugte AI extraction (langsommere)" + + # GlobalConnect invoices must update the internet module immediately after + # extraction. Previously this only happened after a separate manual + # conversion to supplier_invoice, leaving valid extracted invoices queued. + if "extraction_id" in locals() and extraction_id: + latest_extraction = execute_query_single( + "SELECT * FROM extractions WHERE extraction_id = %s", + (extraction_id,), + ) + if latest_extraction and _is_globalconnect_extraction(latest_extraction): + result["internet_sync"] = _sync_globalconnect_extraction_to_internet(latest_extraction) return result diff --git a/app/core/config.py b/app/core/config.py index d718306..902728d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -20,6 +20,7 @@ class Settings(BaseSettings): API_PORT: int = 8000 API_RELOAD: bool = False ENABLE_RELOAD: bool = False # Added to match docker-compose.yml + HUB_BASE_URL: str = "https://hub.bmcnetworks.dk" # Elnet supplier lookup ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api" @@ -68,6 +69,12 @@ class Settings(BaseSettings): ECONOMIC_READ_ONLY: bool = True ECONOMIC_DRY_RUN: bool = True + # Manual migration centre + MIGRATION_CENTER_READ_ONLY: bool = False + MIGRATION_CENTER_STALE_AFTER_DAYS: int = 7 + MIGRATION_CENTER_VTIGER_LOCK_FIELD: str = "" + MIGRATION_CENTER_SIMPLY_LOCK_FIELD: str = "" + # Nextcloud Integration NEXTCLOUD_READ_ONLY: bool = True NEXTCLOUD_DRY_RUN: bool = True diff --git a/app/dashboard/backend/mission_router.py b/app/dashboard/backend/mission_router.py index f4b9248..e9d3ab7 100644 --- a/app/dashboard/backend/mission_router.py +++ b/app/dashboard/backend/mission_router.py @@ -121,6 +121,10 @@ class MissionProjectLinkCasePayload(BaseModel): project_task_type: Optional[str] = None +class MissionCallCaseLinkPayload(BaseModel): + sag_id: int = Field(..., gt=0) + + def _first_query_param(request: Request, *names: str) -> Optional[str]: for name in names: value = request.query_params.get(name) @@ -351,6 +355,66 @@ async def get_mission_state(): return MissionService.get_state() +@router.get("/mission/calls/history") +async def get_mission_call_history(limit: int = Query(100, ge=1, le=500)): + return {"calls": MissionService.get_call_history(limit=limit)} + + +@router.get("/mission/cases/search") +async def search_mission_cases(q: str = Query("", max_length=120), limit: int = Query(20, ge=1, le=50)): + needle = str(q or "").strip() + like = f"%{needle}%" + rows = execute_query( + """ + SELECT + s.id, + s.titel, + s.status, + c.name AS customer_name + FROM sag_sager s + LEFT JOIN customers c ON c.id = s.customer_id + WHERE s.deleted_at IS NULL + AND ( + %s = '' + OR s.id::text = %s + OR s.titel ILIKE %s + OR c.name ILIKE %s + ) + ORDER BY + CASE WHEN s.id::text = %s THEN 0 ELSE 1 END, + s.updated_at DESC NULLS LAST, + s.id DESC + LIMIT %s + """, + (needle, needle, like, like, needle, limit), + ) or [] + return {"cases": rows} + + +@router.patch("/mission/calls/{call_id}/case") +async def link_mission_call_to_case(call_id: int, request: Request, payload: MissionCallCaseLinkPayload): + _require_authenticated_user(request) + case = execute_query_single( + "SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL", + (payload.sag_id,), + ) + if not case: + raise HTTPException(status_code=404, detail="Sag ikke fundet") + + rows = execute_query( + """ + UPDATE telefoni_opkald + SET sag_id = %s + WHERE id = %s + RETURNING id, callid, sag_id + """, + (payload.sag_id, call_id), + ) or [] + if not rows: + raise HTTPException(status_code=404, detail="Opkald ikke fundet eller kan ikke redigeres") + return {**dict(rows[0]), "sag_titel": case.get("titel")} + + @router.get("/mission/projects") async def get_mission_projects(limit: int = Query(120, ge=1, le=500)): return { diff --git a/app/dashboard/backend/mission_service.py b/app/dashboard/backend/mission_service.py index 82ea4a3..be41817 100644 --- a/app/dashboard/backend/mission_service.py +++ b/app/dashboard/backend/mission_service.py @@ -224,6 +224,52 @@ class MissionService: ) return rows or [] + @staticmethod + def get_call_history(limit: int = 100) -> list[Dict[str, Any]]: + if not MissionService._table_exists("telefoni_opkald"): + return [] + + rows = execute_query( + """ + SELECT + t.id, + t.callid, + t.direction, + t.ekstern_nummer AS display_number, + t.started_at, + t.ended_at, + COALESCE( + t.duration_sec, + CASE + WHEN t.started_at IS NOT NULL AND t.ended_at IS NOT NULL + THEN GREATEST(EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int, 0) + END + ) AS duration_sec, + COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS employee_name, + NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name, + customer.name AS company_name, + t.sag_id, + s.titel AS sag_titel, + s.status AS sag_status + FROM telefoni_opkald t + LEFT JOIN users u ON u.user_id = t.bruger_id + LEFT JOIN contacts c ON c.id = t.kontakt_id + LEFT JOIN LATERAL ( + SELECT cu.name + FROM contact_companies cc + JOIN customers cu ON cu.id = cc.customer_id + WHERE cc.contact_id = c.id + ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC + LIMIT 1 + ) customer ON TRUE + LEFT JOIN sag_sager s ON s.id = t.sag_id AND s.deleted_at IS NULL + ORDER BY t.started_at DESC, t.id DESC + LIMIT %s + """, + (limit,), + ) or [] + return [dict(row) for row in rows] + @staticmethod def get_active_alerts() -> list[Dict[str, Any]]: if not MissionService._table_exists("mission_uptime_alerts"): @@ -412,6 +458,13 @@ class MissionService: 0 AS score, s.start_date AS started_at, s.deadline AS ended_at, + COALESCE(NULLIF(TRIM(c.name), ''), 'Ingen kunde') AS customer_name, + COALESCE( + NULLIF(TRIM(u.full_name), ''), + NULLIF(TRIM(u.username), ''), + CASE WHEN s.ansvarlig_bruger_id IS NOT NULL THEN CONCAT('Bruger #', s.ansvarlig_bruger_id::text) END + ) AS responsible_name, + next_todo.title AS next_step, s.created_at AS updated_at, 0 AS active_milestones, 0 AS overdue_milestones, @@ -426,6 +479,17 @@ class MissionService: ELSE 0 END AS overdue_tasks FROM sag_sager s + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id + LEFT JOIN customers c ON c.id = s.customer_id + LEFT JOIN LATERAL ( + SELECT t.title + FROM sag_todo_steps t + WHERE t.sag_id = s.id + AND t.deleted_at IS NULL + AND COALESCE(t.is_done, FALSE) = FALSE + ORDER BY COALESCE(t.is_next, FALSE) DESC, t.due_date ASC NULLS LAST, t.id ASC + LIMIT 1 + ) next_todo ON TRUE WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') AND ( @@ -448,8 +512,15 @@ class MissionService: @staticmethod def get_projects(limit: int = 120) -> list[Dict[str, Any]]: + # Project cases are the canonical project portfolio shown in Mission Control. + # `mission_projects` is retained as a legacy fallback for installations that + # do not yet have case-backed projects. + case_projects = MissionService._get_projects_from_cases(limit) + if case_projects: + return case_projects + if not MissionService._table_exists("mission_projects"): - return MissionService._get_projects_from_cases(limit) + return [] rows = execute_query( """ @@ -462,6 +533,12 @@ class MissionService: p.started_at, p.ended_at, p.updated_at, + COALESCE( + NULLIF(TRIM(owner.full_name), ''), + NULLIF(TRIM(owner.username), ''), + task_owner.responsible_name + ) AS responsible_name, + COALESCE(next_milestone.title, next_task.next_step) AS next_step, COUNT(DISTINCT m.id) FILTER ( WHERE m.status NOT IN ('completed', 'cancelled') ) AS active_milestones, @@ -490,10 +567,49 @@ class MissionService: AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') ) AS overdue_tasks FROM mission_projects p + LEFT JOIN users owner ON owner.user_id = p.created_by + LEFT JOIN LATERAL ( + SELECT COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name + FROM sag_sager s_owner + JOIN users u ON u.user_id = s_owner.ansvarlig_bruger_id + WHERE s_owner.project_id = p.id + AND s_owner.deleted_at IS NULL + AND LOWER(COALESCE(s_owner.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') + ORDER BY s_owner.deadline ASC NULLS LAST, s_owner.id ASC + LIMIT 1 + ) task_owner ON TRUE + LEFT JOIN LATERAL ( + SELECT mm.title + FROM mission_project_milestones mm + WHERE mm.project_id = p.id + AND mm.status NOT IN ('completed', 'cancelled') + ORDER BY mm.target_date ASC NULLS LAST, mm.id ASC + LIMIT 1 + ) next_milestone ON TRUE + LEFT JOIN LATERAL ( + SELECT COALESCE(todo.title, s_next.titel) AS next_step + FROM sag_sager s_next + LEFT JOIN LATERAL ( + SELECT t.title + FROM sag_todo_steps t + WHERE t.sag_id = s_next.id + AND t.deleted_at IS NULL + AND COALESCE(t.is_done, FALSE) = FALSE + ORDER BY COALESCE(t.is_next, FALSE) DESC, t.due_date ASC NULLS LAST, t.id ASC + LIMIT 1 + ) todo ON TRUE + WHERE s_next.project_id = p.id + AND s_next.deleted_at IS NULL + AND LOWER(COALESCE(s_next.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') + ORDER BY s_next.deadline ASC NULLS LAST, s_next.id ASC + LIMIT 1 + ) next_task ON TRUE LEFT JOIN mission_project_milestones m ON m.project_id = p.id LEFT JOIN mission_project_blockers b ON b.project_id = p.id LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL - GROUP BY p.id, p.name, p.description, p.status, p.score, p.started_at, p.ended_at, p.updated_at + GROUP BY + p.id, p.name, p.description, p.status, p.score, p.started_at, p.ended_at, p.updated_at, + owner.full_name, owner.username, task_owner.responsible_name, next_milestone.title, next_task.next_step ORDER BY p.updated_at DESC, p.id DESC LIMIT %s """, @@ -506,14 +622,11 @@ class MissionService: item.update(MissionService._compute_project_risk(item)) result.append(item) - # Important fallback: migration may have created mission_projects but with zero rows. - if not result: - return MissionService._get_projects_from_cases(limit) return result @staticmethod def get_project_detail(project_id: int) -> Optional[Dict[str, Any]]: - if not MissionService._table_exists("mission_projects"): + if not MissionService._table_exists("sag_sager"): return None rows = MissionService.get_projects(limit=200) @@ -556,15 +669,19 @@ class MissionService: s.titel, s.status, s.priority, + s.start_date, s.deadline, s.ansvarlig_bruger_id, + COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name, s.project_milestone_id, s.is_project_blocker, s.project_task_type, s.created_at, COALESCE(ts.open_todo_count, 0) AS open_todo_count, - COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles + COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles, + COALESCE(ts.open_todos, '[]'::jsonb) AS open_todos FROM sag_sager s + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER ( @@ -582,6 +699,24 @@ class MissionService: ), NULL ) AS open_todo_titles + , + COALESCE( + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'id', t.id, + 'title', t.title, + 'due_date', t.due_date, + 'is_next', COALESCE(t.is_next, FALSE) + ) + ORDER BY COALESCE(t.is_next, FALSE) DESC, + COALESCE(t.due_date, DATE '9999-12-31') ASC, + t.id ASC + ) FILTER ( + WHERE t.deleted_at IS NULL + AND COALESCE(t.is_done, FALSE) = FALSE + ), + '[]'::jsonb + ) AS open_todos FROM sag_todo_steps t WHERE t.sag_id = s.id ) ts ON TRUE @@ -626,8 +761,8 @@ class MissionService: if isinstance(titles_raw, list): project_open_todo_titles = [str(item).strip() for item in titles_raw if str(item or "").strip()] - # Fallback for case-backed projects: fetch directly related/under cases from relation table. - # This is used when a project is a case of type project/projekt and tasks are linked as case relations. + # Case-backed projects store their actual subcases as directed `undersag` + # relations. Other relation types must not appear as project subcases. if not tasks and MissionService._table_exists("sag_relationer"): tasks = execute_query( """ @@ -638,31 +773,27 @@ class MissionService: FROM sag_relationer sr WHERE sr.deleted_at IS NULL AND sr.kilde_sag_id = %s - - UNION ALL - - SELECT - sr.kilde_sag_id AS task_id, - sr.relationstype AS relation_type - FROM sag_relationer sr - WHERE sr.deleted_at IS NULL - AND sr.målsag_id = %s + AND LOWER(TRIM(sr.relationstype)) IN ('undersag', 'barn') ) SELECT s.id, s.titel, s.status, s.priority, + s.start_date, s.deadline, s.ansvarlig_bruger_id, + COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name, s.project_milestone_id, s.is_project_blocker, COALESCE(NULLIF(TRIM(s.project_task_type), ''), r.relation_type) AS project_task_type, s.created_at, COALESCE(ts.open_todo_count, 0) AS open_todo_count, - COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles + COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles, + COALESCE(ts.open_todos, '[]'::jsonb) AS open_todos FROM related r JOIN sag_sager s ON s.id = r.task_id + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER ( @@ -679,7 +810,24 @@ class MissionService: ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC ), NULL - ) AS open_todo_titles + ) AS open_todo_titles, + COALESCE( + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'id', t.id, + 'title', t.title, + 'due_date', t.due_date, + 'is_next', COALESCE(t.is_next, FALSE) + ) + ORDER BY COALESCE(t.is_next, FALSE) DESC, + COALESCE(t.due_date, DATE '9999-12-31') ASC, + t.id ASC + ) FILTER ( + WHERE t.deleted_at IS NULL + AND COALESCE(t.is_done, FALSE) = FALSE + ), + '[]'::jsonb + ) AS open_todos FROM sag_todo_steps t WHERE t.sag_id = s.id ) ts ON TRUE @@ -690,7 +838,7 @@ class MissionService: s.created_at DESC LIMIT 200 """, - (project_id, project_id, project_id), + (project_id, project_id), ) or [] return { diff --git a/app/dashboard/backend/router.py b/app/dashboard/backend/router.py index 67ed47e..221ed46 100644 --- a/app/dashboard/backend/router.py +++ b/app/dashboard/backend/router.py @@ -190,7 +190,16 @@ async def search_sag(q: str): s.status, s.created_at, s.customer_id, - c.name as customer_name + c.name as customer_name, + ARRAY( + SELECT b.word + FROM sag_buzzwords sb + JOIN buzzwords b ON b.id = sb.buzzword_id + WHERE sb.sag_id = s.id + AND sb.deleted_at IS NULL + AND b.deleted_at IS NULL + ORDER BY b.word + ) AS buzzwords FROM sag_sager s LEFT JOIN customers c ON s.customer_id = c.id WHERE s.deleted_at IS NULL diff --git a/app/dashboard/frontend/mission_control_v2.html b/app/dashboard/frontend/mission_control_v2.html index c18b8f6..b25bdfe 100644 --- a/app/dashboard/frontend/mission_control_v2.html +++ b/app/dashboard/frontend/mission_control_v2.html @@ -5,21 +5,27 @@ {% block extra_css %} {% endblock %} @@ -1048,10 +1752,22 @@
+
BMC Operations · Live command surface
{% if project_only %}Projekt View{% else %}Mission Control{% endif %}
{% if project_only %}Projektoversigt opdateres live{% else %}Forbinder...{% endif %}
-
Auto reset: 10s inaktivitet
+
+ +
+
--:--:--
+
Indlæser dato
+
+ +
{% if not project_only %} @@ -1059,23 +1775,59 @@ + Auto reset: 10s inaktivitet {% endif %}
{% if not project_only %} - - - + + + {% endif %} - + {% if not project_only %} - - + + {% endif %}
+ {% if not project_only %} +
+
+
Systemtilstand
+
Forbinder
+
Kontrollerer live services
+
+
+
Aktive opkald
+
0
+
Live fra telefonikøer
+
+
+
Uden ansvarlig
+
0
+
Kræver triage
+
+
+
Deadline-risiko
+
0
+
Overskredne sager
+
+
+
Datastrøm
+
Live
+
Venter på første signal
+
+
+
+
Live feed
+
Venter på hændelser fra Mission Control…
+
--:--
+
+ {% endif %} +
@@ -1128,15 +1880,15 @@
-

Projekt view

-
Viser projekter fra projects payload med risiko, status, workload og deadlines.
+

Projektportefølje

+
Alle aktive projektsager med periode, næste handling og tydeligt ejerskab.
-
+
Projekt
-
Risiko
+
Start / slut
+
Næste step
+
Ansvarlig
Status
-
Workload
-
Deadline
@@ -1236,6 +1988,24 @@
+
+
+
+

Opkaldshistorik

+
Seneste opkald og deres tilknyttede sag.
+
+ +
+
+
Opkald
+
Kontakt
+
Tid / varighed
+
Tilknyttet sag
+
+
+
@@ -1258,6 +2028,42 @@
+ + + + diff --git a/app/modules/locations/backend/router.py b/app/modules/locations/backend/router.py index 5bdc6be..669e70f 100644 --- a/app/modules/locations/backend/router.py +++ b/app/modules/locations/backend/router.py @@ -866,9 +866,9 @@ async def create_wall_outlet(data: WallOutletCreate): try: rows = execute_query( """INSERT INTO locations_wall_outlets - (location_id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id, switch_hardware_id, switch_name, switch_port, status, notes, is_active) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", - (data.location_id, (data.outlet_number or '').strip() or None, data.customer_id, data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_hardware_id, data.switch_name, data.switch_port, data.status, data.notes, data.is_active), + (location_id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id, switch_hardware_id, switch_name, switch_port, is_wan, status, notes, is_active) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", + (data.location_id, (data.outlet_number or '').strip() or None, data.customer_id, data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_hardware_id, data.switch_name, data.switch_port, data.is_wan, data.status, data.notes, data.is_active), ) or [] except Exception as exc: if 'unique' in str(exc).lower(): diff --git a/app/modules/locations/frontend/views.py b/app/modules/locations/frontend/views.py index 8dadf0e..392042a 100644 --- a/app/modules/locations/frontend/views.py +++ b/app/modules/locations/frontend/views.py @@ -659,7 +659,8 @@ def detail_location_view(id: int = Path(..., gt=0)): wall_outlets = execute_query( """ - SELECT id, outlet_number, customer_id, category, patch_panel, patch_port, switch_hardware_id, switch_name, switch_port, status, notes, is_active + SELECT id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id, + switch_hardware_id, switch_name, switch_port, is_wan, status, notes, is_active FROM locations_wall_outlets WHERE location_id = %s AND deleted_at IS NULL ORDER BY outlet_number @@ -780,7 +781,9 @@ def detail_location_view(id: int = Path(..., gt=0)): for cross_field in cross_fields or []: cross_field["ports"] = execute_query( """SELECT p.id, p.port_number, p.port_order, p.is_active, - o.id AS outlet_id, o.outlet_number, o.status AS outlet_status, + o.id AS outlet_id, o.outlet_number, o.customer_id, o.category, + o.patch_panel, o.patch_port, o.switch_hardware_id, o.switch_name, o.switch_port, + o.status AS outlet_status, o.notes AS outlet_notes, o.is_wan, l.name AS outlet_location_name FROM locations_cross_field_ports p LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL @@ -788,6 +791,17 @@ def detail_location_view(id: int = Path(..., gt=0)): WHERE p.cross_field_id = %s ORDER BY p.port_order""", (cross_field["id"],), ) or [] + for port in cross_field["ports"]: + linked_uisp = uisp_by_hardware_id.get(port.get("switch_hardware_id")) or {} + port["switch_live"] = (linked_uisp.get("live_ports") or {}).get(str(port.get("switch_port"))) + if port.get("is_wan"): + port["smart_state"] = "wan" + elif port.get("outlet_id") and port.get("switch_live") and not port["switch_live"].get("plugged"): + port["smart_state"] = "issue" + elif port.get("outlet_id"): + port["smart_state"] = "assigned" + else: + port["smart_state"] = "free" audit_log = execute_query( """ diff --git a/app/modules/locations/models/schemas.py b/app/modules/locations/models/schemas.py index 37e2e26..a8a71fc 100644 --- a/app/modules/locations/models/schemas.py +++ b/app/modules/locations/models/schemas.py @@ -121,6 +121,7 @@ class WallOutletBase(BaseModel): switch_hardware_id: Optional[int] = Field(None, ge=1) switch_name: Optional[str] = Field(None, max_length=255) switch_port: Optional[str] = Field(None, max_length=100) + is_wan: bool = False status: str = Field('unknown') notes: Optional[str] = None is_active: bool = True @@ -147,6 +148,7 @@ class WallOutletUpdate(BaseModel): switch_hardware_id: Optional[int] = Field(None, ge=1) switch_name: Optional[str] = Field(None, max_length=255) switch_port: Optional[str] = Field(None, max_length=100) + is_wan: Optional[bool] = None status: Optional[str] = None notes: Optional[str] = None is_active: Optional[bool] = None diff --git a/app/modules/locations/templates/detail.html b/app/modules/locations/templates/detail.html index b626f62..2dca007 100644 --- a/app/modules/locations/templates/detail.html +++ b/app/modules/locations/templates/detail.html @@ -7,9 +7,16 @@ .patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); } .patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; } .patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; } - button.patch-port:not(.assigned):hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; } + button.patch-port:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; } .patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; } .patch-port.hardware-linked { background: #6f42c1; border-color: #59359f; color:#fff; } + .patch-port.wan { background: #0dcaf0; border-color: #087990; color:#052c34; box-shadow: inset 0 0 0 2px rgba(255,255,255,.45); } + .patch-port.smart-issue { background:#ffc107; border-color:#b58105; color:#332701; animation:smart-port-pulse 1.8s ease-in-out infinite; } + .patch-port.unconfigured { background:#52677d; border-color:#34495e; color:#fff; } + .patch-port.border-success { border-width:4px !important; border-color:#20c997 !important; box-shadow:0 0 0 1px rgba(255,255,255,.55); } + .patch-port.border-danger { border-width:4px !important; border-color:#ff5c6c !important; box-shadow:0 0 0 1px rgba(255,255,255,.55); } + .smart-port-hidden { display:none !important; } + @keyframes smart-port-pulse { 50% { box-shadow:0 0 0 3px rgba(255,193,7,.3); } } .patch-port.reserved { background: #ffc107; border-color: #d39e00; color:#332701; } .patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; } .patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; } @@ -810,7 +817,7 @@ {% elif location.wall_outlets %}
{% for outlet in location.wall_outlets %} - + {% endfor %}
StikStatusPatchpanelSwitch
{{ outlet.outlet_number or 'Ikke navngivet' }}{% if outlet.category %}
{{ outlet.category }}
{% endif %}
{{ outlet.status }}{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}
{{ outlet.outlet_number or 'Ikke navngivet' }}{% if outlet.is_wan %} WAN{% endif %}{% if outlet.category %}
{{ outlet.category }}
{% endif %}
{{ outlet.status }}{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}
{% else %}Ingen vægstik registreret endnu.{% endif %} @@ -865,9 +872,22 @@
+
+
+ Vis porte: +
+ + + + +
+ Grøn = aktiv · Gul = fejl · Turkis = WAN · Lilla = hardware · Blågrå = ikke konfigureret +
+ +
{% for field in location.cross_fields %}
{{ field.name }} Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}
-
{% for port in field.ports %}{% set port_class = 'assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else '') %}{% endfor %}
+
{% for port in field.ports %}{% set port_class = 'wan' if port.is_wan else ('smart-issue' if port.smart_state == 'issue' else ('assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else 'unconfigured'))) %}{% endfor %}
{% else %}Ingen krydsfelter oprettet endnu.{% endfor %}
@@ -887,7 +907,11 @@
{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}
-
Rækkefølge
{{ hw.status }}
+
+ Nr. {{ hw.location_display_order or loop.index }} +
Rækkefølge
+ {{ hw.status }} +
{% if hw.uisp_device %} {% set uisp = hw.uisp_device %} @@ -895,6 +919,7 @@
UISP live-data
{% if uisp.device_link %}Åbn i UISP{% endif %}
+
Hostnavn{{ uisp.hostname or uisp.display_name or uisp.name or '—' }}
Status{{ uisp.status or 'Ukendt' }}
IP-adresser{{ (uisp.ip_addresses or []) | join(', ') or '—' }}
MAC{{ uisp.mac_address or '—' }}
@@ -909,7 +934,21 @@
{% endif %} {% if hw.switch_ports %} -
Switch-porte ({{ hw.switch_ports | length }})
Grøn/rød kant viser live linkstatus fra UISP.
+
Switch-porte ({{ hw.switch_ports | length }})
Klik på en switch-port for at oprette eller redigere tilknytningen. Grøn/rød kant viser live linkstatus fra UISP.
{% endif %}
{% endfor %} @@ -1092,20 +1131,42 @@ + +