"""Creation helpers: transactional associations and advisory case lookups.""" import json import re from difflib import SequenceMatcher from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, ConfigDict, Field, StrictInt from psycopg2.extras import Json from app.core.auth_dependencies import require_any_permission from app.core.database import execute_query, execute_query_single read_access = require_any_permission('cases.view', 'tickets.view', 'cases.create', 'tickets.create', 'users.manage', 'system.admin') admin_access = require_any_permission('users.manage', 'system.admin') router = APIRouter(prefix='/case-create', dependencies=[Depends(read_access)]) def ids(value, field): if not isinstance(value, list) or any(isinstance(v, bool) or not isinstance(v, int) or v <= 0 for v in value): raise HTTPException(400, f'{field} skal være en liste med positive heltal') return list(dict.fromkeys(value)) def attach_create_relations(cursor, case_id, data, user_id): hardware_ids = ids(data.get('hardware_ids', []), 'hardware_ids') tag_ids = ids(data.get('tag_ids', []), 'tag_ids') if hardware_ids: cursor.execute('SELECT id FROM hardware_assets WHERE id = ANY(%s) AND deleted_at IS NULL', (hardware_ids,)) if {r['id'] for r in cursor.fetchall()} != set(hardware_ids): raise HTTPException(400, 'En valgt hardware findes ikke længere') for hardware_id in hardware_ids: cursor.execute('INSERT INTO sag_hardware (sag_id, hardware_id) VALUES (%s, %s) ON CONFLICT DO NOTHING', (case_id, hardware_id)) actions = [] if tag_ids: cursor.execute('SELECT t.id, t.name, t.tag_group_id, g.behavior FROM tags t LEFT JOIN tag_groups g ON g.id=t.tag_group_id WHERE t.id=ANY(%s) AND t.is_active=TRUE', (tag_ids,)) tags = {r['id']: r for r in cursor.fetchall()} if set(tags) != set(tag_ids): raise HTTPException(400, 'Et valgt tag findes ikke længere eller er inaktivt') # Same single/toggle group semantics as the global picker: last choice wins. chosen = [] for tag_id in tag_ids: tag = tags[tag_id] if tag['behavior'] in ('single', 'toggle'): chosen = [i for i in chosen if tags[i]['tag_group_id'] != tag['tag_group_id']] chosen.append(tag_id) for tag_id in chosen: cursor.execute("INSERT INTO entity_tags (entity_type, entity_id, tag_id, tagged_by) VALUES ('case', %s, %s, %s) ON CONFLICT DO NOTHING", (case_id, tag_id, user_id)) cursor.execute("SELECT action_type, action_config FROM tag_workflows WHERE tag_id=%s AND trigger_event='on_add' AND is_active=TRUE ORDER BY id DESC LIMIT 1", (tag_id,)) action = cursor.fetchone() if action: actions.append({'tag': {'id': tag_id, 'name': tags[tag_id]['name']}, 'action': {'type': action['action_type'], 'config': action['action_config'] or {}}, 'entity_type': 'case', 'entity_id': case_id}) return actions def closed_statuses(): row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'") try: configured = json.loads((row or {}).get('value') or '[]') values = [str(s['value']).strip().lower() for s in configured if isinstance(s, dict) and s.get('is_closed') and s.get('value')] except (ValueError, TypeError): values = [] return values or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'] def title_similarity(left, right): def normalize(value): return ' '.join(re.findall(r'\w+', value.casefold())) left, right = normalize(left), normalize(right) if not left or not right: return 0 a, b = set(left.split()), set(right.split()) return max(SequenceMatcher(None, left, right).ratio(), len(a & b) / len(a | b)) @router.get('/duplicates') def duplicates(customer_id: int, title: str = Query(min_length=5, max_length=1000)): rows = execute_query("""SELECT s.id,s.titel,s.status,COALESCE(u.full_name,u.username) AS ansvarlig_navn FROM sag_sager s LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id WHERE s.customer_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s))""", (customer_id, closed_statuses())) or [] ranked = [(title_similarity(title, r['titel'] or ''), r) for r in rows] ranked.sort(key=lambda item: (-item[0], -item[1]['id'])) return [dict(row, similarity=round(score, 3)) for score, row in ranked if score >= 0.45][:5] @router.get('/workload') def workload(user_id: int): rows = execute_query("""SELECT s.id,s.titel,s.status,s.deadline,c.name AS customer_name,COUNT(*) OVER() AS total FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id WHERE s.ansvarlig_bruger_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s)) ORDER BY s.deadline ASC NULLS LAST,s.id DESC LIMIT 10""", (user_id, closed_statuses())) or [] return {'total': rows[0]['total'] if rows else 0, 'items': rows} @router.get('/contacts-open-cases') def contacts_open_cases(contact_ids: list[int] = Query(min_length=1, max_length=20)): """A small, advisory view used while selecting contacts on a new case.""" contact_ids = ids(contact_ids, 'contact_ids') rows = execute_query("""WITH ranked AS ( SELECT sc.contact_id, s.id, s.titel, s.status, s.deadline, COALESCE(u.full_name, u.username, 'Ingen ansvarlig') AS ansvarlig_navn, COUNT(*) OVER (PARTITION BY sc.contact_id) AS total, ROW_NUMBER() OVER (PARTITION BY sc.contact_id ORDER BY s.deadline ASC NULLS LAST, s.id DESC) AS position FROM sag_kontakter sc JOIN sag_sager s ON s.id=sc.sag_id LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id WHERE sc.contact_id=ANY(%s) AND sc.deleted_at IS NULL AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s)) ) SELECT contact_id,id,titel,status,deadline,ansvarlig_navn,total FROM ranked WHERE position<=5 ORDER BY contact_id,position""", (contact_ids, closed_statuses())) or [] result = {contact_id: {'total': 0, 'items': []} for contact_id in contact_ids} for row in rows: bucket = result[row['contact_id']] bucket['total'] = row['total'] bucket['items'].append({key: row[key] for key in ('id', 'titel', 'status', 'deadline', 'ansvarlig_navn')}) return result class PipelineDefaults(BaseModel): model_config = ConfigDict(extra='forbid') stage_id: Optional[int] = Field(default=None, gt=0) amount: Optional[float] = Field(default=None, ge=0, allow_inf_nan=False) probability: Optional[int] = Field(default=None, ge=0, le=100) description: Optional[str] = Field(default=None, max_length=10000) class TemplateValues(BaseModel): model_config = ConfigDict(extra='forbid') type: str = Field(default='ticket', min_length=1, max_length=80) titel: str = Field(default='', max_length=1000) beskrivelse: str = Field(default='', max_length=50000) status: str = Field(default='åben', min_length=1, max_length=80) assigned_group_id: Optional[int] = Field(default=None, gt=0) tag_ids: list[StrictInt] = Field(default_factory=list, max_length=100) pipeline: Optional[PipelineDefaults] = None class CaseTemplate(BaseModel): model_config = ConfigDict(extra='forbid') name: str = Field(min_length=1, max_length=120) icon: str = Field(default='bi-lightning', pattern=r'^bi-[a-z0-9-]+$', max_length=80) is_active: bool = True sort_order: int = 0 values: TemplateValues @router.get('/templates') def templates(): return execute_query('SELECT *, template_values AS "values" FROM case_create_templates WHERE is_active=TRUE ORDER BY sort_order,name,id') or [] @router.get('/template-options', dependencies=[Depends(admin_access)]) def template_options(): return execute_query('SELECT id,name FROM groups ORDER BY name') or [] @router.get('/templates/admin', dependencies=[Depends(admin_access)]) def admin_templates(): return execute_query('SELECT *, template_values AS "values" FROM case_create_templates ORDER BY sort_order,name,id') or [] def template_args(data): values = data.values.model_dump() values['tag_ids'] = ids(values['tag_ids'], 'tag_ids') if values['tag_ids']: found = execute_query('SELECT id FROM tags WHERE id=ANY(%s) AND is_active=TRUE', (values['tag_ids'],)) or [] if {r['id'] for r in found} != set(values['tag_ids']): raise HTTPException(400, 'Ugyldige tags') if values['assigned_group_id'] and not execute_query_single('SELECT id FROM groups WHERE id=%s', (values['assigned_group_id'],)): raise HTTPException(400, 'Ugyldig gruppe') if values['pipeline'] and values['pipeline']['stage_id'] and not execute_query_single('SELECT id FROM pipeline_stages WHERE id=%s', (values['pipeline']['stage_id'],)): raise HTTPException(400, 'Ugyldig pipeline-stage') if not data.name.strip(): raise HTTPException(400, 'Navn er påkrævet') return (data.name.strip(), data.icon, data.is_active, data.sort_order, Json(values)) @router.post('/templates', dependencies=[Depends(admin_access)]) def create_template(data: CaseTemplate): return execute_query_single('INSERT INTO case_create_templates (name,icon,is_active,sort_order,template_values) VALUES (%s,%s,%s,%s,%s) RETURNING *, template_values AS "values"', template_args(data)) @router.put('/templates/{template_id}', dependencies=[Depends(admin_access)]) def update_template(template_id: int, data: CaseTemplate): row = execute_query_single('UPDATE case_create_templates SET name=%s,icon=%s,is_active=%s,sort_order=%s,template_values=%s,updated_at=NOW() WHERE id=%s RETURNING *, template_values AS "values"', template_args(data) + (template_id,)) if not row: raise HTTPException(404, 'Skabelonen findes ikke') return row @router.delete('/templates/{template_id}', dependencies=[Depends(admin_access)]) def delete_template(template_id: int): row = execute_query_single('DELETE FROM case_create_templates WHERE id=%s RETURNING id', (template_id,)) if not row: raise HTTPException(404, 'Skabelonen findes ikke') return row