2026-08-30 14:34:43 +02:00
import json
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
import logging
from typing import Optional
2026-08-30 14:34:43 +02:00
from fastapi import APIRouter , Depends , HTTPException , Query , Request
from app . core . auth_dependencies import get_current_user , require_any_permission
from app . core . database import execute_query , execute_query_single
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
from app . models . schemas import Solution , SolutionCreate , SolutionUpdate
logger = logging . getLogger ( __name__ )
router = APIRouter ( )
2026-07-28 14:18:24 +02:00
case_edit_access = require_any_permission ( " cases.edit " , " tickets.edit " )
2026-08-30 14:34:43 +02:00
VISIBILITIES = { " internal " , " general " , " customer " }
APPROVAL_STATUSES = { " draft " , " pending " , " approved " , " outdated " , " rejected " }
RESULT_ALIASES = { " resolved " : " Løst " , " partial " : " Delvist " , " unresolved " : " Ej løst " , " løst " : " Løst " , " delvist " : " Delvist " , " workaround " : " Workaround " , " ej løst " : " Ej løst " }
TYPE_ALIASES = { " standard " : " Support " , " permanent " : " Support " , " external " : " Ekstern " , " support " : " Support " , " drift " : " Drift " , " konsulent " : " Konsulent " , " infrastruktur " : " Infrastruktur " , " workaround " : " Support " }
def _user_id ( current_user : dict ) - > Optional [ int ] :
value = current_user . get ( " id " ) or current_user . get ( " user_id " )
return int ( value ) if value is not None else None
def _clean_list ( values ) - > list [ str ] :
cleaned , seen = [ ] , set ( )
for value in values or [ ] :
item = str ( value or " " ) . strip ( )
key = item . casefold ( )
if item and key not in seen :
seen . add ( key )
cleaned . append ( item [ : 100 ] )
return cleaned [ : 30 ]
def _normalize_payload ( data : dict ) - > dict :
if " title " in data :
data [ " title " ] = str ( data . get ( " title " ) or " " ) . strip ( )
if not data [ " title " ] :
raise HTTPException ( status_code = 422 , detail = " Løsningen skal have en titel " )
if " visibility " in data :
data [ " visibility " ] = str ( data . get ( " visibility " ) or " internal " ) . lower ( )
if data [ " visibility " ] not in VISIBILITIES :
raise HTTPException ( status_code = 422 , detail = " Ugyldig synlighed " )
if " approval_status " in data :
data [ " approval_status " ] = str ( data . get ( " approval_status " ) or " draft " ) . lower ( )
if data [ " approval_status " ] not in APPROVAL_STATUSES :
raise HTTPException ( status_code = 422 , detail = " Ugyldig godkendelsesstatus " )
if data . get ( " result " ) is not None :
raw = str ( data [ " result " ] ) . strip ( )
data [ " result " ] = RESULT_ALIASES . get ( raw . casefold ( ) , raw )
if data . get ( " solution_type " ) is not None :
raw = str ( data [ " solution_type " ] ) . strip ( )
data [ " solution_type " ] = TYPE_ALIASES . get ( raw . casefold ( ) , raw )
for field in ( " tags " , " products " ) :
if field in data :
data [ field ] = _clean_list ( data [ field ] )
return data
def _version_solution ( solution : dict , user_id : Optional [ int ] , change_note : Optional [ str ] = None ) - > None :
version_row = execute_query_single ( " SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM sag_solution_versions WHERE solution_id = %s " , ( solution [ " id " ] , ) ) or { " next_version " : 1 }
snapshot = dict ( solution )
for key , value in list ( snapshot . items ( ) ) :
if hasattr ( value , " isoformat " ) :
snapshot [ key ] = value . isoformat ( )
execute_query (
" INSERT INTO sag_solution_versions (solution_id, version_number, snapshot, changed_by_user_id, change_note) VALUES ( %s , %s , %s ::jsonb, %s , %s ) " ,
( solution [ " id " ] , version_row [ " next_version " ] , json . dumps ( snapshot ) , user_id , change_note ) ,
)
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
@router.get ( " /sag/ {sag_id} /solution " , response_model = Optional [ Solution ] )
2026-08-30 14:34:43 +02:00
async def get_solution ( sag_id : int , _current_user : dict = Depends ( get_current_user ) ) :
result = execute_query ( " SELECT * FROM sag_solutions WHERE sag_id = %s " , ( sag_id , ) )
return result [ 0 ] if result else None
@router.get ( " /sag/ {sag_id} /solution/versions " )
async def get_solution_versions ( sag_id : int , _current_user : dict = Depends ( get_current_user ) ) :
solution = execute_query_single ( " SELECT id FROM sag_solutions WHERE sag_id = %s " , ( sag_id , ) )
if not solution :
return { " items " : [ ] , " total " : 0 }
items = execute_query (
""" SELECT v.id, v.version_number, v.change_note, v.created_at,
COALESCE ( u . full_name , u . username ) AS changed_by
FROM sag_solution_versions v LEFT JOIN users u ON u . user_id = v . changed_by_user_id
WHERE v . solution_id = % s ORDER BY v . version_number DESC """ ,
( solution [ " id " ] , ) ,
) or [ ]
return { " items " : items , " total " : len ( items ) }
@router.post ( " /sag/ {sag_id} /solution " , response_model = Solution , dependencies = [ Depends ( case_edit_access ) ] )
async def create_solution ( sag_id : int , solution : SolutionCreate , current_user : dict = Depends ( get_current_user ) ) :
if not execute_query_single ( " SELECT id FROM sag_sager WHERE id= %s AND deleted_at IS NULL " , ( sag_id , ) ) :
raise HTTPException ( status_code = 404 , detail = " Sagen findes ikke " )
if execute_query_single ( " SELECT id FROM sag_solutions WHERE sag_id= %s " , ( sag_id , ) ) :
raise HTTPException ( status_code = 409 , detail = " Der findes allerede en løsning på sagen " )
data = _normalize_payload ( solution . model_dump ( exclude = { " sag_id " , " created_by_user_id " } ) )
user_id = _user_id ( current_user )
result = execute_query (
""" INSERT INTO sag_solutions (
sag_id , title , description , solution_type , result , problem , root_cause , investigation , workaround ,
visibility , approval_status , is_final , tags , products , created_by_user_id , updated_by_user_id
) VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s : : jsonb , % s : : jsonb , % s , % s ) RETURNING * """ ,
( sag_id , data [ " title " ] , data . get ( " description " ) , data . get ( " solution_type " ) , data . get ( " result " ) , data . get ( " problem " ) , data . get ( " root_cause " ) , data . get ( " investigation " ) , data . get ( " workaround " ) , data . get ( " visibility " , " internal " ) , data . get ( " approval_status " , " draft " ) , data . get ( " is_final " , True ) , json . dumps ( data . get ( " tags " , [ ] ) ) , json . dumps ( data . get ( " products " , [ ] ) ) , user_id , user_id ) ,
)
created = result [ 0 ]
_version_solution ( created , user_id , " Løsning oprettet " )
return created
@router.patch ( " /sag/ {sag_id} /solution " , response_model = Solution , dependencies = [ Depends ( case_edit_access ) ] )
async def update_solution ( sag_id : int , updates : SolutionUpdate , current_user : dict = Depends ( get_current_user ) ) :
if not execute_query_single ( " SELECT id FROM sag_solutions WHERE sag_id= %s " , ( sag_id , ) ) :
raise HTTPException ( status_code = 404 , detail = " Løsningen findes ikke " )
data = _normalize_payload ( updates . model_dump ( exclude_unset = True ) )
change_note = data . pop ( " change_note " , None )
allowed = { " title " , " description " , " solution_type " , " result " , " problem " , " root_cause " , " investigation " , " workaround " , " visibility " , " approval_status " , " is_final " , " tags " , " products " }
fields , params = [ ] , [ ]
for key , value in data . items ( ) :
if key not in allowed :
continue
fields . append ( f " { key } = %s " + ( " ::jsonb " if key in { " tags " , " products " } else " " ) )
params . append ( json . dumps ( value ) if key in { " tags " , " products " } else value )
if not fields :
raise HTTPException ( status_code = 400 , detail = " Ingen ændringer at gemme " )
user_id = _user_id ( current_user )
fields . extend ( [ " updated_by_user_id = %s " , " updated_at = NOW() " ] )
params . extend ( [ user_id , sag_id ] )
updated = execute_query ( f " UPDATE sag_solutions SET { ' , ' . join ( fields ) } WHERE sag_id=%s RETURNING * " , tuple ( params ) ) [ 0 ]
_version_solution ( updated , user_id , change_note or " Løsning redigeret " )
return updated
@router.post ( " /sag/ {sag_id} /solution/workflow " , dependencies = [ Depends ( case_edit_access ) ] )
async def solution_workflow ( sag_id : int , request : Request , current_user : dict = Depends ( get_current_user ) ) :
action = str ( ( await request . json ( ) ) . get ( " action " ) or " " ) . lower ( )
solution = execute_query_single ( " SELECT * FROM sag_solutions WHERE sag_id= %s " , ( sag_id , ) )
if not solution :
raise HTTPException ( status_code = 404 , detail = " Løsningen findes ikke " )
user_id = _user_id ( current_user )
if action == " submit " :
status = " pending "
elif action == " approve " :
if not str ( solution . get ( " description " ) or " " ) . strip ( ) :
raise HTTPException ( status_code = 422 , detail = " Beskriv den endelige løsning før godkendelse " )
status = " approved "
elif action in { " reject " , " outdate " } :
status = " rejected " if action == " reject " else " outdated "
else :
raise HTTPException ( status_code = 422 , detail = " Ukendt handling " )
if status == " approved " :
updated = execute_query_single ( " UPDATE sag_solutions SET approval_status= %s ,approved_by_user_id= %s ,approved_at=NOW(),updated_by_user_id= %s ,updated_at=NOW() WHERE sag_id= %s RETURNING * " , ( status , user_id , user_id , sag_id ) )
else :
updated = execute_query_single ( " UPDATE sag_solutions SET approval_status= %s ,approved_by_user_id=NULL,approved_at=NULL,updated_by_user_id= %s ,updated_at=NOW() WHERE sag_id= %s RETURNING * " , ( status , user_id , sag_id ) )
_version_solution ( updated , user_id , f " Status ændret til { status } " )
return updated
@router.post ( " /sag/ {sag_id} /solution/publish " , dependencies = [ Depends ( case_edit_access ) ] )
async def publish_solution ( sag_id : int , current_user : dict = Depends ( get_current_user ) ) :
solution = execute_query_single ( " SELECT * FROM sag_solutions WHERE sag_id= %s " , ( sag_id , ) )
if not solution :
raise HTTPException ( status_code = 404 , detail = " Løsningen findes ikke " )
if solution . get ( " approval_status " ) != " approved " :
raise HTTPException ( status_code = 409 , detail = " Løsningen skal godkendes før udgivelse " )
customer_id = None
if solution . get ( " visibility " ) == " customer " :
customer = execute_query_single ( " SELECT customer_id FROM sag_kunder WHERE sag_id= %s AND deleted_at IS NULL ORDER BY id LIMIT 1 " , ( sag_id , ) )
if not customer :
raise HTTPException ( status_code = 409 , detail = " Kundespecifik viden kræver en kunde på sagen " )
customer_id = customer [ " customer_id " ]
description = str ( solution . get ( " description " ) or " " ) . strip ( )
summary = description [ : 300 ] + ( " … " if len ( description ) > 300 else " " )
article = execute_query_single (
""" INSERT INTO knowledge_articles (
solution_id , sag_id , customer_id , title , summary , problem , root_cause , investigation , solution , workaround ,
visibility , status , tags , products , published_by_user_id , reviewed_at
) VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , ' published ' , % s : : jsonb , % s : : jsonb , % s , NOW ( ) )
ON CONFLICT ( solution_id ) DO UPDATE SET customer_id = EXCLUDED . customer_id , title = EXCLUDED . title ,
summary = EXCLUDED . summary , problem = EXCLUDED . problem , root_cause = EXCLUDED . root_cause ,
investigation = EXCLUDED . investigation , solution = EXCLUDED . solution , workaround = EXCLUDED . workaround ,
visibility = EXCLUDED . visibility , tags = EXCLUDED . tags , products = EXCLUDED . products , status = ' published ' ,
version_number = knowledge_articles . version_number + 1 , published_by_user_id = EXCLUDED . published_by_user_id ,
reviewed_at = NOW ( ) , updated_at = NOW ( ) RETURNING * """ ,
( solution [ " id " ] , sag_id , customer_id , solution [ " title " ] , summary , solution . get ( " problem " ) , solution . get ( " root_cause " ) , solution . get ( " investigation " ) , description , solution . get ( " workaround " ) , solution . get ( " visibility " , " internal " ) , json . dumps ( solution . get ( " tags " ) or [ ] ) , json . dumps ( solution . get ( " products " ) or [ ] ) , _user_id ( current_user ) ) ,
)
return article
@router.get ( " /knowledge/articles " )
async def search_knowledge_articles ( q : str = Query ( " " , max_length = 200 ) , customer_id : Optional [ int ] = None , limit : int = Query ( 25 , ge = 1 , le = 100 ) , offset : int = Query ( 0 , ge = 0 ) , _current_user : dict = Depends ( get_current_user ) ) :
term = q . strip ( )
scope = " (ka.visibility IN ( ' general ' , ' internal ' ) OR (ka.visibility= ' customer ' AND ka.customer_id= %s )) " if customer_id else " ka.visibility IN ( ' general ' , ' internal ' ) "
params : list = [ customer_id ] if customer_id else [ ]
search = " "
if term :
search = " AND (ka.search_document @@ websearch_to_tsquery( ' simple ' , %s ) OR ka.title ILIKE %s ) "
params . extend ( [ term , f " % { term } % " ] )
count = execute_query_single ( f " SELECT COUNT(*) AS total FROM knowledge_articles ka WHERE ka.status= ' published ' AND { scope } { search } " , tuple ( params ) ) or { " total " : 0 }
item_params = [ ]
rank_expr = " ts_rank_cd(ka.search_document, websearch_to_tsquery( ' simple ' , %s )) " if term else " 0 "
if term :
item_params . append ( term )
item_params . extend ( params )
item_params . extend ( [ limit , offset ] )
items = execute_query (
f """ SELECT ka.id,ka.title,ka.summary,ka.visibility,ka.customer_id,ka.tags,ka.products,
ka . version_number , ka . updated_at , ka . sag_id , { rank_expr } AS relevance , c . name AS customer_name
FROM knowledge_articles ka LEFT JOIN customers c ON c . id = ka . customer_id
WHERE ka . status = ' published ' AND { scope } { search }
ORDER BY relevance DESC , ka . updated_at DESC , ka . id DESC LIMIT % s OFFSET % s """ ,
tuple ( item_params ) ,
) or [ ]
return { " items " : items , " total " : int ( count [ " total " ] ) , " limit " : limit , " offset " : offset }
@router.get ( " /knowledge/articles/ {article_id} " )
async def get_knowledge_article ( article_id : int , _current_user : dict = Depends ( get_current_user ) ) :
article = execute_query_single (
""" SELECT ka.*,c.name AS customer_name,COALESCE(u.full_name,u.username) AS published_by
FROM knowledge_articles ka LEFT JOIN customers c ON c . id = ka . customer_id
LEFT JOIN users u ON u . user_id = ka . published_by_user_id
WHERE ka . id = % s AND ka . status = ' published ' """ ,
( article_id , ) ,
)
if not article :
raise HTTPException ( status_code = 404 , detail = " Vidensartiklen findes ikke " )
return article