2026-02-14 02:26:29 +01:00
import logging
2026-06-21 10:26:07 +02:00
import hmac
import hashlib
2026-10-20 07:18:57 +02:00
from datetime import datetime , timedelta , timezone
2026-06-21 10:26:07 +02:00
from fastapi import APIRouter , Depends , HTTPException , Query , Request
2026-02-14 02:26:29 +01:00
from fastapi . responses import Response
2026-06-21 10:26:07 +02:00
from app . core . auth_dependencies import get_current_user
from app . core . config import settings
2026-10-20 07:18:57 +02:00
from app . core . database import execute_query , execute_query_single
2026-02-14 02:26:29 +01:00
logger = logging . getLogger ( __name__ )
router = APIRouter ( )
2026-06-21 10:26:07 +02:00
def _calendar_token_secret ( ) - > bytes :
return ( settings . SECRET_KEY or settings . JWT_SECRET_KEY or " calendar-dev-secret " ) . encode ( " utf-8 " )
def _create_calendar_feed_token ( user_id : int , expires_at : datetime ) - > str :
exp_ts = int ( expires_at . timestamp ( ) )
payload = f " { int ( user_id ) } : { exp_ts } " . encode ( " utf-8 " )
digest = hmac . new ( _calendar_token_secret ( ) , payload , hashlib . sha256 ) . hexdigest ( )
return f " { int ( user_id ) } . { exp_ts } . { digest } "
def _verify_calendar_feed_token ( token : str ) - > int :
parts = str ( token or " " ) . split ( " . " )
if len ( parts ) != 3 :
raise HTTPException ( status_code = 401 , detail = " Invalid calendar token " )
user_part , exp_part , sig_part = parts
try :
user_id = int ( user_part )
exp_ts = int ( exp_part )
except ValueError as exc :
raise HTTPException ( status_code = 401 , detail = " Invalid calendar token " ) from exc
payload = f " { user_id } : { exp_ts } " . encode ( " utf-8 " )
expected = hmac . new ( _calendar_token_secret ( ) , payload , hashlib . sha256 ) . hexdigest ( )
if not hmac . compare_digest ( expected , sig_part ) :
raise HTTPException ( status_code = 401 , detail = " Invalid calendar token " )
if datetime . utcnow ( ) . timestamp ( ) > exp_ts :
raise HTTPException ( status_code = 401 , detail = " Calendar token expired " )
return user_id
def _build_ical_response ( events : list [ dict ] , now : datetime ) - > Response :
lines = [
" BEGIN:VCALENDAR " ,
" VERSION:2.0 " ,
" PRODID:-//BMC Hub//Calendar//DA " ,
" CALSCALE:GREGORIAN " ,
" X-WR-CALNAME:BMC Hub Kalender " ,
" REFRESH-INTERVAL;VALUE=DURATION:PT15M " ,
" X-PUBLISHED-TTL:PT15M " ,
]
for event in events :
start_value = datetime . fromisoformat ( event . get ( " start " ) )
summary = _escape_ical ( event . get ( " title " , " " ) )
description_parts = [ ]
if event . get ( " customer_name " ) :
description_parts . append ( f " Kunde: { event . get ( ' customer_name ' ) } " )
if event . get ( " event_type " ) :
description_parts . append ( f " Type: { event . get ( ' event_type ' ) } " )
if event . get ( " url " ) :
description_parts . append ( f " Link: { event . get ( ' url ' ) } " )
description = _escape_ical ( " \n " . join ( description_parts ) )
uid = _escape_ical ( f " { event . get ( ' id ' ) } @bmc-hub " )
lines . extend ( [
" BEGIN:VEVENT " ,
f " UID: { uid } " ,
f " DTSTAMP: { _format_ical_dt ( now ) } " ,
f " DTSTART: { _format_ical_dt ( start_value ) } " ,
2026-10-08 08:58:02 +02:00
* ( [ f " DTEND: { _format_ical_dt ( datetime . fromisoformat ( event [ ' end ' ] ) ) } " ] if event . get ( " end " ) else [ ] ) ,
2026-06-21 10:26:07 +02:00
f " SUMMARY: { summary } " ,
f " DESCRIPTION: { description } " ,
" END:VEVENT " ,
] )
lines . append ( " END:VCALENDAR " )
return Response (
content = " \r \n " . join ( lines ) ,
media_type = " text/calendar; charset=utf-8 " ,
headers = {
" Content-Disposition " : ' inline; filename= " bmc-hub-calendar.ics " ' ,
" Cache-Control " : " public, max-age=300 " ,
} ,
)
2026-02-14 02:26:29 +01:00
def _parse_iso_datetime ( value : str , fallback : datetime ) - > datetime :
if not value :
return fallback
try :
return datetime . fromisoformat ( value )
except ValueError :
return fallback
def _get_user_id ( request : Request , user_id : int | None , only_mine : bool ) - > int | None :
if user_id is not None :
return user_id
state_user_id = getattr ( request . state , " user_id " , None )
if state_user_id is not None :
return int ( state_user_id )
if only_mine :
raise HTTPException ( status_code = 401 , detail = " User not authenticated " )
return None
def _build_event ( event_type : str , title : str , start_dt : datetime , url : str , extra : dict ) - > dict :
payload = {
" id " : f " { event_type } : { extra . get ( ' reference_id ' ) } " ,
" title " : title ,
" start " : start_dt . isoformat ( ) ,
" url " : url ,
" event_type " : event_type ,
}
payload . update ( extra )
return payload
def _escape_ical ( value : str ) - > str :
return (
value . replace ( " \\ " , " \\ \\ " )
. replace ( " ; " , " \\ ; " )
. replace ( " , " , " \\ , " )
. replace ( " \n " , " \\ n " )
)
def _format_ical_dt ( value : datetime ) - > str :
2026-10-20 07:18:57 +02:00
if value . tzinfo is not None :
return value . astimezone ( timezone . utc ) . strftime ( ' % Y % m %d T % H % M % SZ ' )
2026-02-14 02:26:29 +01:00
return value . strftime ( " % Y % m %d T % H % M % S " )
def _get_calendar_events (
request : Request ,
start_dt : datetime ,
end_dt : datetime ,
only_mine : bool ,
user_id : int | None ,
customer_id : int | None ,
types : str | None ,
) - > list [ dict ] :
allowed_types = {
" case_deadline " ,
" case_deferred " ,
" case_reminder " ,
" deadline " ,
" deferred " ,
" reminder " ,
" meeting " ,
" technician_visit " ,
" obs " ,
2026-10-08 08:58:02 +02:00
" quick_planned " ,
" quick_registered " ,
2026-10-20 07:18:57 +02:00
" planner_booking " ,
" absence " ,
2026-02-14 02:26:29 +01:00
}
requested_types = {
t . strip ( ) for t in ( types . split ( " , " ) if types else [ ] ) if t . strip ( )
}
if requested_types and not requested_types . issubset ( allowed_types ) :
raise HTTPException ( status_code = 400 , detail = " Invalid event types " )
type_map = {
" case_deadline " : " deadline " ,
" case_deferred " : " deferred " ,
" case_reminder " : " reminder " ,
}
normalized_types = { type_map . get ( t , t ) for t in requested_types }
reminder_kinds = { " reminder " , " meeting " , " technician_visit " , " obs " , " deadline " }
include_deadline = not normalized_types or " deadline " in normalized_types
include_deferred = not normalized_types or " deferred " in normalized_types
include_reminder = not normalized_types or bool ( reminder_kinds . intersection ( normalized_types ) )
resolved_user_id = _get_user_id ( request , user_id , only_mine )
events : list [ dict ] = [ ]
if include_deadline :
query = """
SELECT s . id , s . titel , s . deadline , s . customer_id , c . name as customer_name ,
s . ansvarlig_bruger_id , s . created_by_user_id
FROM sag_sager s
LEFT JOIN customers c ON s . customer_id = c . id
WHERE s . deleted_at IS NULL
AND s . deadline IS NOT NULL
AND s . deadline BETWEEN % s AND % s
"""
params : list = [ start_dt , end_dt ]
if only_mine and resolved_user_id is not None :
query + = " AND (s.ansvarlig_bruger_id = %s OR s.created_by_user_id = %s ) "
params . extend ( [ resolved_user_id , resolved_user_id ] )
if customer_id :
query + = " AND s.customer_id = %s "
params . append ( customer_id )
rows = execute_query ( query , tuple ( params ) ) or [ ]
for row in rows :
start_value = row . get ( " deadline " )
if not start_value :
continue
title = f " Deadline: { row . get ( ' titel ' , ' Sag ' ) } "
events . append (
_build_event (
" case_deadline " ,
title ,
start_value ,
feat: Update sag links to include versioning in URLs across multiple templates and services
- Updated links in index_old.html, varekob_salg.html, log.html, opportunities.html, detail.html, and various frontend files to point to the new versioned sag URLs.
- Modified reminder_notification_service.py to reflect the new sag URL structure in notifications.
- Added FedEx shipment management functionality, including API client, service layer, and router for handling FedEx bookings, tracking, and cancellations.
- Created database migration for FedEx shipments, including tables for shipments, packages, and tracking events.
2026-04-30 23:06:00 +02:00
f " /sag/ { row . get ( ' id ' ) } /v3 " ,
2026-02-14 02:26:29 +01:00
{
" reference_id " : row . get ( " id " ) ,
" reference_type " : " case " ,
" event_kind " : " deadline " ,
" customer_id " : row . get ( " customer_id " ) ,
" customer_name " : row . get ( " customer_name " ) ,
" status " : " deadline " ,
} ,
)
)
if include_deferred :
query = """
SELECT s . id , s . titel , s . deferred_until , s . customer_id , c . name as customer_name ,
s . ansvarlig_bruger_id , s . created_by_user_id
FROM sag_sager s
LEFT JOIN customers c ON s . customer_id = c . id
WHERE s . deleted_at IS NULL
AND s . deferred_until IS NOT NULL
AND s . deferred_until BETWEEN % s AND % s
"""
params = [ start_dt , end_dt ]
if only_mine and resolved_user_id is not None :
query + = " AND (s.ansvarlig_bruger_id = %s OR s.created_by_user_id = %s ) "
params . extend ( [ resolved_user_id , resolved_user_id ] )
if customer_id :
query + = " AND s.customer_id = %s "
params . append ( customer_id )
rows = execute_query ( query , tuple ( params ) ) or [ ]
for row in rows :
start_value = row . get ( " deferred_until " )
if not start_value :
continue
title = f " Defer: { row . get ( ' titel ' , ' Sag ' ) } "
events . append (
_build_event (
" case_deferred " ,
title ,
start_value ,
feat: Update sag links to include versioning in URLs across multiple templates and services
- Updated links in index_old.html, varekob_salg.html, log.html, opportunities.html, detail.html, and various frontend files to point to the new versioned sag URLs.
- Modified reminder_notification_service.py to reflect the new sag URL structure in notifications.
- Added FedEx shipment management functionality, including API client, service layer, and router for handling FedEx bookings, tracking, and cancellations.
- Created database migration for FedEx shipments, including tables for shipments, packages, and tracking events.
2026-04-30 23:06:00 +02:00
f " /sag/ { row . get ( ' id ' ) } /v3 " ,
2026-02-14 02:26:29 +01:00
{
" reference_id " : row . get ( " id " ) ,
" reference_type " : " case " ,
" event_kind " : " deferred " ,
" customer_id " : row . get ( " customer_id " ) ,
" customer_name " : row . get ( " customer_name " ) ,
" status " : " deferred " ,
} ,
)
)
if include_reminder :
query = """
SELECT r . id , r . title , r . message , r . priority , r . event_type , r . next_check_at , r . scheduled_at ,
r . sag_id , s . titel as sag_title , s . customer_id , c . name as customer_name ,
r . recipient_user_ids , r . created_by_user_id
FROM sag_reminders r
JOIN sag_sager s ON s . id = r . sag_id
LEFT JOIN customers c ON s . customer_id = c . id
WHERE r . deleted_at IS NULL
AND r . is_active = true
AND s . deleted_at IS NULL
AND COALESCE ( r . next_check_at , r . scheduled_at ) BETWEEN % s AND % s
"""
params = [ start_dt , end_dt ]
requested_reminder_types = sorted ( reminder_kinds . intersection ( normalized_types ) )
if requested_reminder_types :
query + = " AND r.event_type = ANY( %s ) "
params . append ( requested_reminder_types )
if only_mine and resolved_user_id is not None :
query + = " AND ((r.recipient_user_ids IS NOT NULL AND %s = ANY(r.recipient_user_ids)) OR r.created_by_user_id = %s ) "
params . extend ( [ resolved_user_id , resolved_user_id ] )
if customer_id :
query + = " AND s.customer_id = %s "
params . append ( customer_id )
rows = execute_query ( query , tuple ( params ) ) or [ ]
for row in rows :
start_value = row . get ( " next_check_at " ) or row . get ( " scheduled_at " )
if not start_value :
continue
title = f " Reminder: { row . get ( ' title ' , ' Reminder ' ) } "
case_title = row . get ( " sag_title " )
if case_title :
title = f " { title } · { case_title } "
events . append (
_build_event (
" case_reminder " ,
title ,
start_value ,
feat: Update sag links to include versioning in URLs across multiple templates and services
- Updated links in index_old.html, varekob_salg.html, log.html, opportunities.html, detail.html, and various frontend files to point to the new versioned sag URLs.
- Modified reminder_notification_service.py to reflect the new sag URL structure in notifications.
- Added FedEx shipment management functionality, including API client, service layer, and router for handling FedEx bookings, tracking, and cancellations.
- Created database migration for FedEx shipments, including tables for shipments, packages, and tracking events.
2026-04-30 23:06:00 +02:00
f " /sag/ { row . get ( ' sag_id ' ) } /v3 " ,
2026-02-14 02:26:29 +01:00
{
" reference_id " : row . get ( " id " ) ,
" reference_type " : " reminder " ,
" case_id " : row . get ( " sag_id " ) ,
" event_kind " : row . get ( " event_type " ) or " reminder " ,
" customer_id " : row . get ( " customer_id " ) ,
" customer_name " : row . get ( " customer_name " ) ,
" event_type " : row . get ( " event_type " ) ,
" priority " : row . get ( " priority " ) ,
} ,
)
)
2026-10-08 08:58:02 +02:00
quick_states = [ state for state in ( " planned " , " registered " )
if not normalized_types or f " quick_ { state } " in normalized_types ]
if quick_states :
query = """ SELECT q.*, c.name AS customer_name, COALESCE(u.full_name,u.username) AS user_name
FROM quick_time_items q LEFT JOIN customers c ON c . id = q . customer_id
JOIN users u ON u . user_id = q . user_id
WHERE q . state = ANY ( % s ) AND q . starts_at < % s AND q . ends_at > % s """
params = [ quick_states , end_dt , start_dt ]
if only_mine and resolved_user_id is not None :
query + = " AND q.user_id= %s "
params . append ( resolved_user_id )
if customer_id :
query + = " AND q.customer_id= %s "
params . append ( customer_id )
for row in execute_query ( query , tuple ( params ) ) or [ ] :
kind = f " quick_ { row [ ' state ' ] } "
label = " Planlagt tid " if row [ ' state ' ] == ' planned ' else " Brugt tid "
event = _build_event ( kind , f " { label } : { row [ ' description ' ] } · { row [ ' user_name ' ] } " ,
row [ ' starts_at ' ] ,
f " /sag/new?quick_plan= { row [ ' id ' ] } " if row [ ' state ' ] == ' planned ' else ' /timetracking ' ,
{ " reference_id " : row [ ' id ' ] , " reference_type " : " quick_time " ,
" event_kind " : kind , " user_id " : row [ ' user_id ' ] ,
" customer_id " : row . get ( ' customer_id ' ) , " customer_name " : row . get ( ' customer_name ' ) } )
event [ ' end ' ] = row [ ' ends_at ' ] . isoformat ( )
event [ ' backgroundColor ' ] = ' #336699 ' if row [ ' state ' ] == ' planned ' else ' #7c3aed '
event [ ' borderColor ' ] = event [ ' backgroundColor ' ]
event [ ' textColor ' ] = ' #ffffff '
events . append ( event )
2026-10-20 07:18:57 +02:00
if not normalized_types or normalized_types & { ' planner_booking ' , ' absence ' } :
events . extend ( _planning_feed_events ( request , start_dt , end_dt , resolved_user_id , only_mine , customer_id , normalized_types ) )
2026-02-14 02:26:29 +01:00
events . sort ( key = lambda item : item . get ( " start " ) or " " )
return events
2026-10-20 07:18:57 +02:00
def _planning_feed_events ( request , start , end , target , only_mine , customer_id , kinds ) :
""" Read existing reservations into the legacy feed, without copying them. """
present = execute_query_single ( " SELECT to_regclass( ' planner_events ' ) IS NOT NULL AS ready " )
if not present or not present [ ' ready ' ] :
return [ ]
from app . core . auth_service import AuthService
from app . core . case_visibility import filter_data , hidden_case_ids
from app . modules . planner . backend . router import _can_view , _can_view_target , _aware
from app . modules . planner . backend . calendar import visible_absence
viewer = getattr ( request . state , ' user_id ' , None ) or target
row = execute_query_single ( ' SELECT user_id,is_superadmin FROM users WHERE user_id= %s AND is_active ' , ( viewer , ) )
if not row :
return [ ]
actor = { ' id ' : row [ ' user_id ' ] , ' is_superadmin ' : row [ ' is_superadmin ' ] , ' permissions ' : AuthService . get_user_permissions ( row [ ' user_id ' ] ) }
if not _can_view ( actor ) :
return [ ]
if only_mine :
ids = [ target or actor [ ' id ' ] ]
if not _can_view_target ( actor , ids [ 0 ] ) :
raise HTTPException ( 403 , ' Du har ikke adgang til denne medarbejders plan ' )
else :
ids = [ u [ ' user_id ' ] for u in execute_query ( ' SELECT user_id FROM users WHERE is_active ' ) or [ ] if _can_view_target ( actor , u [ ' user_id ' ] ) ]
result = [ ]
if not kinds or ' planner_booking ' in kinds :
rows = execute_query ( ''' SELECT a.id,a.event_id,a.sag_id,a.user_id,a.starts_at,a.ends_at,e.title
FROM planner_allocations a JOIN planner_events e ON e . id = a . event_id LEFT JOIN sag_sager s ON s . id = a . sag_id
WHERE a . deleted_at IS NULL AND a . user_id = ANY ( % s ) AND a . starts_at < % s AND a . ends_at > % s
AND ( % s IS NULL OR s . customer_id = % s ) ORDER BY a . id ''' ,(ids,_aware(end),_aware(start),customer_id,customer_id)) or []
seen = set ( )
for item in rows :
if item [ ' event_id ' ] in seen :
continue
seen . add ( item [ ' event_id ' ] )
result . append ( { ' id ' : f ' planner: { item [ " event_id " ] } ' , ' title ' : item [ ' title ' ] , ' start ' : item [ ' starts_at ' ] . isoformat ( ) ,
' end ' : item [ ' ends_at ' ] . isoformat ( ) , ' sag_id ' : item [ ' sag_id ' ] , ' event_type ' : ' planner_booking ' ,
' url ' : f ' /planning?view=calendar&event_id= { item [ " event_id " ] } ' } )
if ( not kinds or ' absence ' in kinds ) and not customer_id :
labels = { r [ ' code ' ] : r [ ' label ' ] for r in execute_query ( ' SELECT code,label FROM planner_activity_types ' ) or [ ] }
for item in execute_query ( ' SELECT * FROM planner_absences WHERE deleted_at IS NULL AND user_id=ANY( %s ) AND starts_at< %s AND ends_at> %s ' , ( ids , _aware ( end ) , _aware ( start ) ) ) or [ ] :
safe = visible_absence ( item , actor )
result . append ( { ' id ' : f ' absence: { item [ " id " ] } ' , ' title ' : ' Fravær ' if safe [ ' kind ' ] == ' unavailable ' else
labels . get ( safe [ ' kind ' ] , ' Fravær ' ) ,
' start ' : item [ ' starts_at ' ] . isoformat ( ) , ' end ' : item [ ' ends_at ' ] . isoformat ( ) , ' event_type ' : ' absence ' , ' url ' : ' /planning?view=calendar ' } )
return filter_data ( result , hidden_case_ids ( actor ) )
2026-02-14 02:26:29 +01:00
@router.get ( " /calendar/events " )
async def get_calendar_events (
request : Request ,
start : str = Query ( None ) ,
end : str = Query ( None ) ,
only_mine : bool = Query ( True ) ,
user_id : int | None = Query ( None ) ,
customer_id : int | None = Query ( None ) ,
types : str | None = Query ( None ) ,
) :
""" Aggregate calendar events from sag deadlines, deferred dates, and reminders. """
now = datetime . now ( )
start_dt = _parse_iso_datetime ( start , now - timedelta ( days = 14 ) )
end_dt = _parse_iso_datetime ( end , now + timedelta ( days = 60 ) )
if end_dt < start_dt :
raise HTTPException ( status_code = 400 , detail = " Invalid date range " )
events = _get_calendar_events (
request = request ,
start_dt = start_dt ,
end_dt = end_dt ,
only_mine = only_mine ,
user_id = user_id ,
customer_id = customer_id ,
types = types ,
)
return { " events " : events }
@router.get ( " /calendar/ical " )
async def get_calendar_ical (
request : Request ,
start : str = Query ( None ) ,
end : str = Query ( None ) ,
only_mine : bool = Query ( True ) ,
user_id : int | None = Query ( None ) ,
customer_id : int | None = Query ( None ) ,
types : str | None = Query ( None ) ,
) :
""" Serve calendar events as an iCal feed. """
now = datetime . now ( )
start_dt = _parse_iso_datetime ( start , now - timedelta ( days = 14 ) )
end_dt = _parse_iso_datetime ( end , now + timedelta ( days = 60 ) )
if end_dt < start_dt :
raise HTTPException ( status_code = 400 , detail = " Invalid date range " )
events = _get_calendar_events (
request = request ,
start_dt = start_dt ,
end_dt = end_dt ,
only_mine = only_mine ,
user_id = user_id ,
customer_id = customer_id ,
types = types ,
)
2026-10-20 07:18:57 +02:00
from app . core . case_visibility import hidden_case_ids , filter_data
events = filter_data ( events , hidden_case_ids ( { ' id ' : getattr ( request . state , ' user_id ' , None ) ,
' is_superadmin ' : getattr ( request . state , ' case_superadmin ' , False ) } ) )
2026-06-21 10:26:07 +02:00
return _build_ical_response ( events , now )
2026-02-14 02:26:29 +01:00
2026-06-21 10:26:07 +02:00
@router.get ( " /calendar/ical/subscribe " )
async def get_calendar_ical_subscribe_link (
request : Request ,
current_user : dict = Depends ( get_current_user ) ,
) :
""" Return a tokenized iCal subscription URL suitable for Outlook/Internet Calendar. """
expires_at = datetime . utcnow ( ) + timedelta ( days = 3650 )
token = _create_calendar_feed_token ( int ( current_user . get ( " id " ) ) , expires_at )
base = str ( request . base_url ) . rstrip ( " / " )
http_url = f " { base } /api/v1/calendar/ical/feed?token= { token } "
webcal_url = http_url . replace ( " https:// " , " webcals:// " , 1 ) . replace ( " http:// " , " webcal:// " , 1 )
return {
" http_url " : http_url ,
" webcal_url " : webcal_url ,
" expires_at " : expires_at . isoformat ( ) + " Z " ,
}
2026-02-14 02:26:29 +01:00
2026-06-21 10:26:07 +02:00
@router.get ( " /calendar/ical/feed " )
async def get_calendar_ical_feed (
request : Request ,
token : str = Query ( . . . ) ,
start : str = Query ( None ) ,
end : str = Query ( None ) ,
customer_id : int | None = Query ( None ) ,
types : str | None = Query ( None ) ,
) :
""" Token-based iCal feed endpoint intended for external calendar subscriptions. """
now = datetime . now ( )
start_dt = _parse_iso_datetime ( start , now - timedelta ( days = 14 ) )
end_dt = _parse_iso_datetime ( end , now + timedelta ( days = 60 ) )
if end_dt < start_dt :
raise HTTPException ( status_code = 400 , detail = " Invalid date range " )
user_id = _verify_calendar_feed_token ( token )
events = _get_calendar_events (
request = request ,
start_dt = start_dt ,
end_dt = end_dt ,
only_mine = True ,
user_id = user_id ,
customer_id = customer_id ,
types = types ,
2026-02-14 02:26:29 +01:00
)
2026-10-20 07:18:57 +02:00
from app . core . case_visibility import hidden_case_ids , filter_data
events = filter_data ( events , hidden_case_ids ( { ' id ' : user_id } ) )
2026-06-21 10:26:07 +02:00
return _build_ical_response ( events , now )