diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 652d9af..86efb84 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -15,7 +15,7 @@ from uuid import uuid4 from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends from fastapi.responses import FileResponse, HTMLResponse 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, execute_update, table_has_column, get_db_connection, release_db_connection from psycopg2.extras import RealDictCursor from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis from app.core.config import settings @@ -1533,11 +1533,22 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe @router.get("/sag/{sag_id}/modules") -async def get_case_module_prefs(sag_id: int): - """Get module visibility preferences for a case.""" +async def get_case_module_prefs(sag_id: int, current_user: dict = Depends(get_current_user)): + """Get module visibility preferences for the current user on a case.""" try: - query = "SELECT module_key, is_enabled FROM sag_module_prefs WHERE sag_id = %s" - prefs = execute_query(query, (sag_id,)) + user_id = int(current_user["id"]) + query = """ + SELECT module_key, is_enabled + FROM sag_module_prefs + WHERE sag_id = %s + AND module_key <> 'buzzwords' + UNION ALL + SELECT module_key, is_enabled + FROM user_sag_module_prefs + WHERE sag_id = %s + AND user_id = %s + """ + prefs = execute_query(query, (sag_id, sag_id, user_id)) return prefs or [] except Exception as e: logger.error("❌ Error getting module prefs: %s", e) @@ -1545,8 +1556,12 @@ async def get_case_module_prefs(sag_id: int): @router.post("/sag/{sag_id}/modules") -async def set_case_module_pref(sag_id: int, data: dict): - """Set module visibility preference for a case.""" +async def set_case_module_pref( + sag_id: int, + data: dict, + current_user: dict = Depends(get_current_user), +): + """Set a module visibility preference, scoped per user for Buzzwords.""" try: module_key = data.get("module_key") is_enabled = data.get("is_enabled") @@ -1554,14 +1569,26 @@ async def set_case_module_pref(sag_id: int, data: dict): if not module_key or is_enabled is None: raise HTTPException(status_code=400, detail="module_key and is_enabled are required") - query = """ - INSERT INTO sag_module_prefs (sag_id, module_key, is_enabled) - VALUES (%s, %s, %s) - ON CONFLICT (sag_id, module_key) - DO UPDATE SET is_enabled = EXCLUDED.is_enabled - RETURNING module_key, is_enabled - """ - result = execute_query(query, (sag_id, module_key, bool(is_enabled))) + if module_key == "buzzwords": + if not _table_exists("user_sag_module_prefs"): + raise HTTPException(status_code=409, detail="Buzzwords-indstillingen er ikke klar. Kør migration 1042 først.") + query = """ + INSERT INTO user_sag_module_prefs (user_id, sag_id, module_key, is_enabled) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_id, sag_id, module_key) + DO UPDATE SET is_enabled = EXCLUDED.is_enabled + RETURNING module_key, is_enabled + """ + result = execute_query(query, (int(current_user["id"]), sag_id, module_key, bool(is_enabled))) + else: + query = """ + INSERT INTO sag_module_prefs (sag_id, module_key, is_enabled) + VALUES (%s, %s, %s) + ON CONFLICT (sag_id, module_key) + DO UPDATE SET is_enabled = EXCLUDED.is_enabled + RETURNING module_key, is_enabled + """ + result = execute_query(query, (sag_id, module_key, bool(is_enabled))) return result[0] if result else {"module_key": module_key, "is_enabled": bool(is_enabled)} except HTTPException: raise @@ -5691,78 +5718,98 @@ def _decode_header_str(header_val): except: return str(header_val) +def _copy_uploaded_email_attachments_to_case(sag_id: int, email_id: int, attachments: List[Dict]) -> int: + """Copy valid manual-email attachments into the normal case-files module.""" + if not attachments or not _table_exists("sag_files"): + return 0 + + has_source_email = table_has_column("sag_files", "source_email_id") + has_source_type = table_has_column("sag_files", "source_type") + copied = 0 + + for attachment in attachments: + filename = Path(str(attachment.get("filename") or "attachment.bin")).name + content = attachment.get("content") + if not filename or not content: + continue + content = bytes(content) + extension = Path(filename).suffix.lower().lstrip(".") + if not extension or extension not in ALLOWED_EXTENSIONS or len(content) > MAX_ATTACHMENT_SIZE: + logger.warning("Skipping unsupported or oversized email attachment %s for SAG-%s", filename, sag_id) + continue + + if has_source_email: + existing = execute_query( + """SELECT 1 FROM sag_files + WHERE sag_id = %s AND source_email_id = %s AND filename = %s + LIMIT 1""", + (sag_id, email_id, filename), + ) or [] + if existing: + continue + + stored_name = _generate_stored_name(filename, SAG_FILE_SUBDIR) + destination = _resolve_attachment_path(stored_name) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + + columns = ["sag_id", "filename", "content_type", "size_bytes", "stored_name"] + values: List[Any] = [ + sag_id, + filename, + attachment.get("content_type") or "application/octet-stream", + len(content), + stored_name, + ] + if has_source_email: + columns.append("source_email_id") + values.append(email_id) + if has_source_type: + columns.append("source_type") + values.append("email_import") + execute_query( + f"INSERT INTO sag_files ({', '.join(columns)}) VALUES ({', '.join(['%s'] * len(values))})", + tuple(values), + ) + copied += 1 + + return copied + + @router.post("/sag/{sag_id}/upload-email") async def upload_sag_email(sag_id: int, file: UploadFile = File(...)): - """Upload .eml/.msg, parse it, save to email_messages, and link to case.""" - content = await file.read() - filename = file.filename.lower() - email_data = {} - temp_id = uuid4().hex - - # 1. Parse File - if filename.endswith('.msg'): - if not extract_msg: raise HTTPException(500, "extract-msg missing") - import io - msg = extract_msg.Message(io.BytesIO(content)) - email_data = { - 'message_id': msg.messageId or f"msg-{temp_id}", - 'subject': msg.subject or "No Subject", - 'sender_email': msg.sender or "", - 'sender_name': msg.sender or "", - 'recipient_email': msg.to or "", - 'cc': msg.cc or "", - 'body_text': msg.body, - 'body_html': msg.htmlBody, - 'received_date': msg.date or datetime.now(), - 'folder': 'Imported', - 'attachments': [], - 'has_attachments': False, - 'attachment_count': 0 - } - elif filename.endswith('.eml'): - msg = email.message_from_bytes(content) - body_text = "" - if msg.is_multipart(): - for part in msg.walk(): - if part.get_content_type() == "text/plain": - body_text = part.get_payload(decode=True).decode('utf-8', errors='ignore') - break - else: - body_text = msg.get_payload(decode=True).decode('utf-8', errors='ignore') - - email_data = { - 'message_id': msg.get('Message-ID', f"eml-{temp_id}"), - 'in_reply_to': _decode_header_str(msg.get('In-Reply-To', '')), - 'email_references': _decode_header_str(msg.get('References', '')), - 'subject': _decode_header_str(msg.get('Subject', 'No Subject')), - 'sender_email': _decode_header_str(msg.get('From', '')), - 'sender_name': _decode_header_str(msg.get('From', '')), - 'recipient_email': _decode_header_str(msg.get('To', '')), - 'cc': _decode_header_str(msg.get('Cc', '')), - 'received_date': datetime.now(), - 'body_text': body_text, - 'body_html': "", - 'folder': 'Imported', - 'has_attachments': False, - 'attachment_count': 0, - 'attachments': [] - } - else: + """Import .eml/.msg into the email tab and copy its attachments to case files.""" + filename = str(file.filename or "").lower() + if not (filename.endswith(".eml") or filename.endswith(".msg")): raise HTTPException(400, "Only .msg or .eml files allowed") - # 2. Save Email via Service (deduplicate) - svc = EmailService() - existing = execute_query_single("SELECT id FROM email_messages WHERE message_id = %s", (email_data['message_id'],)) - - if existing: - email_id = existing['id'] - else: - # We try to strict save, keeping it simple - email_id = await svc.save_email(email_data) + content = await file.read() + service = EmailService() + email_data = service.parse_msg_file(content) if filename.endswith(".msg") else service.parse_eml_file(content) + if not email_data: + raise HTTPException(status_code=400, detail="E-mailfilen kunne ikke læses") + + email_id = await service.save_uploaded_email(email_data) + if not email_id: + existing = execute_query_single( + "SELECT id FROM email_messages WHERE message_id = %s", + (email_data["message_id"],), + ) + if not existing: + raise HTTPException(status_code=500, detail="E-mailen kunne ikke gemmes") + email_id = existing["id"] - # 3. Link await add_sag_email_link(sag_id, {"email_id": email_id}) - return {"status": "imported", "email_id": email_id} + attachment_files_created = _copy_uploaded_email_attachments_to_case( + sag_id, + int(email_id), + email_data.get("attachments") or [], + ) + return { + "status": "imported", + "email_id": email_id, + "attachment_files_created": attachment_files_created, + } @router.post("/sag/{sag_id}/emails/send") diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index a8912cc..8e8c956 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -959,10 +959,9 @@ async def sag_detaljer(request: Request, sag_id: int): raise HTTPException(status_code=500, detail="Failed to load case details") -@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse) @router.get("/sag/{sag_id}/v4", response_class=HTMLResponse) -async def sag_detaljer_v3(request: Request, sag_id: int): - """Display the active V4 case page; /v3 remains as a compatible alias.""" +async def sag_detaljer_v4(request: Request, sag_id: int): + """Display the active V4 case page.""" try: # Fetch main case sag_query = """ @@ -1300,9 +1299,6 @@ async def sag_detaljer_v3(request: Request, sag_id: int): status_options.append(current_status) is_deadline_overdue = _is_deadline_overdue(sag.get("deadline")) - # V4 is the only active case design. Keep /v3 routable because many - # existing links and bookmarks still use it, but render the same V4 - # template so users cannot accidentally fall back to the old design. template_name = "modules/sag/templates/detail_v4.html" response = templates.TemplateResponse(template_name, { "request": request, @@ -1339,6 +1335,12 @@ async def sag_detaljer_v3(request: Request, sag_id: int): raise HTTPException(status_code=500, detail="Failed to load case details") +@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse) +async def sag_detaljer_v3_legacy(sag_id: int): + """Keep old links working, while making V4 the canonical case URL.""" + return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307) + + @router.get("/sag/{sag_id}/edit", response_class=HTMLResponse) async def sag_rediger(request: Request, sag_id: int): """Display edit case form.""" diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index 50f8729..a5c2059 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -305,10 +305,6 @@
${escapeHtml(bodyText)}` : (bodyHtml || '