196 lines
10 KiB
Python
196 lines
10 KiB
Python
"""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
|