From 71f6372496d1e587d2d19db166399a7cc60e15f0 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 5 May 2026 19:13:54 +0200 Subject: [PATCH] feat: Implement bug reporting feature with screenshot support - Added a new modal for reporting bugs, including fields for describing the issue and attaching files. - Implemented backend API for creating bug reports, including rate limiting and metadata logging. - Introduced a new database table to track bug report submissions for auditing purposes. - Enhanced the frontend to capture screenshots automatically and allow manual file uploads. - Added error handling and user feedback for the bug reporting process. - Updated existing templates and scripts to integrate the new bug reporting functionality. --- app/bug_reports/__init__.py | 0 app/bug_reports/backend/__init__.py | 0 app/bug_reports/backend/models.py | 20 + app/bug_reports/backend/router.py | 212 ++++++++++ app/core/config.py | 9 + app/modules/locations/backend/router.py | 62 ++- app/modules/locations/models/schemas.py | 5 + app/modules/sag/templates/detail_v3.html | 152 ++++++- app/modules/telefoni/templates/log.html | 20 +- app/shared/frontend/base.html | 8 + app/shared/frontend/bug_report_modal.html | 47 +++ fix_telefoni_per_krag_numbers.sql | 90 ++++ main.py | 44 ++ migrations/1004_bug_report_submissions.sql | 15 + static/js/bug-report.js | 452 +++++++++++++++++++++ updateto.sh | 10 +- 16 files changed, 1119 insertions(+), 27 deletions(-) create mode 100644 app/bug_reports/__init__.py create mode 100644 app/bug_reports/backend/__init__.py create mode 100644 app/bug_reports/backend/models.py create mode 100644 app/bug_reports/backend/router.py create mode 100644 app/shared/frontend/bug_report_modal.html create mode 100644 fix_telefoni_per_krag_numbers.sql create mode 100644 migrations/1004_bug_report_submissions.sql create mode 100644 static/js/bug-report.js diff --git a/app/bug_reports/__init__.py b/app/bug_reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bug_reports/backend/__init__.py b/app/bug_reports/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bug_reports/backend/models.py b/app/bug_reports/backend/models.py new file mode 100644 index 0000000..835763e --- /dev/null +++ b/app/bug_reports/backend/models.py @@ -0,0 +1,20 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class BugReportPayload(BaseModel): + actual: str = Field(..., min_length=3, max_length=8000) + expected: str = Field(..., min_length=3, max_length=8000) + screenshot_base64: Optional[str] = Field(default=None, max_length=25_000_000) + metadata: Dict[str, Any] = Field(default_factory=dict) + logs: List[Dict[str, Any]] = Field(default_factory=list) + extra_file_name: Optional[str] = Field(default=None, max_length=255) + extra_file_base64: Optional[str] = Field(default=None, max_length=25_000_000) + + +class BugReportResult(BaseModel): + success: bool + sag_id: int + case_url: str + message: str diff --git a/app/bug_reports/backend/router.py b/app/bug_reports/backend/router.py new file mode 100644 index 0000000..ba2b0f9 --- /dev/null +++ b/app/bug_reports/backend/router.py @@ -0,0 +1,212 @@ +import base64 +import json +import logging +import re +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, Optional +from uuid import uuid4 + +from fastapi import APIRouter, HTTPException, Request + +from app.bug_reports.backend.models import BugReportPayload, BugReportResult +from app.core.config import settings +from app.core.database import execute_query, execute_query_single + +logger = logging.getLogger(__name__) +router = APIRouter() + +UPLOAD_BASE_PATH = Path(settings.UPLOAD_DIR).resolve() +SAG_FILE_SUBDIR = "sag_files" +(UPLOAD_BASE_PATH / SAG_FILE_SUBDIR).mkdir(parents=True, exist_ok=True) + + +def _table_exists(table_name: str) -> bool: + row = execute_query_single( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = %s + LIMIT 1 + """, + (table_name,), + ) + return bool(row) + + +def _decode_data_url(data_url: str) -> tuple[bytes, str]: + # Expected format: data:image/png;base64,.... + match = re.match(r"^data:([\w/+.-]+);base64,(.+)$", data_url or "", flags=re.DOTALL) + if not match: + raise HTTPException(status_code=400, detail="Invalid base64 data URL") + + content_type = match.group(1) + b64_data = match.group(2) + try: + raw = base64.b64decode(b64_data, validate=True) + except Exception: + raise HTTPException(status_code=400, detail="Invalid base64 encoding") + + return raw, content_type + + +def _store_raw_file(raw: bytes, filename: str) -> tuple[str, int]: + safe_name = Path(filename).name + stored_name = f"{SAG_FILE_SUBDIR}/{uuid4().hex}_{safe_name}" + destination = UPLOAD_BASE_PATH / stored_name + destination.parent.mkdir(parents=True, exist_ok=True) + + with destination.open("wb") as f: + f.write(raw) + + return stored_name, len(raw) + + +def _create_sag_file_record(sag_id: int, filename: str, content_type: str, size_bytes: int, stored_name: str) -> None: + execute_query( + """ + INSERT INTO sag_files (sag_id, filename, content_type, size_bytes, stored_name) + VALUES (%s, %s, %s, %s, %s) + """, + (sag_id, filename, content_type, size_bytes, stored_name), + ) + + +def _rate_limit(user_id: int) -> None: + if not _table_exists("bug_report_submissions"): + # If migration is not yet applied, fail-open to avoid blocking support workflows. + return + + max_per_hour = max(1, int(settings.BUG_REPORT_MAX_PER_HOUR)) + row = execute_query_single( + """ + SELECT COUNT(*)::int AS count + FROM bug_report_submissions + WHERE user_id = %s + AND created_at >= %s + """, + (user_id, datetime.utcnow() - timedelta(hours=1)), + ) + count = int((row or {}).get("count") or 0) + if count >= max_per_hour: + raise HTTPException(status_code=429, detail="Rate limit exceeded for bug reports") + + +def _resolve_customer_id() -> int: + configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID) + configured_row = execute_query_single("SELECT id FROM customers WHERE id = %s", (configured_id,)) + if configured_row: + return int(configured_row["id"]) + + named_row = execute_query_single( + """ + SELECT id + FROM customers + WHERE LOWER(name) = LOWER(%s) + ORDER BY id ASC + LIMIT 1 + """, + ("BMC Networks",), + ) + if named_row: + return int(named_row["id"]) + + fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1") + if fallback: + return int(fallback["id"]) + + raise HTTPException(status_code=400, detail="No customers available for bug report case creation") + + +@router.post("/bug-reports", response_model=BugReportResult) +async def create_bug_report(payload: BugReportPayload, request: Request): + user_id = getattr(request.state, "user_id", None) or 1 + + _rate_limit(int(user_id)) + + title_seed = (payload.actual or "").strip().splitlines()[0][:80] + title = f"Bug: {title_seed or 'Ukendt fejl'}" + + metadata_json = json.dumps(payload.metadata or {}, ensure_ascii=False, indent=2) + logs_preview = (payload.logs or [])[:50] + logs_json = json.dumps(logs_preview, ensure_ascii=False, indent=2) + + description = ( + "## Hvad gik galt\n" + f"{payload.actual.strip()}\n\n" + "## Hvad burde være sket\n" + f"{payload.expected.strip()}\n\n" + "## Metadata\n" + f"```json\n{metadata_json}\n```\n\n" + "## Log preview (seneste 50)\n" + f"```json\n{logs_json}\n```\n" + ) + + customer_id = _resolve_customer_id() + assigned_user_id: Optional[int] = settings.BUG_REPORT_AUTO_ASSIGN_USER_ID + + created = execute_query( + """ + INSERT INTO sag_sager + (titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id) + VALUES + (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + title, + description, + "bug_report", + "åben", + customer_id, + assigned_user_id, + user_id, + ), + ) + + if not created: + raise HTTPException(status_code=500, detail="Failed to create bug case") + + sag_id = int(created[0]["id"]) + + # Attach screenshot if provided + if payload.screenshot_base64: + raw, content_type = _decode_data_url(payload.screenshot_base64) + if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES: + raise HTTPException(status_code=400, detail="Screenshot too large") + + stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png") + _create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name) + + # Attach logs as json file + logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8") + stored_name, size = _store_raw_file(logs_raw, f"bugreport_{sag_id}_logs.json") + _create_sag_file_record(sag_id, "logs.json", "application/json", size, stored_name) + + # Optional extra file + if payload.extra_file_base64 and payload.extra_file_name: + raw, content_type = _decode_data_url(payload.extra_file_base64) + if len(raw) > settings.BUG_REPORT_MAX_ATTACHMENT_BYTES: + raise HTTPException(status_code=400, detail="Extra file too large") + stored_name, size = _store_raw_file(raw, payload.extra_file_name) + _create_sag_file_record(sag_id, payload.extra_file_name, content_type, size, stored_name) + + # Track submission for rate-limiting/audit + if _table_exists("bug_report_submissions"): + execute_query( + """ + INSERT INTO bug_report_submissions (sag_id, user_id, screenshot_attached) + VALUES (%s, %s, %s) + """, + (sag_id, user_id, bool(payload.screenshot_base64)), + ) + + logger.info("✅ Bug report case created: SAG-%s by user_id=%s", sag_id, user_id) + + return BugReportResult( + success=True, + sag_id=sag_id, + case_url=f"/sag/{sag_id}/v3", + message="Fejl rapporteret og sag oprettet", + ) diff --git a/app/core/config.py b/app/core/config.py index 65a1f1f..d2e304d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -208,6 +208,15 @@ class Settings(BaseSettings): BACKUP_INCLUDE_LOGS: bool = True # Include logs/ in file backups BACKUP_INCLUDE_DATA: bool = True # Include data/ in file backups UPLOAD_DIR: str = "uploads" # Upload directory path + + # Bug report capture + BUG_REPORT_ENABLED: bool = True + BUG_REPORT_HOTKEY: str = "Ctrl+Shift+B" + BUG_REPORT_MAX_PER_HOUR: int = 12 + BUG_REPORT_DEFAULT_CUSTOMER_ID: int = 1 + BUG_REPORT_AUTO_ASSIGN_USER_ID: int | None = 1 + BUG_REPORT_MAX_SCREENSHOT_BYTES: int = 8 * 1024 * 1024 + BUG_REPORT_MAX_ATTACHMENT_BYTES: int = 20 * 1024 * 1024 # Offsite Backup Settings (SFTP) OFFSITE_ENABLED: bool = False diff --git a/app/modules/locations/backend/router.py b/app/modules/locations/backend/router.py index cbfe881..03cb405 100644 --- a/app/modules/locations/backend/router.py +++ b/app/modules/locations/backend/router.py @@ -1181,6 +1181,12 @@ async def create_contact(location_id: int, data: ContactCreate): - 500: Database error """ try: + def _none_if_empty(value: Optional[str]) -> Optional[str]: + if value is None: + return None + stripped = value.strip() + return stripped or None + # Check location exists location_query = "SELECT name FROM locations_locations WHERE id = %s AND deleted_at IS NULL" location_check = execute_query(location_query, (location_id,)) @@ -1193,6 +1199,43 @@ async def create_contact(location_id: int, data: ContactCreate): ) location_name = location_check[0]['name'] + + contact_name = (data.contact_name or "").strip() + contact_email = _none_if_empty(data.contact_email) + contact_phone = _none_if_empty(data.contact_phone) + contact_role = _none_if_empty(data.role) + + if data.existing_contact_id: + existing_contact_query = """ + SELECT id, first_name, last_name, email, phone, mobile, title + FROM contacts + WHERE id = %s + """ + existing_contact_result = execute_query(existing_contact_query, (data.existing_contact_id,)) + + if not existing_contact_result: + raise HTTPException( + status_code=404, + detail=f"Existing contact with id {data.existing_contact_id} not found" + ) + + existing_contact = existing_contact_result[0] + existing_full_name = ( + f"{(existing_contact.get('first_name') or '').strip()} " + f"{(existing_contact.get('last_name') or '').strip()}" + ).strip() + + if not contact_name: + contact_name = existing_full_name + if not contact_email: + contact_email = _none_if_empty(existing_contact.get('email')) + if not contact_phone: + contact_phone = _none_if_empty(existing_contact.get('mobile')) or _none_if_empty(existing_contact.get('phone')) + if not contact_role: + contact_role = _none_if_empty(existing_contact.get('title')) + + if not contact_name: + raise HTTPException(status_code=400, detail="contact_name is required") # If is_primary is true, unset primary flag on other contacts if data.is_primary: @@ -1215,10 +1258,10 @@ async def create_contact(location_id: int, data: ContactCreate): params = ( location_id, - data.contact_name, - data.contact_email, - data.contact_phone, - data.role, + contact_name, + contact_email, + contact_phone, + contact_role, data.is_primary ) @@ -1230,7 +1273,16 @@ async def create_contact(location_id: int, data: ContactCreate): contact = Contact(**result[0]) - logger.info(f"✅ Contact added: {data.contact_name} at {location_name} (Location ID: {location_id})") + if data.existing_contact_id: + logger.info( + "✅ Contact added from existing contact %s: %s at %s (Location ID: %s)", + data.existing_contact_id, + contact_name, + location_name, + location_id, + ) + else: + logger.info(f"✅ Contact added: {contact_name} at {location_name} (Location ID: {location_id})") return contact except HTTPException: diff --git a/app/modules/locations/models/schemas.py b/app/modules/locations/models/schemas.py index fb88ef5..f00ba7c 100644 --- a/app/modules/locations/models/schemas.py +++ b/app/modules/locations/models/schemas.py @@ -120,6 +120,11 @@ class ContactBase(BaseModel): class ContactCreate(ContactBase): """Request model for creating contact""" is_primary: bool = Field(False, description="Set as primary contact for location") + existing_contact_id: Optional[int] = Field( + None, + ge=1, + description="Optional ID of an existing global contact to copy data from" + ) class ContactUpdate(BaseModel): diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html index 87ab25f..a2bfcf9 100644 --- a/app/modules/sag/templates/detail_v3.html +++ b/app/modules/sag/templates/detail_v3.html @@ -3098,13 +3098,48 @@ } /* V3 readability fix: keep menu structure unchanged, restore normal text sizing */ + #caseTabs { + border-bottom: 1px solid rgba(255, 255, 255, 0.34); + background: rgba(11, 53, 90, 0.36); + border-radius: 10px 10px 0 0; + padding: 0.2rem 0.3rem 0; + } + #caseTabs .nav-link { font-size: 1rem; - font-weight: 500; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + border: 1px solid transparent; + border-bottom: none; + border-radius: 8px 8px 0 0; + padding: 0.58rem 0.9rem; + transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; + } + + #caseTabs .nav-link:hover, + #caseTabs .nav-link:focus-visible { + color: #ffffff; + background: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.28); + outline: none; + } + + #caseTabs .nav-link.active { + color: #0f4c75; + background: #ffffff; + border-color: rgba(255, 255, 255, 0.78); + border-bottom-color: #ffffff; + box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.35) inset; } #caseTabs .nav-link i { font-size: 1rem; + opacity: 0.95; + } + + #caseTabs .nav-link.active i { + color: #0f4c75; + opacity: 1; } .case-detail-page-shell .header-bg .badge { @@ -3357,10 +3392,10 @@ {% endblock %} {% block content %} -
+
-
+
@@ -3391,7 +3426,7 @@
-

{{ case.titel }}

+

{{ case.titel }}

@@ -3415,7 +3450,7 @@
-
+
@@ -3426,7 +3461,7 @@
- {% for st in status_options %} {% endfor %} @@ -3435,7 +3470,7 @@
- {% set topbar_type = (case.template_key or case.type or 'ticket')|lower %} @@ -3448,7 +3483,7 @@
- {% set topbar_priority = (case.priority or 'normal')|lower %} @@ -3468,7 +3503,7 @@
- {% for user in assignment_users or [] %} @@ -3478,7 +3513,7 @@
- {% for group in assignment_groups or [] %} @@ -3514,7 +3549,7 @@
- +