feat: Add end-to-end tests for Sag module HTTP API
- Implemented a comprehensive end-to-end testing script for the Sag module's HTTP API, covering various functionalities including case creation, updates, and file uploads. - Introduced a safe HTML sanitizer utility to ensure safe rendering of HTML content in the BMC Hub UI. - Added database migrations for new features including WAN connection marking for wall outlets, permanent audit trails for supplier invoices, and dedicated permissions for the Sag module. - Created a migration center for manual subscription and invoice migrations with relevant tables and indexes. - Added tests for migration center functionalities, ensuring stability and correctness of the new features.
This commit is contained in:
parent
428ad21132
commit
d81a8f41b4
@ -2,6 +2,7 @@
|
|||||||
# POSTGRESQL DATABASE - Local Development
|
# POSTGRESQL DATABASE - Local Development
|
||||||
# =====================================================
|
# =====================================================
|
||||||
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
|
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
|
||||||
|
HUB_BASE_URL=https://hub.bmcnetworks.dk
|
||||||
|
|
||||||
# Database credentials (bruges af docker-compose)
|
# Database credentials (bruges af docker-compose)
|
||||||
POSTGRES_USER=bmc_hub
|
POSTGRES_USER=bmc_hub
|
||||||
|
|||||||
@ -24,6 +24,7 @@ GITHUB_REPO=ct/bmc_hub
|
|||||||
# POSTGRESQL DATABASE - Production
|
# POSTGRESQL DATABASE - Production
|
||||||
# =====================================================
|
# =====================================================
|
||||||
DATABASE_URL=postgresql://bmc_hub_prod:CHANGE_THIS_PASSWORD@postgres:5432/bmc_hub_prod
|
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)
|
# Database credentials (bruges af docker-compose/podman-compose)
|
||||||
POSTGRES_USER=bmc_hub_prod
|
POSTGRES_USER=bmc_hub_prod
|
||||||
|
|||||||
@ -5,6 +5,7 @@ Sends rich formatted notifications to Mattermost webhook
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Optional, List
|
from typing import Dict, Optional, List
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@ -346,7 +347,7 @@ class MattermostNotification:
|
|||||||
"""
|
"""
|
||||||
if not self.enabled or not self.webhook_url:
|
if not self.enabled or not self.webhook_url:
|
||||||
logger.info("📢 Notification (disabled): %s - job_id=%s", event_type, job_id)
|
logger.info("📢 Notification (disabled): %s - job_id=%s", event_type, job_id)
|
||||||
return
|
return False, "Mattermost is disabled or webhook URL is missing"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
@ -355,21 +356,63 @@ class MattermostNotification:
|
|||||||
logger.info("📢 Notification sent: %s - job_id=%s", event_type, job_id)
|
logger.info("📢 Notification sent: %s - job_id=%s", event_type, job_id)
|
||||||
|
|
||||||
# Log to database
|
# Log to database
|
||||||
execute_insert(
|
if job_id is not None:
|
||||||
"""INSERT INTO backup_notifications
|
execute_insert(
|
||||||
(backup_job_id, event_type, message, mattermost_payload)
|
"""INSERT INTO backup_notifications
|
||||||
VALUES (%s, %s, %s, %s)""",
|
(backup_job_id, event_type, message, mattermost_payload)
|
||||||
(job_id, event_type, payload.get('text', ''), str(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:
|
else:
|
||||||
error_text = await response.text()
|
error_text = await response.text()
|
||||||
logger.error("❌ Notification failed: HTTP %s - %s",
|
should_retry_form = (
|
||||||
response.status, error_text)
|
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:
|
except aiohttp.ClientError as e:
|
||||||
logger.error("❌ Notification connection error: %s", str(e))
|
logger.error("❌ Notification connection error: %s", str(e))
|
||||||
|
return False, f"Mattermost connection error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Notification error: %s", str(e))
|
logger.error("❌ Notification error: %s", str(e))
|
||||||
|
return False, f"Mattermost notification error: {e}"
|
||||||
|
|
||||||
def _should_send_notification(self, event_type: str) -> bool:
|
def _should_send_notification(self, event_type: str) -> bool:
|
||||||
"""Check if notification should be sent based on settings"""
|
"""Check if notification should be sent based on settings"""
|
||||||
@ -386,8 +429,7 @@ class MattermostNotification:
|
|||||||
|
|
||||||
def _get_hub_url(self) -> str:
|
def _get_hub_url(self) -> str:
|
||||||
"""Get BMC Hub base URL for action buttons"""
|
"""Get BMC Hub base URL for action buttons"""
|
||||||
# TODO: Add HUB_BASE_URL to config
|
return str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").strip().rstrip("/")
|
||||||
return "http://localhost:8000" # Fallback
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
|
|||||||
@ -25,6 +25,17 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_PURCHASE_CASE_TYPE = "indkøb"
|
_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")
|
SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist")
|
||||||
|
|
||||||
@ -256,7 +267,15 @@ def _ensure_internet_change_case(
|
|||||||
owner_customer_id: Optional[int],
|
owner_customer_id: Optional[int],
|
||||||
changes: Dict[str, Dict[str, object]],
|
changes: Dict[str, Dict[str, object]],
|
||||||
) -> Optional[int]:
|
) -> 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
|
return None
|
||||||
|
|
||||||
title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}"
|
title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}"
|
||||||
@ -287,7 +306,7 @@ def _ensure_internet_change_case(
|
|||||||
return None
|
return None
|
||||||
change_lines = "\n".join(
|
change_lines = "\n".join(
|
||||||
f"- {field}: {change.get('from')} -> {change.get('to')}"
|
f"- {field}: {change.get('from')} -> {change.get('to')}"
|
||||||
for field, change in changes.items()
|
for field, change in relevant_changes.items()
|
||||||
)
|
)
|
||||||
description = (
|
description = (
|
||||||
"Automatisk oprettet ved import af internetfaktura.\n"
|
"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)
|
normalized_target = _normalize_company_name(end_customer_name)
|
||||||
address_parts = [part.strip().upper() for part in re.split(r"[, ]+", service_address) if part.strip()]
|
address_parts = [part.strip().upper() for part in re.split(r"[, ]+", service_address) if part.strip()]
|
||||||
|
|
||||||
best_match = None
|
best_matches = []
|
||||||
best_score = 0
|
best_score = 0
|
||||||
for customer in customers:
|
for customer in customers:
|
||||||
customer_name = str(customer.get("name") or "").strip()
|
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:
|
if score > best_score:
|
||||||
best_score = 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:
|
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]
|
primary_line = sorted_lines[0]
|
||||||
display_reference = str(primary_line.get("provider_reference") or primary_line.get("circuit_id") or reference).strip()
|
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)
|
service_address = _build_service_address(primary_line)
|
||||||
if not service_address:
|
if not service_address:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@ -1175,9 +1201,40 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
|||||||
invoice_number,
|
invoice_number,
|
||||||
)
|
)
|
||||||
return None
|
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)
|
description = str(primary_line.get("description") or reference)
|
||||||
end_customer_name = str(primary_line.get("end_customer_name") or "").strip()
|
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
|
owner_customer = matched_customer or internal_owner
|
||||||
is_confident = _has_confident_globalconnect_mapping(matched_customer, service_address)
|
is_confident = _has_confident_globalconnect_mapping(matched_customer, service_address)
|
||||||
connection_name = (
|
connection_name = (
|
||||||
@ -1194,21 +1251,6 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
|||||||
else:
|
else:
|
||||||
note_text = base_note if is_confident else f"{base_note} {mapping_note}"
|
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)
|
download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description)
|
||||||
target_status = "active" if (is_confident or internal_owner) else "pending"
|
target_status = "active" if (is_confident or internal_owner) else "pending"
|
||||||
shared_value_type = _shared_connection_value_type(internal_owner, matched_customer)
|
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:
|
if not row:
|
||||||
return False
|
return False
|
||||||
if str(row.get("allocation_model") or "").lower() == "shared":
|
|
||||||
return True
|
|
||||||
if not service_address:
|
if not service_address:
|
||||||
return True
|
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)
|
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:
|
def _connection_skip_reason(line: Dict) -> str:
|
||||||
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
||||||
if not reference:
|
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:
|
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"}]
|
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"]
|
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):
|
if not _is_globalconnect_extraction(extraction_row):
|
||||||
return {"skipped": True, "reason": "not_globalconnect"}
|
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:
|
if not lines:
|
||||||
return {"skipped": True, "reason": "no_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:
|
for audit_index, line in ip_range_lines:
|
||||||
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
||||||
service_address = _build_service_address(line)
|
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
|
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):
|
if connection_id and not _connection_can_host_ip_range(connection_id, service_address):
|
||||||
connection_id = None
|
connection_id = None
|
||||||
if not connection_id and reference:
|
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
|
line_audit[audit_index]["reason"] = connection_conflict_reason
|
||||||
continue
|
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 (
|
if connection_id and (
|
||||||
simulate
|
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
|
created_or_updated_ranges += 1
|
||||||
if resolved_from_existing:
|
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]:
|
def _find_existing_product_id(vendor_id: Optional[int], description: str, sku: Optional[str]) -> Optional[int]:
|
||||||
sku_value = str(sku or "").strip()
|
sku_value = str(sku or "").strip()
|
||||||
desc_value = str(description or "").strip()
|
desc_value = str(description or "").strip()
|
||||||
@ -3078,7 +3354,7 @@ async def sync_extraction_to_internet(file_id: int):
|
|||||||
if not extraction:
|
if not extraction:
|
||||||
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
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 {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
@ -4813,6 +5089,17 @@ async def reprocess_uploaded_file(file_id: int):
|
|||||||
if vendor:
|
if vendor:
|
||||||
result["warning"] = f"⚠️ Ingen template fundet for {vendor['name']} - brugte AI extraction (langsommere)"
|
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
|
return result
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class Settings(BaseSettings):
|
|||||||
API_PORT: int = 8000
|
API_PORT: int = 8000
|
||||||
API_RELOAD: bool = False
|
API_RELOAD: bool = False
|
||||||
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
|
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
|
||||||
|
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
|
||||||
|
|
||||||
# Elnet supplier lookup
|
# Elnet supplier lookup
|
||||||
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
|
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
|
||||||
@ -68,6 +69,12 @@ class Settings(BaseSettings):
|
|||||||
ECONOMIC_READ_ONLY: bool = True
|
ECONOMIC_READ_ONLY: bool = True
|
||||||
ECONOMIC_DRY_RUN: 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 Integration
|
||||||
NEXTCLOUD_READ_ONLY: bool = True
|
NEXTCLOUD_READ_ONLY: bool = True
|
||||||
NEXTCLOUD_DRY_RUN: bool = True
|
NEXTCLOUD_DRY_RUN: bool = True
|
||||||
|
|||||||
@ -121,6 +121,10 @@ class MissionProjectLinkCasePayload(BaseModel):
|
|||||||
project_task_type: Optional[str] = None
|
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]:
|
def _first_query_param(request: Request, *names: str) -> Optional[str]:
|
||||||
for name in names:
|
for name in names:
|
||||||
value = request.query_params.get(name)
|
value = request.query_params.get(name)
|
||||||
@ -351,6 +355,66 @@ async def get_mission_state():
|
|||||||
return MissionService.get_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")
|
@router.get("/mission/projects")
|
||||||
async def get_mission_projects(limit: int = Query(120, ge=1, le=500)):
|
async def get_mission_projects(limit: int = Query(120, ge=1, le=500)):
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -224,6 +224,52 @@ class MissionService:
|
|||||||
)
|
)
|
||||||
return rows or []
|
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
|
@staticmethod
|
||||||
def get_active_alerts() -> list[Dict[str, Any]]:
|
def get_active_alerts() -> list[Dict[str, Any]]:
|
||||||
if not MissionService._table_exists("mission_uptime_alerts"):
|
if not MissionService._table_exists("mission_uptime_alerts"):
|
||||||
@ -412,6 +458,13 @@ class MissionService:
|
|||||||
0 AS score,
|
0 AS score,
|
||||||
s.start_date AS started_at,
|
s.start_date AS started_at,
|
||||||
s.deadline AS ended_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,
|
s.created_at AS updated_at,
|
||||||
0 AS active_milestones,
|
0 AS active_milestones,
|
||||||
0 AS overdue_milestones,
|
0 AS overdue_milestones,
|
||||||
@ -426,6 +479,17 @@ class MissionService:
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END AS overdue_tasks
|
END AS overdue_tasks
|
||||||
FROM sag_sager s
|
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
|
WHERE s.deleted_at IS NULL
|
||||||
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
||||||
AND (
|
AND (
|
||||||
@ -448,8 +512,15 @@ class MissionService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_projects(limit: int = 120) -> list[Dict[str, Any]]:
|
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"):
|
if not MissionService._table_exists("mission_projects"):
|
||||||
return MissionService._get_projects_from_cases(limit)
|
return []
|
||||||
|
|
||||||
rows = execute_query(
|
rows = execute_query(
|
||||||
"""
|
"""
|
||||||
@ -462,6 +533,12 @@ class MissionService:
|
|||||||
p.started_at,
|
p.started_at,
|
||||||
p.ended_at,
|
p.ended_at,
|
||||||
p.updated_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 (
|
COUNT(DISTINCT m.id) FILTER (
|
||||||
WHERE m.status NOT IN ('completed', 'cancelled')
|
WHERE m.status NOT IN ('completed', 'cancelled')
|
||||||
) AS active_milestones,
|
) AS active_milestones,
|
||||||
@ -490,10 +567,49 @@ class MissionService:
|
|||||||
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
||||||
) AS overdue_tasks
|
) AS overdue_tasks
|
||||||
FROM mission_projects p
|
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_milestones m ON m.project_id = p.id
|
||||||
LEFT JOIN mission_project_blockers b ON b.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
|
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
|
ORDER BY p.updated_at DESC, p.id DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
@ -506,14 +622,11 @@ class MissionService:
|
|||||||
item.update(MissionService._compute_project_risk(item))
|
item.update(MissionService._compute_project_risk(item))
|
||||||
result.append(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
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_project_detail(project_id: int) -> Optional[Dict[str, Any]]:
|
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
|
return None
|
||||||
|
|
||||||
rows = MissionService.get_projects(limit=200)
|
rows = MissionService.get_projects(limit=200)
|
||||||
@ -556,15 +669,19 @@ class MissionService:
|
|||||||
s.titel,
|
s.titel,
|
||||||
s.status,
|
s.status,
|
||||||
s.priority,
|
s.priority,
|
||||||
|
s.start_date,
|
||||||
s.deadline,
|
s.deadline,
|
||||||
s.ansvarlig_bruger_id,
|
s.ansvarlig_bruger_id,
|
||||||
|
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name,
|
||||||
s.project_milestone_id,
|
s.project_milestone_id,
|
||||||
s.is_project_blocker,
|
s.is_project_blocker,
|
||||||
s.project_task_type,
|
s.project_task_type,
|
||||||
s.created_at,
|
s.created_at,
|
||||||
COALESCE(ts.open_todo_count, 0) AS open_todo_count,
|
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
|
FROM sag_sager s
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) FILTER (
|
COUNT(*) FILTER (
|
||||||
@ -582,6 +699,24 @@ class MissionService:
|
|||||||
),
|
),
|
||||||
NULL
|
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
|
FROM sag_todo_steps t
|
||||||
WHERE t.sag_id = s.id
|
WHERE t.sag_id = s.id
|
||||||
) ts ON TRUE
|
) ts ON TRUE
|
||||||
@ -626,8 +761,8 @@ class MissionService:
|
|||||||
if isinstance(titles_raw, list):
|
if isinstance(titles_raw, list):
|
||||||
project_open_todo_titles = [str(item).strip() for item in titles_raw if str(item or "").strip()]
|
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.
|
# Case-backed projects store their actual subcases as directed `undersag`
|
||||||
# This is used when a project is a case of type project/projekt and tasks are linked as case relations.
|
# relations. Other relation types must not appear as project subcases.
|
||||||
if not tasks and MissionService._table_exists("sag_relationer"):
|
if not tasks and MissionService._table_exists("sag_relationer"):
|
||||||
tasks = execute_query(
|
tasks = execute_query(
|
||||||
"""
|
"""
|
||||||
@ -638,31 +773,27 @@ class MissionService:
|
|||||||
FROM sag_relationer sr
|
FROM sag_relationer sr
|
||||||
WHERE sr.deleted_at IS NULL
|
WHERE sr.deleted_at IS NULL
|
||||||
AND sr.kilde_sag_id = %s
|
AND sr.kilde_sag_id = %s
|
||||||
|
AND LOWER(TRIM(sr.relationstype)) IN ('undersag', 'barn')
|
||||||
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
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
s.id,
|
s.id,
|
||||||
s.titel,
|
s.titel,
|
||||||
s.status,
|
s.status,
|
||||||
s.priority,
|
s.priority,
|
||||||
|
s.start_date,
|
||||||
s.deadline,
|
s.deadline,
|
||||||
s.ansvarlig_bruger_id,
|
s.ansvarlig_bruger_id,
|
||||||
|
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name,
|
||||||
s.project_milestone_id,
|
s.project_milestone_id,
|
||||||
s.is_project_blocker,
|
s.is_project_blocker,
|
||||||
COALESCE(NULLIF(TRIM(s.project_task_type), ''), r.relation_type) AS project_task_type,
|
COALESCE(NULLIF(TRIM(s.project_task_type), ''), r.relation_type) AS project_task_type,
|
||||||
s.created_at,
|
s.created_at,
|
||||||
COALESCE(ts.open_todo_count, 0) AS open_todo_count,
|
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
|
FROM related r
|
||||||
JOIN sag_sager s ON s.id = r.task_id
|
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 (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) FILTER (
|
COUNT(*) FILTER (
|
||||||
@ -679,7 +810,24 @@ class MissionService:
|
|||||||
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
|
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
|
||||||
),
|
),
|
||||||
NULL
|
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
|
FROM sag_todo_steps t
|
||||||
WHERE t.sag_id = s.id
|
WHERE t.sag_id = s.id
|
||||||
) ts ON TRUE
|
) ts ON TRUE
|
||||||
@ -690,7 +838,7 @@ class MissionService:
|
|||||||
s.created_at DESC
|
s.created_at DESC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
""",
|
""",
|
||||||
(project_id, project_id, project_id),
|
(project_id, project_id),
|
||||||
) or []
|
) or []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -190,7 +190,16 @@ async def search_sag(q: str):
|
|||||||
s.status,
|
s.status,
|
||||||
s.created_at,
|
s.created_at,
|
||||||
s.customer_id,
|
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
|
FROM sag_sager s
|
||||||
LEFT JOIN customers c ON s.customer_id = c.id
|
LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
WHERE s.deleted_at IS NULL
|
WHERE s.deleted_at IS NULL
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -9,12 +9,11 @@ from typing import List, Optional, Dict, Any
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import html
|
|
||||||
import re
|
import re
|
||||||
from html.parser import HTMLParser
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||||
|
from app.utils.safe_html import sanitize_safe_html
|
||||||
from app.services.email_processor_service import EmailProcessorService
|
from app.services.email_processor_service import EmailProcessorService
|
||||||
from app.services.email_workflow_service import email_workflow_service
|
from app.services.email_workflow_service import email_workflow_service
|
||||||
from app.services.ollama_service import ollama_service
|
from app.services.ollama_service import ollama_service
|
||||||
@ -130,84 +129,8 @@ def _is_supplier_case_type(case_type: Optional[str]) -> bool:
|
|||||||
return "indk" in value or "leverand" in value or "supplier" in value
|
return "indk" in value or "leverand" in value or "supplier" in value
|
||||||
|
|
||||||
|
|
||||||
class _SafeDescriptionHtmlSanitizer(HTMLParser):
|
|
||||||
"""Allow-list HTML sanitizer for case descriptions created from email content."""
|
|
||||||
|
|
||||||
_ALLOWED_TAGS = {
|
|
||||||
"b", "strong", "i", "em", "u",
|
|
||||||
"p", "br", "hr",
|
|
||||||
"ul", "ol", "li",
|
|
||||||
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption",
|
|
||||||
"a",
|
|
||||||
}
|
|
||||||
_VOID_TAGS = {"br", "hr"}
|
|
||||||
_ALLOWED_ATTRS = {
|
|
||||||
"a": {"href", "title", "target", "rel"},
|
|
||||||
"th": {"colspan", "rowspan"},
|
|
||||||
"td": {"colspan", "rowspan"},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(convert_charrefs=True)
|
|
||||||
self._parts: List[str] = []
|
|
||||||
|
|
||||||
def handle_starttag(self, tag, attrs):
|
|
||||||
tag = (tag or "").lower()
|
|
||||||
if tag not in self._ALLOWED_TAGS:
|
|
||||||
return
|
|
||||||
|
|
||||||
attr_allow = self._ALLOWED_ATTRS.get(tag, set())
|
|
||||||
safe_attrs = []
|
|
||||||
for key, value in attrs or []:
|
|
||||||
key_l = str(key or "").lower()
|
|
||||||
if key_l not in attr_allow:
|
|
||||||
continue
|
|
||||||
safe_value = str(value or "").strip()
|
|
||||||
if key_l == "href":
|
|
||||||
href_l = safe_value.lower()
|
|
||||||
if href_l.startswith(("javascript:", "data:", "vbscript:")):
|
|
||||||
continue
|
|
||||||
safe_attrs.append(f'{key_l}="{html.escape(safe_value, quote=True)}"')
|
|
||||||
|
|
||||||
attrs_html = (" " + " ".join(safe_attrs)) if safe_attrs else ""
|
|
||||||
self._parts.append(f"<{tag}{attrs_html}>")
|
|
||||||
|
|
||||||
def handle_endtag(self, tag):
|
|
||||||
tag = (tag or "").lower()
|
|
||||||
if tag not in self._ALLOWED_TAGS or tag in self._VOID_TAGS:
|
|
||||||
return
|
|
||||||
self._parts.append(f"</{tag}>")
|
|
||||||
|
|
||||||
def handle_data(self, data):
|
|
||||||
self._parts.append(html.escape(data or ""))
|
|
||||||
|
|
||||||
def handle_entityref(self, name):
|
|
||||||
self._parts.append(f"&{name};")
|
|
||||||
|
|
||||||
def handle_charref(self, name):
|
|
||||||
self._parts.append(f"&#{name};")
|
|
||||||
|
|
||||||
def get_html(self) -> str:
|
|
||||||
return "".join(self._parts).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_case_description_html(value: Optional[str]) -> str:
|
def _sanitize_case_description_html(value: Optional[str]) -> str:
|
||||||
raw = str(value or "").strip()
|
return sanitize_safe_html(value)
|
||||||
if not raw:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# Fast path: plain text stays plain text.
|
|
||||||
if "<" not in raw and ">" not in raw:
|
|
||||||
return raw
|
|
||||||
|
|
||||||
sanitizer = _SafeDescriptionHtmlSanitizer()
|
|
||||||
try:
|
|
||||||
sanitizer.feed(raw)
|
|
||||||
sanitizer.close()
|
|
||||||
return sanitizer.get_html()
|
|
||||||
except Exception:
|
|
||||||
# Fallback to escaped text if parsing fails.
|
|
||||||
return html.escape(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_domain_from_email(email: Optional[str]) -> str:
|
def _extract_domain_from_email(email: Optional[str]) -> str:
|
||||||
|
|||||||
@ -1008,14 +1008,19 @@
|
|||||||
|
|
||||||
/* Email Upload Drop Zone */
|
/* Email Upload Drop Zone */
|
||||||
.email-upload-zone {
|
.email-upload-zone {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.4rem 0.65rem;
|
||||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone {
|
.drop-zone {
|
||||||
border: 2px dashed var(--accent);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem 0.45rem;
|
||||||
|
border: 1px dashed var(--accent);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
padding: 1.5rem 1rem;
|
padding: 0.48rem 0.65rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: rgba(15, 76, 117, 0.03);
|
background: rgba(15, 76, 117, 0.03);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -1036,15 +1041,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone i {
|
.drop-zone i {
|
||||||
font-size: 2rem;
|
font-size: 1.05rem;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
display: block;
|
display: inline-block;
|
||||||
margin-bottom: 0.5rem;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone p {
|
.drop-zone p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone small {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone .d-block {
|
||||||
|
display: inline !important;
|
||||||
|
margin-top: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-progress {
|
.upload-progress {
|
||||||
@ -1122,7 +1136,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email Upload Drop Zone -->
|
<!-- Email Upload Drop Zone -->
|
||||||
<div class="email-upload-zone" id="emailUploadZone" style="display: none;">
|
<div class="email-upload-zone" id="emailUploadZone">
|
||||||
<div class="drop-zone" id="dropZone">
|
<div class="drop-zone" id="dropZone">
|
||||||
<i class="bi bi-cloud-upload"></i>
|
<i class="bi bi-cloud-upload"></i>
|
||||||
<p class="mb-1"><strong>Træk emails hertil</strong></p>
|
<p class="mb-1"><strong>Træk emails hertil</strong></p>
|
||||||
@ -1137,11 +1151,6 @@
|
|||||||
<small class="text-muted d-block text-center mt-1" id="uploadStatus">Uploader...</small>
|
<small class="text-muted d-block text-center mt-1" id="uploadStatus">Uploader...</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 border-bottom">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary w-100" onclick="toggleUploadZone()">
|
|
||||||
<i class="bi bi-upload me-1"></i> Upload Emails
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="email-list-filters">
|
<div class="email-list-filters">
|
||||||
<button class="filter-pill active" data-filter="active" onclick="setFilter('active')">
|
<button class="filter-pill active" data-filter="active" onclick="setFilter('active')">
|
||||||
@ -5278,16 +5287,6 @@ function showNotification(message, type = 'info') {
|
|||||||
setTimeout(() => toast.remove(), 3000);
|
setTimeout(() => toast.remove(), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email Upload Functionality
|
|
||||||
function toggleUploadZone() {
|
|
||||||
const uploadZone = document.getElementById('emailUploadZone');
|
|
||||||
if (uploadZone.style.display === 'none') {
|
|
||||||
uploadZone.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
uploadZone.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup drag and drop
|
// Setup drag and drop
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const dropZone = document.getElementById('dropZone');
|
const dropZone = document.getElementById('dropZone');
|
||||||
@ -5398,7 +5397,6 @@ async function uploadEmailFiles(files) {
|
|||||||
console.log('✅ Email list and stats reloaded');
|
console.log('✅ Email list and stats reloaded');
|
||||||
uploadProgress.style.display = 'none';
|
uploadProgress.style.display = 'none';
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
toggleUploadZone(); // Close upload zone after successful upload
|
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
console.log('ℹ️ No new emails uploaded, not reloading');
|
console.log('ℹ️ No new emails uploaded, not reloading');
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
|
||||||
{% block title %}Email v2 - BMC Hub{% endblock %}
|
{% block title %}Email - BMC Hub{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@ -47,6 +47,45 @@
|
|||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emails-quick-upload {
|
||||||
|
margin: 0.65rem 0.9rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px dashed color-mix(in srgb, var(--accent, #0f4c75) 45%, var(--border-color));
|
||||||
|
border-radius: 9px;
|
||||||
|
background: color-mix(in srgb, var(--accent, #0f4c75) 4%, var(--bg-card));
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emails-quick-upload:hover,
|
||||||
|
.emails-quick-upload.dragover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent, #0f4c75) 9%, var(--bg-card));
|
||||||
|
}
|
||||||
|
|
||||||
|
.emails-quick-upload.busy {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emails-primary-action {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent, #0f4c75) 35%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
|
||||||
|
}
|
||||||
|
|
||||||
|
.emails-shortcuts {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emails-shortcuts kbd {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
.emails-v2-filters {
|
.emails-v2-filters {
|
||||||
padding: 0.65rem 0.9rem;
|
padding: 0.65rem 0.9rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
@ -357,19 +396,25 @@
|
|||||||
<div class="emails-v2-shell">
|
<div class="emails-v2-shell">
|
||||||
<section class="emails-v2-panel">
|
<section class="emails-v2-panel">
|
||||||
<div class="emails-v2-header">
|
<div class="emails-v2-header">
|
||||||
<h1 class="emails-v2-title">Email v2</h1>
|
<h1 class="emails-v2-title"><i class="bi bi-inbox me-2"></i>Email</h1>
|
||||||
<div class="emails-v2-version-nav">
|
<button id="v2Refresh" class="btn btn-sm btn-outline-primary" title="Opdater (R)">
|
||||||
<a href="/emails/v1" class="btn btn-sm btn-outline-secondary">Gå til v1</a>
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
<a href="/emails/v2" class="btn btn-sm btn-primary" aria-current="page">v2</a>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="emails-v2-search">
|
<div class="emails-v2-search">
|
||||||
<input id="v2Search" class="form-control form-control-sm" placeholder="Søg afsender eller emne...">
|
<input id="v2Search" class="form-control form-control-sm" placeholder="Søg i mails… (tryk /)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="v2Filters" class="emails-v2-filters"></div>
|
<div id="v2Filters" class="emails-v2-filters"></div>
|
||||||
|
|
||||||
|
<div class="emails-quick-upload" id="v2UploadZone" role="button" tabindex="0">
|
||||||
|
<span id="v2UploadLabel">
|
||||||
|
<i class="bi bi-paperclip me-1"></i>Træk .eml/.msg hertil eller klik
|
||||||
|
</span>
|
||||||
|
<input id="v2UploadInput" type="file" accept=".eml,.msg" multiple hidden>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="v2List" class="emails-v2-list"></div>
|
<div id="v2List" class="emails-v2-list"></div>
|
||||||
|
|
||||||
<div id="v2ListStatus" class="emails-v2-status">Klar</div>
|
<div id="v2ListStatus" class="emails-v2-status">Klar</div>
|
||||||
@ -378,10 +423,7 @@
|
|||||||
<section class="emails-v2-panel">
|
<section class="emails-v2-panel">
|
||||||
<div class="emails-v2-header">
|
<div class="emails-v2-header">
|
||||||
<h2 class="emails-v2-title">Detalje</h2>
|
<h2 class="emails-v2-title">Detalje</h2>
|
||||||
<div class="d-flex gap-2">
|
<span class="emails-shortcuts"><kbd>J</kbd>/<kbd>K</kbd> næste/forrige</span>
|
||||||
<button id="v2FetchTest" class="btn btn-sm btn-outline-success">Hent fra test-mappe</button>
|
|
||||||
<button id="v2Refresh" class="btn btn-sm btn-outline-primary">Opdater</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div>
|
<div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div>
|
||||||
@ -394,7 +436,7 @@
|
|||||||
<section class="emails-v2-panel">
|
<section class="emails-v2-panel">
|
||||||
<div class="emails-v2-header">
|
<div class="emails-v2-header">
|
||||||
<h2 class="emails-v2-title">Handlinger</h2>
|
<h2 class="emails-v2-title">Handlinger</h2>
|
||||||
<a href="/emails/v1" class="btn btn-sm btn-outline-secondary">Sammenlign v1</a>
|
<span class="small text-muted">Vælg hvad der skal ske</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="v2SideActions" class="emails-v2-detail-empty">Vælg en email for handlinger</div>
|
<div id="v2SideActions" class="emails-v2-detail-empty">Vælg en email for handlinger</div>
|
||||||
@ -408,8 +450,6 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const TEST_MAILBOX_FOLDER = 'BMC_TEST';
|
|
||||||
|
|
||||||
const FILTERS = [
|
const FILTERS = [
|
||||||
{ key: 'active', label: 'Aktive' },
|
{ key: 'active', label: 'Aktive' },
|
||||||
{ key: 'awaiting_user_action', label: 'Afventer handling' },
|
{ key: 'awaiting_user_action', label: 'Afventer handling' },
|
||||||
@ -424,7 +464,7 @@
|
|||||||
workflowPreview: null,
|
workflowPreview: null,
|
||||||
filter: 'active',
|
filter: 'active',
|
||||||
query: '',
|
query: '',
|
||||||
folder: TEST_MAILBOX_FOLDER,
|
folder: '',
|
||||||
searchTimer: null,
|
searchTimer: null,
|
||||||
sagSearchTimer: null,
|
sagSearchTimer: null,
|
||||||
vendorSuggestion: null,
|
vendorSuggestion: null,
|
||||||
@ -667,13 +707,15 @@
|
|||||||
state.emails = emails;
|
state.emails = emails;
|
||||||
renderFilters();
|
renderFilters();
|
||||||
renderList();
|
renderList();
|
||||||
setListStatus(`${emails.length} emails vist (mappe: ${state.folder || 'alle'})`);
|
setListStatus(`${emails.length} emails`);
|
||||||
|
|
||||||
if (state.selectedEmailId) {
|
if (state.selectedEmailId) {
|
||||||
const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId));
|
const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId));
|
||||||
if (stillExists) {
|
if (stillExists) {
|
||||||
await selectEmail(state.selectedEmailId, { silentListRefresh: true });
|
await selectEmail(state.selectedEmailId, { silentListRefresh: true });
|
||||||
}
|
}
|
||||||
|
} else if (emails.length) {
|
||||||
|
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.emails = [];
|
state.emails = [];
|
||||||
@ -884,19 +926,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFromTestFolder() {
|
|
||||||
try {
|
|
||||||
setDetailStatus('Henter nye emails fra test-mappe...');
|
|
||||||
await apiFetch(`/api/v1/emails/process?folder=${encodeURIComponent(state.folder)}&limit=50`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
await loadEmails();
|
|
||||||
setDetailStatus(`Import fuldført fra ${state.folder}`);
|
|
||||||
} catch (error) {
|
|
||||||
setDetailStatus(`Kunne ikke hente fra test-mappe: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCaseFromCurrent() {
|
async function createCaseFromCurrent() {
|
||||||
if (!state.selectedEmailId || !state.selectedEmail) return;
|
if (!state.selectedEmailId || !state.selectedEmail) return;
|
||||||
|
|
||||||
@ -1153,53 +1182,69 @@
|
|||||||
|
|
||||||
sideActions.className = 'emails-v2-actions-pane';
|
sideActions.className = 'emails-v2-actions-pane';
|
||||||
sideActions.innerHTML = `
|
sideActions.innerHTML = `
|
||||||
<div class="emails-v2-card">
|
${email.linked_case_id ? `
|
||||||
<h6>Hurtighandlinger</h6>
|
<div class="emails-v2-card emails-primary-action">
|
||||||
<div class="emails-v2-actions">
|
<h6>Allerede knyttet til sag</h6>
|
||||||
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">${email.is_read ? 'Marker som ulæst' : 'Marker som læst'}</button>
|
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
|
||||||
<button id="v2Archive" class="btn btn-sm btn-outline-primary">Arkivér</button>
|
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn SAG #${Number(email.linked_case_id)}
|
||||||
<button id="v2Processed" class="btn btn-sm btn-outline-success">Markér behandlet</button>
|
</a>
|
||||||
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
|
<button id="v2Processed" class="btn btn-sm btn-outline-success w-100 mt-2">
|
||||||
<button id="v2ExecuteWorkflows" class="btn btn-sm btn-outline-dark">Kør workflows</button>
|
<i class="bi bi-check2 me-1"></i>Markér mailen behandlet
|
||||||
<button id="v2AutoRun" class="btn btn-sm btn-outline-danger" disabled>Autokør</button>
|
</button>
|
||||||
<button id="v2AutoParse" class="btn btn-sm btn-primary">Auto parse tråd/sag</button>
|
</div>` : `
|
||||||
|
<div class="emails-v2-card emails-primary-action">
|
||||||
|
<h6>Opret sag fra denne email</h6>
|
||||||
|
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<select id="v2CaseType" class="form-select form-select-sm">
|
||||||
|
<option value="support">Support</option>
|
||||||
|
<option value="bogholderi">Bogholderi</option>
|
||||||
|
<option value="leverandor">Leverandør</option>
|
||||||
|
<option value="helhedsopgave">Projekt/helhedsopgave</option>
|
||||||
|
<option value="andet">Andet</option>
|
||||||
|
</select>
|
||||||
|
<button id="v2CreateCase" class="btn btn-primary text-nowrap">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Opret
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`}
|
||||||
|
|
||||||
<div class="emails-v2-card">
|
<div class="emails-v2-card">
|
||||||
<h6>Workflow match-preview</h6>
|
<h6>${email.linked_case_id ? 'Skift sagstilknytning' : 'Eller knyt til eksisterende sag'}</h6>
|
||||||
<div id="v2WorkflowPreview"><div class="small text-muted">Henter preview...</div></div>
|
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Skriv sagsnummer eller titel…">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="emails-v2-card">
|
|
||||||
<h6>Link til eksisterende sag</h6>
|
|
||||||
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Søg sag-ID, titel eller beskrivelse...">
|
|
||||||
<div id="v2SagResults" class="emails-v2-sag-results"></div>
|
<div id="v2SagResults" class="emails-v2-sag-results"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="emails-v2-card">
|
<div class="emails-v2-card">
|
||||||
<h6>Opret ny sag fra email</h6>
|
<h6>Hurtige mailhandlinger</h6>
|
||||||
<div class="row g-2">
|
<div class="emails-v2-actions mb-0">
|
||||||
<div class="col-12">
|
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
|
||||||
<input id="v2CaseTitle" class="form-control form-control-sm" value="${escapeHtml(email.subject || '')}" placeholder="Sags titel">
|
<i class="bi bi-envelope${email.is_read ? '' : '-open'} me-1"></i>${email.is_read ? 'Ulæst' : 'Læst'}
|
||||||
</div>
|
</button>
|
||||||
<div class="col-12">
|
<button id="v2Archive" class="btn btn-sm btn-outline-secondary">
|
||||||
<select id="v2CaseType" class="form-select form-select-sm">
|
<i class="bi bi-archive me-1"></i>Arkivér
|
||||||
<option value="support">Support</option>
|
</button>
|
||||||
<option value="bogholderi">Bogholderi</option>
|
${email.linked_case_id ? '' : `
|
||||||
<option value="leverandor">Leverandør</option>
|
<button id="v2Processed" class="btn btn-sm btn-outline-success">
|
||||||
<option value="helhedsopgave">Helhedsopgave</option>
|
<i class="bi bi-check2 me-1"></i>Behandlet
|
||||||
<option value="andet">Andet</option>
|
</button>`}
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="emails-v2-right-actions mt-2">
|
|
||||||
<button id="v2CreateCase" class="btn btn-sm btn-primary">Opret sag</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="emails-v2-card">
|
<details class="emails-v2-card">
|
||||||
<h6>Leverandør faktura</h6>
|
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
|
||||||
|
<div class="emails-v2-actions mt-3 mb-2">
|
||||||
|
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
|
||||||
|
<button id="v2ExecuteWorkflows" class="btn btn-sm btn-outline-dark">Kør workflows</button>
|
||||||
|
<button id="v2AutoRun" class="btn btn-sm btn-outline-danger" disabled>Autokør</button>
|
||||||
|
<button id="v2AutoParse" class="btn btn-sm btn-outline-primary">Find tråd/sag automatisk</button>
|
||||||
|
</div>
|
||||||
|
<div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="emails-v2-card">
|
||||||
|
<summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
|
||||||
|
<h6 class="mt-3">Leverandør faktura</h6>
|
||||||
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
|
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
|
||||||
<div class="emails-v2-kv"><div class="k">CVR</div><div class="v">${escapeHtml(email.extracted_vendor_cvr || '-')}</div></div>
|
<div class="emails-v2-kv"><div class="k">CVR</div><div class="v">${escapeHtml(email.extracted_vendor_cvr || '-')}</div></div>
|
||||||
<div class="emails-v2-kv"><div class="k">Faktura nr</div><div class="v">${escapeHtml(email.extracted_invoice_number || '-')}</div></div>
|
<div class="emails-v2-kv"><div class="k">Faktura nr</div><div class="v">${escapeHtml(email.extracted_invoice_number || '-')}</div></div>
|
||||||
@ -1210,16 +1255,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="v2VendorSuggestion" class="mt-2"><div class="small text-muted">Ingen forslag endnu</div></div>
|
<div id="v2VendorSuggestion" class="mt-2"><div class="small text-muted">Ingen forslag endnu</div></div>
|
||||||
<div id="v2SupplierStatus" class="emails-v2-inline-status"></div>
|
<div id="v2SupplierStatus" class="emails-v2-inline-status"></div>
|
||||||
</div>
|
<hr>
|
||||||
|
<h6>Automatisk kundematch</h6>
|
||||||
<div class="emails-v2-card">
|
|
||||||
<h6>Auto match til kunde/sag</h6>
|
|
||||||
<div class="emails-v2-right-actions">
|
<div class="emails-v2-right-actions">
|
||||||
<button id="v2DomainSuggestionBtn" class="btn btn-sm btn-outline-secondary">Hent domæne-kundeforslag</button>
|
<button id="v2DomainSuggestionBtn" class="btn btn-sm btn-outline-secondary">Hent domæne-kundeforslag</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="v2DomainSuggestion" class="mt-2"><div class="small text-muted">Ingen domæneforslag endnu</div></div>
|
<div id="v2DomainSuggestion" class="mt-2"><div class="small text-muted">Ingen domæneforslag endnu</div></div>
|
||||||
<div id="v2DomainStatus" class="emails-v2-inline-status"></div>
|
<div id="v2DomainStatus" class="emails-v2-inline-status"></div>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
<details class="emails-v2-card">
|
<details class="emails-v2-card">
|
||||||
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
|
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
|
||||||
@ -1262,6 +1305,101 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadEmailFiles(files) {
|
||||||
|
const accepted = Array.from(files || []).filter((file) =>
|
||||||
|
/\.(eml|msg)$/i.test(file.name || '')
|
||||||
|
);
|
||||||
|
if (!accepted.length) {
|
||||||
|
setListStatus('Vælg .eml- eller .msg-filer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zone = document.getElementById('v2UploadZone');
|
||||||
|
const label = document.getElementById('v2UploadLabel');
|
||||||
|
const original = label?.innerHTML;
|
||||||
|
if (zone) {
|
||||||
|
zone.classList.add('busy');
|
||||||
|
}
|
||||||
|
if (label) {
|
||||||
|
label.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>Importerer ${accepted.length} mail${accepted.length === 1 ? '' : 's'}…`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
accepted.forEach((file) => formData.append('files', file));
|
||||||
|
const result = await apiFetch('/api/v1/emails/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const uploaded = Number(result?.uploaded || 0);
|
||||||
|
const duplicates = Number(result?.duplicates || 0);
|
||||||
|
setListStatus(`${uploaded} importeret${duplicates ? ` · ${duplicates} fandtes allerede` : ''}`);
|
||||||
|
state.filter = 'active';
|
||||||
|
state.selectedEmailId = null;
|
||||||
|
await loadEmails();
|
||||||
|
} catch (error) {
|
||||||
|
setListStatus(`Import fejlede: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
if (zone) {
|
||||||
|
zone.classList.remove('busy', 'dragover');
|
||||||
|
}
|
||||||
|
if (label) label.innerHTML = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupUploadZone() {
|
||||||
|
const zone = document.getElementById('v2UploadZone');
|
||||||
|
const input = document.getElementById('v2UploadInput');
|
||||||
|
if (!zone || !input || zone.dataset.ready === 'true') return;
|
||||||
|
zone.dataset.ready = 'true';
|
||||||
|
zone.addEventListener('click', () => input.click());
|
||||||
|
zone.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
['dragenter', 'dragover'].forEach((type) => zone.addEventListener(type, (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
zone.classList.add('dragover');
|
||||||
|
}));
|
||||||
|
['dragleave', 'drop'].forEach((type) => zone.addEventListener(type, (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
zone.classList.remove('dragover');
|
||||||
|
}));
|
||||||
|
zone.addEventListener('drop', (event) => uploadEmailFiles(event.dataTransfer?.files));
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
uploadEmailFiles(input.files);
|
||||||
|
input.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAdjacentEmail(offset) {
|
||||||
|
if (!state.emails.length) return;
|
||||||
|
const current = state.emails.findIndex((email) => Number(email.id) === Number(state.selectedEmailId));
|
||||||
|
const next = Math.min(Math.max((current < 0 ? 0 : current) + offset, 0), state.emails.length - 1);
|
||||||
|
selectEmail(Number(state.emails[next].id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupKeyboardShortcuts() {
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
const typing = target instanceof HTMLInputElement
|
||||||
|
|| target instanceof HTMLTextAreaElement
|
||||||
|
|| target instanceof HTMLSelectElement
|
||||||
|
|| target?.isContentEditable;
|
||||||
|
if (event.key === '/' && !typing) {
|
||||||
|
event.preventDefault();
|
||||||
|
document.getElementById('v2Search')?.focus();
|
||||||
|
} else if (!typing && event.key.toLowerCase() === 'j') {
|
||||||
|
selectAdjacentEmail(1);
|
||||||
|
} else if (!typing && event.key.toLowerCase() === 'k') {
|
||||||
|
selectAdjacentEmail(-1);
|
||||||
|
} else if (!typing && event.key.toLowerCase() === 'r') {
|
||||||
|
loadEmails();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setupEvents() {
|
function setupEvents() {
|
||||||
document.getElementById('v2Search')?.addEventListener('input', (event) => {
|
document.getElementById('v2Search')?.addEventListener('input', (event) => {
|
||||||
const value = String(event.target.value || '').trim();
|
const value = String(event.target.value || '').trim();
|
||||||
@ -1273,7 +1411,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('v2Refresh')?.addEventListener('click', () => loadEmails());
|
document.getElementById('v2Refresh')?.addEventListener('click', () => loadEmails());
|
||||||
document.getElementById('v2FetchTest')?.addEventListener('click', fetchFromTestFolder);
|
setupUploadZone();
|
||||||
|
setupKeyboardShortcuts();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|||||||
@ -17,10 +17,10 @@ templates = Jinja2Templates(directory="app")
|
|||||||
|
|
||||||
@router.get("/emails", response_class=HTMLResponse)
|
@router.get("/emails", response_class=HTMLResponse)
|
||||||
async def emails_page(request: Request):
|
async def emails_page(request: Request):
|
||||||
"""Email management UI - 3-column modern email interface"""
|
"""Email management UI - fast, simplified workflow."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"emails/frontend/emails.html",
|
"emails/frontend/emails_v2.html",
|
||||||
{"request": request, "email_ui_version": "v1"}
|
{"request": request, "email_ui_version": "v2"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -338,6 +338,12 @@
|
|||||||
<span class="info-label">Mærke/Model</span>
|
<span class="info-label">Mærke/Model</span>
|
||||||
<span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span>
|
<span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% if hardware.current_location_id %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Rækkefølge på lokation</span>
|
||||||
|
<span class="info-value"><span class="badge bg-primary">Nr. {{ hardware.location_display_order or '—' }}</span></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if hardware.eset_uuid %}
|
{% if hardware.eset_uuid %}
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">ESET UUID</span>
|
<span class="info-label">ESET UUID</span>
|
||||||
@ -717,6 +723,7 @@
|
|||||||
{% if uisp_device %}
|
{% if uisp_device %}
|
||||||
{% set overview = uisp_device.overview or {} %}
|
{% set overview = uisp_device.overview or {} %}
|
||||||
<div class="row g-3 small">
|
<div class="row g-3 small">
|
||||||
|
<div class="col-md-3"><span class="text-muted d-block">Hostnavn</span><strong>{{ uisp_device.hostname or uisp_device.display_name or uisp_device.name or '—' }}</strong></div>
|
||||||
<div class="col-md-3"><span class="text-muted d-block">Status</span><strong>{{ uisp_device.status or 'Ukendt' }}</strong></div>
|
<div class="col-md-3"><span class="text-muted d-block">Status</span><strong>{{ uisp_device.status or 'Ukendt' }}</strong></div>
|
||||||
<div class="col-md-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp_device.ip_addresses or []) | join(', ') or '—' }}</strong></div>
|
<div class="col-md-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp_device.ip_addresses or []) | join(', ') or '—' }}</strong></div>
|
||||||
<div class="col-md-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp_device.mac_address or '—' }}</strong></div>
|
<div class="col-md-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp_device.mac_address or '—' }}</strong></div>
|
||||||
|
|||||||
@ -97,7 +97,7 @@
|
|||||||
border-top: 1px solid rgba(0,0,0,0.1);
|
border-top: 1px solid rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.form-container .btn {
|
||||||
padding: 0.75rem 2rem;
|
padding: 0.75rem 2rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@ -108,23 +108,23 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.form-container .btn-primary {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.form-container .btn-primary:hover {
|
||||||
background-color: #0056b3;
|
background-color: #0056b3;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
|
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.form-container .btn-secondary {
|
||||||
background-color: #6c757d;
|
background-color: #6c757d;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.form-container .btn-secondary:hover {
|
||||||
background-color: #5a6268;
|
background-color: #5a6268;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +141,7 @@
|
|||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.form-container .btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,6 +69,19 @@ GENERIC_SEGMENT_TITLE_PATTERNS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_service_location(value: Optional[str]) -> str:
|
||||||
|
normalized = str(value or "").lower()
|
||||||
|
normalized = normalized.replace("æ", "ae").replace("ø", "oe").replace("å", "aa")
|
||||||
|
return re.sub(r"[^a-z0-9]+", "", normalized)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceSyncReviewRequest(BaseModel):
|
||||||
|
line_number: int
|
||||||
|
action: str
|
||||||
|
connection_id: Optional[int] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _internet_customer_doc_dir() -> Path:
|
def _internet_customer_doc_dir() -> Path:
|
||||||
base = Path(settings.UPLOAD_DIR).resolve()
|
base = Path(settings.UPLOAD_DIR).resolve()
|
||||||
target = base / "internet_customer_docs"
|
target = base / "internet_customer_docs"
|
||||||
@ -1247,6 +1260,231 @@ async def internet_connections_health():
|
|||||||
return {"status": "healthy", "service": "internet-connections-module"}
|
return {"status": "healthy", "service": "internet-connections-module"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/internet-connections/invoice-sync-runs")
|
||||||
|
async def list_internet_invoice_sync_runs(
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
):
|
||||||
|
"""Show GlobalConnect invoices and their latest internet-sync outcome."""
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
WITH raw_invoice_keys AS (
|
||||||
|
SELECT si.id AS supplier_invoice_id, si.extraction_id, e.file_id,
|
||||||
|
si.invoice_number, COALESCE(si.vendor_name, e.vendor_name) AS vendor_name,
|
||||||
|
si.invoice_date, si.total_amount, si.currency, si.created_at
|
||||||
|
FROM supplier_invoices si
|
||||||
|
LEFT JOIN extractions e ON e.extraction_id = si.extraction_id
|
||||||
|
WHERE COALESCE(si.vendor_name, e.vendor_name, '') ILIKE '%%GlobalConnect%%'
|
||||||
|
UNION ALL
|
||||||
|
SELECT run.supplier_invoice_id, run.extraction_id, run.file_id, run.invoice_number,
|
||||||
|
run.vendor_name, run.invoice_date, NULL::numeric, NULL::varchar, run.processed_at
|
||||||
|
FROM internet_connections_invoice_sync_runs run
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM supplier_invoices si
|
||||||
|
WHERE si.id = run.supplier_invoice_id
|
||||||
|
OR (run.supplier_invoice_id IS NULL AND si.invoice_number = run.invoice_number
|
||||||
|
AND COALESCE(si.vendor_name, '') = COALESCE(run.vendor_name, ''))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
invoice_keys AS (
|
||||||
|
SELECT DISTINCT ON (
|
||||||
|
TRIM(COALESCE(invoice_number, '')),
|
||||||
|
LOWER(TRIM(COALESCE(vendor_name, '')))
|
||||||
|
) *
|
||||||
|
FROM raw_invoice_keys
|
||||||
|
ORDER BY
|
||||||
|
TRIM(COALESCE(invoice_number, '')),
|
||||||
|
LOWER(TRIM(COALESCE(vendor_name, ''))),
|
||||||
|
supplier_invoice_id NULLS LAST,
|
||||||
|
created_at DESC
|
||||||
|
)
|
||||||
|
SELECT key.*,
|
||||||
|
COALESCE(
|
||||||
|
latest.status,
|
||||||
|
CASE WHEN file.status IN ('failed', 'error') OR NULLIF(TRIM(file.error_message), '') IS NOT NULL THEN 'error' END,
|
||||||
|
CASE WHEN legacy.connection_count > 0 THEN 'legacy_success' ELSE 'not_logged' END
|
||||||
|
) AS processing_status,
|
||||||
|
latest.id AS run_id, latest.connections_synced, latest.connections_created,
|
||||||
|
latest.connections_updated, latest.ip_ranges_synced, latest.total_lines,
|
||||||
|
latest.actionable_lines, latest.skipped_lines,
|
||||||
|
COALESCE(latest.error_message, file.error_message) AS error_message,
|
||||||
|
latest.result_json,
|
||||||
|
COALESCE(latest.processed_at, file.processed_at, key.created_at) AS processed_at,
|
||||||
|
legacy.connection_count AS legacy_connection_count,
|
||||||
|
COALESCE(review.resolved_lines, 0) AS resolved_lines,
|
||||||
|
GREATEST(COALESCE(latest.skipped_lines, 0) - COALESCE(review.resolved_lines, 0), 0) AS unresolved_lines,
|
||||||
|
COALESCE(review.decisions, '[]'::jsonb) AS review_decisions
|
||||||
|
FROM invoice_keys key
|
||||||
|
LEFT JOIN incoming_files file ON file.file_id = key.file_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT run.*
|
||||||
|
FROM internet_connections_invoice_sync_runs run
|
||||||
|
WHERE (key.supplier_invoice_id IS NOT NULL AND run.supplier_invoice_id = key.supplier_invoice_id)
|
||||||
|
OR (key.extraction_id IS NOT NULL AND run.extraction_id = key.extraction_id)
|
||||||
|
OR (run.invoice_number = key.invoice_number
|
||||||
|
AND COALESCE(run.vendor_name, '') = COALESCE(key.vendor_name, ''))
|
||||||
|
ORDER BY run.processed_at DESC, run.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(DISTINCT history.connection_id)::integer AS connection_count
|
||||||
|
FROM internet_connections_history history
|
||||||
|
WHERE key.invoice_number IS NOT NULL
|
||||||
|
AND history.summary ILIKE ('%%' || key.invoice_number || '%%')
|
||||||
|
AND history.event_type IN (
|
||||||
|
'connection_created_from_supplier_invoice', 'supplier_invoice_sync_changed',
|
||||||
|
'supplier_invoice_ip_range_changed', 'supplier_invoice_ip_range_created'
|
||||||
|
)
|
||||||
|
) legacy ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*)::integer AS resolved_lines,
|
||||||
|
COALESCE(
|
||||||
|
jsonb_agg(jsonb_build_object(
|
||||||
|
'line_number', decision.line_number,
|
||||||
|
'action', decision.action,
|
||||||
|
'connection_id', decision.connection_id,
|
||||||
|
'note', decision.note,
|
||||||
|
'resolved_at', decision.resolved_at
|
||||||
|
) ORDER BY decision.line_number),
|
||||||
|
'[]'::jsonb
|
||||||
|
) AS decisions
|
||||||
|
FROM internet_connections_invoice_review_decisions decision
|
||||||
|
WHERE decision.run_id = latest.id
|
||||||
|
) review ON TRUE
|
||||||
|
WHERE (%s IS NULL OR COALESCE(
|
||||||
|
latest.status,
|
||||||
|
CASE WHEN file.status IN ('failed', 'error') OR NULLIF(TRIM(file.error_message), '') IS NOT NULL THEN 'error' END,
|
||||||
|
CASE WHEN legacy.connection_count > 0 THEN 'legacy_success' ELSE 'not_logged' END
|
||||||
|
) = %s)
|
||||||
|
ORDER BY COALESCE(latest.processed_at, file.processed_at, key.created_at) DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(status, status, limit),
|
||||||
|
) or []
|
||||||
|
items = [dict(row) for row in rows]
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"summary": {
|
||||||
|
"total": len(items),
|
||||||
|
"success": sum(item.get("processing_status") in {"success", "legacy_success"} for item in items),
|
||||||
|
"warnings": sum(item.get("processing_status") in {"warning", "skipped", "not_logged"} for item in items),
|
||||||
|
"errors": sum(item.get("processing_status") == "error" for item in items),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/internet-connections/invoice-sync-runs/{run_id}/review")
|
||||||
|
async def review_internet_invoice_sync_line(run_id: int, data: InvoiceSyncReviewRequest):
|
||||||
|
if data.action not in {"ignore", "link_existing", "create_separate"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldig kontrolhandling")
|
||||||
|
if data.action == "ignore" and not str(data.note or "").strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Angiv en årsag, når en linje ignoreres")
|
||||||
|
|
||||||
|
run = execute_query_single(
|
||||||
|
"SELECT * FROM internet_connections_invoice_sync_runs WHERE id = %s",
|
||||||
|
(run_id,),
|
||||||
|
)
|
||||||
|
if not run:
|
||||||
|
raise HTTPException(status_code=404, detail="Behandlingskørslen blev ikke fundet")
|
||||||
|
result = run.get("result_json") or {}
|
||||||
|
skipped_items = result.get("skipped_items") or []
|
||||||
|
audit_item = next(
|
||||||
|
(item for item in skipped_items if int(item.get("line_number") or -1) == data.line_number),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not audit_item:
|
||||||
|
raise HTTPException(status_code=404, detail="Kontrollinjen blev ikke fundet")
|
||||||
|
|
||||||
|
target_connection_id = data.connection_id
|
||||||
|
if data.action in {"link_existing", "create_separate"}:
|
||||||
|
if data.action == "link_existing":
|
||||||
|
if not target_connection_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Vælg en eksisterende forbindelse")
|
||||||
|
target = execute_query_single(
|
||||||
|
"SELECT id FROM internet_connections_connections WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(target_connection_id,),
|
||||||
|
)
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="Forbindelsen blev ikke fundet")
|
||||||
|
else:
|
||||||
|
reference = str(audit_item.get("provider_reference") or "").strip()
|
||||||
|
service_address = str(audit_item.get("service_address") or "").strip()
|
||||||
|
target_connection_id = execute_insert(
|
||||||
|
"""
|
||||||
|
INSERT INTO internet_connections_connections (
|
||||||
|
name, provider, address, status, circuit_number, notes,
|
||||||
|
allocation_model, value_type
|
||||||
|
)
|
||||||
|
VALUES (%s, 'GlobalConnect A/S', %s, 'pending', %s, %s, 'dedicated', 'other')
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
f"Afventer kontrol · {reference or 'ukendt reference'}",
|
||||||
|
service_address or None,
|
||||||
|
reference or None,
|
||||||
|
f"Oprettet manuelt under kontrol af faktura {run.get('invoice_number')}.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if audit_item.get("classification") == "ip_range":
|
||||||
|
extraction_line = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM extraction_lines
|
||||||
|
WHERE extraction_id = %s AND line_number = %s
|
||||||
|
ORDER BY line_id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(run.get("extraction_id"), data.line_number),
|
||||||
|
)
|
||||||
|
if not extraction_line:
|
||||||
|
raise HTTPException(status_code=404, detail="Den oprindelige fakturalinje blev ikke fundet")
|
||||||
|
from app.billing.backend.supplier_invoices import _upsert_globalconnect_ip_range
|
||||||
|
range_id = _upsert_globalconnect_ip_range(
|
||||||
|
int(target_connection_id),
|
||||||
|
dict(extraction_line),
|
||||||
|
str(run.get("invoice_number") or "ukendt"),
|
||||||
|
)
|
||||||
|
if not range_id:
|
||||||
|
raise HTTPException(status_code=400, detail="IP-rangen kunne ikke forbindes")
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO internet_connections_invoice_review_decisions (
|
||||||
|
run_id, line_number, action, connection_id, note
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (run_id, line_number) DO UPDATE
|
||||||
|
SET action = EXCLUDED.action,
|
||||||
|
connection_id = EXCLUDED.connection_id,
|
||||||
|
note = EXCLUDED.note,
|
||||||
|
resolved_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(run_id, data.line_number, data.action, target_connection_id, (data.note or "").strip() or None),
|
||||||
|
fetch=False,
|
||||||
|
)
|
||||||
|
resolved = execute_query_single(
|
||||||
|
"SELECT COUNT(*)::integer AS count FROM internet_connections_invoice_review_decisions WHERE run_id = %s",
|
||||||
|
(run_id,),
|
||||||
|
) or {"count": 0}
|
||||||
|
skipped_count = len(skipped_items)
|
||||||
|
remaining = max(skipped_count - int(resolved.get("count") or 0), 0)
|
||||||
|
if remaining == 0:
|
||||||
|
execute_query(
|
||||||
|
"UPDATE internet_connections_invoice_sync_runs SET status = 'success' WHERE id = %s",
|
||||||
|
(run_id,),
|
||||||
|
fetch=False,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "resolved",
|
||||||
|
"run_id": run_id,
|
||||||
|
"line_number": data.line_number,
|
||||||
|
"action": data.action,
|
||||||
|
"connection_id": target_connection_id,
|
||||||
|
"remaining": remaining,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/internet-connections", response_model=List[dict])
|
@router.get("/internet-connections", response_model=List[dict])
|
||||||
async def list_connections(
|
async def list_connections(
|
||||||
q: Optional[str] = Query(None),
|
q: Optional[str] = Query(None),
|
||||||
@ -1486,6 +1724,99 @@ async def get_connection(connection_id: int):
|
|||||||
return connection
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/internet-connections/{connection_id:int}/cross-field-ports")
|
||||||
|
async def get_connection_cross_field_ports(connection_id: int):
|
||||||
|
"""Find cross-field ports related by customer or service-location address."""
|
||||||
|
connection = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT ic.id, ic.customer_id, ic.address, parent.address AS parent_address
|
||||||
|
FROM internet_connections_connections ic
|
||||||
|
LEFT JOIN internet_connections_connections parent ON parent.id = ic.parent_id
|
||||||
|
WHERE ic.id = %s AND ic.deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(connection_id,),
|
||||||
|
)
|
||||||
|
if not connection:
|
||||||
|
raise HTTPException(status_code=404, detail="Connection not found")
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT o.id AS outlet_id, o.outlet_number, o.category, o.patch_panel,
|
||||||
|
o.patch_port, o.switch_name, o.switch_port, o.status,
|
||||||
|
o.is_active, o.is_wan, o.customer_id,
|
||||||
|
p.id AS cross_field_port_id, p.port_number, p.port_order,
|
||||||
|
cf.id AS cross_field_id, cf.name AS cross_field_name,
|
||||||
|
l.id AS location_id, l.name AS location_name, l.customer_id AS location_customer_id,
|
||||||
|
l.address_street, l.address_postal_code, l.address_city,
|
||||||
|
h.id AS switch_hardware_id, h.model AS switch_hardware_model
|
||||||
|
FROM locations_wall_outlets o
|
||||||
|
JOIN locations_locations l ON l.id = o.location_id AND l.deleted_at IS NULL
|
||||||
|
LEFT JOIN locations_cross_field_ports p ON p.id = o.cross_field_port_id
|
||||||
|
LEFT JOIN locations_cross_fields cf ON cf.id = p.cross_field_id AND cf.deleted_at IS NULL
|
||||||
|
LEFT JOIN hardware h ON h.id = o.switch_hardware_id AND h.deleted_at IS NULL
|
||||||
|
WHERE o.deleted_at IS NULL
|
||||||
|
AND o.is_active = TRUE
|
||||||
|
AND o.cross_field_port_id IS NOT NULL
|
||||||
|
ORDER BY l.name, cf.display_order, p.port_order, o.id
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
customer_id = int(connection.get("customer_id") or 0)
|
||||||
|
target_addresses = {
|
||||||
|
_normalize_service_location(value)
|
||||||
|
for value in (connection.get("address"), connection.get("parent_address"))
|
||||||
|
if value
|
||||||
|
}
|
||||||
|
target_addresses.discard("")
|
||||||
|
|
||||||
|
ports = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
def clean_location_part(value: object) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
return "" if text.lower() in {"none", "null", "-"} else text
|
||||||
|
|
||||||
|
location_address = ", ".join(filter(None, [
|
||||||
|
clean_location_part(item.get("address_street")),
|
||||||
|
" ".join(filter(None, [
|
||||||
|
clean_location_part(item.get("address_postal_code")),
|
||||||
|
clean_location_part(item.get("address_city")),
|
||||||
|
])).strip(),
|
||||||
|
]))
|
||||||
|
normalized_location = _normalize_service_location(location_address)
|
||||||
|
customer_match = bool(
|
||||||
|
customer_id
|
||||||
|
and customer_id in {
|
||||||
|
int(item.get("customer_id") or 0),
|
||||||
|
int(item.get("location_customer_id") or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
address_match = bool(
|
||||||
|
normalized_location
|
||||||
|
and any(
|
||||||
|
target == normalized_location
|
||||||
|
or (len(normalized_location) >= 5 and target.startswith(normalized_location))
|
||||||
|
for target in target_addresses
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not customer_match and not address_match:
|
||||||
|
continue
|
||||||
|
item["network_role"] = "wan" if item.get("is_wan") else "lan"
|
||||||
|
item["is_faulty"] = str(item.get("status") or "").lower() == "faulty"
|
||||||
|
item["location_address"] = location_address or None
|
||||||
|
ports.append(item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": ports,
|
||||||
|
"summary": {
|
||||||
|
"total": len(ports),
|
||||||
|
"lan": sum(item["network_role"] == "lan" for item in ports),
|
||||||
|
"wan": sum(item["network_role"] == "wan" for item in ports),
|
||||||
|
"faulty": sum(bool(item["is_faulty"]) for item in ports),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/internet-connections/{connection_id:int}/children", response_model=List[dict])
|
@router.get("/internet-connections/{connection_id:int}/children", response_model=List[dict])
|
||||||
async def list_connection_children(connection_id: int, bmcnet_only: bool = Query(False)):
|
async def list_connection_children(connection_id: int, bmcnet_only: bool = Query(False)):
|
||||||
where_parts = [" AND ic.parent_id = %s "]
|
where_parts = [" AND ic.parent_id = %s "]
|
||||||
|
|||||||
@ -556,6 +556,30 @@
|
|||||||
<div id="pricingHistoryList"></div>
|
<div id="pricingHistoryList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-panel p-4 mb-4 d-none" id="crossFieldPortsPanel">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">Porte i krydsfelt</h5>
|
||||||
|
<div class="small text-muted" id="crossFieldPortsSummary"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Lokation / krydsfelt</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>Vægstik</th>
|
||||||
|
<th>Switch</th>
|
||||||
|
<th>Netværk</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="crossFieldPortsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="detail-panel p-4">
|
<div class="detail-panel p-4">
|
||||||
<h5 class="mb-3">Historik</h5>
|
<h5 class="mb-3">Historik</h5>
|
||||||
<div id="historyList"></div>
|
<div id="historyList"></div>
|
||||||
@ -1296,6 +1320,7 @@
|
|||||||
pricingHistoryResponse,
|
pricingHistoryResponse,
|
||||||
contractsResponse,
|
contractsResponse,
|
||||||
historyResponse,
|
historyResponse,
|
||||||
|
crossFieldPortsResponse,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetch(`/api/v1/internet-connections/${connectionId}`),
|
fetch(`/api/v1/internet-connections/${connectionId}`),
|
||||||
fetch(`/api/v1/internet-connections/${connectionId}/ip-ranges`),
|
fetch(`/api/v1/internet-connections/${connectionId}/ip-ranges`),
|
||||||
@ -1305,6 +1330,7 @@
|
|||||||
fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`),
|
fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`),
|
||||||
fetch('/api/v1/internet-connections/contracts'),
|
fetch('/api/v1/internet-connections/contracts'),
|
||||||
fetch(`/api/v1/internet-connections/${connectionId}/history`),
|
fetch(`/api/v1/internet-connections/${connectionId}/history`),
|
||||||
|
fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!connectionResponse.ok) {
|
if (!connectionResponse.ok) {
|
||||||
@ -1321,6 +1347,7 @@
|
|||||||
const pricingHistory = await pricingHistoryResponse.json();
|
const pricingHistory = await pricingHistoryResponse.json();
|
||||||
const contracts = await contractsResponse.json();
|
const contracts = await contractsResponse.json();
|
||||||
const history = await historyResponse.json();
|
const history = await historyResponse.json();
|
||||||
|
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} });
|
||||||
|
|
||||||
currentConnection = connection;
|
currentConnection = connection;
|
||||||
currentAddresses = Array.isArray(addresses) ? addresses : [];
|
currentAddresses = Array.isArray(addresses) ? addresses : [];
|
||||||
@ -1337,6 +1364,44 @@
|
|||||||
renderRelationGrid(connection);
|
renderRelationGrid(connection);
|
||||||
renderBmcnetChildren(connection, currentBmcnetChildren);
|
renderBmcnetChildren(connection, currentBmcnetChildren);
|
||||||
renderContractsOverview(contracts);
|
renderContractsOverview(contracts);
|
||||||
|
renderCrossFieldPorts(crossFieldPorts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCrossFieldPorts(payload) {
|
||||||
|
const panel = document.getElementById('crossFieldPortsPanel');
|
||||||
|
const body = document.getElementById('crossFieldPortsBody');
|
||||||
|
const summaryEl = document.getElementById('crossFieldPortsSummary');
|
||||||
|
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||||
|
const summary = payload?.summary || {};
|
||||||
|
panel.classList.toggle('d-none', items.length === 0);
|
||||||
|
if (!items.length) {
|
||||||
|
body.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryEl.textContent = `${summary.total || items.length} porte · ${summary.lan || 0} LAN · ${summary.wan || 0} WAN · ${summary.faulty || 0} defekte`;
|
||||||
|
body.innerHTML = items.map((port) => {
|
||||||
|
const status = String(port.status || '').toLowerCase();
|
||||||
|
const statusLabel = port.is_faulty ? 'Defekt' : (status === 'reserved' ? 'Reserveret' : 'Aktiv');
|
||||||
|
const statusClass = port.is_faulty ? 'bg-danger' : (status === 'reserved' ? 'bg-warning text-dark' : 'bg-success');
|
||||||
|
const roleClass = port.network_role === 'wan' ? 'bg-primary' : 'bg-info text-dark';
|
||||||
|
const switchLabel = port.switch_name || port.switch_hardware_model || '-';
|
||||||
|
return `
|
||||||
|
<tr class="${port.is_faulty ? 'table-danger' : ''}">
|
||||||
|
<td>
|
||||||
|
<a href="/app/locations/${port.location_id}?tab=cross-field" class="fw-semibold text-decoration-none">
|
||||||
|
${escapeHtml(port.location_name || '-')}
|
||||||
|
</a>
|
||||||
|
<div class="small text-muted">${escapeHtml(port.cross_field_name || '-')}</div>
|
||||||
|
</td>
|
||||||
|
<td><strong>${escapeHtml(port.port_number || port.patch_port || '-')}</strong></td>
|
||||||
|
<td>${escapeHtml(port.outlet_number || '-')}</td>
|
||||||
|
<td>${escapeHtml(switchLabel)}${port.switch_port ? ` · port ${escapeHtml(port.switch_port)}` : ''}</td>
|
||||||
|
<td><span class="badge ${roleClass}">${port.network_role === 'wan' ? 'WAN' : 'LAN'}</span></td>
|
||||||
|
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCore(connection, pricing, summary) {
|
function renderCore(connection, pricing, summary) {
|
||||||
|
|||||||
@ -137,6 +137,24 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invoice-sync-status {
|
||||||
|
display:inline-flex;
|
||||||
|
align-items:center;
|
||||||
|
border-radius:999px;
|
||||||
|
padding:.3rem .65rem;
|
||||||
|
font-size:.72rem;
|
||||||
|
font-weight:800;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-sync-status.success { background:rgba(25,135,84,.14); color:#146c43; }
|
||||||
|
.invoice-sync-status.warning,
|
||||||
|
.invoice-sync-status.skipped,
|
||||||
|
.invoice-sync-status.not_logged { background:rgba(255,193,7,.2); color:#765a02; }
|
||||||
|
.invoice-sync-status.error { background:rgba(220,53,69,.14); color:#b02a37; }
|
||||||
|
.invoice-sync-status.legacy_success { background:rgba(13,110,253,.12); color:#084298; }
|
||||||
|
.invoice-sync-error { max-width:420px; white-space:normal; color:#b02a37; font-size:.8rem; }
|
||||||
|
|
||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
.internet-toolbar {
|
.internet-toolbar {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -155,6 +173,9 @@
|
|||||||
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
|
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<a class="btn btn-outline-danger" href="#invoiceProcessingOverview">
|
||||||
|
<i class="bi bi-receipt-cutoff me-1"></i>Fakturabehandling
|
||||||
|
</a>
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">
|
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
||||||
</button>
|
</button>
|
||||||
@ -327,10 +348,58 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="internet-panel p-4 mb-4" id="invoiceProcessingOverview">
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="h5 mb-1">Behandlede internetfakturaer</h3>
|
||||||
|
<div class="internet-mini">GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<select class="form-select form-select-sm" id="invoiceSyncStatusFilter" onchange="loadInvoiceSyncRuns()">
|
||||||
|
<option value="">Alle resultater</option>
|
||||||
|
<option value="success">Gennemført</option>
|
||||||
|
<option value="warning">Kræver kontrol</option>
|
||||||
|
<option value="error">Fejl</option>
|
||||||
|
<option value="skipped">Sprunget over</option>
|
||||||
|
<option value="not_logged">Ældre uden detaljer</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadInvoiceSyncRuns()"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Fakturaer</div><div class="internet-kpi-value" id="invoiceMetricTotal">0</div></div></div>
|
||||||
|
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Gennemført</div><div class="internet-kpi-value text-success" id="invoiceMetricSuccess">0</div></div></div>
|
||||||
|
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Kræver kontrol</div><div class="internet-kpi-value text-warning" id="invoiceMetricWarnings">0</div></div></div>
|
||||||
|
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Fejl</div><div class="internet-kpi-value text-danger" id="invoiceMetricErrors">0</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Faktura</th><th>Behandlet</th><th>Resultat</th><th>Forbindelser</th><th>IP-ranges</th><th>Fejl / bemærkning</th></tr></thead>
|
||||||
|
<tbody id="invoiceSyncTableBody"><tr><td colspan="6" class="text-muted py-4">Indlæser fakturabehandling…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="invoiceReviewModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div><h5 class="modal-title mb-1">Kontrollér internetfaktura <span id="invoiceReviewNumber"></span></h5><div class="internet-mini" id="invoiceReviewSummary"></div></div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-info small">Vælg en eksisterende forbindelse, opret en separat afventende forbindelse, eller ignorér linjen med en begrundelse. Fakturaen markeres gennemført, når alle kontrollinjer er afklaret.</div>
|
||||||
|
<div id="invoiceReviewLines"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let allConnections = [];
|
let allConnections = [];
|
||||||
|
let invoiceSyncItems = [];
|
||||||
let activeTab = 'all';
|
let activeTab = 'all';
|
||||||
let subscriptionOptions = [];
|
let subscriptionOptions = [];
|
||||||
|
|
||||||
@ -405,6 +474,165 @@
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeInvoiceText(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoiceSyncBadge(status) {
|
||||||
|
const labels = {
|
||||||
|
success: 'Gennemført',
|
||||||
|
legacy_success: 'Ældre · gennemført',
|
||||||
|
warning: 'Kræver kontrol',
|
||||||
|
skipped: 'Sprunget over',
|
||||||
|
error: 'Fejl',
|
||||||
|
not_logged: 'Ingen detaljer',
|
||||||
|
};
|
||||||
|
return `<span class="invoice-sync-status ${escapeInvoiceText(status)}">${labels[status] || escapeInvoiceText(status || 'Ukendt')}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInvoiceSyncDate(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? escapeInvoiceText(value) : date.toLocaleString('da-DK');
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoiceSyncMessage(item) {
|
||||||
|
if (item.error_message) return `<div class="invoice-sync-error">${escapeInvoiceText(item.error_message)}</div>`;
|
||||||
|
if (item.processing_status === 'warning') {
|
||||||
|
const skipped = Number(item.skipped_lines || 0);
|
||||||
|
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
|
||||||
|
const reasons = [...new Set(skippedItems.map(entry => entry.reason).filter(Boolean))];
|
||||||
|
return `<div class="text-warning small mb-2">${Number(item.unresolved_lines ?? skipped)} af ${skipped} linje(r) mangler kontrol${reasons.length ? `: ${escapeInvoiceText(reasons.join('; '))}` : ''}</div>${item.run_id ? `<button class="btn btn-sm btn-warning" type="button" onclick="openInvoiceReview(${Number(item.run_id)})"><i class="bi bi-check2-square me-1"></i>Kontrollér</button>` : ''}`;
|
||||||
|
}
|
||||||
|
if (item.processing_status === 'skipped') return '<div class="text-warning small">Fakturaen gav ingen internetdata til synkronisering.</div>';
|
||||||
|
if (item.processing_status === 'not_logged') return '<div class="text-muted small">Ældre faktura uden gemt behandlingsresultat.</div>';
|
||||||
|
if (item.processing_status === 'legacy_success') return '<div class="text-muted small">Fundet via eksisterende forbindelseshistorik.</div>';
|
||||||
|
return '<div class="text-muted small">Ingen fejl registreret.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvoiceSyncRuns() {
|
||||||
|
const status = document.getElementById('invoiceSyncStatusFilter').value;
|
||||||
|
const params = new URLSearchParams({limit: '200'});
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
const body = document.getElementById('invoiceSyncTableBody');
|
||||||
|
body.innerHTML = '<tr><td colspan="6" class="text-muted py-4">Indlæser fakturabehandling…</td></tr>';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs?${params.toString()}`);
|
||||||
|
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Kunne ikke hente fakturabehandling.'));
|
||||||
|
const payload = await response.json();
|
||||||
|
const items = Array.isArray(payload.items) ? payload.items : [];
|
||||||
|
invoiceSyncItems = items;
|
||||||
|
const summary = payload.summary || {};
|
||||||
|
document.getElementById('invoiceMetricTotal').textContent = String(summary.total || 0);
|
||||||
|
document.getElementById('invoiceMetricSuccess').textContent = String(summary.success || 0);
|
||||||
|
document.getElementById('invoiceMetricWarnings').textContent = String(summary.warnings || 0);
|
||||||
|
document.getElementById('invoiceMetricErrors').textContent = String(summary.errors || 0);
|
||||||
|
if (!items.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="6" class="text-muted py-4">Ingen fakturaer matcher filteret.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = items.map(item => `
|
||||||
|
<tr>
|
||||||
|
<td><div class="fw-semibold">${escapeInvoiceText(item.invoice_number || '-')}</div><div class="internet-mini">${escapeInvoiceText(item.vendor_name || 'GlobalConnect')} · ${escapeInvoiceText(item.invoice_date || '-')}</div></td>
|
||||||
|
<td>${formatInvoiceSyncDate(item.processed_at)}</td>
|
||||||
|
<td>${invoiceSyncBadge(item.processing_status)}</td>
|
||||||
|
<td><strong>${Number(item.connections_synced || item.legacy_connection_count || 0)}</strong><div class="internet-mini">${Number(item.connections_created || 0)} nye · ${Number(item.connections_updated || 0)} opdateret</div></td>
|
||||||
|
<td><strong>${Number(item.ip_ranges_synced || 0)}</strong></td>
|
||||||
|
<td>${invoiceSyncMessage(item)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
body.innerHTML = `<tr><td colspan="6" class="text-danger py-4">${escapeInvoiceText(error.message || 'Kunne ikke indlæse fakturabehandling.')}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewConnectionOptions(selectedId = '') {
|
||||||
|
return '<option value="">Vælg forbindelse…</option>' + allConnections
|
||||||
|
.filter(item => String(item.provider || '').toLowerCase().includes('globalconnect'))
|
||||||
|
.map(item => `<option value="${Number(item.id)}" ${String(item.id) === String(selectedId) ? 'selected' : ''}>${escapeInvoiceText(item.circuit_number || item.name || `#${item.id}`)} · ${escapeInvoiceText(item.address || '-')}</option>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInvoiceReview(runId) {
|
||||||
|
const item = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
|
||||||
|
if (!item) return;
|
||||||
|
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
|
||||||
|
const decisions = Array.isArray(item.review_decisions) ? item.review_decisions : [];
|
||||||
|
const decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision]));
|
||||||
|
document.getElementById('invoiceReviewNumber').textContent = item.invoice_number || '-';
|
||||||
|
document.getElementById('invoiceReviewSummary').textContent = `${Number(item.resolved_lines || 0)} løst · ${Number(item.unresolved_lines || 0)} mangler`;
|
||||||
|
const groups = skippedItems.reduce((result, line) => {
|
||||||
|
const reason = line.reason || 'Anden kontrol';
|
||||||
|
(result[reason] ||= []).push(line);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
document.getElementById('invoiceReviewLines').innerHTML = Object.entries(groups).map(([reason, lines]) => `
|
||||||
|
<section class="mb-4">
|
||||||
|
<h6 class="mb-2">${escapeInvoiceText(reason)} <span class="badge bg-secondary">${lines.length}</span></h6>
|
||||||
|
<div class="vstack gap-2">
|
||||||
|
${lines.map(line => {
|
||||||
|
const decision = decisionsByLine.get(Number(line.line_number));
|
||||||
|
return `<div class="border rounded p-3 ${decision ? 'bg-light opacity-75' : ''}">
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-semibold">Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}</div>
|
||||||
|
<div class="internet-mini">Reference: ${escapeInvoiceText(line.provider_reference || '-')} · Adresse: ${escapeInvoiceText(line.service_address || '-')}</div>
|
||||||
|
${line.ip_address ? `<div class="internet-mini">IP-range: ${escapeInvoiceText(line.ip_address)}</div>` : ''}
|
||||||
|
${decision ? `<div class="text-success small mt-2"><i class="bi bi-check-circle me-1"></i>Løst: ${escapeInvoiceText(decision.action)}${decision.note ? ` · ${escapeInvoiceText(decision.note)}` : ''}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${decision ? '' : `<div style="min-width:340px;">
|
||||||
|
<select class="form-select form-select-sm mb-2" id="reviewConnection-${runId}-${Number(line.line_number)}">${reviewConnectionOptions()}</select>
|
||||||
|
<input class="form-control form-control-sm mb-2" id="reviewNote-${runId}-${Number(line.line_number)}" placeholder="Begrundelse / note">
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'link_existing')">Brug valgt</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'create_separate')">Opret separat</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'ignore')">Ignorer</button>
|
||||||
|
</div>
|
||||||
|
</div>`}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`).join('') || '<div class="text-success">Alle kontrollinjer er løst.</div>';
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitInvoiceReview(runId, lineNumber, action) {
|
||||||
|
const connectionId = document.getElementById(`reviewConnection-${runId}-${lineNumber}`)?.value || null;
|
||||||
|
const note = document.getElementById(`reviewNote-${runId}-${lineNumber}`)?.value.trim() || '';
|
||||||
|
if (action === 'link_existing' && !connectionId) {
|
||||||
|
alert('Vælg først en eksisterende forbindelse.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'ignore' && !note) {
|
||||||
|
alert('Skriv en begrundelse for at ignorere linjen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'create_separate' && !confirm('Opret en separat, afventende forbindelse for denne linje?')) return;
|
||||||
|
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs/${runId}/review`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
line_number: lineNumber,
|
||||||
|
action,
|
||||||
|
connection_id: connectionId ? Number(connectionId) : null,
|
||||||
|
note: note || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
alert(await extractErrorMessage(response, 'Kontrollen kunne ikke gemmes.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadInvoiceSyncRuns();
|
||||||
|
openInvoiceReview(runId);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadInternetPage() {
|
async function loadInternetPage() {
|
||||||
const search = document.getElementById('searchInput').value.trim();
|
const search = document.getElementById('searchInput').value.trim();
|
||||||
const provider = document.getElementById('providerFilter').value.trim();
|
const provider = document.getElementById('providerFilter').value.trim();
|
||||||
@ -626,7 +854,7 @@
|
|||||||
});
|
});
|
||||||
toggleCreateValueFields();
|
toggleCreateValueFields();
|
||||||
await loadSubscriptionOptions();
|
await loadSubscriptionOptions();
|
||||||
await loadInternetPage();
|
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<datalist id="subscriptionLookupList"></datalist>
|
<datalist id="subscriptionLookupList"></datalist>
|
||||||
|
|||||||
@ -866,9 +866,9 @@ async def create_wall_outlet(data: WallOutletCreate):
|
|||||||
try:
|
try:
|
||||||
rows = execute_query(
|
rows = execute_query(
|
||||||
"""INSERT INTO locations_wall_outlets
|
"""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)
|
(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) RETURNING id""",
|
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.status, data.notes, data.is_active),
|
(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 []
|
) or []
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if 'unique' in str(exc).lower():
|
if 'unique' in str(exc).lower():
|
||||||
|
|||||||
@ -659,7 +659,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
|||||||
|
|
||||||
wall_outlets = execute_query(
|
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
|
FROM locations_wall_outlets
|
||||||
WHERE location_id = %s AND deleted_at IS NULL
|
WHERE location_id = %s AND deleted_at IS NULL
|
||||||
ORDER BY outlet_number
|
ORDER BY outlet_number
|
||||||
@ -780,7 +781,9 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
|||||||
for cross_field in cross_fields or []:
|
for cross_field in cross_fields or []:
|
||||||
cross_field["ports"] = execute_query(
|
cross_field["ports"] = execute_query(
|
||||||
"""SELECT p.id, p.port_number, p.port_order, p.is_active,
|
"""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
|
l.name AS outlet_location_name
|
||||||
FROM locations_cross_field_ports p
|
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
|
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""",
|
WHERE p.cross_field_id = %s ORDER BY p.port_order""",
|
||||||
(cross_field["id"],),
|
(cross_field["id"],),
|
||||||
) or []
|
) 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(
|
audit_log = execute_query(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -121,6 +121,7 @@ class WallOutletBase(BaseModel):
|
|||||||
switch_hardware_id: Optional[int] = Field(None, ge=1)
|
switch_hardware_id: Optional[int] = Field(None, ge=1)
|
||||||
switch_name: Optional[str] = Field(None, max_length=255)
|
switch_name: Optional[str] = Field(None, max_length=255)
|
||||||
switch_port: Optional[str] = Field(None, max_length=100)
|
switch_port: Optional[str] = Field(None, max_length=100)
|
||||||
|
is_wan: bool = False
|
||||||
status: str = Field('unknown')
|
status: str = Field('unknown')
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
@ -147,6 +148,7 @@ class WallOutletUpdate(BaseModel):
|
|||||||
switch_hardware_id: Optional[int] = Field(None, ge=1)
|
switch_hardware_id: Optional[int] = Field(None, ge=1)
|
||||||
switch_name: Optional[str] = Field(None, max_length=255)
|
switch_name: Optional[str] = Field(None, max_length=255)
|
||||||
switch_port: Optional[str] = Field(None, max_length=100)
|
switch_port: Optional[str] = Field(None, max_length=100)
|
||||||
|
is_wan: Optional[bool] = None
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|||||||
@ -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 { 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-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%; }
|
.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.assigned { background: #198754; border-color: #146c43; color:#fff; }
|
||||||
.patch-port.hardware-linked { background: #6f42c1; border-color: #59359f; 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.reserved { background: #ffc107; border-color: #d39e00; color:#332701; }
|
||||||
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
|
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
|
||||||
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
|
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
|
||||||
@ -810,7 +817,7 @@
|
|||||||
{% elif location.wall_outlets %}
|
{% elif location.wall_outlets %}
|
||||||
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody>
|
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody>
|
||||||
{% for outlet in location.wall_outlets %}
|
{% for outlet in location.wall_outlets %}
|
||||||
<tr><td><strong>{{ outlet.outlet_number or 'Ikke navngivet' }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number or '' }}" data-customer-id="{{ outlet.customer_id or '' }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
|
<tr><td><strong>{{ outlet.outlet_number or 'Ikke navngivet' }}</strong>{% if outlet.is_wan %} <span class="badge bg-info text-dark">WAN</span>{% endif %}{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number or '' }}" data-customer-id="{{ outlet.customer_id or '' }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-is-wan="{{ 'true' if outlet.is_wan else 'false' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody></table></div>
|
</tbody></table></div>
|
||||||
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
|
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
|
||||||
@ -865,9 +872,22 @@
|
|||||||
<div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm" id="addCrossFieldHardwareBtn"><i class="bi bi-hdd-network me-1"></i>Tilføj switch</button><button type="button" class="btn btn-primary btn-sm" id="addCrossFieldBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj krydsfelt</button></div>
|
<div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm" id="addCrossFieldHardwareBtn"><i class="bi bi-hdd-network me-1"></i>Tilføj switch</button><button type="button" class="btn btn-primary btn-sm" id="addCrossFieldBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj krydsfelt</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4 p-3 rounded border bg-light">
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<strong class="small">Vis porte:</strong>
|
||||||
|
<div class="btn-group btn-group-sm" role="group" aria-label="Filtrer porte">
|
||||||
|
<button type="button" class="btn btn-primary smart-port-filter active" data-filter="all">Alle</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning smart-port-filter" data-filter="issue">Kun fejl <span class="badge text-bg-warning ms-1" id="smartIssueCount">0</span></button>
|
||||||
|
<button type="button" class="btn btn-outline-info smart-port-filter" data-filter="wan">WAN</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary smart-port-filter" data-filter="free">Ledige</button>
|
||||||
|
</div>
|
||||||
|
<span class="small text-muted">Grøn = aktiv · Gul = fejl · Turkis = WAN · Lilla = hardware · Blågrå = ikke konfigureret</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="openBulkPatchBtn"><i class="bi bi-diagram-3 me-1"></i>Massepatch</button>
|
||||||
|
</div>
|
||||||
{% for field in location.cross_fields %}
|
{% for field in location.cross_fields %}
|
||||||
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}</span></div><div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm edit-port-labels-btn" data-id="{{ field.id }}" data-name="{{ field.name }}"><i class="bi bi-list-ol"></i> Portnumre</button><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-label-format="{{ field.port_label_format }}" data-start-number="{{ field.start_port_number }}" data-row-size="{{ field.panel_row_size }}" data-display-order="{{ field.display_order }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div></div>
|
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}</span></div><div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm edit-port-labels-btn" data-id="{{ field.id }}" data-name="{{ field.name }}"><i class="bi bi-list-ol"></i> Portnumre</button><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-label-format="{{ field.port_label_format }}" data-start-number="{{ field.start_port_number }}" data-row-size="{{ field.panel_row_size }}" data-display-order="{{ field.display_order }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div></div>
|
||||||
<div class="patch-panel"><div class="patch-panel-grid" style="grid-template-columns: repeat({{ field.panel_row_size or 24 }}, minmax(34px, 1fr));">{% 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 '') %}<button type="button" class="patch-port {{ port_class }}" {% if not port.outlet_id %}data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}"{% else %}disabled{% endif %} title="{% if port.outlet_id %}{{ port.outlet_location_name }} · {{ port.outlet_number }} ({{ port.outlet_status }}){% else %}Ledig port — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% endif %}</button>{% endfor %}</div></div></div>
|
<div class="patch-panel"><div class="patch-panel-grid" style="grid-template-columns: repeat({{ field.panel_row_size or 24 }}, minmax(34px, 1fr));">{% 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'))) %}<button type="button" class="patch-port smart-port {{ port_class }}" data-smart-state="{{ port.smart_state }}" data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}" data-outlet-id="{{ port.outlet_id or '' }}" data-outlet-number="{{ port.outlet_number or '' }}" data-outlet-customer-id="{{ port.customer_id or '' }}" data-outlet-category="{{ port.category or '' }}" data-outlet-panel="{{ port.patch_panel or '' }}" data-outlet-patch-port="{{ port.patch_port or '' }}" data-outlet-switch="{{ port.switch_name or '' }}" data-outlet-switch-port="{{ port.switch_port or '' }}" data-outlet-is-wan="{{ 'true' if port.is_wan else 'false' }}" data-outlet-status="{{ port.outlet_status or '' }}" data-outlet-notes="{{ port.outlet_notes or '' }}" title="{% if port.outlet_id %}{{ port.outlet_location_name }} → {{ port.outlet_number or 'vægstik' }} → {{ field.name }} port {{ port.port_number }}{% if port.switch_name %} → {{ port.switch_name }} port {{ port.switch_port }}{% endif %}{% if port.smart_state == 'issue' %} · FEJL: registreret, men intet fysisk link{% endif %}{% else %}Ikke konfigureret — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.is_wan %}<span class="patch-port-outlet">WAN{% if port.outlet_number %} · {{ port.outlet_number }}{% endif %}</span>{% elif port.smart_state == 'issue' %}<span class="patch-port-outlet">⚠ INTET LINK</span>{% elif port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% else %}<span class="patch-port-outlet">IKKE KONFIG.</span>{% endif %}</button>{% endfor %}</div></div></div>
|
||||||
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
|
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -887,7 +907,11 @@
|
|||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex justify-content-between align-items-center gap-3">
|
<div class="d-flex justify-content-between align-items-center gap-3">
|
||||||
<div><div class="fw-600"><a href="/hardware/{{ hw.id }}" class="text-decoration-none">{{ hw.brand }} {{ hw.model }}</a></div><div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div></div>
|
<div><div class="fw-600"><a href="/hardware/{{ hw.id }}" class="text-decoration-none">{{ hw.brand }} {{ hw.model }}</a></div><div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div></div>
|
||||||
<div class="d-flex align-items-center gap-2"><div class="input-group input-group-sm" style="width: 175px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div><span class="badge bg-secondary">{{ hw.status }}</span></div>
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge bg-primary text-nowrap">Nr. {{ hw.location_display_order or loop.index }}</span>
|
||||||
|
<div class="input-group input-group-sm" style="width: 225px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}" aria-label="Rækkefølge for {{ hw.brand }} {{ hw.model }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div>
|
||||||
|
<span class="badge bg-secondary">{{ hw.status }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if hw.uisp_device %}
|
{% if hw.uisp_device %}
|
||||||
{% set uisp = hw.uisp_device %}
|
{% set uisp = hw.uisp_device %}
|
||||||
@ -895,6 +919,7 @@
|
|||||||
<div class="mt-3 p-3 rounded border bg-light">
|
<div class="mt-3 p-3 rounded border bg-light">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2"><div class="small fw-semibold text-primary"><i class="bi bi-broadcast-pin me-1"></i>UISP live-data</div>{% if uisp.device_link %}<a href="{{ uisp.device_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn i UISP</a>{% endif %}</div>
|
<div class="d-flex justify-content-between align-items-center mb-2"><div class="small fw-semibold text-primary"><i class="bi bi-broadcast-pin me-1"></i>UISP live-data</div>{% if uisp.device_link %}<a href="{{ uisp.device_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn i UISP</a>{% endif %}</div>
|
||||||
<div class="row g-2 small">
|
<div class="row g-2 small">
|
||||||
|
<div class="col-sm-3"><span class="text-muted d-block">Hostnavn</span><strong>{{ uisp.hostname or uisp.display_name or uisp.name or '—' }}</strong></div>
|
||||||
<div class="col-sm-3"><span class="text-muted d-block">Status</span><strong>{{ uisp.status or 'Ukendt' }}</strong></div>
|
<div class="col-sm-3"><span class="text-muted d-block">Status</span><strong>{{ uisp.status or 'Ukendt' }}</strong></div>
|
||||||
<div class="col-sm-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp.ip_addresses or []) | join(', ') or '—' }}</strong></div>
|
<div class="col-sm-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp.ip_addresses or []) | join(', ') or '—' }}</strong></div>
|
||||||
<div class="col-sm-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp.mac_address or '—' }}</strong></div>
|
<div class="col-sm-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp.mac_address or '—' }}</strong></div>
|
||||||
@ -909,7 +934,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if hw.switch_ports %}
|
{% if hw.switch_ports %}
|
||||||
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}<a href="/hardware/{{ hw.id }}" class="patch-port text-decoration-none {% if port.hardware_link %}hardware-linked{% elif port.outlet %}assigned{% endif %}{% if port.live %} {{ 'border border-success' if port.live.plugged else 'border border-danger' }}{% endif %}" title="{% if port.live %}Live: {{ port.live.status or ('forbundet' if port.live.plugged else 'ikke forbundet') }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}. {% endif %}{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% else %}Ledig port — åbn switch for at tilknytte{% endif %}"><span>{{ port.port_number }}</span>{% if port.live %}<span class="patch-port-outlet">{{ 'LIVE' if port.live.plugged else 'INTET LINK' }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}</span>{% elif port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>{% else %}<span class="patch-port-outlet">Ledig</span>{% endif %}</a>{% endfor %}</div></div><div class="form-text mt-2">Grøn/rød kant viser live linkstatus fra UISP.</div></details>
|
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}
|
||||||
|
{% if port.hardware_link %}
|
||||||
|
<a href="/hardware/{{ hw.id }}" class="patch-port smart-port text-decoration-none hardware-linked{% if port.live %} {{ 'border border-success' if port.live.plugged else 'border border-danger' }}{% endif %}" data-smart-state="hardware" title="Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %} — klik for hardwaredetaljer">
|
||||||
|
{% else %}
|
||||||
|
{% set switch_state = 'wan' if port.outlet and port.outlet.is_wan else ('issue' if (port.outlet and port.live and not port.live.plugged) or (not port.outlet and port.live and port.live.plugged) else ('assigned' if port.outlet else 'free')) %}
|
||||||
|
<button type="button" class="patch-port smart-port switch-port-action{% if switch_state == 'wan' %} wan{% elif switch_state == 'issue' %} smart-issue{% elif port.outlet %} assigned{% else %} unconfigured{% endif %}{% if port.live %} {{ 'border border-success' if port.live.plugged else 'border border-danger' }}{% endif %}" data-smart-state="{{ switch_state }}" data-switch-name="{{ hw.uisp_device.hostname if hw.uisp_device and hw.uisp_device.hostname else ((hw.brand ~ ' · ' if hw.brand else '') ~ (hw.model or '') ~ (' · ' ~ hw.serial_number if hw.serial_number else '')) }}" data-switch-port="{{ port.port_number }}" data-outlet-id="{{ port.outlet.id if port.outlet else '' }}" data-outlet-number="{{ port.outlet.outlet_number if port.outlet else '' }}" data-outlet-customer-id="{{ port.outlet.customer_id if port.outlet else '' }}" data-outlet-category="{{ port.outlet.category if port.outlet else '' }}" data-outlet-panel="{{ port.outlet.patch_panel if port.outlet else '' }}" data-outlet-patch-port="{{ port.outlet.patch_port if port.outlet else '' }}" data-outlet-cross-field-port-id="{{ port.outlet.cross_field_port_id if port.outlet else '' }}" data-outlet-is-wan="{{ 'true' if port.outlet and port.outlet.is_wan else 'false' }}" data-outlet-status="{{ port.outlet.status if port.outlet else '' }}" data-outlet-notes="{{ port.outlet.notes if port.outlet else '' }}" title="{% if port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% if port.outlet.is_wan %} · WAN{% endif %}{% if switch_state == 'issue' %} · FEJL: registreret, men intet fysisk link{% endif %} — klik for at redigere{% elif switch_state == 'issue' %}UISP ser fysisk link, men porten er ikke registreret{% else %}Ikke konfigureret — klik for at tilknytte vægstik{% endif %}">
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ port.port_number }}</span>
|
||||||
|
{% if port.live %}<span class="patch-port-outlet">{{ 'LIVE' if port.live.plugged else 'INTET LINK' }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}</span>
|
||||||
|
{% elif port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>
|
||||||
|
{% elif port.outlet and port.outlet.is_wan %}<span class="patch-port-outlet">WAN{% if port.outlet.outlet_number %} · {{ port.outlet.outlet_number }}{% endif %}</span>
|
||||||
|
{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>
|
||||||
|
{% else %}<span class="patch-port-outlet">IKKE KONFIG.</span>{% endif %}
|
||||||
|
{% if port.hardware_link %}</a>{% else %}</button>{% endif %}
|
||||||
|
{% endfor %}</div></div><div class="form-text mt-2">Klik på en switch-port for at oprette eller redigere tilknytningen. Grøn/rød kant viser live linkstatus fra UISP.</div></details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -1092,20 +1131,42 @@
|
|||||||
|
|
||||||
<div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div>
|
<div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="bulkPatchModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg"><form class="modal-content" id="bulkPatchForm">
|
||||||
|
<div class="modal-header"><h5 class="modal-title">Massepatch porte</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-warning small"><strong>Kontroller før du gemmer.</strong> Funktionen forbinder et sammenhængende område af krydsfelt-porte med samme antal switch-porte.</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6"><label class="form-label">Krydsfelt</label><select class="form-select" id="bulkCrossField" required></select></div>
|
||||||
|
<div class="col-md-3"><label class="form-label">Fra portposition</label><input class="form-control" id="bulkFromPort" type="number" min="1" value="1" required></div>
|
||||||
|
<div class="col-md-3"><label class="form-label">Til portposition</label><input class="form-control" id="bulkToPort" type="number" min="1" value="24" required></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Switch (UISP-hostnavn)</label><select class="form-select" id="bulkSwitch" required></select></div>
|
||||||
|
<div class="col-md-3"><label class="form-label">Start switch-port</label><input class="form-control" id="bulkSwitchStart" type="number" min="1" value="1" required></div>
|
||||||
|
<div class="col-md-3 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" id="bulkIsWan" type="checkbox"><label class="form-check-label fw-semibold" for="bulkIsWan">WAN</label></div></div>
|
||||||
|
<div class="col-12"><label class="form-label">Firma</label><input class="form-control mb-2" id="bulkCustomerSearch" type="search" placeholder="Søg firma…"><select class="form-select" id="bulkCustomer"><option value="">Ingen specifik kunde</option></select></div>
|
||||||
|
<div class="col-12"><div class="p-3 rounded border bg-light small" id="bulkPatchPreview">Vælg område og switch for at se en forhåndsvisning.</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button type="submit" class="btn btn-primary" id="bulkPatchSubmit">Kontrollér og gem</button></div>
|
||||||
|
</form></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="outletModalTitle">Tilføj vægstik</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
<div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="outletModalTitle">Tilføj vægstik</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
<form id="outletForm"><div class="modal-body">
|
<form id="outletForm"><div class="modal-body">
|
||||||
<input type="hidden" id="outletId"><div class="row g-3">
|
<input type="hidden" id="outletId"><div class="row g-3">
|
||||||
|
<div class="col-12"><div class="alert alert-primary py-2 mb-0 small" id="outletConnectionPath"><strong>Kabelsti:</strong> endnu ikke forbundet</div></div>
|
||||||
<div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div>
|
<div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div>
|
||||||
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer</label><input class="form-control" id="outletNumber" placeholder="Valgfrit, fx A-12 eller 1.23.04"></div>
|
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer</label><input class="form-control" id="outletNumber" placeholder="Valgfrit, fx A-12 eller 1.23.04"></div>
|
||||||
<div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div>
|
<div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div>
|
||||||
<div class="col-12"><label class="form-label">Kunde på porten</label><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Bruges fx hvis et stik eller en switch-port er tildelt en bestemt lejer/kunde.</div></div>
|
<div class="col-12"><label class="form-label" for="outletCustomerSearch">Søg firma</label><input class="form-control mb-2" id="outletCustomerSearch" type="search" placeholder="Skriv firmanavn…"><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Søg og vælg firmaet, som porten er tildelt.</div></div>
|
||||||
<div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div>
|
<div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div>
|
||||||
<div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div>
|
<div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div>
|
||||||
<div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div>
|
<div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div>
|
||||||
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" list="outletSwitchOptions" placeholder="Vælg registreret switch eller skriv navn"><datalist id="outletSwitchOptions"></datalist></div>
|
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" list="outletSwitchOptions" placeholder="Vælg registreret switch eller skriv navn"><datalist id="outletSwitchOptions"></datalist></div>
|
||||||
<div class="col-md-6"><label class="form-label">Switch-port</label><select class="form-select" id="outletSwitchPort"><option value="">Vælg port</option></select><div class="form-text" id="outletSwitchPortHelp">Vælg først en switch.</div></div>
|
<div class="col-md-6"><label class="form-label">Switch-port</label><select class="form-select" id="outletSwitchPort"><option value="">Vælg port</option></select><div class="form-text" id="outletSwitchPortHelp">Vælg først en switch.</div></div>
|
||||||
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div>
|
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div>
|
||||||
|
<div class="col-md-6 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" role="switch" id="outletIsWan"><label class="form-check-label fw-semibold" for="outletIsWan">WAN-forbindelse</label><div class="form-text">WAN-porte vises med turkis farve.</div></div></div>
|
||||||
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
|
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
|
||||||
</div>
|
</div>
|
||||||
</div><div class="modal-footer"><button type="button" class="btn btn-outline-danger me-auto d-none" id="deleteOutletBtn">Slet stik</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div></form>
|
</div><div class="modal-footer"><button type="button" class="btn btn-outline-danger me-auto d-none" id="deleteOutletBtn">Slet stik</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div></form>
|
||||||
@ -1147,6 +1208,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const locationId = '{{ location.id }}';
|
const locationId = '{{ location.id }}';
|
||||||
const locationHardware = {{ location.hardware | tojson }};
|
const locationHardware = {{ location.hardware | tojson }};
|
||||||
const locationWallOutlets = {{ location.wall_outlets | tojson }};
|
const locationWallOutlets = {{ location.wall_outlets | tojson }};
|
||||||
|
const locationCrossFields = {{ location.cross_fields | tojson }};
|
||||||
const existingContactSearchInput = document.getElementById('existingContactSearch');
|
const existingContactSearchInput = document.getElementById('existingContactSearch');
|
||||||
const existingContactResultsContainer = document.getElementById('existingContactResults');
|
const existingContactResultsContainer = document.getElementById('existingContactResults');
|
||||||
const existingContactIdInput = document.getElementById('existingContactId');
|
const existingContactIdInput = document.getElementById('existingContactId');
|
||||||
@ -1498,6 +1560,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const outletValue = (id) => document.getElementById(id).value.trim() || null;
|
const outletValue = (id) => document.getElementById(id).value.trim() || null;
|
||||||
|
|
||||||
function switchDisplayName(hardware) {
|
function switchDisplayName(hardware) {
|
||||||
|
const uispHostname = hardware.uisp_device?.hostname;
|
||||||
|
if (uispHostname) return uispHostname;
|
||||||
return [hardware.brand, hardware.model, hardware.serial_number].filter(Boolean).join(' · ') || `Switch #${hardware.id}`;
|
return [hardware.brand, hardware.model, hardware.serial_number].filter(Boolean).join(' · ') || `Switch #${hardware.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1510,6 +1574,125 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
return Number.isInteger(count) && count > 0 ? count : 0;
|
return Number.isInteger(count) && count > 0 ? count : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const smartPorts = Array.from(document.querySelectorAll('.smart-port'));
|
||||||
|
document.getElementById('smartIssueCount').textContent = String(smartPorts.filter(port => port.dataset.smartState === 'issue').length);
|
||||||
|
document.querySelectorAll('.smart-port-filter').forEach(button => button.addEventListener('click', () => {
|
||||||
|
const filter = button.dataset.filter;
|
||||||
|
smartPorts.forEach(port => port.classList.toggle('smart-port-hidden', filter !== 'all' && port.dataset.smartState !== filter));
|
||||||
|
document.querySelectorAll('.smart-port-filter').forEach(item => {
|
||||||
|
item.classList.toggle('active', item === button);
|
||||||
|
item.classList.toggle('btn-primary', item === button);
|
||||||
|
if (item !== button) item.classList.remove('btn-primary');
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bulkPatchElement = document.getElementById('bulkPatchModal');
|
||||||
|
const bulkPatchModal = bulkPatchElement ? new bootstrap.Modal(bulkPatchElement) : null;
|
||||||
|
const bulkFieldSelect = document.getElementById('bulkCrossField');
|
||||||
|
const bulkSwitchSelect = document.getElementById('bulkSwitch');
|
||||||
|
const bulkCustomerSelect = document.getElementById('bulkCustomer');
|
||||||
|
|
||||||
|
function selectedBulkField() {
|
||||||
|
return (locationCrossFields || []).find(field => Number(field.id) === Number(bulkFieldSelect.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkPreview() {
|
||||||
|
const field = selectedBulkField();
|
||||||
|
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
|
||||||
|
const from = Number(document.getElementById('bulkFromPort').value);
|
||||||
|
const to = Number(document.getElementById('bulkToPort').value);
|
||||||
|
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
|
||||||
|
const count = Number.isInteger(from) && Number.isInteger(to) && to >= from ? to - from + 1 : 0;
|
||||||
|
const firstPort = field?.ports?.[from - 1]?.port_number || '—';
|
||||||
|
const lastPort = field?.ports?.[to - 1]?.port_number || '—';
|
||||||
|
document.getElementById('bulkPatchPreview').innerHTML = count
|
||||||
|
? `<strong>${count} forbindelser:</strong> ${field?.name || '—'} port ${firstPort}–${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${switchStart}–${switchStart + count - 1}`
|
||||||
|
: 'Vælg et gyldigt portområde.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBulkPatch() {
|
||||||
|
bulkFieldSelect.innerHTML = (locationCrossFields || []).map(field => `<option value="${field.id}">${field.name} · ${field.port_count} porte</option>`).join('');
|
||||||
|
const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk');
|
||||||
|
bulkSwitchSelect.innerHTML = switches.map(item => `<option value="${item.id}">${switchDisplayName(item)} · ${switchPortCount(item)} porte</option>`).join('');
|
||||||
|
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
|
||||||
|
const data = response.ok ? await response.json() : [];
|
||||||
|
const customers = Array.isArray(data) ? data : (data.customers || []);
|
||||||
|
bulkCustomerSelect.innerHTML = '<option value="">Ingen specifik kunde</option>' + customers.map(customer => `<option value="${customer.id}">${customer.name || customer.navn || `Kunde #${customer.id}`}</option>`).join('');
|
||||||
|
const firstField = selectedBulkField();
|
||||||
|
document.getElementById('bulkToPort').value = Math.min(24, firstField?.ports?.length || 1);
|
||||||
|
updateBulkPreview();
|
||||||
|
bulkPatchModal?.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('openBulkPatchBtn')?.addEventListener('click', openBulkPatch);
|
||||||
|
['bulkCrossField', 'bulkSwitch', 'bulkFromPort', 'bulkToPort', 'bulkSwitchStart'].forEach(id => document.getElementById(id)?.addEventListener('input', updateBulkPreview));
|
||||||
|
document.getElementById('bulkCustomerSearch')?.addEventListener('input', event => {
|
||||||
|
const query = event.target.value.trim().toLocaleLowerCase('da');
|
||||||
|
Array.from(bulkCustomerSelect.options).forEach((option, index) => {
|
||||||
|
option.hidden = index > 0 && Boolean(query) && !option.textContent.toLocaleLowerCase('da').includes(query);
|
||||||
|
});
|
||||||
|
const match = Array.from(bulkCustomerSelect.options).find((option, index) => index > 0 && !option.hidden);
|
||||||
|
if (query && match) bulkCustomerSelect.value = match.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('bulkPatchForm')?.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const field = selectedBulkField();
|
||||||
|
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
|
||||||
|
const from = Number(document.getElementById('bulkFromPort').value);
|
||||||
|
const to = Number(document.getElementById('bulkToPort').value);
|
||||||
|
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
|
||||||
|
if (!field || !hardware || !Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < from || to > field.ports.length) {
|
||||||
|
alert('Vælg et gyldigt krydsfelt, en switch og et portområde.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ports = field.ports.slice(from - 1, to);
|
||||||
|
if (switchStart < 1 || switchStart + ports.length - 1 > switchPortCount(hardware)) {
|
||||||
|
alert('Portområdet går ud over switchens registrerede antal porte.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const conflicts = ports.map((port, index) => switchPortConflict(hardware, switchDisplayName(hardware), switchStart + index, port.outlet_id || null)).filter(Boolean);
|
||||||
|
if (conflicts.length) {
|
||||||
|
alert(`Massepatch blev stoppet: ${conflicts.length} switch-port(e) er allerede knyttet til andre vægstik.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existingCount = ports.filter(port => port.outlet_id).length;
|
||||||
|
if (!confirm(`Opret/opdatér ${ports.length} forbindelser?\n\n${existingCount} eksisterende vægstik bliver opdateret.`)) return;
|
||||||
|
const submit = document.getElementById('bulkPatchSubmit');
|
||||||
|
submit.disabled = true;
|
||||||
|
submit.textContent = 'Gemmer…';
|
||||||
|
const customerId = bulkCustomerSelect.value ? Number(bulkCustomerSelect.value) : null;
|
||||||
|
const isWan = document.getElementById('bulkIsWan').checked;
|
||||||
|
const results = await Promise.all(ports.map(async (port, index) => {
|
||||||
|
const payload = {
|
||||||
|
outlet_number: port.outlet_number || `${field.name}-${port.port_number}`,
|
||||||
|
customer_id: customerId,
|
||||||
|
category: port.category || null,
|
||||||
|
patch_panel: field.name,
|
||||||
|
patch_port: String(port.port_number),
|
||||||
|
cross_field_port_id: Number(port.id),
|
||||||
|
switch_hardware_id: Number(hardware.id),
|
||||||
|
switch_name: switchDisplayName(hardware),
|
||||||
|
switch_port: String(switchStart + index),
|
||||||
|
is_wan: isWan,
|
||||||
|
status: 'active',
|
||||||
|
notes: port.outlet_notes || null
|
||||||
|
};
|
||||||
|
if (!port.outlet_id) payload.location_id = Number(locationId);
|
||||||
|
const response = await fetch(port.outlet_id ? `/api/v1/locations/outlets/${port.outlet_id}` : '/api/v1/locations/outlets', {
|
||||||
|
method: port.outlet_id ? 'PATCH' : 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
}));
|
||||||
|
const failed = results.filter(ok => !ok).length;
|
||||||
|
if (failed) {
|
||||||
|
alert(`${results.length - failed} forbindelser blev gemt, men ${failed} fejlede. Siden genindlæses.`);
|
||||||
|
}
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
function selectedSwitchHardwareId() {
|
function selectedSwitchHardwareId() {
|
||||||
const selectedName = document.getElementById('outletSwitch').value;
|
const selectedName = document.getElementById('outletSwitch').value;
|
||||||
const match = (locationHardware || []).find(item => switchDisplayName(item) === selectedName);
|
const match = (locationHardware || []).find(item => switchDisplayName(item) === selectedName);
|
||||||
@ -1587,20 +1770,46 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
async function loadOutletCustomers(selectedCustomerId = null) {
|
async function loadOutletCustomers(selectedCustomerId = null) {
|
||||||
const select = document.getElementById('outletCustomerId');
|
const select = document.getElementById('outletCustomerId');
|
||||||
const customers = await fetch('/api/v1/customers?limit=1000').then(response => response.ok ? response.json() : []);
|
document.getElementById('outletCustomerSearch').value = '';
|
||||||
select.innerHTML = '<option value="">Ingen specifik kunde / brug lokationens kunde</option>';
|
select.innerHTML = '<option value="">Ingen specifik kunde / brug lokationens kunde</option>';
|
||||||
(customers || []).forEach(customer => {
|
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
|
||||||
|
if (!response.ok) {
|
||||||
|
select.insertAdjacentHTML('beforeend', '<option value="" disabled>Kunne ikke indlæse kunder</option>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const customers = Array.isArray(data) ? data : (data.customers || []);
|
||||||
|
customers.forEach(customer => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = String(customer.id);
|
option.value = String(customer.id);
|
||||||
option.textContent = customer.name || customer.navn || `Kunde #${customer.id}`;
|
option.textContent = customer.name || customer.navn || `Kunde #${customer.id}`;
|
||||||
option.selected = String(customer.id) === String(selectedCustomerId || '');
|
option.selected = String(customer.id) === String(selectedCustomerId || '');
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
|
if (!customers.length) {
|
||||||
|
select.insertAdjacentHTML('beforeend', '<option value="" disabled>Ingen kunder fundet</option>');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('outletCustomerSearch')?.addEventListener('input', (event) => {
|
||||||
|
const query = event.target.value.trim().toLocaleLowerCase('da');
|
||||||
|
const select = document.getElementById('outletCustomerId');
|
||||||
|
Array.from(select.options).forEach((option, index) => {
|
||||||
|
option.hidden = index > 0 && Boolean(query) && !option.textContent.toLocaleLowerCase('da').includes(query);
|
||||||
|
});
|
||||||
|
const firstMatch = Array.from(select.options).find((option, index) => index > 0 && !option.hidden && !option.disabled);
|
||||||
|
if (query && firstMatch) select.value = firstMatch.value;
|
||||||
|
});
|
||||||
|
|
||||||
async function openOutletModal(outlet = null, selectedPort = null) {
|
async function openOutletModal(outlet = null, selectedPort = null) {
|
||||||
if (!outletModal) return;
|
if (!outletModal) return;
|
||||||
await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
|
const isExistingOutlet = Boolean(outlet?.id);
|
||||||
|
outletModal.show();
|
||||||
|
try {
|
||||||
|
await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Kunne ikke indlæse vægstik-data', error);
|
||||||
|
}
|
||||||
document.getElementById('outletId').value = outlet?.id || '';
|
document.getElementById('outletId').value = outlet?.id || '';
|
||||||
document.getElementById('outletNumber').value = outlet?.number || '';
|
document.getElementById('outletNumber').value = outlet?.number || '';
|
||||||
document.getElementById('outletCategory').value = outlet?.category || '';
|
document.getElementById('outletCategory').value = outlet?.category || '';
|
||||||
@ -1608,7 +1817,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
|
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
|
||||||
loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null);
|
loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null);
|
||||||
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
|
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
|
||||||
|
document.getElementById('outletIsWan').checked = Boolean(outlet?.isWan);
|
||||||
document.getElementById('outletNotes').value = outlet?.notes || '';
|
document.getElementById('outletNotes').value = outlet?.notes || '';
|
||||||
|
const pathParts = [
|
||||||
|
outlet?.number || 'Nyt vægstik',
|
||||||
|
selectedPort ? `${selectedPort.fieldName} port ${selectedPort.portNumber}` : (outlet?.panel && outlet?.patchPort ? `${outlet.panel} port ${outlet.patchPort}` : null),
|
||||||
|
outlet?.switchName && outlet?.switchPort ? `${outlet.switchName} port ${outlet.switchPort}` : null
|
||||||
|
].filter(Boolean);
|
||||||
|
document.getElementById('outletConnectionPath').textContent = `Kabelsti: ${pathParts.join(' → ')}`;
|
||||||
|
if (outlet?.crossFieldPortId) {
|
||||||
|
const portSelect = document.getElementById('outletCrossFieldPort');
|
||||||
|
portSelect.value = String(outlet.crossFieldPortId);
|
||||||
|
if (portSelect.value !== String(outlet.crossFieldPortId)) {
|
||||||
|
portSelect.insertAdjacentHTML('beforeend', `<option value="${outlet.crossFieldPortId}" selected>Tilknyttet krydsfelt-port</option>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (selectedPort) {
|
if (selectedPort) {
|
||||||
const portSelect = document.getElementById('outletCrossFieldPort');
|
const portSelect = document.getElementById('outletCrossFieldPort');
|
||||||
portSelect.value = String(selectedPort.id);
|
portSelect.value = String(selectedPort.id);
|
||||||
@ -1618,16 +1841,52 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
document.getElementById('outletPatchPanel').value = selectedPort.fieldName;
|
document.getElementById('outletPatchPanel').value = selectedPort.fieldName;
|
||||||
document.getElementById('outletPatchPort').value = selectedPort.portNumber;
|
document.getElementById('outletPatchPort').value = selectedPort.portNumber;
|
||||||
}
|
}
|
||||||
document.getElementById('outletModalTitle').textContent = outlet ? 'Rediger vægstik' : 'Tilføj vægstik';
|
document.getElementById('outletModalTitle').textContent = isExistingOutlet ? 'Rediger vægstik' : 'Tilføj vægstik';
|
||||||
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !outlet);
|
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !isExistingOutlet);
|
||||||
outletModal.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
|
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
|
||||||
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => openOutletModal(null, {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber})));
|
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => {
|
||||||
|
const selectedPort = {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber};
|
||||||
|
const outlet = port.dataset.outletId ? {
|
||||||
|
id: port.dataset.outletId,
|
||||||
|
number: port.dataset.outletNumber,
|
||||||
|
customerId: port.dataset.outletCustomerId,
|
||||||
|
category: port.dataset.outletCategory,
|
||||||
|
panel: port.dataset.outletPanel,
|
||||||
|
patchPort: port.dataset.outletPatchPort,
|
||||||
|
switchName: port.dataset.outletSwitch,
|
||||||
|
switchPort: port.dataset.outletSwitchPort,
|
||||||
|
isWan: port.dataset.outletIsWan === 'true',
|
||||||
|
status: port.dataset.outletStatus,
|
||||||
|
notes: port.dataset.outletNotes
|
||||||
|
} : null;
|
||||||
|
openOutletModal(outlet, selectedPort);
|
||||||
|
}));
|
||||||
|
document.querySelectorAll('.switch-port-action').forEach(port => port.addEventListener('click', () => {
|
||||||
|
const outlet = port.dataset.outletId ? {
|
||||||
|
id: port.dataset.outletId,
|
||||||
|
number: port.dataset.outletNumber,
|
||||||
|
customerId: port.dataset.outletCustomerId,
|
||||||
|
category: port.dataset.outletCategory,
|
||||||
|
panel: port.dataset.outletPanel,
|
||||||
|
patchPort: port.dataset.outletPatchPort,
|
||||||
|
crossFieldPortId: port.dataset.outletCrossFieldPortId,
|
||||||
|
switchName: port.dataset.switchName,
|
||||||
|
switchPort: port.dataset.switchPort,
|
||||||
|
isWan: port.dataset.outletIsWan === 'true',
|
||||||
|
status: port.dataset.outletStatus,
|
||||||
|
notes: port.dataset.outletNotes
|
||||||
|
} : {
|
||||||
|
switchName: port.dataset.switchName,
|
||||||
|
switchPort: port.dataset.switchPort,
|
||||||
|
isWan: false
|
||||||
|
};
|
||||||
|
openOutletModal(outlet);
|
||||||
|
}));
|
||||||
document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({
|
document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({
|
||||||
id: btn.dataset.id, number: btn.dataset.number, customerId: btn.dataset.customerId, category: btn.dataset.category, panel: btn.dataset.panel,
|
id: btn.dataset.id, number: btn.dataset.number, customerId: btn.dataset.customerId, category: btn.dataset.category, panel: btn.dataset.panel,
|
||||||
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort,
|
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort, isWan: btn.dataset.isWan === 'true',
|
||||||
status: btn.dataset.status, notes: btn.dataset.notes
|
status: btn.dataset.status, notes: btn.dataset.notes
|
||||||
})));
|
})));
|
||||||
|
|
||||||
@ -1639,6 +1898,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
|
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
|
||||||
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
|
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
|
||||||
switch_hardware_id: selectedSwitchHardwareId(), switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
|
switch_hardware_id: selectedSwitchHardwareId(), switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
|
||||||
|
is_wan: document.getElementById('outletIsWan').checked,
|
||||||
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
|
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
|
||||||
};
|
};
|
||||||
const selectedSwitch = (locationHardware || []).find(item => Number(item.id) === selectedSwitchHardwareId());
|
const selectedSwitch = (locationHardware || []).find(item => Number(item.id) === selectedSwitchHardwareId());
|
||||||
|
|||||||
1
app/modules/migration_center/__init__.py
Normal file
1
app/modules/migration_center/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Manual migration centre for CRM subscriptions and imported invoice lines."""
|
||||||
1
app/modules/migration_center/backend/__init__.py
Normal file
1
app/modules/migration_center/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Migration centre backend."""
|
||||||
1415
app/modules/migration_center/backend/router.py
Normal file
1415
app/modules/migration_center/backend/router.py
Normal file
File diff suppressed because it is too large
Load Diff
641
app/modules/migration_center/backend/service.py
Normal file
641
app/modules/migration_center/backend/service.py
Normal file
@ -0,0 +1,641 @@
|
|||||||
|
"""Core repositories and workflows for the manual migration centre."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from psycopg2.extras import Json, RealDictCursor
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import (
|
||||||
|
execute_query,
|
||||||
|
execute_query_single,
|
||||||
|
get_db_connection,
|
||||||
|
release_db_connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MUTABLE_LOCK_STATES = {"unlocked", "lock_failed"}
|
||||||
|
|
||||||
|
|
||||||
|
def subscription_like_item_sql(alias: str = "migration_center_session_items") -> str:
|
||||||
|
"""Fast predicate using the classification refreshed when snapshots change."""
|
||||||
|
p = f"{alias}." if alias else ""
|
||||||
|
return f"{p}subscription_like=TRUE"
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_subscription_relevance(session_id: int) -> Dict[str, int]:
|
||||||
|
"""Classify imported invoice lines once so all views share a fast, consistent filter."""
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE migration_center_session_items
|
||||||
|
SET subscription_like=TRUE, subscription_relevance_reason='crm_subscription'
|
||||||
|
WHERE session_id=%s AND source_system<>'economic'
|
||||||
|
""",
|
||||||
|
(session_id,), fetch=False,
|
||||||
|
)
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE migration_center_session_items
|
||||||
|
SET subscription_like=FALSE, subscription_relevance_reason='one_off'
|
||||||
|
WHERE session_id=%s AND source_system='economic'
|
||||||
|
""",
|
||||||
|
(session_id,), fetch=False,
|
||||||
|
)
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE migration_center_session_items
|
||||||
|
SET subscription_relevance_reason='excluded_charge'
|
||||||
|
WHERE session_id=%s AND source_system='economic'
|
||||||
|
AND (
|
||||||
|
POSITION('gebyr' IN LOWER(COALESCE(product_name,'')))>0
|
||||||
|
OR POSITION('fragt' IN LOWER(COALESCE(product_name,'')))>0
|
||||||
|
OR POSITION('porto' IN LOWER(COALESCE(product_name,'')))>0
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(session_id,), fetch=False,
|
||||||
|
)
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE migration_center_session_items
|
||||||
|
SET subscription_like=TRUE, subscription_relevance_reason='subscription_keyword'
|
||||||
|
WHERE session_id=%s AND source_system='economic'
|
||||||
|
AND subscription_relevance_reason<>'excluded_charge'
|
||||||
|
AND LOWER(COALESCE(product_name,'')) ~
|
||||||
|
'(abonnement|subscription|måned|kvartal|årlig|licens|license|fiber|internet|bredbånd|hosting|domæne|domain|cloud|microsoft|office[ ]?365|backup|supportaftale|driftsaftale|udlejning|leje|telefoni|simkort|eset)'
|
||||||
|
""",
|
||||||
|
(session_id,), fetch=False,
|
||||||
|
)
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
WITH recurring AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(NULLIF(customer_no,''),source_customer_id,customer_name) AS customer_key,
|
||||||
|
COALESCE(NULLIF(product_code,''),LOWER(REGEXP_REPLACE(product_name,'\\s+',' ','g'))) AS product_key
|
||||||
|
FROM migration_center_session_items
|
||||||
|
WHERE session_id=%s AND source_system='economic'
|
||||||
|
AND subscription_relevance_reason<>'excluded_charge'
|
||||||
|
AND invoice_date >= (DATE_TRUNC('month',CURRENT_DATE)-INTERVAL '12 months')::date
|
||||||
|
GROUP BY 1,2
|
||||||
|
HAVING COUNT(DISTINCT invoice_no)>=2
|
||||||
|
AND COUNT(DISTINCT DATE_TRUNC('month',invoice_date))>=2
|
||||||
|
)
|
||||||
|
UPDATE migration_center_session_items item
|
||||||
|
SET subscription_like=TRUE, subscription_relevance_reason='recurring_invoice'
|
||||||
|
FROM recurring
|
||||||
|
WHERE item.session_id=%s AND item.source_system='economic'
|
||||||
|
AND item.subscription_relevance_reason<>'excluded_charge'
|
||||||
|
AND COALESCE(NULLIF(item.customer_no,''),item.source_customer_id,item.customer_name)
|
||||||
|
IS NOT DISTINCT FROM recurring.customer_key
|
||||||
|
AND COALESCE(NULLIF(item.product_code,''),LOWER(REGEXP_REPLACE(item.product_name,'\\s+',' ','g')))
|
||||||
|
IS NOT DISTINCT FROM recurring.product_key
|
||||||
|
""",
|
||||||
|
(session_id, session_id), fetch=False,
|
||||||
|
)
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE migration_center_session_items item
|
||||||
|
SET subscription_like=TRUE, subscription_relevance_reason='existing_subscription'
|
||||||
|
WHERE item.session_id=%s AND item.source_system='economic'
|
||||||
|
AND item.subscription_relevance_reason<>'excluded_charge'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM sag_subscriptions subscription
|
||||||
|
WHERE subscription.customer_id=item.hub_customer_id
|
||||||
|
AND subscription.status<>'cancelled'
|
||||||
|
AND (
|
||||||
|
LOWER(TRIM(COALESCE(subscription.product_name,'')))=
|
||||||
|
LOWER(TRIM(COALESCE(item.product_name,'')))
|
||||||
|
OR ABS(COALESCE(subscription.price,0)-COALESCE(item.amount,0))<=0.01
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(session_id,), fetch=False,
|
||||||
|
)
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FILTER (WHERE subscription_like) AS visible,
|
||||||
|
COUNT(*) FILTER (WHERE NOT subscription_like) AS hidden
|
||||||
|
FROM migration_center_session_items WHERE session_id=%s
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
return {"visible": int(row["visible"]), "hidden": int(row["hidden"])}
|
||||||
|
|
||||||
|
|
||||||
|
def json_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, (datetime, date)):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(k): json_value(v) for k, v in value.items()}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [json_value(v) for v in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_hash(payload: Dict[str, Any]) -> str:
|
||||||
|
packed = json.dumps(json_value(payload), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(packed.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def user_id(current_user: Dict[str, Any]) -> Optional[int]:
|
||||||
|
value = current_user.get("id") or current_user.get("user_id")
|
||||||
|
return int(value) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def audit(
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
current_user: Dict[str, Any],
|
||||||
|
action: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: Any = None,
|
||||||
|
session_id: Optional[int] = None,
|
||||||
|
item_id: Optional[int] = None,
|
||||||
|
old_value: Any = None,
|
||||||
|
new_value: Any = None,
|
||||||
|
source_hash_value: Optional[str] = None,
|
||||||
|
success: bool = True,
|
||||||
|
error_message: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_center_audit_log
|
||||||
|
(session_id, session_item_id, entity_type, entity_id, action, old_value, new_value,
|
||||||
|
source_hash, performed_by_user_id, ip_address, success, error_message)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
session_id, item_id, entity_type, str(entity_id) if entity_id is not None else None,
|
||||||
|
action, Json(json_value(old_value)) if old_value is not None else None,
|
||||||
|
Json(json_value(new_value)) if new_value is not None else None,
|
||||||
|
source_hash_value, user_id(current_user),
|
||||||
|
request.client.host if request.client else None, success, error_message,
|
||||||
|
),
|
||||||
|
fetch=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_writable(session_id: Optional[int] = None) -> None:
|
||||||
|
if getattr(settings, "MIGRATION_CENTER_READ_ONLY", False):
|
||||||
|
raise HTTPException(status_code=423, detail="Migreringscenteret er i read-only tilstand")
|
||||||
|
if session_id:
|
||||||
|
session = execute_query_single(
|
||||||
|
"SELECT read_only, status FROM migration_center_sessions WHERE id = %s", (session_id,)
|
||||||
|
)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
|
||||||
|
if session["read_only"] or session["status"] in {"completed", "archived"}:
|
||||||
|
raise HTTPException(status_code=423, detail="Kontrolsessionen er skrivebeskyttet")
|
||||||
|
|
||||||
|
|
||||||
|
class EconomicSnapshotRepository:
|
||||||
|
"""Read-only access to the Invoice Error Finder invoice snapshot."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def latest_run() -> Optional[Dict[str, Any]]:
|
||||||
|
return execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT r.id, r.source_type, r.started_at, r.completed_at, r.status,
|
||||||
|
r.records_imported, r.records_failed, COUNT(i.id) AS invoice_count
|
||||||
|
FROM invoice_error_finder_import_runs r
|
||||||
|
JOIN invoice_error_finder_economic_invoices i ON i.import_run_id = r.id
|
||||||
|
WHERE r.source_type = 'economic_invoices'
|
||||||
|
AND r.status IN ('success', 'partial')
|
||||||
|
AND r.completed_at IS NOT NULL
|
||||||
|
GROUP BY r.id
|
||||||
|
HAVING COUNT(i.id) > 0
|
||||||
|
ORDER BY r.completed_at DESC, r.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def lines(run_id: int) -> List[Dict[str, Any]]:
|
||||||
|
# DISTINCT ON makes the source identity stable even if an API endpoint repeats a line.
|
||||||
|
return execute_query(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT ON (
|
||||||
|
COALESCE(i.source_invoice_number, i.id::text),
|
||||||
|
i.source_type,
|
||||||
|
COALESCE(l.line_number, l.id)
|
||||||
|
)
|
||||||
|
i.id AS invoice_id, i.source_invoice_number, i.source_type, i.customer_number,
|
||||||
|
i.customer_name, i.invoice_date, i.currency, i.source_raw AS invoice_raw,
|
||||||
|
l.id AS invoice_line_id, l.line_number, l.product_number, l.product_name,
|
||||||
|
l.description, l.quantity, l.unit_price, l.line_net_amount, l.source_raw AS line_raw
|
||||||
|
FROM invoice_error_finder_economic_invoices i
|
||||||
|
JOIN invoice_error_finder_economic_invoice_lines l ON l.invoice_id = i.id
|
||||||
|
WHERE i.import_run_id = %s
|
||||||
|
AND i.invoice_date >= (DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '12 months')::date
|
||||||
|
AND i.invoice_date <= CURRENT_DATE
|
||||||
|
AND LOWER(
|
||||||
|
COALESCE(l.product_name,'') || ' ' ||
|
||||||
|
COALESCE(l.description,'') || ' ' ||
|
||||||
|
COALESCE(l.product_number,'')
|
||||||
|
) NOT SIMILAR TO '%%(gebyr|fragt|porto)%%'
|
||||||
|
ORDER BY
|
||||||
|
COALESCE(i.source_invoice_number, i.id::text),
|
||||||
|
i.source_type,
|
||||||
|
COALESCE(l.line_number, l.id),
|
||||||
|
l.id DESC
|
||||||
|
""",
|
||||||
|
(run_id,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
|
||||||
|
def attach_economic_snapshot(session_id: int, run: Dict[str, Any]) -> Dict[str, int]:
|
||||||
|
"""Attach the selected usable 13-month IEF snapshot to an explicit session."""
|
||||||
|
counts = {"created": 0, "unchanged": 0}
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE migration_center_sessions
|
||||||
|
SET economic_import_run_id=%s, economic_snapshot_at=%s, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=%s
|
||||||
|
""",
|
||||||
|
(run["id"], run["completed_at"], session_id),
|
||||||
|
)
|
||||||
|
for row in EconomicSnapshotRepository.lines(run["id"]):
|
||||||
|
item = _normalize_economic_line(dict(row))
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_center_session_items
|
||||||
|
(session_id, entity_type, source_system, source_record_id, source_customer_id,
|
||||||
|
customer_no, customer_name, product_code, product_name, amount, quantity,
|
||||||
|
billing_frequency, period_from, period_to, invoice_no, invoice_date,
|
||||||
|
source_payload, source_hash)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (session_id, entity_type, source_system, source_record_id) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
session_id, item["entity_type"], item["source_system"], item["source_record_id"],
|
||||||
|
item["source_customer_id"], item["customer_no"], item["customer_name"],
|
||||||
|
item["product_code"], item["product_name"], item["amount"], item["quantity"],
|
||||||
|
item["billing_frequency"], item["period_from"], item["period_to"], item["invoice_no"],
|
||||||
|
item["invoice_date"], Json(json_value(item["source_payload"])), item["source_hash"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted = cursor.fetchone()
|
||||||
|
counts["created" if inserted else "unchanged"] += 1
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_economic_line(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
source_id = f"{row.get('source_type')}:{row.get('source_invoice_number') or row['invoice_id']}:{row.get('line_number') or row['invoice_line_id']}"
|
||||||
|
raw = {"invoice": row.get("invoice_raw") or {}, "line": row.get("line_raw") or {}}
|
||||||
|
normalized = {
|
||||||
|
"entity_type": "invoice_line",
|
||||||
|
"source_system": "economic",
|
||||||
|
"source_record_id": source_id,
|
||||||
|
"source_customer_id": str(row.get("customer_number") or ""),
|
||||||
|
"customer_no": str(row.get("customer_number") or ""),
|
||||||
|
"customer_name": str(row.get("customer_name") or "")[:255] or None,
|
||||||
|
"product_code": str(row.get("product_number") or "")[:100] or None,
|
||||||
|
"product_name": str(row.get("product_name") or row.get("description") or "Fakturalinje")[:500],
|
||||||
|
"amount": row.get("line_net_amount") or 0,
|
||||||
|
"quantity": row.get("quantity") or 1,
|
||||||
|
"billing_frequency": None,
|
||||||
|
"period_from": row.get("invoice_date"),
|
||||||
|
"period_to": None,
|
||||||
|
"invoice_no": row.get("source_invoice_number"),
|
||||||
|
"invoice_date": row.get("invoice_date"),
|
||||||
|
"source_payload": raw,
|
||||||
|
}
|
||||||
|
normalized["source_hash"] = snapshot_hash(normalized)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _customer_candidates(item: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, name, cvr_number, email, email_domain, economic_customer_number
|
||||||
|
FROM customers
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
ORDER BY id
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
source_no = str(item.get("customer_no") or "").strip().lower()
|
||||||
|
source_name = str(item.get("customer_name") or "").strip().lower()
|
||||||
|
source_cvr = str((item.get("source_payload") or {}).get("customer_cvr") or "").replace(" ", "").strip()
|
||||||
|
candidates = []
|
||||||
|
for row in rows:
|
||||||
|
rules: List[str] = []
|
||||||
|
score = 0.0
|
||||||
|
if source_no and str(row.get("economic_customer_number") or "").strip().lower() == source_no:
|
||||||
|
score += 0.75
|
||||||
|
rules.append("Kundenummer stemmer")
|
||||||
|
if source_cvr and str(row.get("cvr_number") or "").replace(" ", "").strip() == source_cvr:
|
||||||
|
score += 0.90
|
||||||
|
rules.append("CVR stemmer")
|
||||||
|
name_ratio = SequenceMatcher(None, source_name, str(row.get("name") or "").strip().lower()).ratio()
|
||||||
|
if source_name and name_ratio >= 0.70:
|
||||||
|
score += min(0.25, name_ratio * 0.25)
|
||||||
|
rules.append(f"Firmanavn ligner ({round(name_ratio * 100)} %)")
|
||||||
|
if score:
|
||||||
|
candidates.append({"id": row["id"], "score": min(score, 1.0), "rules": rules})
|
||||||
|
return sorted(candidates, key=lambda value: value["score"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_source_customer(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Resolve authoritative CRM mappings before heuristic customer matching."""
|
||||||
|
source_id = str(item.get("source_customer_id") or "").strip()
|
||||||
|
if not source_id:
|
||||||
|
return None
|
||||||
|
if item.get("source_system") == "simply":
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT source_customer_name AS customer_name, source_customer_cvr AS cvr,
|
||||||
|
hub_customer_id
|
||||||
|
FROM simply_subscription_staging
|
||||||
|
WHERE source_account_id=%s
|
||||||
|
ORDER BY (hub_customer_id IS NOT NULL) DESC, updated_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(source_id,),
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
result = dict(row)
|
||||||
|
result["rule"] = "Eksisterende Simply-kundemapping"
|
||||||
|
return result
|
||||||
|
if item.get("source_system") == "vtiger":
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id AS hub_customer_id, name AS customer_name, cvr_number AS cvr
|
||||||
|
FROM customers WHERE vtiger_id=%s AND deleted_at IS NULL LIMIT 1
|
||||||
|
""",
|
||||||
|
(source_id,),
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
result = dict(row)
|
||||||
|
result["rule"] = "Vtiger-konto-id stemmer"
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_candidates(item: Dict[str, Any], customer_id: Optional[int]) -> List[Dict[str, Any]]:
|
||||||
|
if not customer_id:
|
||||||
|
return []
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, product_name, price, billing_interval, start_date, end_date
|
||||||
|
FROM sag_subscriptions
|
||||||
|
WHERE customer_id = %s AND status <> 'cancelled'
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
""",
|
||||||
|
(customer_id,),
|
||||||
|
) or []
|
||||||
|
source_name = str(item.get("product_name") or "").strip().lower()
|
||||||
|
source_amount = Decimal(str(item.get("amount") or 0))
|
||||||
|
candidates = []
|
||||||
|
for row in rows:
|
||||||
|
rules: List[str] = []
|
||||||
|
score = 0.0
|
||||||
|
ratio = SequenceMatcher(None, source_name, str(row.get("product_name") or "").strip().lower()).ratio()
|
||||||
|
if ratio >= 0.55:
|
||||||
|
score += ratio * 0.55
|
||||||
|
rules.append(f"Produktnavn ligner ({round(ratio * 100)} %)")
|
||||||
|
hub_amount = Decimal(str(row.get("price") or 0))
|
||||||
|
if abs(source_amount - hub_amount) <= Decimal("0.01"):
|
||||||
|
score += 0.35
|
||||||
|
rules.append("Beløb stemmer")
|
||||||
|
elif max(abs(source_amount), Decimal("1")) and abs(source_amount - hub_amount) / max(abs(source_amount), Decimal("1")) <= Decimal("0.10"):
|
||||||
|
score += 0.15
|
||||||
|
rules.append("Beløb afviger højst 10 %")
|
||||||
|
if item.get("billing_frequency") and item["billing_frequency"] == row.get("billing_interval"):
|
||||||
|
score += 0.10
|
||||||
|
rules.append("Frekvens stemmer")
|
||||||
|
if score >= 0.35:
|
||||||
|
candidates.append({"id": row["id"], "score": min(score, 1.0), "rules": rules})
|
||||||
|
return sorted(candidates, key=lambda value: value["score"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def match_item(item_id: int) -> Dict[str, Any]:
|
||||||
|
item = execute_query_single("SELECT * FROM migration_center_session_items WHERE id = %s", (item_id,))
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Post blev ikke fundet")
|
||||||
|
authoritative = _resolve_source_customer(item)
|
||||||
|
if authoritative:
|
||||||
|
enriched_payload = dict(item.get("source_payload") or {})
|
||||||
|
if authoritative.get("cvr"):
|
||||||
|
enriched_payload["customer_cvr"] = authoritative["cvr"]
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE migration_center_session_items
|
||||||
|
SET customer_name=COALESCE(NULLIF(%s,''),customer_name),
|
||||||
|
hub_customer_id=COALESCE(%s,hub_customer_id), source_payload=%s,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=%s AND lock_status IN ('unlocked','lock_failed')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
authoritative.get("customer_name"), authoritative.get("hub_customer_id"),
|
||||||
|
Json(json_value(enriched_payload)), item_id,
|
||||||
|
), fetch=False,
|
||||||
|
)
|
||||||
|
item = execute_query_single("SELECT * FROM migration_center_session_items WHERE id=%s", (item_id,))
|
||||||
|
customer_matches = _customer_candidates(item)
|
||||||
|
customer_id = item.get("hub_customer_id") or (customer_matches[0]["id"] if customer_matches else None)
|
||||||
|
sub_matches = _subscription_candidates(item, customer_id)
|
||||||
|
best = sub_matches[0] if sub_matches else None
|
||||||
|
explanations = []
|
||||||
|
if authoritative:
|
||||||
|
explanations.append(authoritative["rule"])
|
||||||
|
if customer_matches:
|
||||||
|
explanations.extend(customer_matches[0]["rules"])
|
||||||
|
explanations.extend(best["rules"] if best else ["Intet sikkert abonnement-match"])
|
||||||
|
confidence = best["score"] if best else (customer_matches[0]["score"] * 0.4 if customer_matches else 0)
|
||||||
|
status = "match_found" if best and confidence >= 0.60 else ("manual_review" if confidence else "no_match")
|
||||||
|
hub_status = item["hub_status"]
|
||||||
|
if hub_status == "not_created" and not best:
|
||||||
|
hub_status = "ready_for_creation" if customer_id else "not_created"
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE migration_center_session_items
|
||||||
|
SET hub_customer_id = COALESCE(hub_customer_id, %s), suggested_hub_record_id = %s,
|
||||||
|
match_confidence = %s, match_explanation = %s, match_status = %s,
|
||||||
|
hub_status = %s, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s AND lock_status IN ('unlocked','lock_failed')
|
||||||
|
""",
|
||||||
|
(customer_id, best["id"] if best else None, confidence, Json(explanations), status, hub_status, item_id),
|
||||||
|
fetch=False,
|
||||||
|
)
|
||||||
|
execute_query(
|
||||||
|
"DELETE FROM migration_center_matches WHERE session_item_id=%s AND approved IS NULL",
|
||||||
|
(item_id,), fetch=False,
|
||||||
|
)
|
||||||
|
for candidate in customer_matches[:5]:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_center_matches
|
||||||
|
(session_item_id, matched_entity_type, matched_hub_id, confidence, rules)
|
||||||
|
VALUES (%s,'customer',%s,%s,%s)
|
||||||
|
ON CONFLICT (session_item_id, matched_entity_type, matched_hub_id)
|
||||||
|
DO UPDATE SET confidence=EXCLUDED.confidence, rules=EXCLUDED.rules
|
||||||
|
""",
|
||||||
|
(item_id, candidate["id"], candidate["score"], Json(candidate["rules"])),
|
||||||
|
fetch=False,
|
||||||
|
)
|
||||||
|
for candidate in sub_matches[:5]:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_center_matches
|
||||||
|
(session_item_id, matched_entity_type, matched_hub_id, confidence, rules)
|
||||||
|
VALUES (%s,'subscription',%s,%s,%s)
|
||||||
|
ON CONFLICT (session_item_id, matched_entity_type, matched_hub_id)
|
||||||
|
DO UPDATE SET confidence=EXCLUDED.confidence, rules=EXCLUDED.rules
|
||||||
|
""",
|
||||||
|
(item_id, candidate["id"], candidate["score"], Json(candidate["rules"])),
|
||||||
|
fetch=False,
|
||||||
|
)
|
||||||
|
return execute_query_single("SELECT * FROM migration_center_session_items WHERE id = %s", (item_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(name: str, current_user: Dict[str, Any], request: Request) -> Dict[str, Any]:
|
||||||
|
ensure_writable()
|
||||||
|
run = EconomicSnapshotRepository.latest_run()
|
||||||
|
if not run:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Ingen færdig e-conomic-import findes i Faktura-fejl-finder",
|
||||||
|
)
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_center_sessions
|
||||||
|
(name, status, economic_import_run_id, economic_snapshot_at, created_by_user_id)
|
||||||
|
VALUES (%s, 'active', %s, %s, %s) RETURNING *
|
||||||
|
""",
|
||||||
|
(name.strip(), run["id"], run["completed_at"], user_id(current_user)),
|
||||||
|
)
|
||||||
|
session = dict(cursor.fetchone())
|
||||||
|
for row in EconomicSnapshotRepository.lines(run["id"]):
|
||||||
|
item = _normalize_economic_line(dict(row))
|
||||||
|
if item["source_customer_id"]:
|
||||||
|
customer_payload = {
|
||||||
|
"source_customer_id": item["source_customer_id"],
|
||||||
|
"customer_no": item["customer_no"],
|
||||||
|
"customer_name": item["customer_name"] or item["source_customer_id"],
|
||||||
|
}
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_center_source_customers
|
||||||
|
(source_system, source_customer_id, customer_no, customer_name, raw_payload, snapshot_hash)
|
||||||
|
VALUES ('economic',%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (source_system, source_customer_id) DO UPDATE SET
|
||||||
|
customer_no=EXCLUDED.customer_no, customer_name=EXCLUDED.customer_name,
|
||||||
|
raw_payload=EXCLUDED.raw_payload, snapshot_hash=EXCLUDED.snapshot_hash,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item["source_customer_id"], item["customer_no"], customer_payload["customer_name"],
|
||||||
|
Json(customer_payload), snapshot_hash(customer_payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_center_session_items
|
||||||
|
(session_id, entity_type, source_system, source_record_id, source_customer_id,
|
||||||
|
customer_no, customer_name, product_code, product_name, amount, quantity,
|
||||||
|
billing_frequency, period_from, period_to, invoice_no, invoice_date,
|
||||||
|
source_payload, source_hash)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (session_id, entity_type, source_system, source_record_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
session["id"], item["entity_type"], item["source_system"], item["source_record_id"],
|
||||||
|
item["source_customer_id"], item["customer_no"], item["customer_name"],
|
||||||
|
item["product_code"], item["product_name"], item["amount"], item["quantity"],
|
||||||
|
item["billing_frequency"], item["period_from"], item["period_to"], item["invoice_no"],
|
||||||
|
item["invoice_date"], Json(json_value(item["source_payload"])), item["source_hash"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
audit(
|
||||||
|
request=request, current_user=current_user, action="session_created",
|
||||||
|
entity_type="session", entity_id=session["id"], session_id=session["id"], new_value=session,
|
||||||
|
)
|
||||||
|
# Matching is deliberately outside the snapshot transaction and can be re-run safely.
|
||||||
|
ids = execute_query("SELECT id FROM migration_center_session_items WHERE session_id = %s", (session["id"],)) or []
|
||||||
|
for row in ids:
|
||||||
|
match_item(int(row["id"]))
|
||||||
|
refresh_subscription_relevance(int(session["id"]))
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_token(item: Dict[str, Any], current_user: Dict[str, Any]) -> str:
|
||||||
|
payload = {
|
||||||
|
"purpose": "migration_center_create",
|
||||||
|
"item_id": item["id"],
|
||||||
|
"source_hash": item["source_hash"],
|
||||||
|
"user_id": user_id(current_user),
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_preflight(token: str, item: Dict[str, Any], current_user: Dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail="Preflight er udløbet eller ugyldig") from exc
|
||||||
|
if (
|
||||||
|
payload.get("purpose") != "migration_center_create"
|
||||||
|
or int(payload.get("item_id", 0)) != int(item["id"])
|
||||||
|
or payload.get("source_hash") != item["source_hash"]
|
||||||
|
or int(payload.get("user_id", 0)) != int(user_id(current_user) or 0)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=409, detail="Kildedata eller bruger er ændret siden preflight")
|
||||||
|
|
||||||
|
|
||||||
|
def report_csv(session_id: int) -> str:
|
||||||
|
session = execute_query_single("SELECT * FROM migration_center_sessions WHERE id = %s", (session_id,))
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT entity_type, source_system, source_record_id, invoice_no, customer_no, customer_name,
|
||||||
|
product_code, product_name, amount, quantity, match_status, approval_status,
|
||||||
|
hub_status, lock_status, hub_customer_id, hub_sag_id, hub_record_id,
|
||||||
|
ignore_reason, verified_at, locked_at
|
||||||
|
FROM migration_center_session_items
|
||||||
|
WHERE session_id = %s
|
||||||
|
AND {subscription_like_item_sql()}
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
) or []
|
||||||
|
output = io.StringIO()
|
||||||
|
fields = list(rows[0].keys()) if rows else [
|
||||||
|
"entity_type", "source_system", "source_record_id", "match_status",
|
||||||
|
"approval_status", "hub_status", "lock_status",
|
||||||
|
]
|
||||||
|
writer = csv.DictWriter(output, fieldnames=fields, extrasaction="ignore")
|
||||||
|
writer.writeheader()
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow({key: json_value(value) for key, value in dict(row).items()})
|
||||||
|
return output.getvalue()
|
||||||
1
app/modules/migration_center/frontend/__init__.py
Normal file
1
app/modules/migration_center/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Migration centre frontend."""
|
||||||
20
app/modules/migration_center/frontend/views.py
Normal file
20
app/modules/migration_center/frontend/views.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"""Jinja views for the migration centre."""
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.core.auth_dependencies import require_permission
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/migration-center", response_class=HTMLResponse)
|
||||||
|
async def migration_center(
|
||||||
|
request: Request,
|
||||||
|
current_user: dict = Depends(require_permission("migration_center.view")),
|
||||||
|
):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/migration_center/templates/index.html",
|
||||||
|
{"request": request, "current_user": current_user},
|
||||||
|
)
|
||||||
11
app/modules/migration_center/module.json
Normal file
11
app/modules/migration_center/module.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "migration_center",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Manuel migrering og kontrol af abonnementer og fakturalinjer.",
|
||||||
|
"author": "BMC Networks",
|
||||||
|
"enabled": true,
|
||||||
|
"dependencies": ["sag", "invoice_error_finder"],
|
||||||
|
"table_prefix": "migration_center_",
|
||||||
|
"api_prefix": "/api/v1/migration-center",
|
||||||
|
"tags": ["Migration", "Subscriptions", "Invoices"]
|
||||||
|
}
|
||||||
349
app/modules/migration_center/templates/index.html
Normal file
349
app/modules/migration_center/templates/index.html
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
{% block title %}Migreringscenter - BMC Hub{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.mc-shell { --mc-navy:#14243a; --mc-blue:#2563eb; --mc-bg:#f4f7fb; color:#172033; }
|
||||||
|
.mc-hero { background:linear-gradient(120deg,#14243a,#203e65); color:white; border-radius:18px; padding:24px; }
|
||||||
|
.mc-stat { border:0; border-radius:14px; box-shadow:0 5px 20px rgba(20,36,58,.07); }
|
||||||
|
.mc-label { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#728099; font-weight:700; }
|
||||||
|
.mc-number { font-size:1.65rem; font-weight:750; }
|
||||||
|
.mc-table-wrap { border-radius:14px; overflow:hidden; box-shadow:0 5px 20px rgba(20,36,58,.07); background:white; }
|
||||||
|
.mc-table th { white-space:nowrap; font-size:.76rem; text-transform:uppercase; color:#6b778c; background:#f8fafc; }
|
||||||
|
.mc-table td { vertical-align:middle; }
|
||||||
|
.mc-product { max-width:300px; }
|
||||||
|
.mc-badge { border-radius:999px; padding:.34rem .6rem; font-size:.72rem; font-weight:700; display:inline-block; }
|
||||||
|
.mc-green { background:#dcfce7; color:#166534; } .mc-yellow { background:#fef3c7; color:#92400e; }
|
||||||
|
.mc-red { background:#fee2e2; color:#991b1b; } .mc-gray { background:#e5e7eb; color:#374151; }
|
||||||
|
.mc-blue { background:#dbeafe; color:#1e40af; }
|
||||||
|
.mc-queue { border-left:4px solid var(--mc-blue); }
|
||||||
|
.mc-source-card { background:#f8fafc; border:1px solid #e5eaf1; border-radius:12px; padding:16px; }
|
||||||
|
.mc-explanation { margin:0; padding-left:1.1rem; color:#56647a; font-size:.88rem; }
|
||||||
|
.mc-actions .btn { margin:2px; }
|
||||||
|
@media(max-width:768px){ .mc-hero{padding:18px}.mc-product{max-width:180px}.mc-actions{min-width:170px} }
|
||||||
|
</style>
|
||||||
|
<div class="container-fluid py-4 mc-shell">
|
||||||
|
<section class="mc-hero mb-4">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-uppercase small opacity-75 fw-semibold mb-1">Manuel kontrol og overførsel</div>
|
||||||
|
<h1 class="h3 mb-2">Migreringscenter</h1>
|
||||||
|
<p class="mb-0 opacity-75">Abonnementer fra CRM og fakturalinjer fra Faktura-fejl-finder. Intet oprettes automatisk.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<select id="sessionSelect" class="form-select" style="min-width:230px" aria-label="Kontrolsession"></select>
|
||||||
|
<button class="btn btn-light" onclick="createSession()"><i class="bi bi-plus-circle me-1"></i>Ny session</button>
|
||||||
|
<button class="btn btn-outline-light" onclick="importCrm('vtiger')">Hent Vtiger</button>
|
||||||
|
<button class="btn btn-outline-light" onclick="importCrm('simply')">Hent Simply</button>
|
||||||
|
<button class="btn btn-warning" onclick="loadEconomic13Months()">Indlæs 13 mdr. fakturaer</button>
|
||||||
|
<button class="btn btn-outline-light" onclick="rematchAll()">Match alle igen</button>
|
||||||
|
<a id="exportLink" class="btn btn-outline-light" href="#"><i class="bi bi-download me-1"></i>CSV</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="snapshotWarning" class="alert alert-warning mt-3 mb-0 d-none"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="emptyState" class="card border-0 shadow-sm d-none">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-inboxes fs-1 text-muted"></i>
|
||||||
|
<h2 class="h5 mt-3">Ingen kontrolsession endnu</h2>
|
||||||
|
<p class="text-muted">Opret en session for at fastlåse seneste e-conomic-grundlag fra Faktura-fejl-finder.</p>
|
||||||
|
<button class="btn btn-primary" onclick="createSession()">Opret kontrolsession</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="workspace" class="d-none">
|
||||||
|
<div class="row g-3 mb-4" id="stats"></div>
|
||||||
|
|
||||||
|
<ul class="nav nav-pills mb-3 gap-1" id="mcTabs">
|
||||||
|
<li class="nav-item"><button class="nav-link active" data-view="all">Dashboard</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-view="queue">Arbejdsbakke</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-view="subscription">Abonnementer</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-view="invoice_line">Fakturaer</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-view="invoice_history">13 mdr. historik</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-view="subscription_candidates">Mulige abonnementer</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-view="deviations">Afvigelser</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-view="locked">Låste</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-view="audit">Log / historik</button></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<section id="queuePanel" class="card mc-stat mc-queue mb-4 d-none">
|
||||||
|
<div class="card-body" id="queueContent"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="listPanel">
|
||||||
|
<div class="card mc-stat mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-12 col-lg-3"><input id="searchInput" class="form-control" placeholder="Søg produkt, faktura eller kilde-id"></div>
|
||||||
|
<div class="col-12 col-lg-3"><select id="companyFilter" class="form-select"><option value="">Alle virksomheder</option></select></div>
|
||||||
|
<div class="col-6 col-lg-2"><select id="sourceFilter" class="form-select"><option value="">Alle kilder</option><option value="vtiger">Vtiger</option><option value="simply">Simply CRM</option><option value="economic">e-conomic</option></select></div>
|
||||||
|
<div class="col-6 col-lg-1"><select id="statusFilter" class="form-select"><option value="">Status</option><option value="no_match">Intet match</option><option value="manual_review">Manuel kontrol</option><option value="source_changed">Kilde ændret</option><option value="match_found">Match fundet</option></select></div>
|
||||||
|
<div class="col-6 col-lg-1"><select id="lockFilter" class="form-select"><option value="">Lås</option><option value="unlocked">Ulåst</option><option value="locking_pending">Låser</option><option value="lock_failed">Låsefejl</option><option value="locked">Låst</option></select></div>
|
||||||
|
<div class="col-12 col-lg-2"><button class="btn btn-primary w-100" onclick="loadItems(1)">Filtrér</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mc-table-wrap">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mc-table mb-0">
|
||||||
|
<thead><tr><th>Dato</th><th>Kunde</th><th>Kilde</th><th>Produkt</th><th>Beløb</th><th>Match</th><th>Workflow</th><th>Hub-ID</th><th>Handling</th></tr></thead>
|
||||||
|
<tbody id="itemsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center p-3 border-top">
|
||||||
|
<small class="text-muted" id="pageInfo"></small>
|
||||||
|
<div class="btn-group"><button class="btn btn-sm btn-outline-secondary" id="prevPage">Forrige</button><button class="btn btn-sm btn-outline-secondary" id="nextPage">Næste</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="auditPanel" class="mc-table-wrap d-none">
|
||||||
|
<div class="table-responsive"><table class="table mc-table mb-0"><thead><tr><th>Tidspunkt</th><th>Bruger</th><th>Handling</th><th>Type</th><th>Reference</th><th>Resultat</th></tr></thead><tbody id="auditBody"></tbody></table></div>
|
||||||
|
</section>
|
||||||
|
<section id="historyPanel" class="mc-table-wrap d-none">
|
||||||
|
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between gap-3 align-items-end"><div><h2 class="h5 mb-1" id="historyTitle">Fakturahistorik</h2><p class="text-muted mb-0">Grupperet på kunde og vare for de seneste 13 måneder. Forslag opretter aldrig noget automatisk.</p></div><div style="min-width:300px"><label class="form-label mc-label">Filtrér på virksomhed</label><select id="historyCompanyFilter" class="form-select" onchange="loadInvoiceHistory(view==='subscription_candidates')"><option value="">Alle virksomheder</option></select></div></div>
|
||||||
|
<div class="table-responsive"><table class="table table-hover mc-table mb-0"><thead><tr><th>Kunde</th><th>Vare</th><th>Fakturaer</th><th>Måneder</th><th>Periode</th><th>Beløb</th><th>Frekvens</th><th>Hub-match</th><th>Handling</th></tr></thead><tbody id="historyBody"></tbody></table></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="actionModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content">
|
||||||
|
<div class="modal-header"><h2 class="modal-title h5" id="modalTitle">Post</h2><button class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
|
<div class="modal-body" id="modalBody"></div>
|
||||||
|
<div class="modal-footer" id="modalFooter"><button class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API='/api/v1/migration-center';
|
||||||
|
let sessions=[], sessionId=null, page=1, total=0, view='all', currentItem=null, pendingPreflight=null;
|
||||||
|
const modal=()=>bootstrap.Modal.getOrCreateInstance(document.getElementById('actionModal'));
|
||||||
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
async function api(path, options={}){
|
||||||
|
const res=await fetch(API+path,{headers:{'Content-Type':'application/json',...(options.headers||{})},...options});
|
||||||
|
if(!res.ok){let d={};try{d=await res.json()}catch{};throw new Error(typeof d.detail==='object'?(d.detail.message||JSON.stringify(d.detail)):(d.detail||`HTTP ${res.status}`))}
|
||||||
|
return res.headers.get('content-type')?.includes('json')?res.json():res.text();
|
||||||
|
}
|
||||||
|
function badge(value){
|
||||||
|
const green=['created_in_hub','linked_to_existing','verified','approved','locked','match_found'];
|
||||||
|
const red=['conflict','source_changed','lock_failed','no_match','rejected'];
|
||||||
|
const gray=['locked'];
|
||||||
|
return `<span class="mc-badge ${gray.includes(value)?'mc-gray':green.includes(value)?'mc-green':red.includes(value)?'mc-red':'mc-yellow'}">${esc((value||'-').replaceAll('_',' '))}</span>`;
|
||||||
|
}
|
||||||
|
async function init(){
|
||||||
|
sessions=await api('/sessions');
|
||||||
|
const select=document.getElementById('sessionSelect');
|
||||||
|
select.innerHTML=sessions.map(s=>`<option value="${s.id}">${esc(s.name)} · ${s.treated_count}/${s.item_count}</option>`).join('');
|
||||||
|
const remembered=Number(localStorage.getItem('migrationCenterSession'));
|
||||||
|
sessionId=(sessions.find(s=>s.id===remembered)||sessions[0])?.id||null;
|
||||||
|
if(!sessionId){document.getElementById('emptyState').classList.remove('d-none');return}
|
||||||
|
select.value=sessionId; select.onchange=()=>{sessionId=Number(select.value);localStorage.setItem('migrationCenterSession',sessionId);refresh()};
|
||||||
|
document.getElementById('workspace').classList.remove('d-none');
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
async function createSession(){
|
||||||
|
const name=prompt('Navn på kontrolsession',`Migrering ${new Date().toLocaleDateString('da-DK',{month:'long',year:'numeric'})}`);
|
||||||
|
if(!name)return;
|
||||||
|
try{const s=await api('/sessions',{method:'POST',body:JSON.stringify({name})});localStorage.setItem('migrationCenterSession',s.id);location.reload()}catch(e){alert(e.message)}
|
||||||
|
}
|
||||||
|
async function importCrm(source){
|
||||||
|
if(!sessionId)return alert('Opret en session først');
|
||||||
|
if(!confirm(`Hent et nyt ${source} snapshot ind i denne kontrolsession? Ændrede poster kræver ny kontrol.`))return;
|
||||||
|
try{const d=await api(`/sessions/${sessionId}/crm-import?source=${source}`,{method:'POST'});alert(`${d.records} poster hentet · ${d.created} nye · ${d.changed} ændrede`);await refresh()}catch(e){alert(e.message)}
|
||||||
|
}
|
||||||
|
async function loadEconomic13Months(){
|
||||||
|
if(!sessionId)return alert('Opret en session først');
|
||||||
|
if(!confirm('Indlæs de seneste 13 måneders allerede importerede fakturaer fra Faktura-fejl-finder?'))return;
|
||||||
|
try{const d=await api(`/sessions/${sessionId}/economic-snapshot`,{method:'POST'});alert(`${d.created} fakturalinjer indlæst · ${d.customers_mapped} kundematches`);await refresh()}catch(e){alert(e.message)}
|
||||||
|
}
|
||||||
|
async function rematchAll(){
|
||||||
|
if(!sessionId)return;
|
||||||
|
try{const d=await api(`/sessions/${sessionId}/rematch`,{method:'POST'});alert(`${d.processed} poster behandlet · ${d.customers_mapped} virksomheder mappet · ${d.matches_found} abonnementmatches`);await refresh()}catch(e){alert(e.message)}
|
||||||
|
}
|
||||||
|
async function refresh(){document.getElementById('exportLink').href=`${API}/sessions/${sessionId}/report.csv`;await Promise.all([loadDashboard(),loadCompanyOptions(),loadItems(1)])}
|
||||||
|
async function loadCompanyOptions(){
|
||||||
|
const rows=await api(`/sessions/${sessionId}/company-options`);
|
||||||
|
for(const id of ['companyFilter','historyCompanyFilter']){
|
||||||
|
const select=document.getElementById(id), selected=select.value;
|
||||||
|
select.innerHTML='<option value="">Alle virksomheder</option>'+rows.map(r=>`<option value="${esc(r.customer_name)}">${esc(r.customer_name)} (${r.item_count})</option>`).join('');
|
||||||
|
if([...select.options].some(o=>o.value===selected))select.value=selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadDashboard(){
|
||||||
|
const d=await api(`/sessions/${sessionId}/dashboard`), c=d.counts;
|
||||||
|
const defs=[['Poster',c.total,'bi-stack'],['Klar',c.ready,'bi-check2-square'],['Oprettet',c.created,'bi-cloud-check'],['Afvigelser',c.conflicts,'bi-exclamation-triangle'],['Ignoreret',c.ignored,'bi-slash-circle'],['Låst',c.locked,'bi-lock']];
|
||||||
|
document.getElementById('stats').innerHTML=defs.map(x=>`<div class="col-6 col-lg-2"><div class="card mc-stat h-100"><div class="card-body"><div class="mc-label"><i class="bi ${x[2]} me-1"></i>${x[0]}</div><div class="mc-number">${x[1]||0}</div></div></div></div>`).join('');
|
||||||
|
const age=(Date.now()-new Date(d.session.economic_snapshot_at))/86400000;
|
||||||
|
const warn=document.getElementById('snapshotWarning');
|
||||||
|
if(d.read_only){warn.classList.remove('d-none');warn.textContent='Migreringscenteret er i read-only tilstand. Historik og rapporter er fortsat tilgængelige.'}
|
||||||
|
else if(age>d.stale_after_days){warn.classList.remove('d-none');warn.innerHTML=`Fakturagrundlaget er ${Math.floor(age)} dage gammelt. <a href="/invoice-error-finder" class="alert-link">Åbn Faktura-fejl-finder</a> før du opretter en ny session.`}else warn.classList.add('d-none');
|
||||||
|
}
|
||||||
|
function query(){
|
||||||
|
const p=new URLSearchParams({page, page_size:50});
|
||||||
|
const q=document.getElementById('searchInput').value.trim(); if(q)p.set('q',q);
|
||||||
|
const source=document.getElementById('sourceFilter').value;if(source)p.set('source_system',source);
|
||||||
|
const company=document.getElementById('companyFilter').value;if(company)p.set('company_name',company);
|
||||||
|
const status=document.getElementById('statusFilter').value;if(status)p.set('match_status',status);
|
||||||
|
const lock=document.getElementById('lockFilter').value;if(lock)p.set('lock_status',lock);
|
||||||
|
if(['subscription','invoice_line'].includes(view))p.set('entity_type',view);
|
||||||
|
if(view==='deviations')p.set('only_deviations','true');
|
||||||
|
if(view==='locked')p.set('lock_status','locked');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
async function loadItems(p=1){
|
||||||
|
page=p;
|
||||||
|
if(view==='queue'){await loadQueue();return}
|
||||||
|
if(view==='audit'){await loadAudit();return}
|
||||||
|
if(['invoice_history','subscription_candidates'].includes(view)){await loadInvoiceHistory(view==='subscription_candidates');return}
|
||||||
|
const d=await api(`/sessions/${sessionId}/items?${query()}`);total=d.total;
|
||||||
|
document.getElementById('itemsBody').innerHTML=d.items.length?d.items.map(rowHtml).join(''):`<tr><td colspan="9" class="text-center text-muted py-5">Ingen poster matcher filtrene</td></tr>`;
|
||||||
|
const end=Math.min(page*50,total);document.getElementById('pageInfo').textContent=total?`${(page-1)*50+1}-${end} af ${total}`:'0 poster';
|
||||||
|
document.getElementById('prevPage').disabled=page===1;document.getElementById('nextPage').disabled=end>=total;
|
||||||
|
}
|
||||||
|
async function loadInvoiceHistory(onlyCandidates){
|
||||||
|
const company=document.getElementById('historyCompanyFilter').value;
|
||||||
|
const d=await api(`/sessions/${sessionId}/invoice-history?only_subscription_candidates=${onlyCandidates?'true':'false'}${company?`&company_name=${encodeURIComponent(company)}`:''}`);
|
||||||
|
document.getElementById('historyTitle').textContent=onlyCandidates?'Mulige manglende abonnementer':'Fakturahistorik · seneste 13 måneder';
|
||||||
|
document.getElementById('historyBody').innerHTML=d.groups.length?d.groups.map(g=>`<tr>
|
||||||
|
<td><strong>${esc(g.customer_name||'Ukendt')}</strong><br><small class="text-muted">${esc(g.customer_no||'')}</small></td>
|
||||||
|
<td><strong>${esc(g.product_name)}</strong><br><small class="text-muted">${esc(g.product_code||'')}</small></td>
|
||||||
|
<td>${g.invoice_count}</td><td>${g.invoiced_months} / 13</td><td class="text-nowrap">${esc(g.first_invoice_date)} – ${esc(g.last_invoice_date)}</td>
|
||||||
|
<td>${Number(g.min_amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}${Number(g.max_amount||0)!==Number(g.min_amount||0)?` – ${Number(g.max_amount).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}`:''}</td>
|
||||||
|
<td>${badge(g.suggested_frequency)}</td><td>${g.has_hub_subscription?badge('match_found'):badge('no_match')}</td>
|
||||||
|
<td>${g.subscription_candidate?`<button class="btn btn-sm btn-primary" onclick="openItem(${g.representative_item_id})">Vurdér / opret abonnement</button>`:'<span class="text-muted">Kontrol</span>'}</td></tr>`).join(''):`<tr><td colspan="9" class="text-center text-muted py-5">Ingen fakturagrupper fundet</td></tr>`;
|
||||||
|
}
|
||||||
|
function rowHtml(i){
|
||||||
|
const explanations=(i.match_explanation||[]).map(esc).join(' · ');
|
||||||
|
return `<tr>
|
||||||
|
<td class="text-nowrap">${esc(i.invoice_date||i.period_from||'-')}</td>
|
||||||
|
<td><button class="btn btn-link p-0 text-start" onclick="${i.hub_customer_id?`showCustomer(${i.hub_customer_id})`:'void 0'}"><strong>${esc(i.customer_name||'Ukendt')}</strong></button><br><small class="text-muted">${esc(i.customer_no||'')}</small></td>
|
||||||
|
<td>${badge(i.source_system)}</td><td class="mc-product"><strong>${esc(i.product_name)}</strong><br><small class="text-muted">${esc(i.product_code||i.invoice_no||'')}</small></td>
|
||||||
|
<td class="text-nowrap">${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</td>
|
||||||
|
<td>${badge(i.match_status)}<br><small class="text-muted" title="${esc(explanations)}">${i.match_confidence!=null?Math.round(i.match_confidence*100)+' %':'-'}</small></td>
|
||||||
|
<td>${badge(i.hub_status)} ${badge(i.approval_status)} ${badge(i.lock_status)}</td>
|
||||||
|
<td>${i.hub_record_id?`<a href="${i.hub_sag_id?`/sag/${i.hub_sag_id}/v3`:`/subscriptions/${i.hub_record_id}`}">#${i.hub_record_id}</a>`:'-'}</td>
|
||||||
|
<td class="mc-actions"><button class="btn btn-sm btn-outline-primary" onclick="openItem(${i.id})">Vis / behandl</button></td></tr>`;
|
||||||
|
}
|
||||||
|
function filterCustomer(name){document.getElementById('searchInput').value=name;loadItems(1)}
|
||||||
|
async function showCustomer(customerId){
|
||||||
|
try{
|
||||||
|
const d=await api(`/sessions/${sessionId}/customers/${customerId}`);
|
||||||
|
document.getElementById('modalTitle').textContent=`Kundeoverblik · ${d.customer.name}`;
|
||||||
|
document.getElementById('modalBody').innerHTML=`<div class="row g-3 mb-3"><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">CVR</div>${esc(d.customer.cvr_number||'-')}</div></div><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">Migreringsposter</div>${d.items.length}</div></div><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">Hub-abonnementer</div>${d.hub_subscriptions.length}</div></div></div>
|
||||||
|
<h3 class="h6">Afvigelser og behandling</h3><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Kilde</th><th>Produkt</th><th>Match</th><th>Workflow</th></tr></thead><tbody>${d.items.map(i=>`<tr><td>${esc(i.source_system)}</td><td>${esc(i.product_name)}</td><td>${badge(i.match_status)}</td><td>${badge(i.approval_status)} ${badge(i.lock_status)}</td></tr>`).join('')}</tbody></table></div>
|
||||||
|
<h3 class="h6 mt-3">Eksisterende poster i Hubben</h3><ul>${d.hub_subscriptions.map(s=>`<li>#${s.id} · ${esc(s.product_name||s.subscription_number)} · ${esc(s.status)}</li>`).join('')||'<li>Ingen</li>'}</ul>`;
|
||||||
|
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-secondary" data-bs-dismiss="modal">Luk</button><button class="btn btn-dark" ${d.can_lock_all?'':'disabled'} onclick="lockCustomer(${customerId})">Lås alt for kunde</button>`;
|
||||||
|
modal().show();
|
||||||
|
}catch(e){alert(e.message)}
|
||||||
|
}
|
||||||
|
async function lockCustomer(customerId){if(confirm('Lås alle færdigbehandlede poster for kunden?'))await mutate(`/sessions/${sessionId}/customers/${customerId}/lock`)}
|
||||||
|
async function loadQueue(after){
|
||||||
|
const d=await api(`/sessions/${sessionId}/queue/next${after?`?after_id=${after}`:''}`);
|
||||||
|
const panel=document.getElementById('queueContent');
|
||||||
|
if(!d.item){panel.innerHTML='<div class="text-center py-4"><i class="bi bi-check-circle text-success fs-2"></i><h3 class="h5 mt-2">Arbejdsbakken er tom</h3></div>';return}
|
||||||
|
const i=d.item; currentItem=i;
|
||||||
|
panel.innerHTML=`<div class="d-flex justify-content-between align-items-start mb-3"><div><div class="mc-label">Næste manuelle beslutning</div><h2 class="h5 mb-0">${esc(i.customer_name||'Ukendt kunde')}</h2></div>${badge(i.match_status)}</div>
|
||||||
|
<div class="row g-3"><div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Kilde · ${esc(i.source_system)}</div><strong>${esc(i.product_name)}</strong><div>${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</div><small>${esc(i.source_record_id)}</small></div></div>
|
||||||
|
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Foreslået Hub-match</div><strong>${i.suggested_hub_record_id?'Abonnement #'+i.suggested_hub_record_id:'Intet sikkert match'}</strong><ul class="mc-explanation mt-2">${(i.match_explanation||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul></div></div></div>
|
||||||
|
<div class="mt-3 d-flex gap-2 flex-wrap"><button class="btn btn-primary" onclick="openItem(${i.id})">Behandl</button><button class="btn btn-outline-secondary" onclick="loadQueue(${i.id})">Spring over</button></div>`;
|
||||||
|
}
|
||||||
|
async function loadAudit(){
|
||||||
|
const rows=await api(`/sessions/${sessionId}/audit`);
|
||||||
|
document.getElementById('auditBody').innerHTML=rows.map(a=>`<tr><td>${new Date(a.performed_at).toLocaleString('da-DK')}</td><td>${esc(a.performed_by||'System')}</td><td>${esc(a.action)}</td><td>${esc(a.entity_type)}</td><td>${esc(a.entity_id||'')}</td><td>${a.success?badge('verified'):badge('conflict')}</td></tr>`).join('');
|
||||||
|
}
|
||||||
|
async function openItem(id){
|
||||||
|
let context=await api(`/items/${id}/context`).catch(()=>null), i=context?.item;
|
||||||
|
if(!i)return alert('Posten kunne ikke hentes');
|
||||||
|
currentItem=i;
|
||||||
|
document.getElementById('modalTitle').textContent=`${i.customer_name||'Ukendt kunde'} · ${i.product_name}`;
|
||||||
|
document.getElementById('modalBody').innerHTML=`<div class="row g-3">
|
||||||
|
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Kildedata</div><dl class="row small mb-0"><dt class="col-5">Kilde</dt><dd class="col-7">${esc(i.source_system)}</dd><dt class="col-5">Reference</dt><dd class="col-7 text-break">${esc(i.source_record_id)}</dd><dt class="col-5">Kunde</dt><dd class="col-7">${esc(i.customer_name||'-')}</dd><dt class="col-5">Produkt</dt><dd class="col-7">${esc(i.product_name)}</dd><dt class="col-5">Beløb</dt><dd class="col-7">${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</dd></dl></div></div>
|
||||||
|
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Matchforklaring</div><ul class="mc-explanation">${(i.match_explanation||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul><div class="mt-3">${badge(i.match_status)} ${badge(i.hub_status)} ${badge(i.lock_status)}</div></div></div></div>
|
||||||
|
<div class="col-12"><details><summary class="small fw-semibold">Vis alle rå felter fra den valgte kilde</summary><pre class="small bg-dark text-light rounded p-3 mt-2 overflow-auto" style="max-height:280px">${esc(JSON.stringify(i.source_payload||{},null,2))}</pre></details></div>
|
||||||
|
<div class="col-12"><hr><div class="d-flex justify-content-between align-items-center mb-2"><h3 class="h5 mb-0">Samlet kundegrundlag</h3><small class="text-muted">e-conomic ${context.counts.economic} · Vtiger ${context.counts.vtiger} · Simply ${context.counts.simply} · Hub ${context.counts.hub_subscriptions}</small></div>
|
||||||
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
|
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#ctxEconomic">e-conomic (${context.counts.economic})</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxVtiger">Vtiger (${context.counts.vtiger})</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxSimply">Simply (${context.counts.simply})</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxHub">Hubben (${context.counts.hub_subscriptions})</button></li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content border border-top-0 rounded-bottom p-3">
|
||||||
|
<div class="tab-pane fade show active" id="ctxEconomic">${sourceContextTable(context.sources.economic,'Fakturadato / nr.')}</div>
|
||||||
|
<div class="tab-pane fade" id="ctxVtiger">${sourceContextTable(context.sources.vtiger,'Start / slut')}</div>
|
||||||
|
<div class="tab-pane fade" id="ctxSimply">${sourceContextTable(context.sources.simply,'Start / slut')}</div>
|
||||||
|
<div class="tab-pane fade" id="ctxHub">${hubContext(context.hub)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr><div class="row g-2"><div class="col-md-4"><label class="form-label">Hub-kunde ID</label><div class="input-group"><input id="hubCustomerId" class="form-control" type="number" value="${i.hub_customer_id||''}"><button class="btn btn-outline-primary" onclick="linkCustomer()">Link</button></div><button class="btn btn-link px-0 btn-sm" onclick="createCustomer()">Opret kunde eksplicit</button></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">Hub-sag ID</label><div class="input-group"><input id="hubCaseId" class="form-control" type="number" value="${i.hub_sag_id||''}"><button class="btn btn-outline-primary" onclick="linkCase()">Link</button></div><button class="btn btn-link px-0 btn-sm" onclick="createCase()">Opret sag eksplicit</button></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">Eksisterende abonnement ID</label><div class="input-group"><input id="hubSubscriptionId" class="form-control" type="number" value="${i.suggested_hub_record_id||''}"><button class="btn btn-outline-primary" onclick="linkSubscription()">Link</button></div></div></div>
|
||||||
|
<div class="mt-3"><label class="form-label">Manuel note</label><textarea id="itemNote" class="form-control" rows="2">${esc(i.manual_note||'')}</textarea><button class="btn btn-sm btn-outline-secondary mt-2" onclick="saveNote()">Gem note</button></div>`;
|
||||||
|
const disabled=['locked','locking_pending'].includes(i.lock_status)?'disabled':'';
|
||||||
|
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button><button class="btn btn-outline-warning" ${disabled} onclick="ignoreItem()">Ignorér</button><button class="btn btn-outline-success" ${disabled} onclick="verifyItem()">Markér kontrolleret</button><button class="btn btn-outline-dark" ${disabled} onclick="lockItem()">Lås</button><button class="btn btn-primary" ${disabled} onclick="preflightCreate()">Opret i Hubben</button>`;
|
||||||
|
modal().show();
|
||||||
|
}
|
||||||
|
function sourceContextTable(rows,dateLabel){
|
||||||
|
if(!rows?.length)return '<div class="text-muted py-3">Ingen relevante poster fra denne kilde for kunden.</div>';
|
||||||
|
return `<div class="table-responsive" style="max-height:420px"><table class="table table-sm align-middle"><thead class="sticky-top bg-white"><tr><th>${dateLabel}</th><th>Vare</th><th>Beløb</th><th>Status</th><th></th></tr></thead><tbody>${rows.map(r=>`<tr class="${r.id===currentItem.id?'table-primary':''}"><td>${esc(r.invoice_date||r.period_from||'-')}<br><small>${esc(r.invoice_no||r.source_record_id)}</small></td><td><strong>${esc(r.product_name)}</strong><br><small>${esc(r.product_code||'')}</small><details><summary class="small text-muted">Alle kildefelter</summary><pre class="small bg-light p-2 mt-1" style="max-width:520px;max-height:220px;overflow:auto">${esc(JSON.stringify(r.source_payload||{},null,2))}</pre></details></td><td class="text-nowrap">${Number(r.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</td><td>${badge(r.hub_status)}</td><td><button class="btn btn-sm btn-outline-primary" onclick="openItem(${r.id})">Brug som grundlag</button></td></tr>`).join('')}</tbody></table></div>`;
|
||||||
|
}
|
||||||
|
function hubContext(hub){
|
||||||
|
const customer=hub.customer;
|
||||||
|
return `${customer?`<div class="mc-source-card mb-3"><div class="mc-label">Hub-kunde #${customer.id}</div><strong>${esc(customer.name)}</strong><div class="small">CVR ${esc(customer.cvr_number||'-')} · e-conomic ${esc(customer.economic_customer_number||'-')} · Vtiger ${esc(customer.vtiger_id||'-')}</div></div>`:'<div class="alert alert-warning">Kilden er endnu ikke linket til en Hub-kunde.</div>'}
|
||||||
|
<h4 class="h6">Eksisterende abonnementer</h4>${hub.subscriptions?.length?`<div class="table-responsive"><table class="table table-sm"><thead><tr><th>ID</th><th>Produkt</th><th>Pris/frekvens</th><th>Status</th><th></th></tr></thead><tbody>${hub.subscriptions.map(s=>`<tr><td>#${s.id}</td><td><strong>${esc(s.product_name||s.subscription_number)}</strong>${(s.lines||[]).map(l=>`<br><small>${esc(l.description)} · ${l.quantity} × ${Number(l.unit_price||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</small>`).join('')}</td><td>${Number(s.price||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})} / ${esc(s.billing_interval)}</td><td>${badge(s.status)}</td><td><button class="btn btn-sm btn-outline-primary" onclick="document.getElementById('hubSubscriptionId').value=${s.id}">Vælg til link</button></td></tr>`).join('')}</tbody></table></div>`:'<p class="text-muted">Ingen abonnementer i Hubben.</p>'}
|
||||||
|
<h4 class="h6 mt-3">Sager</h4><div class="d-flex flex-wrap gap-2">${(hub.cases||[]).map(s=>`<button class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('hubCaseId').value=${s.id}">#${s.id} ${esc(s.titel)}</button>`).join('')||'<span class="text-muted">Ingen sager</span>'}</div>`;
|
||||||
|
}
|
||||||
|
async function mutate(path,body){try{await api(path,{method:'POST',body:body?JSON.stringify(body):undefined});modal().hide();await refresh()}catch(e){alert(e.message)}}
|
||||||
|
async function linkCustomer(){const id=Number(document.getElementById('hubCustomerId').value);if(id)await mutate(`/items/${currentItem.id}/customer-link`,{hub_id:id})}
|
||||||
|
async function linkCase(){const id=Number(document.getElementById('hubCaseId').value);if(id)await mutate(`/items/${currentItem.id}/case-link`,{hub_id:id})}
|
||||||
|
async function linkSubscription(){const id=Number(document.getElementById('hubSubscriptionId').value);if(id)await mutate(`/items/${currentItem.id}/hub-link`,{hub_id:id})}
|
||||||
|
async function createCustomer(){const name=prompt('Kundenavn',currentItem.customer_name||'');if(!name)return;const cvr=prompt('CVR (valgfri)','');if(!confirm(`Opret kunden "${name}" i Hubben?`))return;await mutate(`/items/${currentItem.id}/customers`,{name,cvr_number:cvr||null,customer_no:currentItem.customer_no||null})}
|
||||||
|
async function createCase(){const title=prompt('Sagens titel',`Migrering · ${currentItem.product_name}`);if(!title||!confirm(`Opret sagen "${title}"?`))return;await mutate(`/items/${currentItem.id}/cases`,{title,description:`Manuelt oprettet fra migreringscenter, kilde ${currentItem.source_record_id}`})}
|
||||||
|
async function saveNote(){try{await api(`/items/${currentItem.id}/note`,{method:'PUT',body:JSON.stringify({note:document.getElementById('itemNote').value})});alert('Noten er gemt')}catch(e){alert(e.message)}}
|
||||||
|
async function ignoreItem(){const reason=prompt('Årsag til ignorering');if(reason)await mutate(`/items/${currentItem.id}/ignore`,{reason})}
|
||||||
|
async function verifyItem(){if(confirm('Markér posten som kontrolleret?'))await mutate(`/items/${currentItem.id}/verify`)}
|
||||||
|
async function lockItem(){if(confirm('Lås posten endeligt? Den kan ikke låses op i denne version.'))await mutate(`/items/${currentItem.id}/lock`)}
|
||||||
|
async function preflightCreate(){
|
||||||
|
try{
|
||||||
|
const p=await api(`/items/${currentItem.id}/preflight`);
|
||||||
|
if(!p.ready)return alert(`Oprettelsen kan ikke gennemføres:\n\n${p.blockers.join('\n')}`);
|
||||||
|
pendingPreflight=p;
|
||||||
|
const v=p.preview, frequencyLabels={daily:'Dagligt',biweekly:'Hver 14. dag',monthly:'Månedligt',quarterly:'Kvartalsvist',yearly:'Årligt'};
|
||||||
|
document.getElementById('modalTitle').textContent='Bekræft oprettelse i Hubben';
|
||||||
|
document.getElementById('modalBody').innerHTML=`<div class="alert alert-primary"><i class="bi bi-shield-check me-2"></i>Der oprettes kun dette ene kladde-abonnement. Intet andet overføres.</div>
|
||||||
|
<div class="mc-source-card"><div class="mc-label mb-3">Data der sendes til Hubben</div><dl class="row mb-0">
|
||||||
|
<dt class="col-sm-4">Kunde</dt><dd class="col-sm-8"><strong>${esc(v.customer||'-')}</strong>${v.customer_no?`<br><small class="text-muted">Kundenr. ${esc(v.customer_no)}</small>`:''}</dd>
|
||||||
|
<dt class="col-sm-4">Hub-kunde</dt><dd class="col-sm-8">#${esc(v.hub_customer_id)}</dd>
|
||||||
|
<dt class="col-sm-4">Hub-sag</dt><dd class="col-sm-8">#${esc(v.hub_sag_id)}</dd>
|
||||||
|
<dt class="col-sm-4">Produkt</dt><dd class="col-sm-8"><strong>${esc(v.product)}</strong>${v.product_code?`<br><small class="text-muted">${esc(v.product_code)}</small>`:''}</dd>
|
||||||
|
<dt class="col-sm-4">Beløb</dt><dd class="col-sm-8"><strong>${Number(v.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</strong></dd>
|
||||||
|
<dt class="col-sm-4">Antal</dt><dd class="col-sm-8">${esc(v.quantity||1)}</dd>
|
||||||
|
<dt class="col-sm-4">Fakturering</dt><dd class="col-sm-8">${esc(frequencyLabels[v.frequency]||v.frequency)}</dd>
|
||||||
|
<dt class="col-sm-4">Periode</dt><dd class="col-sm-8">${esc(v.start_date||'-')} – ${esc(v.end_date||'løbende')}</dd>
|
||||||
|
<dt class="col-sm-4">Kilde</dt><dd class="col-sm-8">${esc(v.source)} · ${esc(v.source_record_id)}</dd>
|
||||||
|
</dl></div>
|
||||||
|
<p class="small text-muted mt-3 mb-0">Abonnementet oprettes som kladde med én varelinje og skal fortsat kontrolleres og låses separat.</p>`;
|
||||||
|
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-outline-secondary" onclick="openItem(${currentItem.id})">Tilbage</button><button class="btn btn-primary" onclick="confirmCreateInHub(this)"><i class="bi bi-check-circle me-1"></i>Bekræft oprettelse</button>`;
|
||||||
|
}catch(e){alert(e.message)}
|
||||||
|
}
|
||||||
|
async function confirmCreateInHub(button){
|
||||||
|
if(!pendingPreflight)return;
|
||||||
|
if(button){button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Opretter…'}
|
||||||
|
try{
|
||||||
|
await api(`/items/${currentItem.id}/create-in-hub`,{method:'POST',body:JSON.stringify({preflight_token:pendingPreflight.token,idempotency_key:crypto.randomUUID()})});
|
||||||
|
pendingPreflight=null;modal().hide();await refresh();
|
||||||
|
}catch(e){if(button)button.disabled=false;alert(e.message)}
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#mcTabs .nav-link').forEach(btn=>btn.onclick=async()=>{
|
||||||
|
document.querySelectorAll('#mcTabs .nav-link').forEach(x=>x.classList.remove('active'));btn.classList.add('active');view=btn.dataset.view;
|
||||||
|
document.getElementById('queuePanel').classList.toggle('d-none',view!=='queue');
|
||||||
|
document.getElementById('auditPanel').classList.toggle('d-none',view!=='audit');
|
||||||
|
document.getElementById('historyPanel').classList.toggle('d-none',!['invoice_history','subscription_candidates'].includes(view));
|
||||||
|
document.getElementById('listPanel').classList.toggle('d-none',['queue','audit','invoice_history','subscription_candidates'].includes(view));
|
||||||
|
await loadItems(1);
|
||||||
|
});
|
||||||
|
document.getElementById('prevPage').onclick=()=>loadItems(page-1);document.getElementById('nextPage').onclick=()=>loadItems(page+1);
|
||||||
|
document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')loadItems(1)});
|
||||||
|
init().catch(e=>{document.getElementById('emptyState').classList.remove('d-none');document.getElementById('emptyState').querySelector('.card-body').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -10,11 +10,14 @@ from fastapi import APIRouter, HTTPException, status, Depends, Request
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_insert
|
from app.core.database import execute_query, execute_insert
|
||||||
|
from app.core.auth_dependencies import require_any_permission
|
||||||
from app.services.reminder_notification_service import reminder_notification_service
|
from app.services.reminder_notification_service import reminder_notification_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
case_read_access = require_any_permission("cases.view", "tickets.view")
|
||||||
|
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -47,9 +50,10 @@ def _get_user_id_from_request(request: Request) -> int:
|
|||||||
class UserNotificationPreferences(BaseModel):
|
class UserNotificationPreferences(BaseModel):
|
||||||
"""User notification preferences"""
|
"""User notification preferences"""
|
||||||
notify_mattermost: bool = True
|
notify_mattermost: bool = True
|
||||||
notify_email: bool = False
|
notify_email: bool = True
|
||||||
notify_frontend: bool = True
|
notify_frontend: bool = True
|
||||||
email_override: Optional[str] = None
|
email_override: Optional[str] = None
|
||||||
|
mattermost_username: Optional[str] = None
|
||||||
quiet_hours_enabled: bool = False
|
quiet_hours_enabled: bool = False
|
||||||
quiet_hours_start: Optional[str] = None # HH:MM format
|
quiet_hours_start: Optional[str] = None # HH:MM format
|
||||||
quiet_hours_end: Optional[str] = None # HH:MM format
|
quiet_hours_end: Optional[str] = None # HH:MM format
|
||||||
@ -154,7 +158,8 @@ async def get_user_notification_preferences(request: Request):
|
|||||||
query = """
|
query = """
|
||||||
SELECT
|
SELECT
|
||||||
notify_mattermost, notify_email, notify_frontend,
|
notify_mattermost, notify_email, notify_frontend,
|
||||||
email_override, quiet_hours_enabled, quiet_hours_start, quiet_hours_end
|
email_override, mattermost_username,
|
||||||
|
quiet_hours_enabled, quiet_hours_start, quiet_hours_end
|
||||||
FROM user_notification_preferences
|
FROM user_notification_preferences
|
||||||
WHERE user_id = %s
|
WHERE user_id = %s
|
||||||
"""
|
"""
|
||||||
@ -165,9 +170,10 @@ async def get_user_notification_preferences(request: Request):
|
|||||||
r = result[0]
|
r = result[0]
|
||||||
return UserNotificationPreferences(
|
return UserNotificationPreferences(
|
||||||
notify_mattermost=r.get('notify_mattermost', True),
|
notify_mattermost=r.get('notify_mattermost', True),
|
||||||
notify_email=r.get('notify_email', False),
|
notify_email=r.get('notify_email', True),
|
||||||
notify_frontend=r.get('notify_frontend', True),
|
notify_frontend=r.get('notify_frontend', True),
|
||||||
email_override=r.get('email_override'),
|
email_override=r.get('email_override'),
|
||||||
|
mattermost_username=r.get('mattermost_username'),
|
||||||
quiet_hours_enabled=r.get('quiet_hours_enabled', False),
|
quiet_hours_enabled=r.get('quiet_hours_enabled', False),
|
||||||
quiet_hours_start=r.get('quiet_hours_start'),
|
quiet_hours_start=r.get('quiet_hours_start'),
|
||||||
quiet_hours_end=r.get('quiet_hours_end')
|
quiet_hours_end=r.get('quiet_hours_end')
|
||||||
@ -198,6 +204,7 @@ async def update_user_notification_preferences(
|
|||||||
notify_email = %s,
|
notify_email = %s,
|
||||||
notify_frontend = %s,
|
notify_frontend = %s,
|
||||||
email_override = %s,
|
email_override = %s,
|
||||||
|
mattermost_username = %s,
|
||||||
quiet_hours_enabled = %s,
|
quiet_hours_enabled = %s,
|
||||||
quiet_hours_start = %s,
|
quiet_hours_start = %s,
|
||||||
quiet_hours_end = %s,
|
quiet_hours_end = %s,
|
||||||
@ -211,6 +218,7 @@ async def update_user_notification_preferences(
|
|||||||
preferences.notify_email,
|
preferences.notify_email,
|
||||||
preferences.notify_frontend,
|
preferences.notify_frontend,
|
||||||
preferences.email_override,
|
preferences.email_override,
|
||||||
|
preferences.mattermost_username,
|
||||||
preferences.quiet_hours_enabled,
|
preferences.quiet_hours_enabled,
|
||||||
preferences.quiet_hours_start,
|
preferences.quiet_hours_start,
|
||||||
preferences.quiet_hours_end,
|
preferences.quiet_hours_end,
|
||||||
@ -221,9 +229,10 @@ async def update_user_notification_preferences(
|
|||||||
query = """
|
query = """
|
||||||
INSERT INTO user_notification_preferences (
|
INSERT INTO user_notification_preferences (
|
||||||
user_id, notify_mattermost, notify_email, notify_frontend,
|
user_id, notify_mattermost, notify_email, notify_frontend,
|
||||||
email_override, quiet_hours_enabled, quiet_hours_start, quiet_hours_end
|
email_override, mattermost_username,
|
||||||
|
quiet_hours_enabled, quiet_hours_start, quiet_hours_end
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -233,6 +242,7 @@ async def update_user_notification_preferences(
|
|||||||
preferences.notify_email,
|
preferences.notify_email,
|
||||||
preferences.notify_frontend,
|
preferences.notify_frontend,
|
||||||
preferences.email_override,
|
preferences.email_override,
|
||||||
|
preferences.mattermost_username,
|
||||||
preferences.quiet_hours_enabled,
|
preferences.quiet_hours_enabled,
|
||||||
preferences.quiet_hours_start,
|
preferences.quiet_hours_start,
|
||||||
preferences.quiet_hours_end
|
preferences.quiet_hours_end
|
||||||
@ -250,7 +260,11 @@ async def update_user_notification_preferences(
|
|||||||
# Reminder CRUD Endpoints
|
# Reminder CRUD Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
@router.get("/api/v1/sag/{sag_id}/reminders", response_model=List[ReminderResponse])
|
@router.get(
|
||||||
|
"/api/v1/sag/{sag_id}/reminders",
|
||||||
|
response_model=List[ReminderResponse],
|
||||||
|
dependencies=[Depends(case_read_access)],
|
||||||
|
)
|
||||||
async def list_sag_reminders(sag_id: int):
|
async def list_sag_reminders(sag_id: int):
|
||||||
"""List all reminders for a case"""
|
"""List all reminders for a case"""
|
||||||
|
|
||||||
@ -323,7 +337,11 @@ async def list_my_reminders(request: Request):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/sag/{sag_id}/reminders", response_model=ReminderResponse)
|
@router.post(
|
||||||
|
"/api/v1/sag/{sag_id}/reminders",
|
||||||
|
response_model=ReminderResponse,
|
||||||
|
dependencies=[Depends(case_edit_access)],
|
||||||
|
)
|
||||||
async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderCreate):
|
async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderCreate):
|
||||||
"""Create a new reminder for a case"""
|
"""Create a new reminder for a case"""
|
||||||
user_id = _get_user_id_from_request(request)
|
user_id = _get_user_id_from_request(request)
|
||||||
@ -418,9 +436,10 @@ async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderC
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/api/v1/sag/reminders/{reminder_id}")
|
@router.patch("/api/v1/sag/reminders/{reminder_id}", dependencies=[Depends(case_edit_access)])
|
||||||
async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
|
async def update_sag_reminder(reminder_id: int, update: ReminderUpdate, request: Request):
|
||||||
"""Update a reminder"""
|
"""Update a reminder"""
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
|
||||||
# Build update query dynamically
|
# Build update query dynamically
|
||||||
updates = []
|
updates = []
|
||||||
@ -472,10 +491,10 @@ async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
|
|||||||
query = f"""
|
query = f"""
|
||||||
UPDATE sag_reminders
|
UPDATE sag_reminders
|
||||||
SET {', '.join(updates)}
|
SET {', '.join(updates)}
|
||||||
WHERE id = %s
|
WHERE id = %s AND created_by_user_id = %s AND deleted_at IS NULL
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
|
params.append(user_id)
|
||||||
result = execute_insert(query, tuple(params))
|
result = execute_insert(query, tuple(params))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Reminder not found")
|
raise HTTPException(status_code=404, detail="Reminder not found")
|
||||||
@ -483,30 +502,35 @@ async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
|
|||||||
logger.info(f"✅ Reminder {reminder_id} updated")
|
logger.info(f"✅ Reminder {reminder_id} updated")
|
||||||
return {"success": True, "message": "Reminder updated"}
|
return {"success": True, "message": "Reminder updated"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error updating reminder: {e}")
|
logger.error(f"❌ Error updating reminder: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/sag/reminders/{reminder_id}")
|
@router.delete("/api/v1/sag/reminders/{reminder_id}", dependencies=[Depends(case_edit_access)])
|
||||||
async def delete_sag_reminder(reminder_id: int):
|
async def delete_sag_reminder(reminder_id: int, request: Request):
|
||||||
"""Soft-delete a reminder"""
|
"""Soft-delete a reminder"""
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
UPDATE sag_reminders
|
UPDATE sag_reminders
|
||||||
SET deleted_at = CURRENT_TIMESTAMP, is_active = false
|
SET deleted_at = CURRENT_TIMESTAMP, is_active = false
|
||||||
WHERE id = %s
|
WHERE id = %s AND created_by_user_id = %s AND deleted_at IS NULL
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = execute_insert(query, (reminder_id,))
|
result = execute_insert(query, (reminder_id, user_id))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Reminder not found")
|
raise HTTPException(status_code=404, detail="Reminder not found")
|
||||||
|
|
||||||
logger.info(f"✅ Reminder {reminder_id} deleted")
|
logger.info(f"✅ Reminder {reminder_id} deleted")
|
||||||
return {"success": True, "message": "Reminder deleted"}
|
return {"success": True, "message": "Reminder deleted"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error deleting reminder: {e}")
|
logger.error(f"❌ Error deleting reminder: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@ -11,13 +11,14 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
|
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
|
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
|
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.auth_dependencies import get_current_user, require_any_permission
|
||||||
from app.services.email_service import EmailService
|
from app.services.email_service import EmailService
|
||||||
from app.services.case_analysis_service import CaseAnalysisService
|
from app.services.case_analysis_service import CaseAnalysisService
|
||||||
from app.services.ollama_service import ollama_service
|
from app.services.ollama_service import ollama_service
|
||||||
@ -31,7 +32,38 @@ import email
|
|||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
case_read_access = require_any_permission("cases.view", "tickets.view")
|
||||||
|
case_create_access = require_any_permission("cases.create", "tickets.create")
|
||||||
|
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
|
||||||
|
case_delete_access = require_any_permission("cases.delete", "tickets.delete")
|
||||||
|
|
||||||
|
|
||||||
|
async def case_route_access(request: Request, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
"""Apply read/create/edit/delete access consistently to every case route."""
|
||||||
|
if current_user.get("is_superadmin"):
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
method = request.method.upper()
|
||||||
|
normalized_path = request.url.path.rstrip("/")
|
||||||
|
if method in {"GET", "HEAD", "OPTIONS"}:
|
||||||
|
required = {"cases.view", "tickets.view"}
|
||||||
|
elif method == "POST" and normalized_path == "/api/v1/sag":
|
||||||
|
required = {"cases.create", "tickets.create"}
|
||||||
|
elif method == "DELETE" and re.fullmatch(r"/api/v1/sag/\d+", normalized_path):
|
||||||
|
required = {"cases.delete", "tickets.delete"}
|
||||||
|
else:
|
||||||
|
required = {"cases.edit", "tickets.edit"}
|
||||||
|
|
||||||
|
available = set(current_user.get("permissions") or [])
|
||||||
|
if available.intersection(required):
|
||||||
|
return current_user
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Missing required permission. Need one of: {', '.join(sorted(required))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(case_route_access)])
|
||||||
|
|
||||||
|
|
||||||
def _table_exists(table_name: str) -> bool:
|
def _table_exists(table_name: str) -> bool:
|
||||||
@ -142,7 +174,7 @@ def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Opti
|
|||||||
try:
|
try:
|
||||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||||
if parsed.tzinfo is not None:
|
if parsed.tzinfo is not None:
|
||||||
parsed = parsed.replace(tzinfo=None)
|
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
return parsed.strftime("%Y-%m-%d %H:%M:%S")
|
return parsed.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid datetime format for {field_name}")
|
raise HTTPException(status_code=400, detail=f"Invalid datetime format for {field_name}")
|
||||||
@ -208,6 +240,38 @@ def _normalize_deferred_statuses(value: Optional[object]) -> Optional[str]:
|
|||||||
return ", ".join(cleaned)
|
return ", ".join(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_relation_input(sag_id: int, data: dict) -> tuple[int, str]:
|
||||||
|
try:
|
||||||
|
target_id = int(data.get("målsag_id"))
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="målsag_id must be an integer")
|
||||||
|
|
||||||
|
raw_relation_type = str(data.get("relationstype") or "").strip()
|
||||||
|
relation_aliases = {
|
||||||
|
"relateret til": "Relateret til",
|
||||||
|
"relateret_til": "Relateret til",
|
||||||
|
"afledt af": "Afledt af",
|
||||||
|
"afledt_af": "Afledt af",
|
||||||
|
"årsag til": "Årsag til",
|
||||||
|
"årsag_til": "Årsag til",
|
||||||
|
"blokkerer": "Blokkerer",
|
||||||
|
"afhænger af": "afhænger af",
|
||||||
|
"afhænger_af": "afhænger af",
|
||||||
|
"undersag": "undersag",
|
||||||
|
"duplikat": "duplikat",
|
||||||
|
"forælder": "forælder",
|
||||||
|
"barn": "barn",
|
||||||
|
"udfører for": "udfører_for",
|
||||||
|
"udfører_for": "udfører_for",
|
||||||
|
}
|
||||||
|
relation_type = relation_aliases.get(raw_relation_type.casefold())
|
||||||
|
if not relation_type:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid relationstype")
|
||||||
|
if sag_id == target_id:
|
||||||
|
raise HTTPException(status_code=400, detail="A case cannot be related to itself")
|
||||||
|
return target_id, relation_type
|
||||||
|
|
||||||
|
|
||||||
def _deferred_status_matches(
|
def _deferred_status_matches(
|
||||||
deferred_until_status: Optional[str],
|
deferred_until_status: Optional[str],
|
||||||
previous_status: Optional[str],
|
previous_status: Optional[str],
|
||||||
@ -926,8 +990,8 @@ async def list_all_sale_items(
|
|||||||
logger.error("❌ Error listing sale items: %s", e)
|
logger.error("❌ Error listing sale items: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to list sale items")
|
raise HTTPException(status_code=500, detail="Failed to list sale items")
|
||||||
|
|
||||||
@router.post("/sag")
|
@router.post("/sag", dependencies=[Depends(case_create_access)])
|
||||||
async def create_sag(data: dict):
|
async def create_sag(request: Request, data: dict):
|
||||||
"""Create a case and its optional pipeline/order data atomically."""
|
"""Create a case and its optional pipeline/order data atomically."""
|
||||||
try:
|
try:
|
||||||
if not data.get("titel"):
|
if not data.get("titel"):
|
||||||
@ -1025,7 +1089,7 @@ async def create_sag(data: dict):
|
|||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
|
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
|
||||||
assigned_group_id, data.get("created_by_user_id", 1), deadline, deferred_until, data.get("deferred_until_case_id"),
|
assigned_group_id, _get_user_id_from_request(request), deadline, deferred_until, data.get("deferred_until_case_id"),
|
||||||
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
|
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
|
||||||
)
|
)
|
||||||
result = cursor.fetchone()
|
result = cursor.fetchone()
|
||||||
@ -1471,7 +1535,7 @@ async def delete_todo_step(step_id: int):
|
|||||||
logger.error("❌ Error deleting todo step: %s", e)
|
logger.error("❌ Error deleting todo step: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to delete todo step")
|
raise HTTPException(status_code=500, detail="Failed to delete todo step")
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id:int}")
|
@router.patch("/sag/{sag_id:int}", dependencies=[Depends(case_edit_access)])
|
||||||
async def update_sag(sag_id: int, updates: dict = Body(...)):
|
async def update_sag(sag_id: int, updates: dict = Body(...)):
|
||||||
"""Update a case."""
|
"""Update a case."""
|
||||||
try:
|
try:
|
||||||
@ -1680,7 +1744,7 @@ class BeskrivelsePatch(BaseModel):
|
|||||||
beskrivelse: str
|
beskrivelse: str
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id}/beskrivelse")
|
@router.patch("/sag/{sag_id}/beskrivelse", dependencies=[Depends(case_edit_access)])
|
||||||
async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: Request):
|
async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: Request):
|
||||||
"""Update case description and store a change history entry."""
|
"""Update case description and store a change history entry."""
|
||||||
try:
|
try:
|
||||||
@ -1759,7 +1823,7 @@ class PipelineUpdate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id}/pipeline")
|
@router.patch("/sag/{sag_id}/pipeline", dependencies=[Depends(case_edit_access)])
|
||||||
async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
|
async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
|
||||||
"""Update pipeline fields for a case."""
|
"""Update pipeline fields for a case."""
|
||||||
try:
|
try:
|
||||||
@ -1817,7 +1881,7 @@ async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
|
|||||||
logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e)
|
logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to update pipeline")
|
raise HTTPException(status_code=500, detail="Failed to update pipeline")
|
||||||
|
|
||||||
@router.delete("/sag/{sag_id:int}")
|
@router.delete("/sag/{sag_id:int}", dependencies=[Depends(case_delete_access)])
|
||||||
async def delete_sag(sag_id: int):
|
async def delete_sag(sag_id: int):
|
||||||
"""Soft-delete a case."""
|
"""Soft-delete a case."""
|
||||||
try:
|
try:
|
||||||
@ -1870,15 +1934,14 @@ async def get_relationer(sag_id: int):
|
|||||||
logger.error("❌ Error getting relations: %s", e)
|
logger.error("❌ Error getting relations: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to get relations")
|
raise HTTPException(status_code=500, detail="Failed to get relations")
|
||||||
|
|
||||||
@router.post("/sag/{sag_id}/relationer")
|
@router.post("/sag/{sag_id}/relationer", dependencies=[Depends(case_edit_access)])
|
||||||
async def create_relation(sag_id: int, data: dict):
|
async def create_relation(sag_id: int, data: dict):
|
||||||
"""Add a relation to another case."""
|
"""Add a relation to another case."""
|
||||||
try:
|
try:
|
||||||
if not data.get("målsag_id") or not data.get("relationstype"):
|
if not data.get("målsag_id") or not data.get("relationstype"):
|
||||||
raise HTTPException(status_code=400, detail="målsag_id and relationstype required")
|
raise HTTPException(status_code=400, detail="målsag_id and relationstype required")
|
||||||
|
|
||||||
målsag_id = data.get("målsag_id")
|
målsag_id, relationstype = _normalize_relation_input(sag_id, data)
|
||||||
relationstype = data.get("relationstype")
|
|
||||||
|
|
||||||
# Validate both cases exist
|
# Validate both cases exist
|
||||||
check1 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
check1 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
||||||
@ -1887,6 +1950,20 @@ async def create_relation(sag_id: int, data: dict):
|
|||||||
if not check1 or not check2:
|
if not check1 or not check2:
|
||||||
raise HTTPException(status_code=404, detail="One or both cases not found")
|
raise HTTPException(status_code=404, detail="One or both cases not found")
|
||||||
|
|
||||||
|
duplicate = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM sag_relationer
|
||||||
|
WHERE kilde_sag_id = %s
|
||||||
|
AND målsag_id = %s
|
||||||
|
AND relationstype = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(sag_id, målsag_id, relationstype),
|
||||||
|
)
|
||||||
|
if duplicate:
|
||||||
|
raise HTTPException(status_code=409, detail="Relation already exists")
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
|
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
|
||||||
VALUES (%s, %s, %s)
|
VALUES (%s, %s, %s)
|
||||||
@ -1904,7 +1981,7 @@ async def create_relation(sag_id: int, data: dict):
|
|||||||
logger.error("❌ Error creating relation: %s", e)
|
logger.error("❌ Error creating relation: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to create relation")
|
raise HTTPException(status_code=500, detail="Failed to create relation")
|
||||||
|
|
||||||
@router.delete("/sag/{sag_id}/relationer/{relation_id}")
|
@router.delete("/sag/{sag_id}/relationer/{relation_id}", dependencies=[Depends(case_edit_access)])
|
||||||
async def delete_relation(sag_id: int, relation_id: int):
|
async def delete_relation(sag_id: int, relation_id: int):
|
||||||
"""Soft-delete a relation."""
|
"""Soft-delete a relation."""
|
||||||
try:
|
try:
|
||||||
@ -1958,7 +2035,7 @@ async def get_tags(sag_id: int):
|
|||||||
logger.error("❌ Error getting tags: %s", e)
|
logger.error("❌ Error getting tags: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to get tags")
|
raise HTTPException(status_code=500, detail="Failed to get tags")
|
||||||
|
|
||||||
@router.post("/sag/{sag_id}/tags")
|
@router.post("/sag/{sag_id}/tags", dependencies=[Depends(case_edit_access)])
|
||||||
async def add_tag(sag_id: int, data: dict):
|
async def add_tag(sag_id: int, data: dict):
|
||||||
"""Add a tag to a case."""
|
"""Add a tag to a case."""
|
||||||
try:
|
try:
|
||||||
@ -1986,7 +2063,7 @@ async def add_tag(sag_id: int, data: dict):
|
|||||||
logger.error("❌ Error adding tag: %s", e)
|
logger.error("❌ Error adding tag: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to add tag")
|
raise HTTPException(status_code=500, detail="Failed to add tag")
|
||||||
|
|
||||||
@router.delete("/sag/{sag_id}/tags/{tag_id}")
|
@router.delete("/sag/{sag_id}/tags/{tag_id}", dependencies=[Depends(case_edit_access)])
|
||||||
async def delete_tag(sag_id: int, tag_id: int):
|
async def delete_tag(sag_id: int, tag_id: int):
|
||||||
"""Soft-delete a tag."""
|
"""Soft-delete a tag."""
|
||||||
try:
|
try:
|
||||||
@ -4407,15 +4484,20 @@ def _generate_stored_name(filename: str, subdir: str) -> str:
|
|||||||
return f"{subdir}/{unique}"
|
return f"{subdir}/{unique}"
|
||||||
|
|
||||||
def _resolve_attachment_path(stored_name: str) -> Path:
|
def _resolve_attachment_path(stored_name: str) -> Path:
|
||||||
return UPLOAD_BASE_PATH / stored_name
|
candidate = (UPLOAD_BASE_PATH / str(stored_name or "")).resolve()
|
||||||
|
try:
|
||||||
|
candidate.relative_to(UPLOAD_BASE_PATH)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid attachment path") from exc
|
||||||
|
return candidate
|
||||||
|
|
||||||
def _store_upload_file(upload_file: UploadFile, subdir: str):
|
def _store_upload_file(upload_file: UploadFile, subdir: str):
|
||||||
if not upload_file.filename:
|
if not upload_file.filename:
|
||||||
raise HTTPException(400, detail="Filename missing")
|
raise HTTPException(400, detail="Filename missing")
|
||||||
|
|
||||||
ext = Path(upload_file.filename).suffix.lower().lstrip(".")
|
ext = Path(upload_file.filename).suffix.lower().lstrip(".")
|
||||||
# Basic check - allow more types for generic files?
|
if not ext or ext not in ALLOWED_EXTENSIONS:
|
||||||
# if ext not in ALLOWED_EXTENSIONS: ...
|
raise HTTPException(status_code=400, detail=f"File type .{ext or '?'} is not allowed")
|
||||||
|
|
||||||
upload_file.file.seek(0, os.SEEK_END)
|
upload_file.file.seek(0, os.SEEK_END)
|
||||||
size = upload_file.file.tell()
|
size = upload_file.file.tell()
|
||||||
@ -4462,7 +4544,7 @@ async def list_sag_files(sag_id: int):
|
|||||||
logger.error("❌ Error listing files: %s", e)
|
logger.error("❌ Error listing files: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to list files")
|
raise HTTPException(status_code=500, detail="Failed to list files")
|
||||||
|
|
||||||
@router.post("/sag/{sag_id}/files")
|
@router.post("/sag/{sag_id}/files", dependencies=[Depends(case_edit_access)])
|
||||||
async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
|
async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
|
||||||
"""Upload files to a case."""
|
"""Upload files to a case."""
|
||||||
if not _table_exists("sag_files"):
|
if not _table_exists("sag_files"):
|
||||||
@ -4473,6 +4555,7 @@ async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
|
|||||||
raise HTTPException(status_code=404, detail="Case not found")
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
saved_files = []
|
saved_files = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
try:
|
try:
|
||||||
@ -4488,12 +4571,17 @@ async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
|
|||||||
saved = result[0]
|
saved = result[0]
|
||||||
saved["download_url"] = f"/api/v1/sag/{sag_id}/files/{saved['id']}"
|
saved["download_url"] = f"/api/v1/sag/{sag_id}/files/{saved['id']}"
|
||||||
saved_files.append(saved)
|
saved_files.append(saved)
|
||||||
except HTTPException:
|
except HTTPException as exc:
|
||||||
continue # Skip invalid
|
errors.append({"filename": file.filename, "detail": exc.detail})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving file {file.filename}: {e}")
|
logger.error(f"Error saving file {file.filename}: {e}")
|
||||||
continue
|
errors.append({"filename": file.filename, "detail": "Server upload failed"})
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"message": "One or more files could not be uploaded", "files": errors, "saved": saved_files},
|
||||||
|
)
|
||||||
return saved_files
|
return saved_files
|
||||||
|
|
||||||
@router.get("/sag/{sag_id}/files/{file_id}")
|
@router.get("/sag/{sag_id}/files/{file_id}")
|
||||||
@ -4582,7 +4670,7 @@ async def preview_sag_pdf_as_image(sag_id: int, file_id: int, page: int = Query(
|
|||||||
logger.error("❌ PDF preview render failed for SAG-%s file %s: %s", sag_id, file_id, e)
|
logger.error("❌ PDF preview render failed for SAG-%s file %s: %s", sag_id, file_id, e)
|
||||||
raise HTTPException(status_code=500, detail="Could not render PDF preview")
|
raise HTTPException(status_code=500, detail="Could not render PDF preview")
|
||||||
|
|
||||||
@router.delete("/sag/{sag_id}/files/{file_id}")
|
@router.delete("/sag/{sag_id}/files/{file_id}", dependencies=[Depends(case_edit_access)])
|
||||||
async def delete_sag_file(sag_id: int, file_id: int):
|
async def delete_sag_file(sag_id: int, file_id: int):
|
||||||
"""Delete a file."""
|
"""Delete a file."""
|
||||||
if not _table_exists("sag_files"):
|
if not _table_exists("sag_files"):
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
|
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
|
||||||
|
from app.core.auth_dependencies import require_any_permission
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
|
||||||
|
|
||||||
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
|
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
|
||||||
async def get_solution(sag_id: int):
|
async def get_solution(sag_id: int):
|
||||||
@ -21,8 +23,12 @@ async def get_solution(sag_id: int):
|
|||||||
logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
|
logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to get solution")
|
raise HTTPException(status_code=500, detail="Failed to get solution")
|
||||||
|
|
||||||
@router.post("/sag/{sag_id}/solution", response_model=Solution)
|
@router.post(
|
||||||
async def create_solution(sag_id: int, solution: SolutionCreate):
|
"/sag/{sag_id}/solution",
|
||||||
|
response_model=Solution,
|
||||||
|
dependencies=[Depends(case_edit_access)],
|
||||||
|
)
|
||||||
|
async def create_solution(sag_id: int, solution: SolutionCreate, request: Request):
|
||||||
"""Create a solution for a case."""
|
"""Create a solution for a case."""
|
||||||
try:
|
try:
|
||||||
# Check if case exists
|
# Check if case exists
|
||||||
@ -47,7 +53,7 @@ async def create_solution(sag_id: int, solution: SolutionCreate):
|
|||||||
solution.description,
|
solution.description,
|
||||||
solution.solution_type,
|
solution.solution_type,
|
||||||
solution.result,
|
solution.result,
|
||||||
solution.created_by_user_id
|
getattr(request.state, "user_id", None)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = execute_query(query, params)
|
result = execute_query(query, params)
|
||||||
@ -61,7 +67,11 @@ async def create_solution(sag_id: int, solution: SolutionCreate):
|
|||||||
logger.error("❌ Error creating solution: %s", e)
|
logger.error("❌ Error creating solution: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to create solution")
|
raise HTTPException(status_code=500, detail="Failed to create solution")
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id}/solution", response_model=Solution)
|
@router.patch(
|
||||||
|
"/sag/{sag_id}/solution",
|
||||||
|
response_model=Solution,
|
||||||
|
dependencies=[Depends(case_edit_access)],
|
||||||
|
)
|
||||||
async def update_solution(sag_id: int, updates: SolutionUpdate):
|
async def update_solution(sag_id: int, updates: SolutionUpdate):
|
||||||
"""Update a solution."""
|
"""Update a solution."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
|
from app.utils.safe_html import sanitize_safe_html
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -76,6 +77,7 @@ def _is_deadline_overdue(deadline_value) -> bool:
|
|||||||
|
|
||||||
# Setup template directory
|
# Setup template directory
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
|
templates.env.filters["safe_case_html"] = sanitize_safe_html
|
||||||
|
|
||||||
|
|
||||||
def _fetch_assignment_users():
|
def _fetch_assignment_users():
|
||||||
|
|||||||
418
app/modules/sag/scripts/sag_module_e2e.py
Normal file
418
app/modules/sag/scripts/sag_module_e2e.py
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reversibel end-to-end test af Sag-modulets HTTP API.
|
||||||
|
|
||||||
|
Eksempel:
|
||||||
|
BMC_TEST_PASSWORD='...' python app/modules/sag/scripts/sag_module_e2e.py \
|
||||||
|
--username admin --customer-id 1
|
||||||
|
|
||||||
|
Alternativt kan et eksisterende JWT angives via BMC_TEST_TOKEN eller --token.
|
||||||
|
Testen opretter to tydeligt navngivne testsager og sletter dem igen i finally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Result:
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
detail: str = ""
|
||||||
|
duration_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(RuntimeError):
|
||||||
|
def __init__(self, method: str, path: str, status: int, body: Any):
|
||||||
|
super().__init__(f"{method} {path} -> HTTP {status}: {body}")
|
||||||
|
self.status = status
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
|
||||||
|
class SagE2E:
|
||||||
|
def __init__(self, args: argparse.Namespace):
|
||||||
|
self.args = args
|
||||||
|
self.base = args.base_url.rstrip("/")
|
||||||
|
self.token = args.token or os.getenv("BMC_TEST_TOKEN")
|
||||||
|
self.results: list[Result] = []
|
||||||
|
self.created_cases: list[int] = []
|
||||||
|
self.cleanup: list[tuple[str, Callable[[], Any]]] = []
|
||||||
|
self.run_id = f"{datetime.now():%Y%m%d-%H%M%S}-{uuid.uuid4().hex[:6]}"
|
||||||
|
self.marker = f"SAG-E2E-{self.run_id}"
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
data: Any = None,
|
||||||
|
*,
|
||||||
|
expected: tuple[int, ...] = (200,),
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
raw: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
request_headers = {"Accept": "application/json"}
|
||||||
|
if self.token:
|
||||||
|
request_headers["Authorization"] = f"Bearer {self.token}"
|
||||||
|
if headers:
|
||||||
|
request_headers.update(headers)
|
||||||
|
body = data
|
||||||
|
if data is not None and not isinstance(data, bytes):
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
request_headers["Content-Type"] = "application/json"
|
||||||
|
req = Request(self.base + path, data=body, headers=request_headers, method=method)
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=self.args.timeout) as response:
|
||||||
|
payload = response.read()
|
||||||
|
if response.status not in expected:
|
||||||
|
raise ApiError(method, path, response.status, payload.decode(errors="replace"))
|
||||||
|
if raw:
|
||||||
|
return payload
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
return json.loads(payload) if "json" in content_type else payload.decode(errors="replace")
|
||||||
|
except HTTPError as exc:
|
||||||
|
payload = exc.read().decode(errors="replace")
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
if exc.code in expected:
|
||||||
|
return payload
|
||||||
|
raise ApiError(method, path, exc.code, payload) from exc
|
||||||
|
except URLError as exc:
|
||||||
|
raise RuntimeError(f"Kan ikke forbinde til {self.base}: {exc.reason}") from exc
|
||||||
|
|
||||||
|
def check(self, name: str, operation: Callable[[], Any]) -> Any:
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
value = operation()
|
||||||
|
elapsed = int((time.monotonic() - started) * 1000)
|
||||||
|
self.results.append(Result(name, "PASS", duration_ms=elapsed))
|
||||||
|
print(f"PASS {name} ({elapsed} ms)")
|
||||||
|
return value
|
||||||
|
except Exception as exc:
|
||||||
|
elapsed = int((time.monotonic() - started) * 1000)
|
||||||
|
detail = str(exc).replace("\n", " ")[:800]
|
||||||
|
self.results.append(Result(name, "FAIL", detail, elapsed))
|
||||||
|
print(f"FAIL {name}: {detail}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def skip(self, name: str, reason: str) -> None:
|
||||||
|
self.results.append(Result(name, "SKIP", reason))
|
||||||
|
print(f"SKIP {name}: {reason}")
|
||||||
|
|
||||||
|
def authenticate(self) -> dict[str, Any]:
|
||||||
|
if not self.token:
|
||||||
|
if not self.args.username:
|
||||||
|
raise RuntimeError("Angiv --token/BMC_TEST_TOKEN eller --username.")
|
||||||
|
password = self.args.password or os.getenv("BMC_TEST_PASSWORD")
|
||||||
|
if not password and sys.stdin.isatty():
|
||||||
|
password = getpass.getpass("Adgangskode: ")
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError("Angiv --password eller BMC_TEST_PASSWORD.")
|
||||||
|
payload = {"username": self.args.username, "password": password}
|
||||||
|
if self.args.otp:
|
||||||
|
payload["otp_code"] = self.args.otp
|
||||||
|
response = self.request("POST", "/api/v1/auth/login", payload)
|
||||||
|
self.token = response.get("access_token")
|
||||||
|
if not self.token:
|
||||||
|
raise RuntimeError("Login returnerede intet access_token.")
|
||||||
|
me = self.request("GET", "/api/v1/auth/me")
|
||||||
|
print(f"Bruger: {me.get('full_name') or me.get('username') or me.get('user_id')}")
|
||||||
|
return me
|
||||||
|
|
||||||
|
def resolve_customer_id(self) -> int:
|
||||||
|
if self.args.customer_id:
|
||||||
|
return self.args.customer_id
|
||||||
|
customers = self.request("GET", "/api/v1/customers?limit=1&is_active=true")
|
||||||
|
rows = customers.get("customers", []) if isinstance(customers, dict) else customers
|
||||||
|
if not rows:
|
||||||
|
raise RuntimeError("Ingen aktiv kunde fundet. Angiv --customer-id.")
|
||||||
|
return int(rows[0]["id"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def require(value: Any, message: str) -> Any:
|
||||||
|
if not value:
|
||||||
|
raise AssertionError(message)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create_case(self, customer_id: int, suffix: str) -> dict[str, Any]:
|
||||||
|
case = self.request(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/sag",
|
||||||
|
{
|
||||||
|
"titel": f"[{self.marker}] {suffix}",
|
||||||
|
"beskrivelse": "Automatisk testsag. Må slettes.",
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"type": "ticket",
|
||||||
|
"status": "åben",
|
||||||
|
},
|
||||||
|
expected=(200, 201),
|
||||||
|
)
|
||||||
|
case_id = int(case["id"])
|
||||||
|
self.created_cases.append(case_id)
|
||||||
|
return case
|
||||||
|
|
||||||
|
def multipart_file(
|
||||||
|
self, field: str, filename: str, content: bytes, media_type: str
|
||||||
|
) -> tuple[bytes, str]:
|
||||||
|
boundary = f"----SagE2E{uuid.uuid4().hex}"
|
||||||
|
body = (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'
|
||||||
|
f"Content-Type: {media_type}\r\n\r\n"
|
||||||
|
).encode() + content + f"\r\n--{boundary}--\r\n".encode()
|
||||||
|
return body, f"multipart/form-data; boundary={boundary}"
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
self.check("Serverens health endpoint", lambda: self.request("GET", "/health"))
|
||||||
|
if self.check("Login og aktuel bruger", self.authenticate) is None:
|
||||||
|
return
|
||||||
|
customer_id = self.check("Find testkunde", self.resolve_customer_id)
|
||||||
|
if customer_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
parent = self.check("Opret hovedsag", lambda: self.create_case(customer_id, "Hovedsag"))
|
||||||
|
child = self.check("Opret undersag", lambda: self.create_case(customer_id, "Undersag"))
|
||||||
|
if not parent or not child:
|
||||||
|
return
|
||||||
|
parent_id, child_id = int(parent["id"]), int(child["id"])
|
||||||
|
|
||||||
|
self.check("Hent sag", lambda: self.require(
|
||||||
|
self.request("GET", f"/api/v1/sag/{parent_id}").get("id") == parent_id,
|
||||||
|
"Forkert sag returneret",
|
||||||
|
))
|
||||||
|
self.check("Sag vises i sagslisten", lambda: self.require(
|
||||||
|
any(int(row["id"]) == parent_id for row in self.request(
|
||||||
|
"GET", f"/api/v1/sag?search={self.marker}"
|
||||||
|
)),
|
||||||
|
"Testsagen blev ikke fundet i listen",
|
||||||
|
))
|
||||||
|
self.check("Redigér sag", lambda: self.require(
|
||||||
|
self.request("PATCH", f"/api/v1/sag/{parent_id}", {
|
||||||
|
"titel": f"[{self.marker}] Hovedsag redigeret",
|
||||||
|
"status": "under behandling",
|
||||||
|
"deadline": (datetime.now(timezone.utc) + timedelta(days=14)).isoformat(),
|
||||||
|
}).get("id") == parent_id,
|
||||||
|
"Sag blev ikke opdateret",
|
||||||
|
))
|
||||||
|
self.check("Gem beskrivelse", lambda: self.request(
|
||||||
|
"PATCH", f"/api/v1/sag/{parent_id}/beskrivelse",
|
||||||
|
{"beskrivelse": "<p>Sikker <strong>HTML</strong></p><script>nope()</script>"},
|
||||||
|
))
|
||||||
|
self.check("Beskrivelseshistorik", lambda: self.require(
|
||||||
|
self.request("GET", f"/api/v1/sag/{parent_id}/beskrivelse/history"),
|
||||||
|
"Historikken er tom",
|
||||||
|
))
|
||||||
|
self.check("Hent modulindstillinger", lambda: self.request(
|
||||||
|
"GET", f"/api/v1/sag/{parent_id}/modules"
|
||||||
|
))
|
||||||
|
|
||||||
|
comment = self.check("Opret kommentar", lambda: self.request(
|
||||||
|
"POST", f"/api/v1/sag/{parent_id}/kommentarer",
|
||||||
|
{"indhold": f"Kommentar {self.marker}", "er_intern": True},
|
||||||
|
expected=(200, 201),
|
||||||
|
))
|
||||||
|
self.check("Læs kommentarer", lambda: self.require(
|
||||||
|
any(self.marker in str(row.get("indhold", "")) for row in self.request(
|
||||||
|
"GET", f"/api/v1/sag/{parent_id}/kommentarer"
|
||||||
|
)),
|
||||||
|
"Kommentaren blev ikke fundet",
|
||||||
|
))
|
||||||
|
self.check("Læs tidslinje", lambda: self.request(
|
||||||
|
"GET", f"/api/v1/sag/{parent_id}/timeline"
|
||||||
|
))
|
||||||
|
|
||||||
|
todo = self.check("Opret todo", lambda: self.request(
|
||||||
|
"POST", f"/api/v1/sag/{child_id}/todo-steps",
|
||||||
|
{"title": f"Næste handling {self.marker}", "description": "E2E", "due_date": None},
|
||||||
|
expected=(200, 201),
|
||||||
|
))
|
||||||
|
if todo:
|
||||||
|
todo_id = int(todo["id"])
|
||||||
|
self.check("Markér todo som næste", lambda: self.require(
|
||||||
|
self.request("PATCH", f"/api/v1/sag/todo-steps/{todo_id}", {"is_next": True}).get("is_next"),
|
||||||
|
"Todo er ikke markeret som næste",
|
||||||
|
))
|
||||||
|
self.check("Afslut todo", lambda: self.require(
|
||||||
|
self.request("PATCH", f"/api/v1/sag/todo-steps/{todo_id}", {"is_done": True}).get("is_done"),
|
||||||
|
"Todo er ikke afsluttet",
|
||||||
|
))
|
||||||
|
self.check("Slet todo", lambda: self.request(
|
||||||
|
"DELETE", f"/api/v1/sag/todo-steps/{todo_id}"
|
||||||
|
))
|
||||||
|
|
||||||
|
relation = self.check("Opret logisk undersag-relation", lambda: self.request(
|
||||||
|
"POST", f"/api/v1/sag/{parent_id}/relationer",
|
||||||
|
{"målsag_id": child_id, "relationstype": "undersag"},
|
||||||
|
expected=(200, 201),
|
||||||
|
))
|
||||||
|
if relation:
|
||||||
|
relation_id = int(relation["id"])
|
||||||
|
self.check("Læs relationer", lambda: self.require(
|
||||||
|
any(int(row["id"]) == relation_id for row in self.request(
|
||||||
|
"GET", f"/api/v1/sag/{parent_id}/relationer"
|
||||||
|
)),
|
||||||
|
"Relationen blev ikke fundet",
|
||||||
|
))
|
||||||
|
self.check("Afvis dubletrelation", lambda: self.request(
|
||||||
|
"POST", f"/api/v1/sag/{parent_id}/relationer",
|
||||||
|
{"målsag_id": child_id, "relationstype": "undersag"},
|
||||||
|
expected=(409,),
|
||||||
|
))
|
||||||
|
self.check("Slet relation", lambda: self.request(
|
||||||
|
"DELETE", f"/api/v1/sag/{parent_id}/relationer/{relation_id}"
|
||||||
|
))
|
||||||
|
|
||||||
|
tag = self.check("Opret tag", lambda: self.request(
|
||||||
|
"POST", f"/api/v1/sag/{parent_id}/tags", {"tag_navn": self.marker},
|
||||||
|
expected=(200, 201),
|
||||||
|
))
|
||||||
|
if tag:
|
||||||
|
self.check("Læs tags", lambda: self.require(
|
||||||
|
any(row.get("tag_navn") == self.marker for row in self.request(
|
||||||
|
"GET", f"/api/v1/sag/{parent_id}/tags"
|
||||||
|
)), "Tag blev ikke fundet",
|
||||||
|
))
|
||||||
|
self.check("Slet tag", lambda: self.request(
|
||||||
|
"DELETE", f"/api/v1/sag/{parent_id}/tags/{tag['id']}"
|
||||||
|
))
|
||||||
|
|
||||||
|
buzz = self.check("Opret buzzword", lambda: self.request(
|
||||||
|
"POST", f"/api/v1/sag/{parent_id}/buzzwords",
|
||||||
|
{"buzzword": self.marker.lower()}, expected=(200, 201),
|
||||||
|
))
|
||||||
|
if buzz:
|
||||||
|
self.check("Global søgning finder buzzword", lambda: self.require(
|
||||||
|
any(int(row.get("id", -1)) == parent_id for row in self.request(
|
||||||
|
"GET", "/api/v1/search/sag?" + urlencode({"q": self.marker.lower()})
|
||||||
|
)), "Global søgning fandt ikke sagen via buzzword",
|
||||||
|
))
|
||||||
|
self.check("Slet buzzword-link", lambda: self.request(
|
||||||
|
"DELETE", f"/api/v1/sag/{parent_id}/buzzwords/{buzz['buzzword_id']}"
|
||||||
|
))
|
||||||
|
|
||||||
|
item = self.check("Opret salgs-/ordrelinje", lambda: self.request(
|
||||||
|
"POST", f"/api/v1/sag/{parent_id}/sale-items",
|
||||||
|
{"type": "sale", "description": self.marker, "quantity": 1,
|
||||||
|
"unit_price": 125, "amount": 125, "currency": "DKK", "status": "draft"},
|
||||||
|
expected=(200, 201),
|
||||||
|
))
|
||||||
|
if item:
|
||||||
|
item_id = int(item["id"])
|
||||||
|
self.check("Redigér salgs-/ordrelinje", lambda: self.require(
|
||||||
|
self.request("PATCH", f"/api/v1/sag/{parent_id}/sale-items/{item_id}",
|
||||||
|
{"status": "confirmed", "amount": 150}).get("status") == "confirmed",
|
||||||
|
"Linjen blev ikke bekræftet",
|
||||||
|
))
|
||||||
|
self.check("Slet salgs-/ordrelinje", lambda: self.request(
|
||||||
|
"DELETE", f"/api/v1/sag/{parent_id}/sale-items/{item_id}"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Minimal gyldig 1x1 PNG; PNG er blandt Sag-modulets tilladte filtyper.
|
||||||
|
file_content = bytes.fromhex(
|
||||||
|
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
|
||||||
|
"0000000d49444154789c6360000000020001e221bc330000000049454e44ae426082"
|
||||||
|
)
|
||||||
|
body, content_type = self.multipart_file(
|
||||||
|
"files", f"{self.marker}.png", file_content, "image/png"
|
||||||
|
)
|
||||||
|
uploaded = self.check("Upload fil", lambda: self.request(
|
||||||
|
"POST", f"/api/v1/sag/{parent_id}/files", body,
|
||||||
|
headers={"Content-Type": content_type}, expected=(200, 201),
|
||||||
|
))
|
||||||
|
if uploaded:
|
||||||
|
file_id = int(uploaded[0]["id"])
|
||||||
|
self.check("Download fil og kontrollér indhold", lambda: self.require(
|
||||||
|
self.request("GET", f"/api/v1/sag/{parent_id}/files/{file_id}", raw=True) == file_content,
|
||||||
|
"Downloadet fil har forkert indhold",
|
||||||
|
))
|
||||||
|
self.check("Slet fil", lambda: self.request(
|
||||||
|
"DELETE", f"/api/v1/sag/{parent_id}/files/{file_id}"
|
||||||
|
))
|
||||||
|
|
||||||
|
for name, reason in (
|
||||||
|
("Mailafsendelse og mail-link", "kræver en rigtig mailkonto/mail-ID"),
|
||||||
|
("AnyDesk-forbindelse", "kræver installeret klient og gyldigt AnyDesk-ID"),
|
||||||
|
("Direkte labelprint", "kræver fysisk printer"),
|
||||||
|
("Arbejdsseddel og underskrift", "kræver aktiv work-order/token"),
|
||||||
|
("Faktura-/økonomisynkronisering", "må ikke skrive i eksternt økonomisystem fra E2E"),
|
||||||
|
):
|
||||||
|
self.skip(name, reason)
|
||||||
|
|
||||||
|
def cleanup_all(self) -> None:
|
||||||
|
if self.args.keep_data:
|
||||||
|
print("Beholder testdata (--keep-data).")
|
||||||
|
return
|
||||||
|
for case_id in reversed(self.created_cases):
|
||||||
|
try:
|
||||||
|
self.request("DELETE", f"/api/v1/sag/{case_id}", expected=(200, 204, 404))
|
||||||
|
print(f"CLEAN Sag #{case_id}")
|
||||||
|
except Exception as exc:
|
||||||
|
self.results.append(Result(f"Oprydning af sag #{case_id}", "FAIL", str(exc)))
|
||||||
|
print(f"FAIL Oprydning af sag #{case_id}: {exc}")
|
||||||
|
|
||||||
|
def write_report(self) -> None:
|
||||||
|
counts = {status: sum(r.status == status for r in self.results)
|
||||||
|
for status in ("PASS", "FAIL", "SKIP")}
|
||||||
|
report = {
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"base_url": self.base,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"summary": counts,
|
||||||
|
"results": [asdict(result) for result in self.results],
|
||||||
|
}
|
||||||
|
if self.args.json_report:
|
||||||
|
path = Path(self.args.json_report)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
print(f"Rapport: {path.resolve()}")
|
||||||
|
print(f"\nResultat: {counts['PASS']} PASS, {counts['FAIL']} FAIL, {counts['SKIP']} SKIP")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Reversibel E2E-test af BMC Hub Sag-modulet")
|
||||||
|
parser.add_argument("--base-url", default=os.getenv("BMC_BASE_URL", "http://127.0.0.1:8001"))
|
||||||
|
parser.add_argument("--token", help="JWT; alternativt BMC_TEST_TOKEN")
|
||||||
|
parser.add_argument("--username", help="Loginbrugernavn")
|
||||||
|
parser.add_argument("--password", help="Frarådes i shellhistorik; brug BMC_TEST_PASSWORD")
|
||||||
|
parser.add_argument("--otp", help="2FA-kode hvis påkrævet")
|
||||||
|
parser.add_argument("--customer-id", type=int, help="Kunde til midlertidige testsager")
|
||||||
|
parser.add_argument("--timeout", type=float, default=20.0)
|
||||||
|
parser.add_argument("--keep-data", action="store_true", help="Behold testsager ved fejlsøgning")
|
||||||
|
parser.add_argument("--json-report", default="test-results/sag-e2e.json")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
suite = SagE2E(parse_args(argv))
|
||||||
|
print(f"Sag E2E {suite.run_id} mod {suite.base}")
|
||||||
|
try:
|
||||||
|
suite.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nAfbrudt.")
|
||||||
|
except Exception as exc:
|
||||||
|
suite.results.append(Result("Testkørsel", "FAIL", str(exc)))
|
||||||
|
print(f"FAIL Testkørsel: {exc}")
|
||||||
|
finally:
|
||||||
|
suite.cleanup_all()
|
||||||
|
suite.write_report()
|
||||||
|
return 1 if any(result.status == "FAIL" for result in suite.results) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -40,13 +40,27 @@ class RelationService:
|
|||||||
placeholders = ','.join(['%s'] * len(tree_ids))
|
placeholders = ','.join(['%s'] * len(tree_ids))
|
||||||
tree_cases_query = f"""
|
tree_cases_query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
s.id,
|
||||||
titel,
|
s.titel,
|
||||||
status,
|
s.status,
|
||||||
template_key,
|
s.template_key,
|
||||||
COALESCE(template_key, 'ticket') AS type
|
COALESCE(s.template_key, 'ticket') AS type,
|
||||||
FROM sag_sager
|
next_todo.title AS next_todo_title,
|
||||||
WHERE id IN ({placeholders})
|
next_todo.due_date AS next_todo_due_date
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT t.title, t.due_date
|
||||||
|
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.id IN ({placeholders})
|
||||||
"""
|
"""
|
||||||
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
|
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
|
||||||
|
|
||||||
@ -73,6 +87,10 @@ class RelationService:
|
|||||||
return m, k # m is parent of k
|
return m, k # m is parent of k
|
||||||
if rtype_lower in ['årsag til', 'cause of']:
|
if rtype_lower in ['årsag til', 'cause of']:
|
||||||
return k, m # k is parent of m
|
return k, m # k is parent of m
|
||||||
|
if rtype_lower in ['afhænger af', 'depends on']:
|
||||||
|
return m, k # dependency is parent of the dependent case
|
||||||
|
if rtype_lower in ['undersag', 'subcase']:
|
||||||
|
return k, m # source is the parent case
|
||||||
# Default: k is "related" to m, treat as child for visualization if k is current root context
|
# Default: k is "related" to m, treat as child for visualization if k is current root context
|
||||||
# But here we build a directed graph.
|
# But here we build a directed graph.
|
||||||
# If relation is symmetric (Relateret til), we must be careful not to create cycle A->B->A
|
# If relation is symmetric (Relateret til), we must be careful not to create cycle A->B->A
|
||||||
|
|||||||
@ -1203,7 +1203,6 @@
|
|||||||
customer_id: selectedCustomer ? selectedCustomer.id : null,
|
customer_id: selectedCustomer ? selectedCustomer.id : null,
|
||||||
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
||||||
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
|
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
|
||||||
created_by_user_id: 1, // HARDCODED for now, should come from auth
|
|
||||||
deadline: document.getElementById('deadline').value || null
|
deadline: document.getElementById('deadline').value || null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3859,7 +3859,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<!-- View mode -->
|
<!-- View mode -->
|
||||||
<div id="beskrivelse-view" class="narrative-description" style="min-height: 120px; cursor: pointer;" ondblclick="startBeskrivelsEdit()">
|
<div id="beskrivelse-view" class="narrative-description" style="min-height: 120px; cursor: pointer;" ondblclick="startBeskrivelsEdit()">
|
||||||
<div id="beskrivelse-text" class="prose" style="white-space: pre-wrap;">{{ case.beskrivelse or '' }}</div>
|
<div id="beskrivelse-text" class="prose" style="white-space: pre-wrap;">{{ (case.beskrivelse or '')|safe_case_html|safe }}</div>
|
||||||
{% if not case.beskrivelse %}
|
{% if not case.beskrivelse %}
|
||||||
<div id="beskrivelse-empty" class="text-center p-3">
|
<div id="beskrivelse-empty" class="text-center p-3">
|
||||||
<p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p>
|
<p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p>
|
||||||
@ -4130,7 +4130,7 @@
|
|||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-html="true"
|
data-bs-html="true"
|
||||||
data-bs-placement="right"
|
data-bs-placement="right"
|
||||||
title="<strong>Hvad betyder relationstyper?</strong><br><br><strong>Relateret til</strong>: Faglig kobling uden direkte afhængighed.<br><strong>Afledt af</strong>: Denne sag er opstået på baggrund af en anden sag.<br><strong>Årsag til</strong>: Denne sag er årsagen til en anden sag.<br><strong>Blokkerer</strong>: Arbejde i en sag stopper fremdrift i den anden."></i>
|
title="<strong>Vælg det, der beskriver forholdet bedst</strong><br><br><strong>Relateret</strong>: Samme emne uden afhængighed.<br><strong>Undersag</strong>: En mindre del af denne sag.<br><strong>Afhænger af</strong>: Denne sag kan ikke fortsætte før den anden.<br><strong>Blokerer</strong>: Den anden sag kan ikke fortsætte før denne.<br><strong>Duplikat</strong>: Samme problem er registreret to gange."></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
|
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
|
||||||
@ -4179,6 +4179,12 @@
|
|||||||
<div class="relation-type-subtext">(Årsag til)</div>
|
<div class="relation-type-subtext">(Årsag til)</div>
|
||||||
{% elif rel_key == 'blokkerer' %}
|
{% elif rel_key == 'blokkerer' %}
|
||||||
<span class="relation-type-pill is-block" title="Denne sag blokerer den anden, indtil den er løst.">Blokerer</span>
|
<span class="relation-type-pill is-block" title="Denne sag blokerer den anden, indtil den er løst.">Blokerer</span>
|
||||||
|
{% elif rel_key == 'undersag' %}
|
||||||
|
<span class="relation-type-pill is-derived" title="Denne sag er en mindre del af den overordnede sag.">Undersag</span>
|
||||||
|
{% elif rel_key == 'afhænger af' %}
|
||||||
|
<span class="relation-type-pill is-cause" title="Denne sag kan ikke fortsætte før den anden sag er klar.">Afhænger af</span>
|
||||||
|
{% elif rel_key == 'duplikat' %}
|
||||||
|
<span class="relation-type-pill is-related" title="Sagerne beskriver det samme problem.">Duplikat</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="relation-type-pill is-related" title="Sagerne er fagligt koblet uden direkte afhængighed.">Koblet til</span>
|
<span class="relation-type-pill is-related" title="Sagerne er fagligt koblet uden direkte afhængighed.">Koblet til</span>
|
||||||
<div class="relation-type-subtext">(Relateret til)</div>
|
<div class="relation-type-subtext">(Relateret til)</div>
|
||||||
@ -4187,6 +4193,23 @@
|
|||||||
<i class="bi bi-arrow-repeat text-muted ms-1" title="Vises flere steder i relationstræet"></i>
|
<i class="bi bi-arrow-repeat text-muted ms-1" title="Vises flere steder i relationstræet"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if node.case.next_todo_title %}
|
||||||
|
<div class="small fw-semibold">
|
||||||
|
<i class="bi bi-arrow-right-circle-fill text-warning me-1"></i>
|
||||||
|
{{ node.case.next_todo_title }}
|
||||||
|
</div>
|
||||||
|
{% if node.case.next_todo_due_date %}
|
||||||
|
<div class="small text-muted">
|
||||||
|
Frist {{ node.case.next_todo_due_date.strftime('%d.%m.%Y') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif not node.is_current %}
|
||||||
|
<span class="small text-muted">Ingen åben todo</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="small text-muted">–</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
{% if node.relation_id %}
|
{% if node.relation_id %}
|
||||||
@ -4221,6 +4244,7 @@
|
|||||||
<th style="width: 120px;">Status</th>
|
<th style="width: 120px;">Status</th>
|
||||||
<th style="width: 130px;">Type</th>
|
<th style="width: 130px;">Type</th>
|
||||||
<th style="width: 180px;">Sammenhæng</th>
|
<th style="width: 180px;">Sammenhæng</th>
|
||||||
|
<th style="min-width: 220px;">Næste todo</th>
|
||||||
<th class="text-end" style="width: 130px;">Handling</th>
|
<th class="text-end" style="width: 130px;">Handling</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -4352,13 +4376,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-bold">2. Vælg relationstype</label>
|
<label class="form-label fw-bold">2. Hvordan forholder den valgte sag sig til denne sag?</label>
|
||||||
<select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton(); updateRelationTypeHint();">
|
<select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton(); updateRelationTypeHint();">
|
||||||
<option value="">Vælg hvordan sagerne er relateret...</option>
|
<option value="">Vælg forholdet mellem sagerne...</option>
|
||||||
<option value="Relateret til">🔗 Koblet til (Relateret) - Faglig kobling uden direkte afhængighed</option>
|
<option value="Relateret til">🔗 Kun relateret — ingen afhængighed</option>
|
||||||
<option value="Afledt af">↪ Kommer fra (Afledt af) - Denne sag er opstået pga. den anden</option>
|
<option value="undersag">↳ Den valgte sag er en undersag til denne</option>
|
||||||
<option value="Årsag til">➡ Skaber følge-sag (Årsag til) - Denne sag skaber den anden</option>
|
<option value="afhænger af">⏳ Denne sag afhænger af den valgte</option>
|
||||||
<option value="Blokkerer">⛔ Blokerer - Den anden kan ikke videre før denne er løst</option>
|
<option value="Blokkerer">⛔ Denne sag blokerer den valgte</option>
|
||||||
|
<option value="duplikat">⧉ Sagerne beskriver det samme problem</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -4366,10 +4391,7 @@
|
|||||||
|
|
||||||
<div class="alert alert-light border small mb-3">
|
<div class="alert alert-light border small mb-3">
|
||||||
<div class="fw-semibold mb-1">Betydning i praksis</div>
|
<div class="fw-semibold mb-1">Betydning i praksis</div>
|
||||||
<div><strong>Koblet til (Relateret)</strong>: Faglig sammenhæng, men ingen direkte afhængighed.</div>
|
<div>Teksten i valget beskriver retningen direkte. Du skal altså ikke selv afkode “kilde” og “mål”.</div>
|
||||||
<div><strong>Kommer fra (Afledt af)</strong>: Sagen er opstået pga. en anden sag.</div>
|
|
||||||
<div><strong>Skaber følge-sag (Årsag til)</strong>: Sagen skaber behovet for en anden sag.</div>
|
|
||||||
<div><strong>Blokkerer</strong>: Bruges når løsning i én sag er nødvendig før den anden kan videre.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-light d-flex align-items-center" style="font-size: 0.9rem;">
|
<div class="alert alert-light d-flex align-items-center" style="font-size: 0.9rem;">
|
||||||
@ -5309,6 +5331,18 @@
|
|||||||
'Blokkerer': {
|
'Blokkerer': {
|
||||||
icon: '⛔',
|
icon: '⛔',
|
||||||
text: 'Arbejdet i denne sag stopper fremdrift i den anden sag, indtil blokeringen er løst.'
|
text: 'Arbejdet i denne sag stopper fremdrift i den anden sag, indtil blokeringen er løst.'
|
||||||
|
},
|
||||||
|
'undersag': {
|
||||||
|
icon: '↳',
|
||||||
|
text: 'Den valgte sag er en mindre del af denne sag.'
|
||||||
|
},
|
||||||
|
'afhænger af': {
|
||||||
|
icon: '⏳',
|
||||||
|
text: 'Denne sag kan ikke fortsætte, før den valgte sag er klar.'
|
||||||
|
},
|
||||||
|
'duplikat': {
|
||||||
|
icon: '⧉',
|
||||||
|
text: 'Sagerne beskriver det samme problem og bør normalt samles.'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return map[type] || null;
|
return map[type] || null;
|
||||||
@ -5336,20 +5370,24 @@
|
|||||||
if (!select || !hint) return;
|
if (!select || !hint) return;
|
||||||
|
|
||||||
const selected = select.value;
|
const selected = select.value;
|
||||||
if (selected === 'Afledt af') {
|
if (selected === 'undersag') {
|
||||||
hint.innerHTML = '<strong>↪ Effekt:</strong> Nuværende sag markeres som kommer fra den nye sag.';
|
hint.innerHTML = '<strong>↳ Resultat:</strong> Den nye sag bliver en undersag til denne sag.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selected === 'Årsag til') {
|
if (selected === 'afhænger af') {
|
||||||
hint.innerHTML = '<strong>➡ Effekt:</strong> Nuværende sag markeres som at den skaber den nye følge-sag.';
|
hint.innerHTML = '<strong>⏳ Resultat:</strong> Denne sag markeres som afhængig af den nye sag.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selected === 'Blokkerer') {
|
if (selected === 'Blokkerer') {
|
||||||
hint.innerHTML = '<strong>⛔ Effekt:</strong> Nuværende sag markeres som blokering for den nye sag.';
|
hint.innerHTML = '<strong>⛔ Resultat:</strong> Denne sag markeres som blokering for den nye sag.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected === 'duplikat') {
|
||||||
|
hint.innerHTML = '<strong>⧉ Resultat:</strong> Den nye sag markeres som et duplikat af denne sag.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
hint.innerHTML = '<strong>🔗 Effekt:</strong> Sagerne kobles fagligt uden direkte afhængighed (Koblet til).';
|
hint.innerHTML = '<strong>🔗 Resultat:</strong> Sagerne kobles uden direkte afhængighed.';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createRelatedCase() {
|
async function createRelatedCase() {
|
||||||
@ -5766,6 +5804,74 @@
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeCaseEmailHtml(unsafeHtml) {
|
||||||
|
const input = String(unsafeHtml || '').trim();
|
||||||
|
if (!input) return '';
|
||||||
|
|
||||||
|
const allowedTags = new Set([
|
||||||
|
'a', 'b', 'strong', 'i', 'em', 'u', 's', 'br', 'p', 'div', 'span',
|
||||||
|
'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'hr',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'table', 'thead', 'tbody', 'tr', 'th', 'td'
|
||||||
|
]);
|
||||||
|
const allowedAttrs = {
|
||||||
|
a: new Set(['href', 'title', 'target', 'rel']),
|
||||||
|
th: new Set(['colspan', 'rowspan']),
|
||||||
|
td: new Set(['colspan', 'rowspan'])
|
||||||
|
};
|
||||||
|
const safeUrl = (value) => {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
return normalized.startsWith('http://')
|
||||||
|
|| normalized.startsWith('https://')
|
||||||
|
|| normalized.startsWith('mailto:')
|
||||||
|
|| normalized.startsWith('tel:')
|
||||||
|
|| normalized.startsWith('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const doc = new DOMParser().parseFromString(input, 'text/html');
|
||||||
|
const cleanNode = (node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
return document.createTextNode(node.textContent || '');
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
return document.createTextNode('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = node.tagName.toLowerCase();
|
||||||
|
if (!allowedTags.has(tag)) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
Array.from(node.childNodes).forEach((child) => fragment.appendChild(cleanNode(child)));
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
const tagAttrs = allowedAttrs[tag] || new Set();
|
||||||
|
Array.from(node.attributes).forEach((attr) => {
|
||||||
|
const name = attr.name.toLowerCase();
|
||||||
|
const value = attr.value || '';
|
||||||
|
if (!tagAttrs.has(name)) return;
|
||||||
|
if (tag === 'a' && name === 'href') {
|
||||||
|
if (!safeUrl(value)) return;
|
||||||
|
element.setAttribute('href', value);
|
||||||
|
element.setAttribute('target', '_blank');
|
||||||
|
element.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name === 'colspan' || name === 'rowspan') {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isInteger(number) || number < 1 || number > 100) return;
|
||||||
|
element.setAttribute(name, String(number));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Array.from(node.childNodes).forEach((child) => element.appendChild(cleanNode(child)));
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
Array.from(doc.body.childNodes).forEach((child) => wrapper.appendChild(cleanNode(child)));
|
||||||
|
return wrapper.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function selectRelationCase(caseIdValue, caseTitel, customerName, status) {
|
function selectRelationCase(caseIdValue, caseTitel, customerName, status) {
|
||||||
selectedRelationCaseId = caseIdValue;
|
selectedRelationCaseId = caseIdValue;
|
||||||
|
|
||||||
@ -11722,8 +11828,8 @@
|
|||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div class="me-3">
|
<div class="me-3">
|
||||||
<div class="fw-bold">${reminder.title}</div>
|
<div class="fw-bold">${escapeHtml(reminder.title)}</div>
|
||||||
<div class="text-muted small">${reminder.message || '-'} </div>
|
<div class="text-muted small">${escapeHtml(reminder.message || '-')} </div>
|
||||||
<div class="small text-muted mt-1">
|
<div class="small text-muted mt-1">
|
||||||
Type: ${eventTypeLabels[reminder.event_type] || reminder.event_type || 'Reminder'} · Trigger: ${triggerLabels[reminder.trigger_type] || reminder.trigger_type} · Gentagelse: ${recurrenceLabels[reminder.recurrence_type] || reminder.recurrence_type}
|
Type: ${eventTypeLabels[reminder.event_type] || reminder.event_type || 'Reminder'} · Trigger: ${triggerLabels[reminder.trigger_type] || reminder.trigger_type} · Gentagelse: ${recurrenceLabels[reminder.recurrence_type] || reminder.recurrence_type}
|
||||||
</div>
|
</div>
|
||||||
@ -12729,7 +12835,6 @@
|
|||||||
solution_type: document.getElementById('sol_type').value,
|
solution_type: document.getElementById('sol_type').value,
|
||||||
result: document.getElementById('sol_result').value,
|
result: document.getElementById('sol_result').value,
|
||||||
description: document.getElementById('sol_desc').value,
|
description: document.getElementById('sol_desc').value,
|
||||||
created_by_user_id: 1 // TODO: Get from auth
|
|
||||||
};
|
};
|
||||||
const addTime = document.getElementById('sol_add_time')?.checked;
|
const addTime = document.getElementById('sol_add_time')?.checked;
|
||||||
const timeHours = parseInt(document.getElementById('sol_time_hours').value) || 0;
|
const timeHours = parseInt(document.getElementById('sol_time_hours').value) || 0;
|
||||||
@ -13245,21 +13350,19 @@
|
|||||||
<input type="text" class="form-control" id="newCaseTitle" required placeholder="F.eks. Opfølgning på...">
|
<input type="text" class="form-control" id="newCaseTitle" required placeholder="F.eks. Opfølgning på...">
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Relationstype *</label>
|
<label class="form-label">Hvordan forholder den nye sag sig til denne sag? *</label>
|
||||||
<select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()">
|
<select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()">
|
||||||
<option value="Relateret til">Relateret til (Ingen direkte afhængighed)</option>
|
<option value="undersag">↳ Den nye sag er en undersag til denne</option>
|
||||||
<option value="Afledt af">Afledt af (Nuværende sag er afledt af den nye)</option>
|
<option value="Relateret til">🔗 Kun relateret — ingen afhængighed</option>
|
||||||
<option value="Årsag til">Årsag til (Nuværende sag er årsag til den nye)</option>
|
<option value="afhænger af">⏳ Denne sag afhænger af den nye</option>
|
||||||
<option value="Blokkerer">Blokkerer (Nuværende sag blokerer den nye)</option>
|
<option value="Blokkerer">⛔ Denne sag blokerer den nye</option>
|
||||||
|
<option value="duplikat">⧉ Den nye sag er et duplikat af denne</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="newCaseRelationTypeHint" class="alert alert-info small mb-3"></div>
|
<div id="newCaseRelationTypeHint" class="alert alert-info small mb-3"></div>
|
||||||
<div class="alert alert-light border small">
|
<div class="alert alert-light border small">
|
||||||
<div class="fw-semibold mb-1">Sådan vælger du korrekt relation</div>
|
<div class="fw-semibold mb-1">Vælg ud fra sætningen</div>
|
||||||
<div><strong>Relateret til</strong>: Samme emne/område, men ingen direkte afhængighed.</div>
|
<div>Hvert valg beskriver den nye sag i forhold til den sag, du står på nu.</div>
|
||||||
<div><strong>Afledt af</strong>: Den nye sag opstår fordi den nuværende sag findes.</div>
|
|
||||||
<div><strong>Årsag til</strong>: Den nuværende sag opstår fordi den nye sag findes.</div>
|
|
||||||
<div><strong>Blokkerer</strong>: Løsning i én sag er nødvendig før den anden kan afsluttes.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Beskrivelse</label>
|
<label class="form-label">Beskrivelse</label>
|
||||||
@ -15041,7 +15144,9 @@
|
|||||||
const sourceToken = String(f.source_token || '').toUpperCase();
|
const sourceToken = String(f.source_token || '').toUpperCase();
|
||||||
const isWorkOrder = sourceType === 'scanner_email' && sourceToken.includes('BMCSCAN-WO-');
|
const isWorkOrder = sourceType === 'scanner_email' && sourceToken.includes('BMCSCAN-WO-');
|
||||||
const isScannerFile = sourceType === 'scanner_email';
|
const isScannerFile = sourceType === 'scanner_email';
|
||||||
const displayName = isWorkOrder ? `Arbejdsseddel: ${f.filename}` : f.filename;
|
const displayName = escapeHtml(isWorkOrder ? `Arbejdsseddel: ${f.filename}` : f.filename);
|
||||||
|
const fileId = Number(f.id);
|
||||||
|
const downloadUrl = escapeHtml(String(f.download_url || ''));
|
||||||
const badgeHtml = isWorkOrder
|
const badgeHtml = isWorkOrder
|
||||||
? '<span class="badge rounded-pill text-bg-warning ms-2"><i class="bi bi-clipboard-check me-1"></i>Arbejdsseddel</span>'
|
? '<span class="badge rounded-pill text-bg-warning ms-2"><i class="bi bi-clipboard-check me-1"></i>Arbejdsseddel</span>'
|
||||||
: (isScannerFile
|
: (isScannerFile
|
||||||
@ -15051,7 +15156,7 @@
|
|||||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<div class="ms-2 me-auto">
|
<div class="ms-2 me-auto">
|
||||||
<div class="fw-bold text-truncate d-flex align-items-center" style="max-width: 380px;">
|
<div class="fw-bold text-truncate d-flex align-items-center" style="max-width: 380px;">
|
||||||
<a href="javascript:void(0);" onclick="previewFile(${f.id}, '${f.filename.replace(/'/g, "\\'")}', '${f.content_type || ''}')" class="text-decoration-none text-dark">
|
<a href="javascript:void(0);" onclick="previewFileById(${fileId})" class="text-decoration-none text-dark">
|
||||||
<i class="bi ${isWorkOrder ? 'bi-clipboard-check' : 'bi-file-earmark'} me-1"></i> ${displayName}
|
<i class="bi ${isWorkOrder ? 'bi-clipboard-check' : 'bi-file-earmark'} me-1"></i> ${displayName}
|
||||||
</a>
|
</a>
|
||||||
${badgeHtml}
|
${badgeHtml}
|
||||||
@ -15059,10 +15164,10 @@
|
|||||||
<small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small>
|
<small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex gap-1">
|
||||||
<a href="${f.download_url}?download=true" class="btn btn-sm btn-outline-primary border-0" title="Download">
|
<a href="${downloadUrl}?download=true" class="btn btn-sm btn-outline-primary border-0" title="Download">
|
||||||
<i class="bi bi-download"></i>
|
<i class="bi bi-download"></i>
|
||||||
</a>
|
</a>
|
||||||
<button class="btn btn-sm btn-outline-danger border-0" onclick="deleteFile(${f.id})" title="Slet">
|
<button class="btn btn-sm btn-outline-danger border-0" onclick="deleteFile(${fileId})" title="Slet">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -15153,6 +15258,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// File Preview
|
// File Preview
|
||||||
|
function previewFileById(fileId) {
|
||||||
|
const file = sagFilesCache.find((item) => Number(item.id) === Number(fileId));
|
||||||
|
if (!file) {
|
||||||
|
alert('Filen kunne ikke findes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewFile(Number(file.id), String(file.filename || ''), String(file.content_type || ''));
|
||||||
|
}
|
||||||
|
|
||||||
function previewFile(fileId, filename, contentType) {
|
function previewFile(fileId, filename, contentType) {
|
||||||
const modal = new bootstrap.Modal(document.getElementById('filePreviewModal'));
|
const modal = new bootstrap.Modal(document.getElementById('filePreviewModal'));
|
||||||
const previewContent = document.getElementById('previewContent');
|
const previewContent = document.getElementById('previewContent');
|
||||||
@ -16122,7 +16236,7 @@
|
|||||||
const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-';
|
const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-';
|
||||||
const attachments = Array.isArray(email.attachments) ? email.attachments : [];
|
const attachments = Array.isArray(email.attachments) ? email.attachments : [];
|
||||||
const bodyText = email.body_text || '';
|
const bodyText = email.body_text || '';
|
||||||
const bodyHtml = email.body_html || '';
|
const bodyHtml = sanitizeCaseEmailHtml(email.body_html || '');
|
||||||
selectedLinkedEmailDetail = email;
|
selectedLinkedEmailDetail = email;
|
||||||
|
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
@ -16148,7 +16262,7 @@
|
|||||||
<div id="email-attachments-list" class="d-flex flex-wrap gap-2"></div>
|
<div id="email-attachments-list" class="d-flex flex-wrap gap-2"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 overflow-auto" style="max-height: 45vh; white-space: normal;">
|
<div class="p-3 overflow-auto" style="max-height: 45vh; white-space: normal;">
|
||||||
${bodyText ? `<pre class="mb-0" style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(bodyText)}</pre>` : (bodyHtml ? bodyHtml : '<div class="text-muted">Ingen indhold</div>')}
|
${bodyText ? `<pre class="mb-0" style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(bodyText)}</pre>` : (bodyHtml || '<div class="text-muted">Ingen indhold</div>')}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -18497,20 +18611,27 @@
|
|||||||
const saveBtn = getRelQaPrimaryButton();
|
const saveBtn = getRelQaPrimaryButton();
|
||||||
if (saveBtn) { saveBtn.disabled = true; }
|
if (saveBtn) { saveBtn.disabled = true; }
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/v1/sag/${caseId}/todos`, {
|
const r = await fetch(`/api/v1/sag/${caseId}/todo-steps`, {
|
||||||
method: 'POST', credentials: 'include',
|
method: 'POST', credentials: 'include',
|
||||||
headers: {'Content-Type':'application/json'},
|
headers: {'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ titel: title, frist: due, sag_id: caseId })
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
description: null,
|
||||||
|
due_date: due
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
closeRelQaSurfaceAfterSave();
|
closeRelQaSurfaceAfterSave();
|
||||||
if (typeof showNotification === 'function') showNotification('Opgave oprettet ✓', 'success');
|
if (typeof showNotification === 'function') showNotification('Opgave oprettet ✓', 'success');
|
||||||
} else {
|
} else {
|
||||||
const d = await r.json().catch(()=>({}));
|
const d = await r.json().catch(()=>({}));
|
||||||
if (typeof showNotification === 'function') showNotification(d.detail || 'Opgave-endpoint ikke tilgængeligt endnu', 'warning');
|
if (typeof showNotification === 'function') showNotification(d.detail || 'Kunne ikke oprette opgaven', 'error');
|
||||||
if (saveBtn) saveBtn.disabled = false;
|
if (saveBtn) saveBtn.disabled = false;
|
||||||
}
|
}
|
||||||
} catch { if (saveBtn) saveBtn.disabled = false; }
|
} catch {
|
||||||
|
if (typeof showNotification === 'function') showNotification('Kunne ikke kontakte serveren', 'error');
|
||||||
|
if (saveBtn) saveBtn.disabled = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Quick Tildel sag modal ────────────────────────────────────────
|
// ── Quick Tildel sag modal ────────────────────────────────────────
|
||||||
@ -19123,7 +19244,7 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Update view
|
// Update view
|
||||||
const textEl = document.getElementById('beskrivelse-text');
|
const textEl = document.getElementById('beskrivelse-text');
|
||||||
textEl.innerText = data.beskrivelse || '';
|
textEl.innerHTML = sanitizeCaseEmailHtml(data.beskrivelse || '');
|
||||||
const emptyEl = document.getElementById('beskrivelse-empty');
|
const emptyEl = document.getElementById('beskrivelse-empty');
|
||||||
if (emptyEl) emptyEl.style.display = data.beskrivelse ? 'none' : '';
|
if (emptyEl) emptyEl.style.display = data.beskrivelse ? 'none' : '';
|
||||||
cancelBeskrivelsEdit();
|
cancelBeskrivelsEdit();
|
||||||
|
|||||||
@ -1598,7 +1598,7 @@ class EmailService:
|
|||||||
|
|
||||||
# Prefer Graph send when Graph integration is enabled/configured.
|
# Prefer Graph send when Graph integration is enabled/configured.
|
||||||
if self._graph_send_available():
|
if self._graph_send_available():
|
||||||
graph_ok, graph_message = await self._send_via_graph(
|
graph_ok, graph_message, _graph_metadata = await self._send_via_graph(
|
||||||
to_addresses=to_addresses,
|
to_addresses=to_addresses,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body_text=body_text,
|
body_text=body_text,
|
||||||
|
|||||||
@ -25,9 +25,39 @@ class ReminderNotificationService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.email_service = EmailService()
|
self.email_service = EmailService()
|
||||||
self.mattermost_service = MattermostNotification()
|
self.mattermost_service = MattermostNotification()
|
||||||
|
# Reminder delivery has its own enable flag; backup notifications keep
|
||||||
|
# using the general MATTERMOST_ENABLED setting.
|
||||||
|
self.mattermost_service.enabled = settings.REMINDERS_MATTERMOST_ENABLED
|
||||||
self.dry_run = settings.REMINDERS_DRY_RUN
|
self.dry_run = settings.REMINDERS_DRY_RUN
|
||||||
self.max_per_hour = settings.REMINDERS_MAX_PER_USER_PER_HOUR
|
self.max_per_hour = settings.REMINDERS_MAX_PER_USER_PER_HOUR
|
||||||
|
|
||||||
|
def _refresh_mattermost_settings(self) -> None:
|
||||||
|
"""Load runtime Mattermost configuration from the admin settings table."""
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT key, value
|
||||||
|
FROM settings
|
||||||
|
WHERE key IN ('mattermost_reminders_enabled', 'mattermost_webhook_url', 'mattermost_channel')
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
values = {row["key"]: str(row.get("value") or "").strip() for row in rows}
|
||||||
|
enabled_value = values.get("mattermost_reminders_enabled")
|
||||||
|
enabled = (
|
||||||
|
enabled_value.lower() in {"1", "true", "yes", "on"}
|
||||||
|
if enabled_value is not None
|
||||||
|
else settings.REMINDERS_MATTERMOST_ENABLED
|
||||||
|
)
|
||||||
|
self.mattermost_service.enabled = bool(enabled and settings.REMINDERS_MATTERMOST_ENABLED)
|
||||||
|
self.mattermost_service.webhook_url = (
|
||||||
|
values.get("mattermost_webhook_url") or settings.MATTERMOST_WEBHOOK_URL
|
||||||
|
)
|
||||||
|
self.mattermost_service.channel = values.get("mattermost_channel") or settings.MATTERMOST_CHANNEL
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _case_url(case_id: int) -> str:
|
||||||
|
base_url = str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").strip().rstrip("/")
|
||||||
|
return f"{base_url}/sag/{case_id}/v3"
|
||||||
|
|
||||||
async def send_reminder(
|
async def send_reminder(
|
||||||
self,
|
self,
|
||||||
reminder_id: int,
|
reminder_id: int,
|
||||||
@ -68,6 +98,7 @@ class ReminderNotificationService:
|
|||||||
'rate_limited_users': [],
|
'rate_limited_users': [],
|
||||||
'logged_id': None
|
'logged_id': None
|
||||||
}
|
}
|
||||||
|
self._refresh_mattermost_settings()
|
||||||
|
|
||||||
if self.dry_run:
|
if self.dry_run:
|
||||||
logger.warning(f"🔒 DRY RUN: Would send reminder '{reminder_title}' for case #{sag_id}")
|
logger.warning(f"🔒 DRY RUN: Would send reminder '{reminder_title}' for case #{sag_id}")
|
||||||
@ -102,27 +133,46 @@ class ReminderNotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get user email
|
# Get user email
|
||||||
user_query = "SELECT email FROM users WHERE user_id = %s"
|
user_query = "SELECT email, username FROM users WHERE user_id = %s"
|
||||||
user = execute_query(user_query, (user_id,))
|
user = execute_query(user_query, (user_id,))
|
||||||
user_email = user[0]['email'] if user else None
|
user_email = user_prefs.get('email_override') or (user[0]['email'] if user else None)
|
||||||
|
mattermost_username = (
|
||||||
|
user_prefs.get('mattermost_username')
|
||||||
|
or (user[0].get('username') if user else None)
|
||||||
|
)
|
||||||
|
|
||||||
# Send via channels
|
# Send via channels
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
try:
|
try:
|
||||||
if channel == 'mattermost' and settings.REMINDERS_MATTERMOST_ENABLED:
|
if channel == 'mattermost':
|
||||||
await self._send_mattermost(
|
if not settings.REMINDERS_MATTERMOST_ENABLED:
|
||||||
|
result['errors'].append('Mattermost reminders are disabled')
|
||||||
|
continue
|
||||||
|
sent = await self._send_mattermost(
|
||||||
reminder_title, reminder_message, sag_id, case_title,
|
reminder_title, reminder_message, sag_id, case_title,
|
||||||
priority, additional_info
|
priority, additional_info, mattermost_username
|
||||||
)
|
)
|
||||||
result['channels_used'].append('mattermost')
|
if sent:
|
||||||
|
result['channels_used'].append('mattermost')
|
||||||
|
else:
|
||||||
|
result['errors'].append('Mattermost delivery failed')
|
||||||
|
|
||||||
elif channel == 'email' and settings.REMINDERS_EMAIL_ENABLED and user_email:
|
elif channel == 'email':
|
||||||
await self._send_email(
|
if not settings.REMINDERS_EMAIL_ENABLED:
|
||||||
|
result['errors'].append('Email reminders are disabled')
|
||||||
|
continue
|
||||||
|
if not user_email:
|
||||||
|
result['errors'].append(f'User {user_id} has no email address')
|
||||||
|
continue
|
||||||
|
sent = await self._send_email(
|
||||||
user_email, reminder_title, reminder_message,
|
user_email, reminder_title, reminder_message,
|
||||||
sag_id, case_title, customer_name, priority,
|
sag_id, case_title, customer_name, priority,
|
||||||
case_status, deadline, assigned_user, additional_info
|
case_status, deadline, assigned_user, additional_info
|
||||||
)
|
)
|
||||||
result['channels_used'].append('email')
|
if sent:
|
||||||
|
result['channels_used'].append('email')
|
||||||
|
else:
|
||||||
|
result['errors'].append(f'Email delivery failed for {user_email}')
|
||||||
|
|
||||||
elif channel == 'frontend':
|
elif channel == 'frontend':
|
||||||
# Frontend notifications are handled by polling, no action needed here
|
# Frontend notifications are handled by polling, no action needed here
|
||||||
@ -154,12 +204,17 @@ class ReminderNotificationService:
|
|||||||
for email_addr in recipient_emails:
|
for email_addr in recipient_emails:
|
||||||
try:
|
try:
|
||||||
if settings.REMINDERS_EMAIL_ENABLED:
|
if settings.REMINDERS_EMAIL_ENABLED:
|
||||||
await self._send_email(
|
sent = await self._send_email(
|
||||||
email_addr, reminder_title, reminder_message,
|
email_addr, reminder_title, reminder_message,
|
||||||
sag_id, case_title, customer_name, priority,
|
sag_id, case_title, customer_name, priority,
|
||||||
case_status, deadline, assigned_user, additional_info
|
case_status, deadline, assigned_user, additional_info
|
||||||
)
|
)
|
||||||
result['channels_used'].append('email')
|
if sent:
|
||||||
|
result['channels_used'].append('email')
|
||||||
|
else:
|
||||||
|
result['errors'].append(f'Email delivery failed for {email_addr}')
|
||||||
|
else:
|
||||||
|
result['errors'].append('Email reminders are disabled')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = f"Failed to send email to {email_addr}: {str(e)}"
|
error = f"Failed to send email to {email_addr}: {str(e)}"
|
||||||
@ -191,7 +246,8 @@ class ReminderNotificationService:
|
|||||||
async def _get_user_preferences(self, user_id: int) -> Dict:
|
async def _get_user_preferences(self, user_id: int) -> Dict:
|
||||||
"""Get user notification preferences"""
|
"""Get user notification preferences"""
|
||||||
query = """
|
query = """
|
||||||
SELECT notify_mattermost, notify_email, notify_frontend
|
SELECT notify_mattermost, notify_email, notify_frontend,
|
||||||
|
email_override, mattermost_username
|
||||||
FROM user_notification_preferences
|
FROM user_notification_preferences
|
||||||
WHERE user_id = %s
|
WHERE user_id = %s
|
||||||
"""
|
"""
|
||||||
@ -200,15 +256,19 @@ class ReminderNotificationService:
|
|||||||
if result:
|
if result:
|
||||||
return {
|
return {
|
||||||
'mattermost': result[0].get('notify_mattermost', True),
|
'mattermost': result[0].get('notify_mattermost', True),
|
||||||
'email': result[0].get('notify_email', False),
|
'email': result[0].get('notify_email', True),
|
||||||
'frontend': result[0].get('notify_frontend', True)
|
'frontend': result[0].get('notify_frontend', True),
|
||||||
|
'email_override': result[0].get('email_override')
|
||||||
|
, 'mattermost_username': result[0].get('mattermost_username')
|
||||||
}
|
}
|
||||||
|
|
||||||
# Default preferences
|
# Default preferences
|
||||||
return {
|
return {
|
||||||
'mattermost': True,
|
'mattermost': True,
|
||||||
'email': False,
|
'email': True,
|
||||||
'frontend': True
|
'frontend': True,
|
||||||
|
'email_override': None
|
||||||
|
, 'mattermost_username': None
|
||||||
}
|
}
|
||||||
|
|
||||||
def _determine_channels(
|
def _determine_channels(
|
||||||
@ -222,13 +282,18 @@ class ReminderNotificationService:
|
|||||||
"""Determine which channels to use (merge user prefs with reminder overrides)"""
|
"""Determine which channels to use (merge user prefs with reminder overrides)"""
|
||||||
channels = []
|
channels = []
|
||||||
|
|
||||||
|
if not override:
|
||||||
|
notify_mattermost = user_prefs.get('mattermost', True)
|
||||||
|
notify_email = user_prefs.get('email', True)
|
||||||
|
notify_frontend = user_prefs.get('frontend', True)
|
||||||
|
|
||||||
# Mattermost
|
# Mattermost
|
||||||
mm = notify_mattermost if notify_mattermost is not None else user_prefs.get('mattermost', True)
|
mm = notify_mattermost if notify_mattermost is not None else user_prefs.get('mattermost', True)
|
||||||
if mm:
|
if mm:
|
||||||
channels.append('mattermost')
|
channels.append('mattermost')
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
em = notify_email if notify_email is not None else user_prefs.get('email', False)
|
em = notify_email if notify_email is not None else user_prefs.get('email', True)
|
||||||
if em:
|
if em:
|
||||||
channels.append('email')
|
channels.append('email')
|
||||||
|
|
||||||
@ -246,7 +311,8 @@ class ReminderNotificationService:
|
|||||||
case_id: int,
|
case_id: int,
|
||||||
case_title: str,
|
case_title: str,
|
||||||
priority: str,
|
priority: str,
|
||||||
additional_info: Optional[str]
|
additional_info: Optional[str],
|
||||||
|
mattermost_username: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send reminder via Mattermost"""
|
"""Send reminder via Mattermost"""
|
||||||
if self.dry_run:
|
if self.dry_run:
|
||||||
@ -260,12 +326,13 @@ class ReminderNotificationService:
|
|||||||
'high': '#ffc107',
|
'high': '#ffc107',
|
||||||
'urgent': '#dc3545'
|
'urgent': '#dc3545'
|
||||||
}
|
}
|
||||||
|
case_url = self._case_url(case_id)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'text': f'🔔 **{title}**',
|
'text': f'🔔 **{title}**\n[Åbn sag #{case_id}]({case_url})',
|
||||||
'attachments': [{
|
'attachments': [{
|
||||||
'title': case_title,
|
'title': case_title,
|
||||||
'title_link': f"http://localhost:8001/sag/{case_id}/v3",
|
'title_link': case_url,
|
||||||
'text': message or additional_info or 'Se reminder i systemet',
|
'text': message or additional_info or 'Se reminder i systemet',
|
||||||
'color': color_map.get(priority, color_map['normal']),
|
'color': color_map.get(priority, color_map['normal']),
|
||||||
'fields': [
|
'fields': [
|
||||||
@ -279,17 +346,24 @@ class ReminderNotificationService:
|
|||||||
'value': f'#{case_id}',
|
'value': f'#{case_id}',
|
||||||
'short': True
|
'short': True
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
'actions': [{
|
|
||||||
'name': 'Åbn sag',
|
|
||||||
'type': 'button',
|
|
||||||
'text': 'Se mere',
|
|
||||||
'url': f"http://localhost:8001/sag/{case_id}/v3"
|
|
||||||
}]
|
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
if mattermost_username:
|
||||||
|
payload["channel"] = f"@{str(mattermost_username).strip().lstrip('@')}"
|
||||||
|
|
||||||
success, msg = await self.mattermost_service._send_webhook(payload, 'reminder_notification')
|
success, msg = await self.mattermost_service._send_webhook(payload, 'reminder_notification')
|
||||||
|
if not success and mattermost_username:
|
||||||
|
# Some Mattermost webhooks are locked to their configured
|
||||||
|
# channel and reject channel="@user". Fall back to an explicit
|
||||||
|
# mention in that channel so the recipient still gets notified.
|
||||||
|
username = str(mattermost_username).strip().lstrip("@")
|
||||||
|
payload.pop("channel", None)
|
||||||
|
payload["text"] = f"@{username} {payload['text']}"
|
||||||
|
success, msg = await self.mattermost_service._send_webhook(
|
||||||
|
payload,
|
||||||
|
'reminder_notification_mention',
|
||||||
|
)
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"✅ Mattermost reminder sent: {title}")
|
logger.info(f"✅ Mattermost reminder sent: {title}")
|
||||||
else:
|
else:
|
||||||
@ -337,7 +411,7 @@ class ReminderNotificationService:
|
|||||||
'deadline': deadline,
|
'deadline': deadline,
|
||||||
'assigned_user': assigned_user or 'Ikke tildelt',
|
'assigned_user': assigned_user or 'Ikke tildelt',
|
||||||
'additional_info': additional_info or '',
|
'additional_info': additional_info or '',
|
||||||
'action_url': f"http://localhost:8001/sag/{case_id}/v3",
|
'action_url': self._case_url(case_id),
|
||||||
'footer_date': datetime.now().strftime("%d. %B %Y")
|
'footer_date': datetime.now().strftime("%d. %B %Y")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,19 +2,29 @@
|
|||||||
Settings and User Management API Router
|
Settings and User Management API Router
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.auth_dependencies import require_superadmin
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
_sag_test_lock = threading.Lock()
|
||||||
|
_sag_test_report_dir = Path(__file__).resolve().parents[3] / "data" / "test-results"
|
||||||
|
|
||||||
DEFAULT_EMAIL_SIGNATURE_TEMPLATE = (
|
DEFAULT_EMAIL_SIGNATURE_TEMPLATE = (
|
||||||
"{full_name}\n"
|
"{full_name}\n"
|
||||||
@ -52,6 +62,29 @@ class SettingCreate(BaseModel):
|
|||||||
is_public: Optional[bool] = False
|
is_public: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class MattermostTestRequest(BaseModel):
|
||||||
|
message: Optional[str] = "Test fra BMC Hub"
|
||||||
|
|
||||||
|
|
||||||
|
class SagTestRunRequest(BaseModel):
|
||||||
|
customer_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
MATTERMOST_SETTING_DEFAULTS = (
|
||||||
|
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
|
||||||
|
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
|
||||||
|
("mattermost_channel", "", "Optional Mattermost channel override", "string"),
|
||||||
|
)
|
||||||
|
MASKED_SECRET = "********"
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_setting(row: Dict) -> Dict:
|
||||||
|
item = dict(row)
|
||||||
|
if item.get("key") == "mattermost_webhook_url":
|
||||||
|
item["value"] = MASKED_SECRET if str(item.get("value") or "").strip() else ""
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
@ -75,6 +108,80 @@ class UserUpdate(BaseModel):
|
|||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sag_test_reports(limit: int = 20) -> List[Dict]:
|
||||||
|
if not _sag_test_report_dir.exists():
|
||||||
|
return []
|
||||||
|
reports = []
|
||||||
|
for path in sorted(_sag_test_report_dir.glob("sag-e2e-*.json"), reverse=True)[:limit]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
payload["report_file"] = path.name
|
||||||
|
reports.append(payload)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
logger.warning("Kunne ikke læse Sag-testrapport: %s", path)
|
||||||
|
return reports
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_sag_test(current_user: Dict, customer_id: Optional[int]) -> Dict:
|
||||||
|
from app.modules.sag.scripts.sag_module_e2e import Result, SagE2E
|
||||||
|
|
||||||
|
_sag_test_report_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_path = _sag_test_report_dir / f"sag-e2e-{datetime.now():%Y%m%d-%H%M%S}.json"
|
||||||
|
token = AuthService.create_access_token(
|
||||||
|
user_id=int(current_user["id"]),
|
||||||
|
username=str(current_user["username"]),
|
||||||
|
is_superadmin=bool(current_user.get("is_superadmin")),
|
||||||
|
is_shadow_admin=bool(current_user.get("is_shadow_admin", False)),
|
||||||
|
)
|
||||||
|
args = argparse.Namespace(
|
||||||
|
base_url=os.getenv("SAG_TEST_BASE_URL", "http://127.0.0.1:8000"),
|
||||||
|
token=token,
|
||||||
|
username=None,
|
||||||
|
password=None,
|
||||||
|
otp=None,
|
||||||
|
customer_id=customer_id,
|
||||||
|
timeout=20.0,
|
||||||
|
keep_data=False,
|
||||||
|
json_report=str(report_path),
|
||||||
|
)
|
||||||
|
suite = SagE2E(args)
|
||||||
|
try:
|
||||||
|
suite.run()
|
||||||
|
except Exception as exc:
|
||||||
|
suite.results.append(Result("Testkørsel", "FAIL", str(exc)))
|
||||||
|
finally:
|
||||||
|
suite.cleanup_all()
|
||||||
|
suite.write_report()
|
||||||
|
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
||||||
|
payload["report_file"] = report_path.name
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/tests/sag", tags=["Settings Tests"])
|
||||||
|
async def get_sag_test_reports(
|
||||||
|
current_user: dict = Depends(require_superadmin),
|
||||||
|
):
|
||||||
|
reports = _load_sag_test_reports()
|
||||||
|
return {
|
||||||
|
"running": _sag_test_lock.locked(),
|
||||||
|
"latest": reports[0] if reports else None,
|
||||||
|
"history": reports,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/tests/sag/run", tags=["Settings Tests"])
|
||||||
|
async def run_sag_test(
|
||||||
|
payload: SagTestRunRequest,
|
||||||
|
current_user: dict = Depends(require_superadmin),
|
||||||
|
):
|
||||||
|
if not _sag_test_lock.acquire(blocking=False):
|
||||||
|
raise HTTPException(status_code=409, detail="Sag-testen kører allerede")
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_execute_sag_test, current_user, payload.customer_id)
|
||||||
|
finally:
|
||||||
|
_sag_test_lock.release()
|
||||||
|
|
||||||
|
|
||||||
# Settings Endpoints
|
# Settings Endpoints
|
||||||
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
|
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
|
||||||
async def get_settings(category: Optional[str] = None):
|
async def get_settings(category: Optional[str] = None):
|
||||||
@ -102,6 +209,16 @@ async def get_settings(category: Optional[str] = None):
|
|||||||
True,
|
True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES (%s, %s, 'notifications', %s, %s, false),
|
||||||
|
(%s, %s, 'notifications', %s, %s, false),
|
||||||
|
(%s, %s, 'notifications', %s, %s, false)
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
""",
|
||||||
|
tuple(value for item in MATTERMOST_SETTING_DEFAULTS for value in item),
|
||||||
|
)
|
||||||
|
|
||||||
query = "SELECT * FROM settings"
|
query = "SELECT * FROM settings"
|
||||||
params = []
|
params = []
|
||||||
@ -112,7 +229,7 @@ async def get_settings(category: Optional[str] = None):
|
|||||||
|
|
||||||
query += " ORDER BY category, key"
|
query += " ORDER BY category, key"
|
||||||
result = execute_query(query, tuple(params) if params else None)
|
result = execute_query(query, tuple(params) if params else None)
|
||||||
return result or []
|
return [_mask_setting(row) for row in (result or [])]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/settings", response_model=Setting, tags=["Settings"])
|
@router.post("/settings", response_model=Setting, tags=["Settings"])
|
||||||
@ -147,7 +264,7 @@ async def create_setting(payload: SettingCreate):
|
|||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=500, detail="Failed to create setting")
|
raise HTTPException(status_code=500, detail="Failed to create setting")
|
||||||
return result[0]
|
return _mask_setting(result[0])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings/{key}", response_model=Setting, tags=["Settings"])
|
@router.get("/settings/{key}", response_model=Setting, tags=["Settings"])
|
||||||
@ -231,12 +348,25 @@ async def get_setting(key: str):
|
|||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Setting not found")
|
raise HTTPException(status_code=404, detail="Setting not found")
|
||||||
|
|
||||||
return result[0]
|
return _mask_setting(result[0])
|
||||||
|
|
||||||
|
|
||||||
@router.put("/settings/{key}", response_model=Setting, tags=["Settings"])
|
@router.put("/settings/{key}", response_model=Setting, tags=["Settings"])
|
||||||
async def update_setting(key: str, setting: SettingUpdate):
|
async def update_setting(key: str, setting: SettingUpdate):
|
||||||
"""Update a setting value"""
|
"""Update a setting value"""
|
||||||
|
if key == "mattermost_webhook_url" and setting.value == MASKED_SECRET:
|
||||||
|
current = execute_query("SELECT * FROM settings WHERE key = %s", (key,))
|
||||||
|
if not current:
|
||||||
|
raise HTTPException(status_code=404, detail="Setting not found")
|
||||||
|
return _mask_setting(current[0])
|
||||||
|
if key == "mattermost_channel":
|
||||||
|
channel = setting.value.strip()
|
||||||
|
if channel and not re.fullmatch(r"[a-z0-9_-]+", channel):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Brug Mattermost-kanalens tekniske navn uden mellemrum, fx mollypim-logs",
|
||||||
|
)
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
UPDATE settings
|
UPDATE settings
|
||||||
SET value = %s, updated_at = CURRENT_TIMESTAMP
|
SET value = %s, updated_at = CURRENT_TIMESTAMP
|
||||||
@ -388,7 +518,62 @@ async def update_setting(key: str, setting: SettingUpdate):
|
|||||||
raise HTTPException(status_code=404, detail="Setting not found")
|
raise HTTPException(status_code=404, detail="Setting not found")
|
||||||
|
|
||||||
logger.info(f"✅ Updated setting: {key}")
|
logger.info(f"✅ Updated setting: {key}")
|
||||||
return result[0]
|
return _mask_setting(result[0])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/mattermost/test", tags=["Settings"])
|
||||||
|
async def test_mattermost_setting(payload: MattermostTestRequest):
|
||||||
|
rows = execute_query(
|
||||||
|
"SELECT key, value FROM settings WHERE key IN ('mattermost_webhook_url', 'mattermost_channel')"
|
||||||
|
) or []
|
||||||
|
values = {row["key"]: str(row.get("value") or "").strip() for row in rows}
|
||||||
|
webhook_url = values.get("mattermost_webhook_url", "")
|
||||||
|
if not webhook_url:
|
||||||
|
raise HTTPException(status_code=400, detail="Mattermost webhook URL mangler")
|
||||||
|
if not webhook_url.startswith(("https://", "http://")):
|
||||||
|
raise HTTPException(status_code=400, detail="Mattermost webhook URL skal starte med http:// eller https://")
|
||||||
|
|
||||||
|
message = str(payload.message or "Test fra BMC Hub").strip()[:500]
|
||||||
|
mattermost_payload = {"text": f"✅ **{message}**", "username": "BMC Hub"}
|
||||||
|
if values.get("mattermost_channel"):
|
||||||
|
mattermost_payload["channel"] = values["mattermost_channel"]
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(webhook_url, json=mattermost_payload)
|
||||||
|
response_text = response.text
|
||||||
|
if (
|
||||||
|
response.status_code in (400, 404, 415)
|
||||||
|
and (
|
||||||
|
"media type application/json" in response_text.lower()
|
||||||
|
or "incoming_webhook.general.app_error" in response_text.lower()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
response = await client.post(
|
||||||
|
webhook_url,
|
||||||
|
data={"payload": json.dumps(mattermost_payload, ensure_ascii=False)},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
if response.status_code not in (200, 201, 204):
|
||||||
|
response_body = response.text[:500]
|
||||||
|
if "incoming_webhook.general.app_error" in response_body:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
"Webhooken blev afvist af Mattermost. Opret en ny integration under "
|
||||||
|
"Mattermost → Integrations → Incoming Webhooks, og kopiér hele URL'en "
|
||||||
|
"som Mattermost genererer. Et outgoing webhook-ID eller et deaktiveret "
|
||||||
|
"incoming webhook kan ikke bruges."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"Mattermost returnerede HTTP {response.status_code}: {response.text[:200]}",
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Kunne ikke kontakte Mattermost: {exc}") from exc
|
||||||
|
return {"success": True, "message": "Testbesked sendt til Mattermost"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings/categories/list", tags=["Settings"])
|
@router.get("/settings/categories/list", tags=["Settings"])
|
||||||
|
|||||||
@ -61,6 +61,26 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.test-result-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 82px minmax(180px, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 0.7rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color, rgba(0,0,0,.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-detail {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -122,6 +142,9 @@
|
|||||||
<a class="nav-link" href="#mission" data-tab="mission">
|
<a class="nav-link" href="#mission" data-tab="mission">
|
||||||
<i class="bi bi-broadcast-pin me-2"></i>Mission
|
<i class="bi bi-broadcast-pin me-2"></i>Mission
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link" href="#tests" data-tab="tests">
|
||||||
|
<i class="bi bi-clipboard2-pulse me-2"></i>Tests
|
||||||
|
</a>
|
||||||
<a class="nav-link" href="#system" data-tab="system">
|
<a class="nav-link" href="#system" data-tab="system">
|
||||||
<i class="bi bi-gear me-2"></i>System
|
<i class="bi bi-gear me-2"></i>System
|
||||||
</a>
|
</a>
|
||||||
@ -469,7 +492,7 @@
|
|||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<div class="tab-pane fade" id="notifications">
|
<div class="tab-pane fade" id="notifications">
|
||||||
<div class="card p-4">
|
<div class="card p-4 mb-4">
|
||||||
<h5 class="mb-4 fw-bold">Notifikation Indstillinger</h5>
|
<h5 class="mb-4 fw-bold">Notifikation Indstillinger</h5>
|
||||||
<div id="notificationSettings">
|
<div id="notificationSettings">
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
@ -477,6 +500,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-1 fw-bold">Mattermost</h5>
|
||||||
|
<p class="text-muted mb-0">Send sagsreminders til en Mattermost incoming webhook.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-secondary" id="mattermostSettingsState">Ikke konfigureret</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="mattermostRemindersEnabled">
|
||||||
|
<label class="form-check-label" for="mattermostRemindersEnabled">Aktivér Mattermost-reminders</label>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<label class="form-label" for="mattermostWebhookUrl">Incoming webhook URL</label>
|
||||||
|
<input type="password" class="form-control" id="mattermostWebhookUrl"
|
||||||
|
placeholder="https://mattermost.example/hooks/...">
|
||||||
|
<div class="form-text">En gemt webhook vises aldrig igen. Lad feltet være tomt for at beholde den nuværende.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<label class="form-label" for="mattermostChannel">Kanal (valgfri)</label>
|
||||||
|
<input type="text" class="form-control" id="mattermostChannel" placeholder="fx mollypim-logs">
|
||||||
|
<div class="form-text">Brug kanalens tekniske navn/slug uden mellemrum. Tomt felt bruger webhookens standardkanal.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mt-4">
|
||||||
|
<button class="btn btn-primary" type="button" onclick="saveMattermostSettings()">
|
||||||
|
<i class="bi bi-save me-2"></i>Gem Mattermost
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-primary" type="button" onclick="testMattermostSettings()">
|
||||||
|
<i class="bi bi-send me-2"></i>Send test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="small mt-3" id="mattermostSettingsFeedback"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email Templates -->
|
<!-- Email Templates -->
|
||||||
@ -1507,6 +1565,84 @@ async def scan_document(file_path: str):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Automated tests -->
|
||||||
|
<div class="tab-pane fade" id="tests">
|
||||||
|
<div class="card p-4 mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<h5 class="fw-bold mb-0">Sag-modul – komplet funktionstest</h5>
|
||||||
|
<span class="badge text-bg-secondary" id="sagTestState">Ikke kørt</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Tester sager, undersager, relationer, todos, kommentarer, tags,
|
||||||
|
buzzwords, søgning, ordrelinjer og filer. Testdata ryddes automatisk op.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="button" id="runSagTestBtn" onclick="runSagModuleTest()">
|
||||||
|
<i class="bi bi-play-fill me-2"></i>Kør Sag-test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning py-2 mt-3 mb-0 small">
|
||||||
|
Kun superadmins kan starte testen. Mail, AnyDesk, printer og eksterne
|
||||||
|
økonomisystemer markeres som SKIP, fordi de kræver eksterne tjenester eller hardware.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4" id="sagTestSummary">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card p-3 border-success">
|
||||||
|
<div class="small text-muted text-uppercase fw-semibold">Bestået</div>
|
||||||
|
<div class="fs-2 fw-bold text-success" id="sagTestPassCount">–</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card p-3 border-danger">
|
||||||
|
<div class="small text-muted text-uppercase fw-semibold">Fejl</div>
|
||||||
|
<div class="fs-2 fw-bold text-danger" id="sagTestFailCount">–</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card p-3 border-secondary">
|
||||||
|
<div class="small text-muted text-uppercase fw-semibold">Sprunget over</div>
|
||||||
|
<div class="fs-2 fw-bold text-secondary" id="sagTestSkipCount">–</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4 mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h6 class="fw-bold mb-1">Seneste resultat</h6>
|
||||||
|
<div class="small text-muted" id="sagTestMeta">Ingen testrapport endnu.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="sagTestResults">
|
||||||
|
<div class="text-muted">Kør testen for at se resultater.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4">
|
||||||
|
<h6 class="fw-bold mb-3">Tidligere kørsler</h6>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tidspunkt</th>
|
||||||
|
<th>Bestået</th>
|
||||||
|
<th>Fejl</th>
|
||||||
|
<th>Skip</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sagTestHistory">
|
||||||
|
<tr><td colspan="4" class="text-muted">Ingen historik endnu.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- System Settings -->
|
<!-- System Settings -->
|
||||||
<div class="tab-pane fade" id="system">
|
<div class="tab-pane fade" id="system">
|
||||||
<div class="card p-4 mb-4">
|
<div class="card p-4 mb-4">
|
||||||
@ -1974,6 +2110,7 @@ let pipelineStagesCache = [];
|
|||||||
let nextcloudInstancesCache = [];
|
let nextcloudInstancesCache = [];
|
||||||
let customersCache = [];
|
let customersCache = [];
|
||||||
let timeMultiplierPresetsCache = [];
|
let timeMultiplierPresetsCache = [];
|
||||||
|
let sagTestReportsCache = [];
|
||||||
|
|
||||||
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
|
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
|
||||||
{ label: 'Haster', text: 'Haster', multiplier: 3 },
|
{ label: 'Haster', text: 'Haster', multiplier: 3 },
|
||||||
@ -2672,6 +2809,7 @@ function displaySettingsByCategory() {
|
|||||||
|
|
||||||
// Notification settings
|
// Notification settings
|
||||||
displaySettings('notificationSettings', categories.notifications);
|
displaySettings('notificationSettings', categories.notifications);
|
||||||
|
renderMattermostSettings();
|
||||||
|
|
||||||
// Email templates
|
// Email templates
|
||||||
displaySettings('emailTemplatesInternal', [
|
displaySettings('emailTemplatesInternal', [
|
||||||
@ -2689,6 +2827,100 @@ function displaySettingsByCategory() {
|
|||||||
displaySettings('systemSettings', categories.system);
|
displaySettings('systemSettings', categories.system);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMattermostSettings() {
|
||||||
|
const enabled = allSettings.find(s => s.key === 'mattermost_reminders_enabled');
|
||||||
|
const webhook = allSettings.find(s => s.key === 'mattermost_webhook_url');
|
||||||
|
const channel = allSettings.find(s => s.key === 'mattermost_channel');
|
||||||
|
const enabledEl = document.getElementById('mattermostRemindersEnabled');
|
||||||
|
const webhookEl = document.getElementById('mattermostWebhookUrl');
|
||||||
|
const channelEl = document.getElementById('mattermostChannel');
|
||||||
|
const stateEl = document.getElementById('mattermostSettingsState');
|
||||||
|
if (!enabledEl || !webhookEl || !channelEl || !stateEl) return;
|
||||||
|
|
||||||
|
enabledEl.checked = enabled?.value === 'true';
|
||||||
|
webhookEl.value = '';
|
||||||
|
channelEl.value = channel?.value || '';
|
||||||
|
const configured = webhook?.value === '********';
|
||||||
|
webhookEl.placeholder = configured
|
||||||
|
? 'Webhook er gemt - indtast kun for at ændre'
|
||||||
|
: 'https://mattermost.example/hooks/...';
|
||||||
|
stateEl.className = `badge ${configured && enabledEl.checked ? 'bg-success' : 'bg-secondary'}`;
|
||||||
|
stateEl.textContent = configured
|
||||||
|
? (enabledEl.checked ? 'Aktiv' : 'Webhook gemt')
|
||||||
|
: 'Ikke konfigureret';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMattermostSettings(showToast = true) {
|
||||||
|
const enabled = document.getElementById('mattermostRemindersEnabled').checked;
|
||||||
|
const webhook = document.getElementById('mattermostWebhookUrl').value.trim();
|
||||||
|
const channel = document.getElementById('mattermostChannel').value.trim();
|
||||||
|
const feedback = document.getElementById('mattermostSettingsFeedback');
|
||||||
|
if (webhook && !/^https?:\/\//i.test(webhook)) {
|
||||||
|
feedback.className = 'small mt-3 text-danger';
|
||||||
|
feedback.textContent = 'Webhook URL skal starte med http:// eller https://';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (channel && !/^[a-z0-9_-]+$/.test(channel)) {
|
||||||
|
feedback.className = 'small mt-3 text-danger';
|
||||||
|
feedback.textContent = 'Brug kanalens tekniske navn uden mellemrum, fx mollypim-logs.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
feedback.className = 'small mt-3 text-muted';
|
||||||
|
feedback.textContent = 'Gemmer Mattermost-indstillinger...';
|
||||||
|
const updates = [
|
||||||
|
['mattermost_reminders_enabled', enabled ? 'true' : 'false'],
|
||||||
|
['mattermost_channel', channel],
|
||||||
|
];
|
||||||
|
if (webhook) updates.push(['mattermost_webhook_url', webhook]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [key, value] of updates) {
|
||||||
|
const response = await fetch(`/api/v1/settings/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({value}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await getErrorMessage(response, `Kunne ikke gemme ${key}`));
|
||||||
|
const saved = await response.json();
|
||||||
|
setOrAddSettingInCache(key, saved.value);
|
||||||
|
}
|
||||||
|
if (webhook) setOrAddSettingInCache('mattermost_webhook_url', '********');
|
||||||
|
renderMattermostSettings();
|
||||||
|
feedback.className = 'small mt-3 text-success';
|
||||||
|
feedback.textContent = 'Mattermost-indstillinger gemt.';
|
||||||
|
if (showToast) showNotification('Mattermost-indstillinger gemt', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
feedback.className = 'small mt-3 text-danger';
|
||||||
|
feedback.textContent = error.message || 'Kunne ikke gemme Mattermost-indstillinger';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testMattermostSettings() {
|
||||||
|
const feedback = document.getElementById('mattermostSettingsFeedback');
|
||||||
|
if (!await saveMattermostSettings(false)) return;
|
||||||
|
feedback.className = 'small mt-3 text-muted';
|
||||||
|
feedback.textContent = 'Sender testbesked...';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/settings/mattermost/test', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({message: 'Mattermost virker fra BMC Hub'}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await getErrorMessage(response, 'Testbeskeden fejlede'));
|
||||||
|
feedback.className = 'small mt-3 text-success';
|
||||||
|
feedback.textContent = 'Testbesked sendt til Mattermost.';
|
||||||
|
showNotification('Mattermost-test sendt', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
feedback.className = 'small mt-3 text-danger';
|
||||||
|
feedback.textContent = error.message || 'Kunne ikke sende Mattermost-test';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAnydeskSettings() {
|
async function loadAnydeskSettings() {
|
||||||
const keys = ['anydesk_api_token', 'anydesk_license_id', 'anydesk_read_only', 'anydesk_dry_run'];
|
const keys = ['anydesk_api_token', 'anydesk_license_id', 'anydesk_read_only', 'anydesk_dry_run'];
|
||||||
try {
|
try {
|
||||||
@ -4762,6 +4994,121 @@ function formatDate(dateString) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sagTestEscape(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSagTestReport(report) {
|
||||||
|
const state = document.getElementById('sagTestState');
|
||||||
|
const results = document.getElementById('sagTestResults');
|
||||||
|
if (!report) {
|
||||||
|
state.className = 'badge text-bg-secondary';
|
||||||
|
state.textContent = 'Ikke kørt';
|
||||||
|
document.getElementById('sagTestPassCount').textContent = '–';
|
||||||
|
document.getElementById('sagTestFailCount').textContent = '–';
|
||||||
|
document.getElementById('sagTestSkipCount').textContent = '–';
|
||||||
|
document.getElementById('sagTestMeta').textContent = 'Ingen testrapport endnu.';
|
||||||
|
results.innerHTML = '<div class="text-muted">Kør testen for at se resultater.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = report.summary || {};
|
||||||
|
const failed = Number(summary.FAIL || 0);
|
||||||
|
state.className = `badge ${failed ? 'text-bg-danger' : 'text-bg-success'}`;
|
||||||
|
state.textContent = failed ? 'Fejl fundet' : 'Alle kernetests bestået';
|
||||||
|
document.getElementById('sagTestPassCount').textContent = summary.PASS ?? 0;
|
||||||
|
document.getElementById('sagTestFailCount').textContent = summary.FAIL ?? 0;
|
||||||
|
document.getElementById('sagTestSkipCount').textContent = summary.SKIP ?? 0;
|
||||||
|
document.getElementById('sagTestMeta').textContent =
|
||||||
|
`${formatDate(report.created_at)} · Kørsel ${report.run_id || 'ukendt'}`;
|
||||||
|
|
||||||
|
const badgeClass = { PASS: 'text-bg-success', FAIL: 'text-bg-danger', SKIP: 'text-bg-secondary' };
|
||||||
|
results.innerHTML = (report.results || []).map(item => `
|
||||||
|
<div class="test-result-row">
|
||||||
|
<span class="badge ${badgeClass[item.status] || 'text-bg-secondary'}">${sagTestEscape(item.status)}</span>
|
||||||
|
<div class="fw-semibold">${sagTestEscape(item.name)}</div>
|
||||||
|
<div class="small text-muted">${Number(item.duration_ms || 0)} ms</div>
|
||||||
|
${item.detail ? `<div class="test-result-detail">${sagTestEscape(item.detail)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('') || '<div class="text-muted">Rapporten indeholder ingen resultater.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSagTestHistory(reports) {
|
||||||
|
sagTestReportsCache = Array.isArray(reports) ? reports : [];
|
||||||
|
const body = document.getElementById('sagTestHistory');
|
||||||
|
if (!sagTestReportsCache.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="text-muted">Ingen historik endnu.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = sagTestReportsCache.map((report, index) => {
|
||||||
|
const summary = report.summary || {};
|
||||||
|
return `
|
||||||
|
<tr role="button" onclick="renderSagTestReport(sagTestReportsCache[${index}])">
|
||||||
|
<td>${sagTestEscape(formatDate(report.created_at))}</td>
|
||||||
|
<td><span class="text-success fw-semibold">${Number(summary.PASS || 0)}</span></td>
|
||||||
|
<td><span class="text-danger fw-semibold">${Number(summary.FAIL || 0)}</span></td>
|
||||||
|
<td><span class="text-secondary">${Number(summary.SKIP || 0)}</span></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSagModuleTests() {
|
||||||
|
const state = document.getElementById('sagTestState');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/settings/tests/sag', { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error(await extractApiError(response, 'Kunne ikke hente tests'));
|
||||||
|
const data = await response.json();
|
||||||
|
renderSagTestReport(data.latest);
|
||||||
|
renderSagTestHistory(data.history);
|
||||||
|
if (data.running) {
|
||||||
|
state.className = 'badge text-bg-primary';
|
||||||
|
state.textContent = 'Kører…';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
state.className = 'badge text-bg-danger';
|
||||||
|
state.textContent = 'Kan ikke hente';
|
||||||
|
document.getElementById('sagTestResults').innerHTML =
|
||||||
|
`<div class="alert alert-danger mb-0">${sagTestEscape(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSagModuleTest() {
|
||||||
|
const button = document.getElementById('runSagTestBtn');
|
||||||
|
const state = document.getElementById('sagTestState');
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Kører test…';
|
||||||
|
state.className = 'badge text-bg-primary';
|
||||||
|
state.textContent = 'Kører…';
|
||||||
|
document.getElementById('sagTestResults').innerHTML =
|
||||||
|
'<div class="d-flex align-items-center gap-2 text-muted"><span class="spinner-border spinner-border-sm"></span>Tester Sag-modulet og rydder testdata op…</div>';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/settings/tests/sag/run', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await extractApiError(response, 'Sag-testen fejlede'));
|
||||||
|
const report = await response.json();
|
||||||
|
renderSagTestReport(report);
|
||||||
|
await loadSagModuleTests();
|
||||||
|
} catch (error) {
|
||||||
|
state.className = 'badge text-bg-danger';
|
||||||
|
state.textContent = 'Kørsel fejlede';
|
||||||
|
document.getElementById('sagTestResults').innerHTML =
|
||||||
|
`<div class="alert alert-danger mb-0">${sagTestEscape(error.message)}</div>`;
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = '<i class="bi bi-play-fill me-2"></i>Kør Sag-test';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tab navigation
|
// Tab navigation
|
||||||
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
||||||
link.addEventListener('click', (e) => {
|
link.addEventListener('click', (e) => {
|
||||||
@ -4773,6 +5120,7 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
history.replaceState(null, '', `#${tab}`);
|
||||||
|
|
||||||
// Update nav
|
// Update nav
|
||||||
document.querySelectorAll('.settings-nav .nav-link').forEach(l => l.classList.remove('active'));
|
document.querySelectorAll('.settings-nav .nav-link').forEach(l => l.classList.remove('active'));
|
||||||
@ -4800,6 +5148,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
|||||||
loadAIPrompts();
|
loadAIPrompts();
|
||||||
} else if (tab === 'modules') {
|
} else if (tab === 'modules') {
|
||||||
loadModules();
|
loadModules();
|
||||||
|
} else if (tab === 'tests') {
|
||||||
|
loadSagModuleTests();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -6103,6 +6453,7 @@ const MENU_VISIBILITY_GROUPS = [
|
|||||||
{
|
{
|
||||||
title: 'Data migration underpunkter',
|
title: 'Data migration underpunkter',
|
||||||
items: [
|
items: [
|
||||||
|
{ key: 'menu-datamigration-migration-center', label: 'Migreringscenter' },
|
||||||
{ key: 'menu-datamigration-dashboard', label: 'Dashboard' },
|
{ key: 'menu-datamigration-dashboard', label: 'Dashboard' },
|
||||||
{ key: 'menu-datamigration-registrations', label: 'Registreringer' },
|
{ key: 'menu-datamigration-registrations', label: 'Registreringer' },
|
||||||
{ key: 'menu-datamigration-wizard', label: 'Godkend Timer' },
|
{ key: 'menu-datamigration-wizard', label: 'Godkend Timer' },
|
||||||
@ -6210,6 +6561,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadPipelineStages();
|
loadPipelineStages();
|
||||||
loadMenuVisibilityPreferences();
|
loadMenuVisibilityPreferences();
|
||||||
|
|
||||||
|
const requestedTab = window.location.hash.replace('#', '');
|
||||||
|
const requestedLink = requestedTab
|
||||||
|
? document.querySelector(`.settings-nav .nav-link[data-tab="${CSS.escape(requestedTab)}"]`)
|
||||||
|
: null;
|
||||||
|
if (requestedLink) requestedLink.click();
|
||||||
|
|
||||||
const saveMenuVisibilityBtn = document.getElementById('saveMenuVisibilityBtn');
|
const saveMenuVisibilityBtn = document.getElementById('saveMenuVisibilityBtn');
|
||||||
if (saveMenuVisibilityBtn) {
|
if (saveMenuVisibilityBtn) {
|
||||||
saveMenuVisibilityBtn.addEventListener('click', saveMenuVisibilityPreferences);
|
saveMenuVisibilityBtn.addEventListener('click', saveMenuVisibilityPreferences);
|
||||||
|
|||||||
@ -1014,6 +1014,8 @@
|
|||||||
<i class="bi bi-clock-history me-2"></i>Data migration
|
<i class="bi bi-clock-history me-2"></i>Data migration
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end mt-2">
|
<ul class="dropdown-menu dropdown-menu-end mt-2">
|
||||||
|
<li data-menu-key="menu-datamigration-migration-center"><a class="dropdown-item py-2" href="/migration-center"><i class="bi bi-arrow-left-right me-2"></i>Migreringscenter</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li data-menu-key="menu-datamigration-dashboard"><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
|
<li data-menu-key="menu-datamigration-dashboard"><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
|
||||||
<li data-menu-key="menu-datamigration-registrations"><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
|
<li data-menu-key="menu-datamigration-registrations"><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
|
||||||
<li data-menu-key="menu-datamigration-wizard"><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
|
<li data-menu-key="menu-datamigration-wizard"><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
|
||||||
@ -1137,6 +1139,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Case Results -->
|
||||||
|
<div id="caseResults" class="result-section mb-4" style="display: none;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
||||||
|
<i class="bi bi-card-checklist me-2"></i>Sager
|
||||||
|
</h6>
|
||||||
|
<a href="/sag" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-list-check me-1"></i>Alle sager
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="result-items"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Email Results -->
|
<!-- Email Results -->
|
||||||
<div id="emailResults" class="result-section mb-4" style="display: none;">
|
<div id="emailResults" class="result-section mb-4" style="display: none;">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
@ -1740,6 +1755,7 @@ if (bmcOriginalFetch) {
|
|||||||
document.getElementById('workflowActions').style.display = 'none';
|
document.getElementById('workflowActions').style.display = 'none';
|
||||||
document.getElementById('crmResults').style.display = 'none';
|
document.getElementById('crmResults').style.display = 'none';
|
||||||
document.getElementById('supportResults').style.display = 'none';
|
document.getElementById('supportResults').style.display = 'none';
|
||||||
|
if (document.getElementById('caseResults')) document.getElementById('caseResults').style.display = 'none';
|
||||||
if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
|
if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
|
||||||
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
||||||
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
||||||
@ -2007,6 +2023,46 @@ if (bmcOriginalFetch) {
|
|||||||
console.log('Contacts search not available');
|
console.log('Contacts search not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search cases, including linked tags and buzzwords
|
||||||
|
try {
|
||||||
|
const casesResponse = await fetch(`/api/v1/search/sag?q=${encodeURIComponent(query)}`);
|
||||||
|
const cases = await casesResponse.json();
|
||||||
|
const caseResults = document.getElementById('caseResults');
|
||||||
|
|
||||||
|
if (Array.isArray(cases) && cases.length > 0) {
|
||||||
|
hasResults = true;
|
||||||
|
caseResults.style.display = 'block';
|
||||||
|
const caseList = caseResults.querySelector('.result-items');
|
||||||
|
caseList.innerHTML = cases.slice(0, 10).map(item => {
|
||||||
|
const buzzwords = Array.isArray(item.buzzwords)
|
||||||
|
? item.buzzwords.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const buzzwordHtml = buzzwords.length
|
||||||
|
? ` • <i class="bi bi-lightbulb ms-1"></i> ${buzzwords.map(word => escapeHtml(word)).join(', ')}`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="result-item" onclick="window.location.href='/sag/${Number(item.id)}/v3'" style="cursor: pointer;">
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold">#${Number(item.id)} ${escapeHtml(item.titel || 'Uden titel')}</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
<i class="bi bi-card-checklist me-1"></i>${escapeHtml(item.status || '-')}
|
||||||
|
${item.customer_name ? ` • ${escapeHtml(item.customer_name)}` : ''}
|
||||||
|
${buzzwordHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} else if (caseResults) {
|
||||||
|
caseResults.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Case and buzzword search not available');
|
||||||
|
const caseResults = document.getElementById('caseResults');
|
||||||
|
if (caseResults) caseResults.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Search emails
|
// Search emails
|
||||||
try {
|
try {
|
||||||
const emailsResponse = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
|
const emailsResponse = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
|
||||||
@ -2351,6 +2407,11 @@ if (bmcOriginalFetch) {
|
|||||||
<label class="form-label">Email override</label>
|
<label class="form-label">Email override</label>
|
||||||
<input type="email" class="form-control" id="pref_email_override" placeholder="f.eks. navn@firma.dk">
|
<input type="email" class="form-control" id="pref_email_override" placeholder="f.eks. navn@firma.dk">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Mattermost-brugernavn</label>
|
||||||
|
<input type="text" class="form-control" id="pref_mattermost_username" placeholder="Tomt = BMC Hub-brugernavn">
|
||||||
|
<div class="form-text">Bruges til direkte beskeder som @brugernavn.</div>
|
||||||
|
</div>
|
||||||
<button class="btn btn-sm btn-primary" onclick="saveReminderPreferences()">Gem</button>
|
<button class="btn btn-sm btn-primary" onclick="saveReminderPreferences()">Gem</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2433,6 +2494,7 @@ if (bmcOriginalFetch) {
|
|||||||
{ key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' },
|
{ key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' },
|
||||||
{ key: 'menu-okonomi-fixed-price', label: 'Økonomi: Fastpris Aftaler' },
|
{ key: 'menu-okonomi-fixed-price', label: 'Økonomi: Fastpris Aftaler' },
|
||||||
{ key: 'menu-okonomi-subscriptions', label: 'Økonomi: Abonnementer' },
|
{ key: 'menu-okonomi-subscriptions', label: 'Økonomi: Abonnementer' },
|
||||||
|
{ key: 'menu-datamigration-migration-center', label: 'Data migration: Migreringscenter' },
|
||||||
{ key: 'menu-datamigration-dashboard', label: 'Data migration: Dashboard' },
|
{ key: 'menu-datamigration-dashboard', label: 'Data migration: Dashboard' },
|
||||||
{ key: 'menu-datamigration-registrations', label: 'Data migration: Registreringer' },
|
{ key: 'menu-datamigration-registrations', label: 'Data migration: Registreringer' },
|
||||||
{ key: 'menu-datamigration-wizard', label: 'Data migration: Godkend Timer' },
|
{ key: 'menu-datamigration-wizard', label: 'Data migration: Godkend Timer' },
|
||||||
@ -2545,6 +2607,7 @@ if (bmcOriginalFetch) {
|
|||||||
document.getElementById('pref_notify_email').checked = !!prefs.notify_email;
|
document.getElementById('pref_notify_email').checked = !!prefs.notify_email;
|
||||||
document.getElementById('pref_notify_mattermost').checked = !!prefs.notify_mattermost;
|
document.getElementById('pref_notify_mattermost').checked = !!prefs.notify_mattermost;
|
||||||
document.getElementById('pref_email_override').value = prefs.email_override || '';
|
document.getElementById('pref_email_override').value = prefs.email_override || '';
|
||||||
|
document.getElementById('pref_mattermost_username').value = prefs.mattermost_username || '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load reminder preferences', e);
|
console.error('Failed to load reminder preferences', e);
|
||||||
}
|
}
|
||||||
@ -2555,7 +2618,8 @@ if (bmcOriginalFetch) {
|
|||||||
notify_frontend: document.getElementById('pref_notify_frontend').checked,
|
notify_frontend: document.getElementById('pref_notify_frontend').checked,
|
||||||
notify_email: document.getElementById('pref_notify_email').checked,
|
notify_email: document.getElementById('pref_notify_email').checked,
|
||||||
notify_mattermost: document.getElementById('pref_notify_mattermost').checked,
|
notify_mattermost: document.getElementById('pref_notify_mattermost').checked,
|
||||||
email_override: document.getElementById('pref_email_override').value || null
|
email_override: document.getElementById('pref_email_override').value || null,
|
||||||
|
mattermost_username: document.getElementById('pref_mattermost_username').value.trim().replace(/^@/, '') || null
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
Subscriptions Frontend Views
|
Subscriptions Frontend Views
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -25,3 +25,19 @@ async def subscriptions_simply_imports(request: Request):
|
|||||||
return templates.TemplateResponse("subscriptions/frontend/simply_imports.html", {
|
return templates.TemplateResponse("subscriptions/frontend/simply_imports.html", {
|
||||||
"request": request
|
"request": request
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscriptions/{subscription_id}")
|
||||||
|
async def subscription_detail_redirect(subscription_id: int):
|
||||||
|
"""Compatibility detail URL: subscriptions are edited on their associated case."""
|
||||||
|
from app.core.database import execute_query_single
|
||||||
|
|
||||||
|
subscription = execute_query_single(
|
||||||
|
"SELECT id, sag_id FROM sag_subscriptions WHERE id = %s",
|
||||||
|
(subscription_id,),
|
||||||
|
)
|
||||||
|
if not subscription:
|
||||||
|
return RedirectResponse(url="/subscriptions", status_code=303)
|
||||||
|
if subscription.get("sag_id"):
|
||||||
|
return RedirectResponse(url=f"/sag/{subscription['sag_id']}/v3", status_code=303)
|
||||||
|
return RedirectResponse(url="/subscriptions", status_code=303)
|
||||||
|
|||||||
106
app/utils/safe_html.py
Normal file
106
app/utils/safe_html.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""Small allow-list sanitizer for HTML rendered inside the BMC Hub UI."""
|
||||||
|
|
||||||
|
import html
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class _SafeHtmlSanitizer(HTMLParser):
|
||||||
|
_ALLOWED_TAGS = {
|
||||||
|
"a", "b", "strong", "i", "em", "u", "s",
|
||||||
|
"p", "div", "span", "br", "hr", "blockquote", "pre", "code",
|
||||||
|
"ul", "ol", "li",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption",
|
||||||
|
}
|
||||||
|
_VOID_TAGS = {"br", "hr"}
|
||||||
|
_DROP_WITH_CONTENT = {"script", "style", "iframe", "object", "embed", "svg", "math", "head"}
|
||||||
|
_ALLOWED_ATTRS = {
|
||||||
|
"a": {"href", "title"},
|
||||||
|
"th": {"colspan", "rowspan"},
|
||||||
|
"td": {"colspan", "rowspan"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self._parts: list[str] = []
|
||||||
|
self._drop_depth = 0
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
tag = str(tag or "").lower()
|
||||||
|
if tag in self._DROP_WITH_CONTENT:
|
||||||
|
self._drop_depth += 1
|
||||||
|
return
|
||||||
|
if self._drop_depth or tag not in self._ALLOWED_TAGS:
|
||||||
|
return
|
||||||
|
|
||||||
|
safe_attrs: list[str] = []
|
||||||
|
for key, value in attrs or []:
|
||||||
|
key = str(key or "").lower()
|
||||||
|
if key not in self._ALLOWED_ATTRS.get(tag, set()):
|
||||||
|
continue
|
||||||
|
value = str(value or "").strip()
|
||||||
|
if key == "href":
|
||||||
|
normalized = value.lower()
|
||||||
|
if not normalized.startswith(("https://", "http://", "mailto:", "tel:", "/")):
|
||||||
|
continue
|
||||||
|
if key in {"colspan", "rowspan"}:
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if number < 1 or number > 100:
|
||||||
|
continue
|
||||||
|
value = str(number)
|
||||||
|
safe_attrs.append(f'{key}="{html.escape(value, quote=True)}"')
|
||||||
|
|
||||||
|
attrs_html = f" {' '.join(safe_attrs)}" if safe_attrs else ""
|
||||||
|
if tag == "a":
|
||||||
|
attrs_html += ' target="_blank" rel="noopener noreferrer"'
|
||||||
|
self._parts.append(f"<{tag}{attrs_html}>")
|
||||||
|
|
||||||
|
def handle_startendtag(self, tag, attrs):
|
||||||
|
if str(tag or "").lower() in self._DROP_WITH_CONTENT:
|
||||||
|
return
|
||||||
|
self.handle_starttag(tag, attrs)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
tag = str(tag or "").lower()
|
||||||
|
if tag in self._DROP_WITH_CONTENT:
|
||||||
|
self._drop_depth = max(0, self._drop_depth - 1)
|
||||||
|
return
|
||||||
|
if self._drop_depth or tag not in self._ALLOWED_TAGS or tag in self._VOID_TAGS:
|
||||||
|
return
|
||||||
|
self._parts.append(f"</{tag}>")
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if not self._drop_depth:
|
||||||
|
self._parts.append(html.escape(data or ""))
|
||||||
|
|
||||||
|
def handle_entityref(self, name):
|
||||||
|
if not self._drop_depth:
|
||||||
|
self._parts.append(f"&{name};")
|
||||||
|
|
||||||
|
def handle_charref(self, name):
|
||||||
|
if not self._drop_depth:
|
||||||
|
self._parts.append(f"&#{name};")
|
||||||
|
|
||||||
|
def get_html(self) -> str:
|
||||||
|
return "".join(self._parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_safe_html(value: Optional[str]) -> str:
|
||||||
|
"""Return safe renderable HTML while preserving ordinary plain text."""
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
if "<" not in raw and ">" not in raw:
|
||||||
|
return html.escape(raw)
|
||||||
|
|
||||||
|
sanitizer = _SafeHtmlSanitizer()
|
||||||
|
try:
|
||||||
|
sanitizer.feed(raw)
|
||||||
|
sanitizer.close()
|
||||||
|
return sanitizer.get_html()
|
||||||
|
except Exception:
|
||||||
|
return html.escape(raw)
|
||||||
64
app/vendors/backend/router.py
vendored
64
app/vendors/backend/router.py
vendored
@ -99,6 +99,70 @@ async def get_vendor(vendor_id: int):
|
|||||||
return result[0]
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vendors/{vendor_id}/invoices", tags=["Vendors"])
|
||||||
|
async def get_vendor_invoices(vendor_id: int):
|
||||||
|
"""Return booked invoices together with invoices that only exist as extractions."""
|
||||||
|
if not execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,)):
|
||||||
|
raise HTTPException(status_code=404, detail="Vendor not found")
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
WITH booked AS (
|
||||||
|
SELECT si.id, si.invoice_number, si.invoice_date, si.due_date,
|
||||||
|
si.total_amount, si.currency,
|
||||||
|
CASE
|
||||||
|
WHEN si.status IN ('cancelled', 'credited', 'rejected') THEN si.status
|
||||||
|
WHEN si.paid_date IS NOT NULL THEN 'paid'
|
||||||
|
WHEN si.due_date < CURRENT_DATE AND si.paid_date IS NULL THEN 'overdue'
|
||||||
|
ELSE si.status
|
||||||
|
END AS status,
|
||||||
|
'supplier_invoice'::text AS source_type,
|
||||||
|
si.extraction_id, e.file_id, si.created_at AS sort_date
|
||||||
|
FROM supplier_invoices si
|
||||||
|
LEFT JOIN extractions e ON e.extraction_id = si.extraction_id
|
||||||
|
WHERE si.vendor_id = %s
|
||||||
|
),
|
||||||
|
latest_extractions AS (
|
||||||
|
SELECT DISTINCT ON (TRIM(e.document_id))
|
||||||
|
NULL::integer AS id, e.document_id AS invoice_number,
|
||||||
|
e.document_date AS invoice_date, e.due_date, e.total_amount,
|
||||||
|
e.currency, COALESCE(run.status, e.status, file.status, 'extracted') AS status,
|
||||||
|
'extraction'::text AS source_type,
|
||||||
|
e.extraction_id, e.file_id,
|
||||||
|
COALESCE(run.processed_at, e.created_at) AS sort_date
|
||||||
|
FROM extractions e
|
||||||
|
LEFT JOIN incoming_files file ON file.file_id = e.file_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT sync.status, sync.processed_at
|
||||||
|
FROM internet_connections_invoice_sync_runs sync
|
||||||
|
WHERE sync.extraction_id = e.extraction_id
|
||||||
|
OR (
|
||||||
|
sync.invoice_number = e.document_id
|
||||||
|
AND COALESCE(sync.vendor_name, '') = COALESCE(e.vendor_name, '')
|
||||||
|
)
|
||||||
|
ORDER BY sync.processed_at DESC, sync.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) run ON TRUE
|
||||||
|
WHERE e.vendor_matched_id = %s
|
||||||
|
AND NULLIF(TRIM(e.document_id), '') IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM supplier_invoices si
|
||||||
|
WHERE si.vendor_id = %s
|
||||||
|
AND (si.extraction_id = e.extraction_id OR si.invoice_number = e.document_id)
|
||||||
|
)
|
||||||
|
ORDER BY TRIM(e.document_id), e.created_at DESC, e.extraction_id DESC
|
||||||
|
)
|
||||||
|
SELECT * FROM booked
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM latest_extractions
|
||||||
|
ORDER BY sort_date DESC NULLS LAST, invoice_date DESC NULLS LAST
|
||||||
|
""",
|
||||||
|
(vendor_id, vendor_id, vendor_id),
|
||||||
|
)
|
||||||
|
return rows or []
|
||||||
|
|
||||||
|
|
||||||
@router.post("/vendors", response_model=Vendor, tags=["Vendors"])
|
@router.post("/vendors", response_model=Vendor, tags=["Vendors"])
|
||||||
async def create_vendor(vendor: VendorCreate):
|
async def create_vendor(vendor: VendorCreate):
|
||||||
"""Create a new vendor"""
|
"""Create a new vendor"""
|
||||||
|
|||||||
26
app/vendors/frontend/vendor_detail.html
vendored
26
app/vendors/frontend/vendor_detail.html
vendored
@ -627,7 +627,7 @@ function displayVendor(vendor) {
|
|||||||
|
|
||||||
async function loadVendorInvoices() {
|
async function loadVendorInvoices() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/supplier-invoices?vendor_id=${vendorId}`);
|
const response = await fetch(`/api/v1/vendors/${vendorId}/invoices`);
|
||||||
if (!response.ok) throw new Error('Failed to load invoices');
|
if (!response.ok) throw new Error('Failed to load invoices');
|
||||||
|
|
||||||
const invoices = await response.json();
|
const invoices = await response.json();
|
||||||
@ -673,7 +673,9 @@ function displayInvoices(invoices) {
|
|||||||
<td><strong>${formatCurrency(invoice.total_amount, invoice.currency)}</strong></td>
|
<td><strong>${formatCurrency(invoice.total_amount, invoice.currency)}</strong></td>
|
||||||
<td><span class="badge ${statusClass}">${statusText}</span></td>
|
<td><span class="badge ${statusClass}">${statusText}</span></td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<a href="/billing/supplier-invoices?invoice=${invoice.id}" class="btn btn-sm btn-outline-primary">
|
<a href="${invoice.source_type === 'supplier_invoice'
|
||||||
|
? `/billing/supplier-invoices?invoice=${invoice.id}`
|
||||||
|
: '/economy/internet-connections#invoice-sync'}" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@ -688,7 +690,15 @@ function getInvoiceStatusClass(status) {
|
|||||||
'paid': 'bg-success',
|
'paid': 'bg-success',
|
||||||
'overdue': 'bg-danger',
|
'overdue': 'bg-danger',
|
||||||
'cancelled': 'bg-secondary',
|
'cancelled': 'bg-secondary',
|
||||||
'pending': 'bg-info'
|
'credited': 'bg-secondary',
|
||||||
|
'rejected': 'bg-secondary',
|
||||||
|
'pending': 'bg-info',
|
||||||
|
'success': 'bg-success',
|
||||||
|
'warning': 'bg-warning text-dark',
|
||||||
|
'error': 'bg-danger',
|
||||||
|
'extracted': 'bg-info text-dark',
|
||||||
|
'ai_extracted': 'bg-info text-dark',
|
||||||
|
'processed': 'bg-success'
|
||||||
};
|
};
|
||||||
return classes[status] || 'bg-secondary';
|
return classes[status] || 'bg-secondary';
|
||||||
}
|
}
|
||||||
@ -699,7 +709,15 @@ function getInvoiceStatusText(status) {
|
|||||||
'paid': 'Betalt',
|
'paid': 'Betalt',
|
||||||
'overdue': 'Forfalden',
|
'overdue': 'Forfalden',
|
||||||
'cancelled': 'Annulleret',
|
'cancelled': 'Annulleret',
|
||||||
'pending': 'Afventer'
|
'credited': 'Krediteret',
|
||||||
|
'rejected': 'Afvist',
|
||||||
|
'pending': 'Afventer',
|
||||||
|
'success': 'Behandlet',
|
||||||
|
'warning': 'Kræver kontrol',
|
||||||
|
'error': 'Fejl',
|
||||||
|
'extracted': 'Udtrukket',
|
||||||
|
'ai_extracted': 'Udtrukket',
|
||||||
|
'processed': 'Behandlet'
|
||||||
};
|
};
|
||||||
return texts[status] || status;
|
return texts[status] || status;
|
||||||
}
|
}
|
||||||
|
|||||||
4
main.py
4
main.py
@ -149,6 +149,8 @@ from app.modules.internet_connections.backend import router as internet_connecti
|
|||||||
from app.modules.internet_connections.frontend import views as internet_connections_views
|
from app.modules.internet_connections.frontend import views as internet_connections_views
|
||||||
from app.modules.invoice_error_finder.backend import router as invoice_error_finder_api
|
from app.modules.invoice_error_finder.backend import router as invoice_error_finder_api
|
||||||
from app.modules.invoice_error_finder.frontend import views as invoice_error_finder_views
|
from app.modules.invoice_error_finder.frontend import views as invoice_error_finder_views
|
||||||
|
from app.modules.migration_center.backend import router as migration_center_api
|
||||||
|
from app.modules.migration_center.frontend import views as migration_center_views
|
||||||
from app.bug_reports.backend import router as bug_reports_api
|
from app.bug_reports.backend import router as bug_reports_api
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@ -500,6 +502,7 @@ app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Temp
|
|||||||
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
|
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
|
||||||
app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"])
|
app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"])
|
||||||
app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"])
|
app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"])
|
||||||
|
app.include_router(migration_center_api.router, prefix="/api/v1/migration-center", tags=["Migration Center"])
|
||||||
|
|
||||||
if settings.LINKS_MODULE_ENABLED:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.backend import router as links_api
|
from app.modules.links.backend import router as links_api
|
||||||
@ -539,6 +542,7 @@ app.include_router(manual_views.router, tags=["Frontend"])
|
|||||||
app.include_router(drift_views.router, tags=["Frontend"])
|
app.include_router(drift_views.router, tags=["Frontend"])
|
||||||
app.include_router(internet_connections_views.router, tags=["Frontend"])
|
app.include_router(internet_connections_views.router, tags=["Frontend"])
|
||||||
app.include_router(invoice_error_finder_views.router, tags=["Frontend"])
|
app.include_router(invoice_error_finder_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(migration_center_views.router, tags=["Frontend"])
|
||||||
|
|
||||||
if settings.LINKS_MODULE_ENABLED:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.frontend import views as links_views
|
from app.modules.links.frontend import views as links_views
|
||||||
|
|||||||
7
migrations/1008_wall_outlet_wan.sql
Normal file
7
migrations/1008_wall_outlet_wan.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- Mark wall outlets and their related patch/switch ports as WAN connections.
|
||||||
|
ALTER TABLE locations_wall_outlets
|
||||||
|
ADD COLUMN IF NOT EXISTS is_wan BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wall_outlets_is_wan
|
||||||
|
ON locations_wall_outlets(is_wan)
|
||||||
|
WHERE deleted_at IS NULL AND is_wan = TRUE;
|
||||||
31
migrations/1009_internet_invoice_sync_runs.sql
Normal file
31
migrations/1009_internet_invoice_sync_runs.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
-- Permanent audit trail for supplier invoices processed into internet connections.
|
||||||
|
CREATE TABLE IF NOT EXISTS internet_connections_invoice_sync_runs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
file_id INTEGER REFERENCES incoming_files(file_id) ON DELETE SET NULL,
|
||||||
|
extraction_id INTEGER REFERENCES extractions(extraction_id) ON DELETE SET NULL,
|
||||||
|
supplier_invoice_id INTEGER REFERENCES supplier_invoices(id) ON DELETE SET NULL,
|
||||||
|
invoice_number VARCHAR(100),
|
||||||
|
vendor_name VARCHAR(255),
|
||||||
|
invoice_date DATE,
|
||||||
|
status VARCHAR(20) NOT NULL
|
||||||
|
CHECK (status IN ('success', 'warning', 'skipped', 'error')),
|
||||||
|
connections_synced INTEGER NOT NULL DEFAULT 0,
|
||||||
|
connections_created INTEGER NOT NULL DEFAULT 0,
|
||||||
|
connections_updated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ip_ranges_synced INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_lines INTEGER NOT NULL DEFAULT 0,
|
||||||
|
actionable_lines INTEGER NOT NULL DEFAULT 0,
|
||||||
|
skipped_lines INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
result_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_internet_invoice_sync_runs_processed
|
||||||
|
ON internet_connections_invoice_sync_runs(processed_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_internet_invoice_sync_runs_status
|
||||||
|
ON internet_connections_invoice_sync_runs(status, processed_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_internet_invoice_sync_runs_invoice
|
||||||
|
ON internet_connections_invoice_sync_runs(invoice_number, vendor_name);
|
||||||
15
migrations/1010_internet_invoice_review_decisions.sql
Normal file
15
migrations/1010_internet_invoice_review_decisions.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS internet_connections_invoice_review_decisions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
run_id BIGINT NOT NULL REFERENCES internet_connections_invoice_sync_runs(id) ON DELETE CASCADE,
|
||||||
|
line_number INTEGER NOT NULL,
|
||||||
|
action VARCHAR(30) NOT NULL
|
||||||
|
CHECK (action IN ('ignore', 'link_existing', 'create_separate')),
|
||||||
|
connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE SET NULL,
|
||||||
|
note TEXT,
|
||||||
|
resolved_by_user_id INTEGER,
|
||||||
|
resolved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (run_id, line_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_internet_invoice_review_decisions_run
|
||||||
|
ON internet_connections_invoice_review_decisions(run_id, line_number);
|
||||||
6
migrations/1011_mattermost_notification_settings.sql
Normal file
6
migrations/1011_mattermost_notification_settings.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES
|
||||||
|
('mattermost_reminders_enabled', 'false', 'notifications', 'Send reminders to Mattermost', 'boolean', false),
|
||||||
|
('mattermost_webhook_url', '', 'notifications', 'Mattermost incoming webhook URL', 'string', false),
|
||||||
|
('mattermost_channel', '', 'notifications', 'Optional Mattermost channel override', 'string', false)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
2
migrations/1012_user_mattermost_username.sql
Normal file
2
migrations/1012_user_mattermost_username.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE user_notification_preferences
|
||||||
|
ADD COLUMN IF NOT EXISTS mattermost_username VARCHAR(100);
|
||||||
24
migrations/1013_sag_permissions.sql
Normal file
24
migrations/1013_sag_permissions.sql
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
-- Dedicated permissions for the Sag module.
|
||||||
|
-- Existing group access is preserved by copying equivalent ticket permissions.
|
||||||
|
|
||||||
|
INSERT INTO permissions (code, description, category) VALUES
|
||||||
|
('cases.view', 'Se sager', 'cases'),
|
||||||
|
('cases.create', 'Opret sager', 'cases'),
|
||||||
|
('cases.edit', 'Redigér sager, relationer, filer og reminders', 'cases'),
|
||||||
|
('cases.delete', 'Slet sager', 'cases')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
WITH permission_map(case_code, ticket_code) AS (
|
||||||
|
VALUES
|
||||||
|
('cases.view', 'tickets.view'),
|
||||||
|
('cases.create', 'tickets.create'),
|
||||||
|
('cases.edit', 'tickets.edit'),
|
||||||
|
('cases.delete', 'tickets.delete')
|
||||||
|
)
|
||||||
|
INSERT INTO group_permissions (group_id, permission_id)
|
||||||
|
SELECT DISTINCT gp.group_id, case_permission.id
|
||||||
|
FROM group_permissions gp
|
||||||
|
JOIN permissions ticket_permission ON ticket_permission.id = gp.permission_id
|
||||||
|
JOIN permission_map mapping ON mapping.ticket_code = ticket_permission.code
|
||||||
|
JOIN permissions case_permission ON case_permission.code = mapping.case_code
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
185
migrations/228_migration_center.sql
Normal file
185
migrations/228_migration_center.sql
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
-- Migration 228: Manual subscription and invoice migration centre.
|
||||||
|
-- e-conomic data is deliberately referenced from Invoice Error Finder and never copied back.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_center_sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(160) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('draft', 'active', 'completed', 'archived')),
|
||||||
|
economic_import_run_id INTEGER REFERENCES invoice_error_finder_import_runs(id) ON DELETE RESTRICT,
|
||||||
|
economic_snapshot_at TIMESTAMP,
|
||||||
|
read_only BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_center_source_customers (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
source_system VARCHAR(30) NOT NULL CHECK (source_system IN ('vtiger', 'simply', 'economic')),
|
||||||
|
source_customer_id VARCHAR(120) NOT NULL,
|
||||||
|
customer_no VARCHAR(80),
|
||||||
|
customer_name VARCHAR(255) NOT NULL,
|
||||||
|
cvr VARCHAR(32),
|
||||||
|
email VARCHAR(255),
|
||||||
|
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
snapshot_hash CHAR(64) NOT NULL,
|
||||||
|
hub_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (source_system, source_customer_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_center_source_subscriptions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
source_system VARCHAR(30) NOT NULL CHECK (source_system IN ('vtiger', 'simply')),
|
||||||
|
source_record_id VARCHAR(120) NOT NULL,
|
||||||
|
source_customer_id VARCHAR(120),
|
||||||
|
customer_no VARCHAR(80),
|
||||||
|
customer_name VARCHAR(255),
|
||||||
|
product_code VARCHAR(100),
|
||||||
|
product_name VARCHAR(500) NOT NULL,
|
||||||
|
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
quantity NUMERIC(14,4) NOT NULL DEFAULT 1,
|
||||||
|
billing_frequency VARCHAR(40),
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
snapshot_hash CHAR(64) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (source_system, source_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_center_session_items (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_id INTEGER NOT NULL REFERENCES migration_center_sessions(id) ON DELETE CASCADE,
|
||||||
|
entity_type VARCHAR(20) NOT NULL CHECK (entity_type IN ('subscription', 'invoice_line')),
|
||||||
|
source_system VARCHAR(30) NOT NULL CHECK (source_system IN ('vtiger', 'simply', 'economic')),
|
||||||
|
source_record_id VARCHAR(180) NOT NULL,
|
||||||
|
source_customer_id VARCHAR(120),
|
||||||
|
customer_no VARCHAR(80),
|
||||||
|
customer_name VARCHAR(255),
|
||||||
|
product_code VARCHAR(100),
|
||||||
|
product_name VARCHAR(500) NOT NULL,
|
||||||
|
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
quantity NUMERIC(14,4) NOT NULL DEFAULT 1,
|
||||||
|
billing_frequency VARCHAR(40),
|
||||||
|
period_from DATE,
|
||||||
|
period_to DATE,
|
||||||
|
invoice_no VARCHAR(80),
|
||||||
|
invoice_date DATE,
|
||||||
|
source_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
source_hash CHAR(64) NOT NULL,
|
||||||
|
previous_source_payload JSONB,
|
||||||
|
match_status VARCHAR(30) NOT NULL DEFAULT 'new'
|
||||||
|
CHECK (match_status IN ('new', 'match_found', 'no_match', 'conflict', 'manual_review', 'source_changed')),
|
||||||
|
approval_status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (approval_status IN ('pending', 'approved', 'rejected', 'verified', 'ignored')),
|
||||||
|
hub_status VARCHAR(30) NOT NULL DEFAULT 'not_created'
|
||||||
|
CHECK (hub_status IN ('not_created', 'ready_for_creation', 'created_in_hub', 'linked_to_existing', 'verified')),
|
||||||
|
lock_status VARCHAR(25) NOT NULL DEFAULT 'unlocked'
|
||||||
|
CHECK (lock_status IN ('unlocked', 'locking_pending', 'locked', 'lock_failed')),
|
||||||
|
match_confidence NUMERIC(5,4),
|
||||||
|
match_explanation JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
hub_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
hub_sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
|
||||||
|
hub_record_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL,
|
||||||
|
suggested_hub_record_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL,
|
||||||
|
creation_idempotency_key VARCHAR(120),
|
||||||
|
ignore_reason TEXT,
|
||||||
|
manual_note TEXT,
|
||||||
|
verified_at TIMESTAMP,
|
||||||
|
verified_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
locked_at TIMESTAMP,
|
||||||
|
locked_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (session_id, entity_type, source_system, source_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE migration_center_session_items
|
||||||
|
ADD COLUMN IF NOT EXISTS creation_idempotency_key VARCHAR(120);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mc_items_session_type ON migration_center_session_items(session_id, entity_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mc_items_work_queue ON migration_center_session_items(session_id, lock_status, approval_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mc_items_customer ON migration_center_session_items(session_id, hub_customer_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_mc_items_creation_idempotency
|
||||||
|
ON migration_center_session_items(session_id, creation_idempotency_key)
|
||||||
|
WHERE creation_idempotency_key IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_center_matches (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_item_id BIGINT NOT NULL REFERENCES migration_center_session_items(id) ON DELETE CASCADE,
|
||||||
|
matched_entity_type VARCHAR(30) NOT NULL,
|
||||||
|
matched_hub_id INTEGER NOT NULL,
|
||||||
|
confidence NUMERIC(5,4) NOT NULL,
|
||||||
|
rules JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
approved BOOLEAN,
|
||||||
|
approved_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
approved_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (session_item_id, matched_entity_type, matched_hub_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_center_lock_operations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_item_id BIGINT NOT NULL REFERENCES migration_center_session_items(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(25) NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed')),
|
||||||
|
attempt_no INTEGER NOT NULL DEFAULT 1,
|
||||||
|
external_system VARCHAR(30),
|
||||||
|
request_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
response_payload JSONB,
|
||||||
|
error_message TEXT,
|
||||||
|
requested_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_center_audit_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_id INTEGER REFERENCES migration_center_sessions(id) ON DELETE SET NULL,
|
||||||
|
session_item_id BIGINT REFERENCES migration_center_session_items(id) ON DELETE SET NULL,
|
||||||
|
entity_type VARCHAR(40) NOT NULL,
|
||||||
|
entity_id VARCHAR(180),
|
||||||
|
action VARCHAR(80) NOT NULL,
|
||||||
|
old_value JSONB,
|
||||||
|
new_value JSONB,
|
||||||
|
source_hash CHAR(64),
|
||||||
|
performed_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
success BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
error_message TEXT,
|
||||||
|
performed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mc_audit_session_time ON migration_center_audit_log(session_id, performed_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE sag_subscriptions
|
||||||
|
ADD COLUMN IF NOT EXISTS migration_locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS migration_locked_at TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS migration_locked_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS migration_source_item_id BIGINT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_sag_subscription_migration_source
|
||||||
|
ON sag_subscriptions(migration_source_item_id)
|
||||||
|
WHERE migration_source_item_id IS NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO permissions (code, description, category) VALUES
|
||||||
|
('migration_center.view', 'View migration centre', 'migration_center'),
|
||||||
|
('migration_center.sessions', 'Create and manage migration sessions', 'migration_center'),
|
||||||
|
('migration_center.import', 'Import CRM subscription snapshots', 'migration_center'),
|
||||||
|
('migration_center.create', 'Create customers, cases and subscriptions from migration centre', 'migration_center'),
|
||||||
|
('migration_center.review', 'Link, verify and ignore migration records', 'migration_center'),
|
||||||
|
('migration_center.lock', 'Lock migration records', 'migration_center'),
|
||||||
|
('migration_center.retry', 'Retry failed external locks', 'migration_center'),
|
||||||
|
('migration_center.export', 'Export migration control reports', 'migration_center')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO group_permissions (group_id, permission_id)
|
||||||
|
SELECT g.id, p.id
|
||||||
|
FROM groups g
|
||||||
|
JOIN permissions p ON p.category = 'migration_center'
|
||||||
|
WHERE g.name = 'Administrators'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
-- Speed up the subscription-like invoice line classifier and company filters.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mc_items_subscription_visibility
|
||||||
|
ON migration_center_session_items
|
||||||
|
(session_id, source_system, entity_type, customer_no, product_code, invoice_date);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mc_items_company_name
|
||||||
|
ON migration_center_session_items (session_id, LOWER(customer_name));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_customer_price
|
||||||
|
ON sag_subscriptions (customer_id, price)
|
||||||
|
WHERE status <> 'cancelled';
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE migration_center_session_items
|
||||||
|
ADD COLUMN IF NOT EXISTS subscription_like BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
ADD COLUMN IF NOT EXISTS subscription_relevance_reason VARCHAR(40);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mc_items_visible
|
||||||
|
ON migration_center_session_items (session_id, subscription_like, entity_type, invoice_date DESC);
|
||||||
122
tests/test_migration_center.py
Normal file
122
tests/test_migration_center.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.modules.migration_center.backend import router
|
||||||
|
from app.modules.migration_center.backend.service import (
|
||||||
|
EconomicSnapshotRepository,
|
||||||
|
_resolve_source_customer,
|
||||||
|
preflight_token,
|
||||||
|
snapshot_hash,
|
||||||
|
verify_preflight,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_hash_is_stable_for_key_order_and_decimal():
|
||||||
|
first = snapshot_hash({"amount": Decimal("10.00"), "nested": {"b": 2, "a": 1}})
|
||||||
|
second = snapshot_hash({"nested": {"a": 1, "b": 2}, "amount": Decimal("10.00")})
|
||||||
|
assert first == second
|
||||||
|
assert len(first) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalizes_vtiger_subscription():
|
||||||
|
item = router._normalized_crm_record(
|
||||||
|
"vtiger",
|
||||||
|
{
|
||||||
|
"id": "72x123",
|
||||||
|
"account_id": "3x44",
|
||||||
|
"accountname": "Kunde A/S",
|
||||||
|
"subject": "Driftsaftale",
|
||||||
|
"total": "1.250",
|
||||||
|
"startdate": "2026-01-01",
|
||||||
|
"subscriptionstatus": "active",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert item["entity_type"] == "subscription"
|
||||||
|
assert item["source_record_id"] == "72x123"
|
||||||
|
assert item["source_customer_id"] == "3x44"
|
||||||
|
assert item["product_name"] == "Driftsaftale"
|
||||||
|
assert item["period_from"].isoformat() == "2026-01-01"
|
||||||
|
assert len(item["source_hash"]) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_frequency_is_normalized_to_hub_interval():
|
||||||
|
assert router._hub_interval("monthly_3_last_day") == "monthly"
|
||||||
|
assert router._hub_interval("Quarterly") == "quarterly"
|
||||||
|
assert router._hub_interval("Annual") == "yearly"
|
||||||
|
|
||||||
|
|
||||||
|
def test_economic_repository_is_read_only_query(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_query(sql, params=None):
|
||||||
|
captured["sql"] = sql
|
||||||
|
captured["params"] = params
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.migration_center.backend.service.execute_query",
|
||||||
|
fake_query,
|
||||||
|
)
|
||||||
|
assert EconomicSnapshotRepository.lines(42) == []
|
||||||
|
normalized = " ".join(captured["sql"].split()).upper()
|
||||||
|
assert normalized.startswith("SELECT")
|
||||||
|
assert all(keyword not in normalized for keyword in ("INSERT INTO", "UPDATE ", "DELETE FROM"))
|
||||||
|
assert captured["params"] == (42,)
|
||||||
|
assert "DATE_TRUNC('MONTH', CURRENT_DATE) - INTERVAL '12 MONTHS'" in normalized
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_token_is_bound_to_item_hash_and_user(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.migration_center.backend.service.settings.JWT_SECRET_KEY",
|
||||||
|
"test-migration-secret",
|
||||||
|
)
|
||||||
|
item = {"id": 9, "source_hash": "a" * 64}
|
||||||
|
user = {"id": 7}
|
||||||
|
token = preflight_token(item, user)
|
||||||
|
verify_preflight(token, item, user)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
verify_preflight(token, {**item, "source_hash": "b" * 64}, user)
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_economic_line_text_is_bounded_for_staging_columns():
|
||||||
|
item = __import__(
|
||||||
|
"app.modules.migration_center.backend.service",
|
||||||
|
fromlist=["_normalize_economic_line"],
|
||||||
|
)._normalize_economic_line(
|
||||||
|
{
|
||||||
|
"source_type": "booked", "source_invoice_number": "1", "invoice_id": 1,
|
||||||
|
"line_number": 1, "invoice_line_id": 2, "customer_number": 3,
|
||||||
|
"customer_name": "K" * 300, "description": "P" * 800,
|
||||||
|
"product_number": "X" * 150, "line_net_amount": 100, "quantity": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert len(item["customer_name"]) == 255
|
||||||
|
assert len(item["product_name"]) == 500
|
||||||
|
assert len(item["product_code"]) == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_customer_lock_blocker_states_are_explicit():
|
||||||
|
assert {"unlocked", "lock_failed"} == router.MUTABLE_LOCK_STATES
|
||||||
|
|
||||||
|
|
||||||
|
def test_simply_customer_resolution_reuses_existing_staging_mapping(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.migration_center.backend.service.execute_query_single",
|
||||||
|
lambda sql, params: {
|
||||||
|
"customer_name": "Korrekt Firma A/S",
|
||||||
|
"cvr": "12345678",
|
||||||
|
"hub_customer_id": 44,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = _resolve_source_customer(
|
||||||
|
{"source_system": "simply", "source_customer_id": "11x123"}
|
||||||
|
)
|
||||||
|
assert result["hub_customer_id"] == 44
|
||||||
|
assert result["customer_name"] == "Korrekt Firma A/S"
|
||||||
|
assert result["rule"] == "Eksisterende Simply-kundemapping"
|
||||||
@ -1,86 +1,199 @@
|
|||||||
import sys
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi import HTTPException, UploadFile, Request
|
||||||
from app.main import app
|
|
||||||
from app.core.database import execute_query
|
|
||||||
|
|
||||||
client = TestClient(app)
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
@pytest.fixture(scope="function", autouse=True)
|
from app.utils.safe_html import sanitize_safe_html
|
||||||
def setup_and_teardown():
|
|
||||||
"""Setup and teardown for each test."""
|
|
||||||
# Setup: Clear the database tables before each test
|
|
||||||
execute_query("DELETE FROM sag_tags;")
|
|
||||||
execute_query("DELETE FROM sag_relationer;")
|
|
||||||
execute_query("DELETE FROM sag_sager;")
|
|
||||||
yield
|
|
||||||
# Teardown: Clear the database tables after each test
|
|
||||||
execute_query("DELETE FROM sag_tags;")
|
|
||||||
execute_query("DELETE FROM sag_relationer;")
|
|
||||||
execute_query("DELETE FROM sag_sager;")
|
|
||||||
|
|
||||||
def test_create_case():
|
# Keep these focused unit tests independent of optional auth runtime packages.
|
||||||
"""Test creating a new case."""
|
auth_dependencies_stub = types.ModuleType("app.core.auth_dependencies")
|
||||||
response = client.post("/api/v1/cases", json={
|
|
||||||
"titel": "Test Case",
|
|
||||||
"beskrivelse": "This is a test case.",
|
|
||||||
"template_key": "ticket",
|
|
||||||
"status": "åben",
|
|
||||||
"customer_id": 1,
|
|
||||||
"ansvarlig_bruger_id": 2,
|
|
||||||
"created_by_user_id": 3,
|
|
||||||
"deadline": "2026-02-01T12:00:00"
|
|
||||||
})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["titel"] == "Test Case"
|
|
||||||
assert data["status"] == "åben"
|
|
||||||
|
|
||||||
def test_list_cases():
|
|
||||||
"""Test listing cases."""
|
|
||||||
# Create a case
|
|
||||||
client.post("/api/v1/cases", json={
|
|
||||||
"titel": "Test Case",
|
|
||||||
"beskrivelse": "This is a test case.",
|
|
||||||
"template_key": "ticket",
|
|
||||||
"status": "åben",
|
|
||||||
"customer_id": 1,
|
|
||||||
"ansvarlig_bruger_id": 2,
|
|
||||||
"created_by_user_id": 3,
|
|
||||||
"deadline": "2026-02-01T12:00:00"
|
|
||||||
})
|
|
||||||
|
|
||||||
# List cases
|
def _allow_test_user(*_permissions):
|
||||||
response = client.get("/api/v1/cases")
|
async def dependency(_request: Request):
|
||||||
assert response.status_code == 200
|
return {"id": 1, "username": "test", "permissions": []}
|
||||||
data = response.json()
|
|
||||||
assert len(data) == 1
|
|
||||||
assert data[0]["titel"] == "Test Case"
|
|
||||||
|
|
||||||
def test_soft_delete_case():
|
return dependency
|
||||||
"""Test soft-deleting a case."""
|
|
||||||
# Create a case
|
|
||||||
response = client.post("/api/v1/cases", json={
|
|
||||||
"titel": "Test Case",
|
|
||||||
"beskrivelse": "This is a test case.",
|
|
||||||
"template_key": "ticket",
|
|
||||||
"status": "åben",
|
|
||||||
"customer_id": 1,
|
|
||||||
"ansvarlig_bruger_id": 2,
|
|
||||||
"created_by_user_id": 3,
|
|
||||||
"deadline": "2026-02-01T12:00:00"
|
|
||||||
})
|
|
||||||
case_id = response.json()["id"]
|
|
||||||
|
|
||||||
# Soft-delete the case
|
|
||||||
delete_response = client.delete(f"/api/v1/cases/{case_id}")
|
|
||||||
assert delete_response.status_code == 200
|
|
||||||
|
|
||||||
# Verify the case is soft-deleted
|
auth_dependencies_stub.require_any_permission = _allow_test_user
|
||||||
list_response = client.get("/api/v1/cases")
|
|
||||||
assert list_response.status_code == 200
|
|
||||||
data = list_response.json()
|
async def _get_test_user(_request: Request):
|
||||||
assert len(data) == 0
|
return {
|
||||||
|
"id": 1,
|
||||||
|
"username": "test",
|
||||||
|
"permissions": ["cases.view", "cases.create", "cases.edit", "cases.delete"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
auth_dependencies_stub.get_current_user = _get_test_user
|
||||||
|
sys.modules.setdefault("app.core.auth_dependencies", auth_dependencies_stub)
|
||||||
|
|
||||||
|
from app.modules.sag.backend import router as sag_router
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_timestamp_converts_offset_to_utc():
|
||||||
|
value = sag_router._normalize_optional_timestamp(
|
||||||
|
"2026-07-27T12:00:00+02:00",
|
||||||
|
"deadline",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert value == "2026-07-27 10:00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_timestamp_accepts_naive_datetime():
|
||||||
|
value = sag_router._normalize_optional_timestamp(
|
||||||
|
datetime(2026, 7, 27, 12, 30),
|
||||||
|
"deadline",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert value == "2026-07-27 12:30:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_timestamp_rejects_invalid_value():
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
sag_router._normalize_optional_timestamp("not-a-date", "deadline")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_path_rejects_escape_from_upload_root():
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
sag_router._resolve_attachment_path("../../outside.txt")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_path_accepts_case_subdirectory():
|
||||||
|
path = sag_router._resolve_attachment_path("sag_files/example.txt")
|
||||||
|
|
||||||
|
path.relative_to(sag_router.UPLOAD_BASE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejects_disallowed_extension():
|
||||||
|
upload = UploadFile(filename="payload.html", file=BytesIO(b"<script>alert(1)</script>"))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
sag_router._store_upload_file(upload, sag_router.SAG_FILE_SUBDIR)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert "not allowed" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
|
||||||
|
def test_relation_input_normalizes_valid_relation():
|
||||||
|
target_id, relation_type = sag_router._normalize_relation_input(
|
||||||
|
10,
|
||||||
|
{"målsag_id": "11", "relationstype": " Blokkerer "},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert target_id == 11
|
||||||
|
assert relation_type == "Blokkerer"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw_type", "expected"),
|
||||||
|
[
|
||||||
|
("Relateret til", "Relateret til"),
|
||||||
|
("Afledt af", "Afledt af"),
|
||||||
|
("Årsag til", "Årsag til"),
|
||||||
|
("afledt_af", "Afledt af"),
|
||||||
|
("afhænger af", "afhænger af"),
|
||||||
|
("undersag", "undersag"),
|
||||||
|
("duplikat", "duplikat"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_relation_input_accepts_ui_and_existing_database_types(raw_type, expected):
|
||||||
|
_, relation_type = sag_router._normalize_relation_input(
|
||||||
|
10,
|
||||||
|
{"målsag_id": 11, "relationstype": raw_type},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert relation_type == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_relation_quick_task_uses_todo_steps_api():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
||||||
|
|
||||||
|
assert "fetch(`/api/v1/sag/${caseId}/todo-steps`" in template
|
||||||
|
assert "fetch(`/api/v1/sag/${caseId}/todos`" not in template
|
||||||
|
assert "due_date: due" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_case_html_renders_formatting_and_drops_executable_code():
|
||||||
|
result = sanitize_safe_html(
|
||||||
|
'<style>body{display:none}</style>'
|
||||||
|
'<p onclick="steal()">Hej <strong>verden</strong></p>'
|
||||||
|
'<script>alert("xss")</script>'
|
||||||
|
'<a href="javascript:alert(1)">farligt link</a>'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == (
|
||||||
|
'<p>Hej <strong>verden</strong></p>'
|
||||||
|
'<a target="_blank" rel="noopener noreferrer">farligt link</a>'
|
||||||
|
)
|
||||||
|
assert "display:none" not in result
|
||||||
|
assert "alert" not in result
|
||||||
|
assert "onclick" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload",
|
||||||
|
[
|
||||||
|
{"målsag_id": 10, "relationstype": "barn"},
|
||||||
|
{"målsag_id": 11, "relationstype": "ukendt"},
|
||||||
|
{"målsag_id": "ikke-et-tal", "relationstype": "barn"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_relation_input_rejects_invalid_relation(payload):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
sag_router._normalize_relation_input(10, payload)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def _request(method: str, path: str) -> Request:
|
||||||
|
return Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"method": method,
|
||||||
|
"path": path,
|
||||||
|
"raw_path": path.encode(),
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [],
|
||||||
|
"scheme": "https",
|
||||||
|
"server": ("testserver", 443),
|
||||||
|
"client": ("127.0.0.1", 1234),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_route_access_allows_view_permission_for_get():
|
||||||
|
user = {"permissions": ["cases.view"]}
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
sag_router.case_route_access(_request("GET", "/api/v1/sag/10"), user)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is user
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_route_access_requires_edit_for_nested_post():
|
||||||
|
user = {"permissions": ["cases.view", "cases.create"]}
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
asyncio.run(
|
||||||
|
sag_router.case_route_access(
|
||||||
|
_request("POST", "/api/v1/sag/10/tags"),
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user