feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite

- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs.
- Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups.
- Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
This commit is contained in:
Christian 2025-12-15 23:40:23 +01:00
parent 3fb43783a6
commit 3806c7d011
26 changed files with 7635 additions and 12 deletions

View File

@ -67,6 +67,19 @@ class Settings(BaseSettings):
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
OLLAMA_MODEL: str = "qwen2.5-coder:7b" # qwen2.5-coder fungerer bedre til JSON udtrækning
# Ticket System Module
TICKET_ENABLED: bool = True
TICKET_EMAIL_INTEGRATION: bool = False # 🚨 SAFETY: Disable email-to-ticket until configured
TICKET_AUTO_ASSIGN: bool = False # Auto-assign tickets based on rules
TICKET_DEFAULT_PRIORITY: str = "normal" # low|normal|high|urgent
TICKET_REQUIRE_CUSTOMER: bool = False # Allow tickets without customer link
TICKET_NOTIFICATION_ENABLED: bool = False # Notify on status changes
# Ticket System - e-conomic Integration
TICKET_ECONOMIC_READ_ONLY: bool = True # 🚨 SAFETY: Block all writes to e-conomic
TICKET_ECONOMIC_DRY_RUN: bool = True # 🚨 SAFETY: Log without executing
TICKET_ECONOMIC_AUTO_EXPORT: bool = False # Auto-export billable worklog
# Email System Configuration
EMAIL_TO_TICKET_ENABLED: bool = False # 🚨 SAFETY: Disable auto-processing until configured

View File

@ -266,7 +266,8 @@ class EmailWorkflowService:
try:
# Dispatch to specific action handler
handler_map = {
'create_ticket': self._action_create_ticket,
'create_ticket': self._action_create_ticket_system,
'link_email_to_ticket': self._action_link_email_to_ticket,
'create_time_entry': self._action_create_time_entry,
'link_to_vendor': self._action_link_to_vendor,
'link_to_customer': self._action_link_to_customer,
@ -302,19 +303,81 @@ class EmailWorkflowService:
# Action Handlers
async def _action_create_ticket(self, params: Dict, email_data: Dict) -> Dict:
"""Create a ticket/case from email"""
module = params.get('module', 'support_cases')
priority = params.get('priority', 'normal')
async def _action_create_ticket_system(self, params: Dict, email_data: Dict) -> Dict:
"""Create a ticket from email using new ticket system"""
from app.ticket.backend.email_integration import EmailTicketIntegration
# TODO: Integrate with actual case/ticket system
logger.info(f"🎫 Would create ticket in module '{module}' with priority '{priority}'")
# Build email_data dict for ticket integration
ticket_email_data = {
'message_id': email_data.get('message_id'),
'subject': email_data.get('subject'),
'from_address': email_data.get('sender_email'),
'body': email_data.get('body_text', ''),
'html_body': email_data.get('body_html'),
'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
'in_reply_to': None, # TODO: Extract from email headers
'references': None # TODO: Extract from email headers
}
# Get params from workflow
customer_id = params.get('customer_id') or email_data.get('customer_id')
assigned_to_user_id = params.get('assigned_to_user_id')
logger.info(f"🎫 Creating ticket from email: {email_data.get('message_id')}")
result = await EmailTicketIntegration.process_email_for_ticket(
email_data=ticket_email_data,
customer_id=customer_id,
assigned_to_user_id=assigned_to_user_id
)
logger.info(f"✅ Created ticket {result.get('ticket_number')} from email")
return {
'action': 'create_ticket',
'module': module,
'priority': priority,
'note': 'Ticket creation not yet implemented'
'ticket_id': result.get('ticket_id'),
'ticket_number': result.get('ticket_number'),
'created': result.get('created', False),
'linked': result.get('linked', False)
}
async def _action_link_email_to_ticket(self, params: Dict, email_data: Dict) -> Dict:
"""Link email to existing ticket"""
from app.ticket.backend.email_integration import EmailTicketIntegration
ticket_number = params.get('ticket_number')
if not ticket_number:
logger.warning("⚠️ No ticket_number provided for link_email_to_ticket action")
return {
'action': 'link_email_to_ticket',
'status': 'failed',
'error': 'ticket_number required'
}
ticket_email_data = {
'message_id': email_data.get('message_id'),
'subject': email_data.get('subject'),
'from_address': email_data.get('sender_email'),
'body': email_data.get('body_text', ''),
'html_body': email_data.get('body_html'),
'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
}
logger.info(f"🔗 Linking email to ticket {ticket_number}")
result = await EmailTicketIntegration.link_email_to_ticket(
ticket_number=ticket_number,
email_data=ticket_email_data
)
logger.info(f"✅ Linked email to ticket {ticket_number}")
return {
'action': 'link_email_to_ticket',
'ticket_id': result.get('ticket_id'),
'ticket_number': result.get('ticket_number'),
'linked': True
}
async def _action_create_time_entry(self, params: Dict, email_data: Dict) -> Dict:

View File

@ -231,8 +231,12 @@
<i class="bi bi-headset me-2"></i>Support
</a>
<ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="#">Sager</a></li>
<li><a class="dropdown-item py-2" href="#">Ny Sag</a></li>
<li><a class="dropdown-item py-2" href="/ticket/dashboard"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/ticket/tickets"><i class="bi bi-ticket-detailed me-2"></i>Alle Tickets</a></li>
<li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
<li><a class="dropdown-item py-2" href="#">Klippekort</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
</ul>
@ -491,9 +495,11 @@
</div>
</div>
{% block content_wrapper %}
<div class="container-fluid px-4 py-4">
{% block content %}{% endblock %}
</div>
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>

10
app/ticket/__init__.py Normal file
View File

@ -0,0 +1,10 @@
"""
Ticket System & Klippekort Module
==================================
Isoleret modul til ticket management og klippekort (prepaid time cards).
Alle tabeller bruger 'tticket_' prefix og kan uninstalleres uden at påvirke core data.
"""
__version__ = "1.0.0"

View File

@ -0,0 +1,6 @@
"""
Ticket Module Backend
=====================
Business logic, API endpoints, og services for ticket-systemet.
"""

View File

@ -0,0 +1,402 @@
"""
E-conomic Export Service for Ticket System
Exports billable worklog entries to e-conomic as invoice lines
🚨 SAFETY MODES (inherited from EconomicService):
- TICKET_ECONOMIC_READ_ONLY: Blocks ALL write operations when True
- TICKET_ECONOMIC_DRY_RUN: Logs operations but doesn't send to e-conomic when True
- TICKET_ECONOMIC_AUTO_EXPORT: Enable automatic export on worklog approval
Integration Pattern:
1. Worklog entry marked "billable" Export to e-conomic
2. Create invoice draft with worklog line items
3. Store economic_invoice_number in tticket_worklog
4. Mark worklog as "billed" after successful export
"""
import logging
from typing import Dict, List, Optional
from datetime import date, datetime
from decimal import Decimal
from app.core.database import execute_query, execute_update
from app.core.config import settings
from app.services.economic_service import EconomicService
from psycopg2.extras import Json
logger = logging.getLogger(__name__)
class TicketEconomicExportService:
"""
Handles export of billable worklog to e-conomic
"""
def __init__(self):
self.economic = EconomicService()
self.read_only = getattr(settings, 'TICKET_ECONOMIC_READ_ONLY', True)
self.dry_run = getattr(settings, 'TICKET_ECONOMIC_DRY_RUN', True)
self.auto_export = getattr(settings, 'TICKET_ECONOMIC_AUTO_EXPORT', False)
# Log safety status
if self.read_only:
logger.warning("🔒 TICKET E-CONOMIC READ-ONLY MODE ENABLED")
elif self.dry_run:
logger.warning("🏃 TICKET E-CONOMIC DRY-RUN MODE ENABLED")
else:
logger.warning("⚠️ TICKET E-CONOMIC WRITE MODE ACTIVE")
def _check_export_permission(self, operation: str) -> bool:
"""Check if export operations are allowed"""
if self.read_only:
logger.error(f"🚫 BLOCKED: {operation} - TICKET_ECONOMIC_READ_ONLY=true")
logger.error("To enable: Set TICKET_ECONOMIC_READ_ONLY=false in .env")
return False
if self.dry_run:
logger.warning(f"🏃 DRY-RUN: {operation} - TICKET_ECONOMIC_DRY_RUN=true")
logger.warning("To execute: Set TICKET_ECONOMIC_DRY_RUN=false in .env")
return False
logger.warning(f"⚠️ EXECUTING: {operation} - Will create in e-conomic")
return True
async def export_billable_worklog_batch(
self,
customer_id: int,
worklog_ids: Optional[List[int]] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> Dict:
"""
Export billable worklog entries to e-conomic as draft invoice
Args:
customer_id: Customer ID for the invoice
worklog_ids: Specific worklog entry IDs to export (optional)
date_from: Start date filter (optional)
date_to: End date filter (optional)
Returns:
Dict with export results
"""
try:
logger.info(f"📦 Starting worklog export for customer {customer_id}")
# Get customer e-conomic info
customer = await self._get_customer_economic_info(customer_id)
if not customer:
raise ValueError(f"Customer {customer_id} not found or missing e-conomic mapping")
# Get billable worklog entries
worklogs = await self._get_billable_worklog(
customer_id, worklog_ids, date_from, date_to
)
if not worklogs:
logger.info("✅ No billable worklog entries found")
return {
'status': 'no_entries',
'exported_count': 0
}
logger.info(f"📋 Found {len(worklogs)} billable entries")
# Safety check
if not self._check_export_permission(f"Export {len(worklogs)} worklog entries"):
return {
'status': 'blocked',
'reason': 'Safety mode enabled',
'read_only': self.read_only,
'dry_run': self.dry_run,
'entries': len(worklogs)
}
# Create invoice in e-conomic
invoice_result = await self._create_economic_invoice(customer, worklogs)
# Update worklog entries with invoice number
await self._mark_worklogs_as_billed(
worklog_ids=[w['id'] for w in worklogs],
economic_invoice_number=invoice_result.get('draftInvoiceNumber')
)
logger.info(f"✅ Successfully exported {len(worklogs)} entries to e-conomic")
return {
'status': 'exported',
'exported_count': len(worklogs),
'invoice_number': invoice_result.get('draftInvoiceNumber'),
'total_hours': sum(float(w['hours']) for w in worklogs),
'total_amount': sum(float(w['amount']) for w in worklogs),
'entries': [
{
'id': w['id'],
'ticket_number': w['ticket_number'],
'hours': float(w['hours'])
}
for w in worklogs
]
}
except Exception as e:
logger.error(f"❌ Failed to export worklog: {e}")
raise
async def _get_customer_economic_info(self, customer_id: int) -> Optional[Dict]:
"""
Get customer e-conomic mapping information
Returns:
Dict with debtor_number, payment_terms, etc.
"""
query = """
SELECT
id,
name,
email,
economic_customer_number,
payment_terms_number,
address,
postal_code,
city
FROM customers
WHERE id = %s
"""
customer = execute_query(query, (customer_id,), fetchone=True)
if not customer:
logger.error(f"❌ Customer {customer_id} not found")
return None
if not customer.get('economic_customer_number'):
logger.error(f"❌ Customer {customer_id} missing economic_customer_number")
return None
return customer
async def _get_billable_worklog(
self,
customer_id: int,
worklog_ids: Optional[List[int]] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> List[Dict]:
"""
Get billable worklog entries ready for export
Returns:
List of worklog dicts with ticket and customer info
"""
query = """
SELECT
w.id,
w.ticket_id,
w.work_date,
w.hours,
w.work_type,
w.description,
w.billing_method,
t.ticket_number,
t.subject AS ticket_subject,
t.customer_id,
c.economic_customer_number,
(w.hours * 850) AS amount
FROM tticket_worklog w
INNER JOIN tticket_tickets t ON t.id = w.ticket_id
INNER JOIN customers c ON c.id = t.customer_id
WHERE w.status = 'billable'
AND w.billing_method = 'invoice'
AND w.billed_at IS NULL
AND t.customer_id = %s
"""
params = [customer_id]
if worklog_ids:
query += " AND w.id = ANY(%s)"
params.append(worklog_ids)
if date_from:
query += " AND w.work_date >= %s"
params.append(date_from)
if date_to:
query += " AND w.work_date <= %s"
params.append(date_to)
query += " ORDER BY w.work_date ASC, w.created_at ASC"
return execute_query(query, tuple(params))
async def _create_economic_invoice(self, customer: Dict, worklogs: List[Dict]) -> Dict:
"""
Create draft invoice in e-conomic with worklog line items
Args:
customer: Customer dict with e-conomic mapping
worklogs: List of billable worklog entries
Returns:
e-conomic invoice response dict
"""
# Build invoice payload
invoice_data = {
"date": datetime.now().strftime("%Y-%m-%d"),
"customer": {
"customerNumber": customer['economic_customer_number']
},
"recipient": {
"name": customer['name'],
"address": customer.get('address', ''),
"zip": customer.get('postal_code', ''),
"city": customer.get('city', ''),
"vatZone": {
"vatZoneNumber": 1 # Denmark
}
},
"paymentTerms": {
"paymentTermsNumber": customer.get('payment_terms_number', 1)
},
"layout": {
"layoutNumber": 19 # Default layout
},
"lines": []
}
# Add worklog entries as invoice lines
for worklog in worklogs:
product_number = 'SUPPORT' # Default product number
# Build line description
ticket_ref = f"[{worklog['ticket_number']}] {worklog['ticket_subject']}"
work_desc = worklog.get('description', 'Support arbejde')
line_text = f"{ticket_ref}\n{work_desc}\n{worklog['work_date']}"
line = {
"product": {
"productNumber": product_number[:25] # Max 25 chars
},
"description": line_text[:250], # Max 250 chars
"quantity": float(worklog['hours']),
"unitNetPrice": float(worklog['amount']) / float(worklog['hours']),
"unit": {
"unitNumber": 1 # Hours
}
}
invoice_data['lines'].append(line)
logger.info(f"📄 Creating e-conomic invoice with {len(invoice_data['lines'])} lines")
logger.info(f"📊 Invoice payload: {invoice_data}")
# DRY-RUN: Just return mock response
if self.dry_run:
logger.warning("🏃 DRY-RUN: Would create invoice in e-conomic")
return {
'draftInvoiceNumber': 999999,
'grossAmount': sum(float(w['amount']) for w in worklogs),
'netAmount': sum(float(w['amount']) for w in worklogs),
'vatAmount': 0,
'dryRun': True
}
# REAL EXECUTION: Create in e-conomic
try:
result = await self.economic.create_draft_invoice(invoice_data)
logger.info(f"✅ Created e-conomic draft invoice: {result.get('draftInvoiceNumber')}")
return result
except Exception as e:
logger.error(f"❌ Failed to create e-conomic invoice: {e}")
raise
async def _mark_worklogs_as_billed(
self,
worklog_ids: List[int],
economic_invoice_number: Optional[int]
) -> None:
"""
Mark worklog entries as billed with e-conomic reference
Args:
worklog_ids: List of worklog entry IDs
economic_invoice_number: e-conomic invoice number
"""
if not worklog_ids:
return
query = """
UPDATE tticket_worklog
SET billed_at = CURRENT_TIMESTAMP,
economic_invoice_number = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = ANY(%s)
"""
execute_update(query, (economic_invoice_number, worklog_ids))
logger.info(f"✅ Marked {len(worklog_ids)} worklog entries as billed")
async def get_export_preview(
self,
customer_id: int,
worklog_ids: Optional[List[int]] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> Dict:
"""
Preview what would be exported without actually exporting
Args:
Same as export_billable_worklog_batch
Returns:
Dict with preview information
"""
try:
customer = await self._get_customer_economic_info(customer_id)
if not customer:
return {
'status': 'error',
'error': 'Customer not found or missing e-conomic mapping'
}
worklogs = await self._get_billable_worklog(
customer_id, worklog_ids, date_from, date_to
)
total_hours = sum(float(w['hours']) for w in worklogs)
total_amount = sum(float(w['amount']) for w in worklogs)
return {
'status': 'preview',
'customer_id': customer_id,
'customer_name': customer['name'],
'economic_customer_number': customer['economic_customer_number'],
'entry_count': len(worklogs),
'total_hours': float(total_hours),
'total_amount': float(total_amount),
'entries': [
{
'id': w['id'],
'ticket_number': w['ticket_number'],
'work_date': w['work_date'].strftime('%Y-%m-%d'),
'hours': float(w['hours']),
'amount': float(w['amount']),
'description': w['description']
}
for w in worklogs
]
}
except Exception as e:
logger.error(f"❌ Failed to generate export preview: {e}")
return {
'status': 'error',
'error': str(e)
}
# Singleton instance
ticket_economic_service = TicketEconomicExportService()

View File

@ -0,0 +1,472 @@
"""
Email-to-Ticket Integration for BMC Hub Ticket System
Implements Option B: Message-ID Threading
- Parse email message_id from workflow context
- Create tickets with email metadata
- Link emails to existing tickets using In-Reply-To header
- Extract ticket tags from email body
- Store email threading info in tticket_email_log
"""
import logging
import re
from typing import Dict, Any, Optional, List
from datetime import datetime
from app.core.database import execute_query, execute_insert
from app.ticket.backend.ticket_service import TicketService
from app.ticket.backend.models import TTicketCreate, TicketPriority
from psycopg2.extras import Json
logger = logging.getLogger(__name__)
class EmailTicketIntegration:
"""Handles email-to-ticket conversion and threading"""
# Regex patterns for ticket detection
TICKET_NUMBER_PATTERN = r'TKT-\d{8}-\d{3}'
TAG_PATTERN = r'#(\w+)'
@staticmethod
async def create_ticket_from_email(
email_data: Dict[str, Any],
customer_id: Optional[int] = None,
assigned_to_user_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Create ticket from email workflow action
Args:
email_data: Dict with keys:
- message_id: Email Message-ID header
- subject: Email subject
- from_address: Sender email
- body: Email body text
- html_body: Email HTML body (optional)
- received_at: Email timestamp (ISO format)
- in_reply_to: In-Reply-To header (optional)
- references: References header (optional)
customer_id: BMC customer ID (if known)
assigned_to_user_id: User to assign ticket to
Returns:
Dict with ticket_id and ticket_number
"""
try:
logger.info(f"🎫 Creating ticket from email: {email_data.get('message_id')}")
# Extract tags from email body
tags = EmailTicketIntegration._extract_tags(email_data.get('body', ''))
# Determine priority from subject/tags
priority = EmailTicketIntegration._determine_priority(
email_data.get('subject', ''),
tags
)
# Create ticket
ticket_create = TTicketCreate(
subject=email_data.get('subject', 'Email without subject'),
description=EmailTicketIntegration._format_description(email_data),
customer_id=customer_id,
assigned_to_user_id=assigned_to_user_id,
priority=priority,
tags=tags,
custom_fields={
'email_from': email_data.get('from_address'),
'email_message_id': email_data.get('message_id'),
'created_from_email': True
}
)
# Create via service (handles auto-number generation)
ticket = await TicketService.create_ticket(ticket_create, created_by_user_id=None)
# Log email-ticket linkage
await EmailTicketIntegration._log_email_linkage(
ticket_id=ticket['id'],
email_data=email_data
)
logger.info(f"✅ Created ticket {ticket['ticket_number']} from email {email_data.get('message_id')}")
return {
'ticket_id': ticket['id'],
'ticket_number': ticket['ticket_number'],
'created': True
}
except Exception as e:
logger.error(f"❌ Failed to create ticket from email: {e}")
raise
@staticmethod
async def link_email_to_ticket(
ticket_number: str,
email_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
Link email to existing ticket (reply threading)
Args:
ticket_number: TKT-YYYYMMDD-XXX format
email_data: Same format as create_ticket_from_email
Returns:
Dict with ticket_id and linked=True
"""
try:
logger.info(f"🔗 Linking email to ticket {ticket_number}")
# Find ticket by ticket_number
query = "SELECT id FROM tticket_tickets WHERE ticket_number = %s"
result = execute_query(query, (ticket_number,), fetchone=True)
if not result:
logger.warning(f"⚠️ Ticket {ticket_number} not found - creating new ticket instead")
return await EmailTicketIntegration.create_ticket_from_email(email_data)
ticket_id = result['id']
# Log email linkage
await EmailTicketIntegration._log_email_linkage(
ticket_id=ticket_id,
email_data=email_data,
is_reply=True
)
# Add comment with email content
from app.ticket.backend.models import TTicketCommentCreate
comment_text = f"📧 Email from {email_data.get('from_address')}:\n\n{email_data.get('body', '')}"
await TicketService.add_comment(
ticket_id=ticket_id,
comment=TTicketCommentCreate(
comment_text=comment_text,
internal_note=False
),
user_id=None
)
logger.info(f"✅ Linked email to ticket {ticket_number} (ID: {ticket_id})")
return {
'ticket_id': ticket_id,
'ticket_number': ticket_number,
'linked': True
}
except Exception as e:
logger.error(f"❌ Failed to link email to ticket: {e}")
raise
@staticmethod
async def process_email_for_ticket(
email_data: Dict[str, Any],
customer_id: Optional[int] = None,
assigned_to_user_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Smart processor: Creates new ticket OR links to existing based on threading
Args:
email_data: Email metadata dict
customer_id: Customer ID if known
assigned_to_user_id: User to assign new tickets to
Returns:
Dict with ticket_id, ticket_number, created/linked status
"""
try:
# Check if email is reply to existing ticket
ticket_number = EmailTicketIntegration._find_ticket_in_thread(email_data)
if ticket_number:
# Reply to existing ticket
return await EmailTicketIntegration.link_email_to_ticket(
ticket_number=ticket_number,
email_data=email_data
)
else:
# New ticket
return await EmailTicketIntegration.create_ticket_from_email(
email_data=email_data,
customer_id=customer_id,
assigned_to_user_id=assigned_to_user_id
)
except Exception as e:
logger.error(f"❌ Failed to process email for ticket: {e}")
raise
@staticmethod
def _find_ticket_in_thread(email_data: Dict[str, Any]) -> Optional[str]:
"""
Find ticket number in email thread (In-Reply-To, References, Subject)
Returns:
Ticket number (TKT-YYYYMMDD-XXX) or None
"""
# Check In-Reply-To header first
in_reply_to = email_data.get('in_reply_to', '')
if in_reply_to:
match = re.search(EmailTicketIntegration.TICKET_NUMBER_PATTERN, in_reply_to)
if match:
return match.group(0)
# Check References header
references = email_data.get('references', '')
if references:
match = re.search(EmailTicketIntegration.TICKET_NUMBER_PATTERN, references)
if match:
return match.group(0)
# Check subject line for [TKT-YYYYMMDD-XXX] or Re: TKT-YYYYMMDD-XXX
subject = email_data.get('subject', '')
match = re.search(EmailTicketIntegration.TICKET_NUMBER_PATTERN, subject)
if match:
return match.group(0)
return None
@staticmethod
def _extract_tags(body: str) -> List[str]:
"""
Extract #hashtags from email body
Returns:
List of lowercase tags without # prefix
"""
if not body:
return []
tags = re.findall(EmailTicketIntegration.TAG_PATTERN, body)
return [tag.lower() for tag in tags if len(tag) > 2][:10] # Max 10 tags
@staticmethod
def _determine_priority(subject: str, tags: List[str]) -> TicketPriority:
"""
Determine ticket priority from subject/tags
Returns:
TicketPriority enum value
"""
subject_lower = subject.lower()
all_text = f"{subject_lower} {' '.join(tags)}"
# Critical keywords
if any(word in all_text for word in ['kritisk', 'critical', 'down', 'nede', 'urgent', 'akut']):
return TicketPriority.critical
# High priority keywords
if any(word in all_text for word in ['høj', 'high', 'vigtig', 'important', 'haster']):
return TicketPriority.high
# Low priority keywords
if any(word in all_text for word in ['lav', 'low', 'spørgsmål', 'question', 'info']):
return TicketPriority.low
return TicketPriority.normal
@staticmethod
def _format_description(email_data: Dict[str, Any]) -> str:
"""
Format email body as ticket description
Returns:
Formatted description text
"""
body = email_data.get('body', '').strip()
from_address = email_data.get('from_address', 'unknown')
received_at = email_data.get('received_at', '')
description = f"📧 Email fra: {from_address}\n"
description += f"📅 Modtaget: {received_at}\n"
description += f"{'='*60}\n\n"
description += body
return description
@staticmethod
async def _log_email_linkage(
ticket_id: int,
email_data: Dict[str, Any],
is_reply: bool = False
) -> None:
"""
Store email-ticket linkage in tticket_email_log
Args:
ticket_id: Ticket ID
email_data: Email metadata
is_reply: True if email is reply to existing ticket
"""
query = """
INSERT INTO tticket_email_log (
ticket_id,
email_message_id,
email_subject,
email_from,
email_received_at,
is_reply,
thread_data
) VALUES (%s, %s, %s, %s, %s, %s, %s)
"""
# Parse received_at timestamp
received_at = email_data.get('received_at')
if isinstance(received_at, str):
try:
received_at = datetime.fromisoformat(received_at.replace('Z', '+00:00'))
except:
received_at = None
thread_data = {
'in_reply_to': email_data.get('in_reply_to'),
'references': email_data.get('references'),
'message_id': email_data.get('message_id')
}
execute_insert(
query,
(
ticket_id,
email_data.get('message_id'),
email_data.get('subject'),
email_data.get('from_address'),
received_at,
is_reply,
Json(thread_data)
)
)
logger.info(f"📝 Logged email linkage for ticket {ticket_id}")
@staticmethod
async def get_ticket_email_thread(ticket_id: int) -> List[Dict[str, Any]]:
"""
Get all emails linked to a ticket (chronological order)
Args:
ticket_id: Ticket ID
Returns:
List of email log entries
"""
query = """
SELECT
id,
email_message_id,
email_subject,
email_from,
email_received_at,
is_reply,
thread_data,
created_at
FROM tticket_email_log
WHERE ticket_id = %s
ORDER BY email_received_at ASC
"""
return execute_query(query, (ticket_id,), fetchall=True)
@staticmethod
async def find_tickets_by_email_address(email_address: str) -> List[Dict[str, Any]]:
"""
Find all tickets associated with an email address
Args:
email_address: Email address to search for
Returns:
List of ticket info dicts
"""
query = """
SELECT DISTINCT
t.id,
t.ticket_number,
t.subject,
t.status,
t.created_at
FROM tticket_tickets t
INNER JOIN tticket_email_log e ON e.ticket_id = t.id
WHERE e.email_from = %s
ORDER BY t.created_at DESC
"""
return execute_query(query, (email_address,), fetchall=True)
# Workflow action functions (called from email workflow engine)
async def workflow_action_create_ticket(
context: Dict[str, Any],
action_params: Dict[str, Any]
) -> Dict[str, Any]:
"""
Workflow action: Create ticket from email
Usage in workflow:
{
"action": "create_ticket",
"params": {
"customer_id": 123, # Optional
"assigned_to_user_id": 5, # Optional
"priority": "high" # Optional override
}
}
Args:
context: Email workflow context with email_data
action_params: Action parameters from workflow definition
Returns:
Result dict with ticket info
"""
email_data = context.get('email_data', {})
result = await EmailTicketIntegration.process_email_for_ticket(
email_data=email_data,
customer_id=action_params.get('customer_id'),
assigned_to_user_id=action_params.get('assigned_to_user_id')
)
return result
async def workflow_action_link_email_to_ticket(
context: Dict[str, Any],
action_params: Dict[str, Any]
) -> Dict[str, Any]:
"""
Workflow action: Link email to existing ticket
Usage in workflow:
{
"action": "link_email_to_ticket",
"params": {
"ticket_number": "TKT-20251215-001"
}
}
Args:
context: Email workflow context
action_params: Must contain ticket_number
Returns:
Result dict with link status
"""
email_data = context.get('email_data', {})
ticket_number = action_params.get('ticket_number')
if not ticket_number:
raise ValueError("ticket_number required in action params")
result = await EmailTicketIntegration.link_email_to_ticket(
ticket_number=ticket_number,
email_data=email_data
)
return result

View File

@ -0,0 +1,504 @@
"""
Klippekort (Prepaid Time Card) Service
=======================================
Business logic for prepaid time cards: purchase, balance, deduction.
CONSTRAINT: Only 1 active card per customer (enforced by database UNIQUE index).
"""
import logging
from datetime import datetime
from decimal import Decimal
from typing import Optional, Dict, Any, List
from app.core.database import execute_query, execute_insert, execute_update
from app.ticket.backend.models import (
TPrepaidCard,
TPrepaidCardCreate,
TPrepaidCardUpdate,
TPrepaidCardWithStats,
TPrepaidTransaction,
TPrepaidTransactionCreate,
PrepaidCardStatus,
TransactionType
)
logger = logging.getLogger(__name__)
class KlippekortService:
"""Service for prepaid card operations"""
@staticmethod
def purchase_card(
card_data: TPrepaidCardCreate,
user_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Purchase a new prepaid card
CONSTRAINT: Only 1 active card allowed per customer.
This will fail if customer already has an active card.
Args:
card_data: Card purchase data
user_id: User making purchase
Returns:
Created card dict
Raises:
ValueError: If customer already has active card
"""
from psycopg2.extras import Json
# Check if customer already has an active card
existing = execute_query(
"""
SELECT id, card_number FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
""",
(card_data.customer_id,),
fetchone=True
)
if existing:
raise ValueError(
f"Customer {card_data.customer_id} already has an active card: {existing['card_number']}. "
"Please deactivate or deplete the existing card before purchasing a new one."
)
logger.info(f"💳 Purchasing prepaid card for customer {card_data.customer_id}: {card_data.purchased_hours}h")
# Insert card (trigger will auto-generate card_number if NULL)
card_id = execute_insert(
"""
INSERT INTO tticket_prepaid_cards (
card_number, customer_id, purchased_hours, price_per_hour, total_amount,
status, expires_at, notes, economic_invoice_number, economic_product_number,
created_by_user_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
card_data.card_number,
card_data.customer_id,
card_data.purchased_hours,
card_data.price_per_hour,
card_data.total_amount,
'active',
card_data.expires_at,
card_data.notes,
card_data.economic_invoice_number,
card_data.economic_product_number,
user_id or card_data.created_by_user_id
)
)
# Create initial transaction
execute_insert(
"""
INSERT INTO tticket_prepaid_transactions
(card_id, transaction_type, hours, balance_after, description, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
card_id,
'purchase',
card_data.purchased_hours,
card_data.purchased_hours,
f"Initial purchase: {card_data.purchased_hours}h @ {card_data.price_per_hour} DKK/h",
user_id
)
)
# Fetch created card
card = execute_query(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
(card_id,),
fetchone=True
)
logger.info(f"✅ Created prepaid card {card['card_number']} (ID: {card_id})")
return card
@staticmethod
def get_card(card_id: int) -> Optional[Dict[str, Any]]:
"""Get prepaid card by ID"""
return execute_query(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
(card_id,),
fetchone=True
)
@staticmethod
def get_card_with_stats(card_id: int) -> Optional[Dict[str, Any]]:
"""Get prepaid card with usage statistics"""
return execute_query(
"SELECT * FROM tticket_prepaid_balances WHERE id = %s",
(card_id,),
fetchone=True
)
@staticmethod
def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]:
"""
Get active prepaid card for customer
Returns None if no active card exists.
"""
return execute_query(
"""
SELECT * FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
""",
(customer_id,),
fetchone=True
)
@staticmethod
def check_balance(customer_id: int) -> Dict[str, Any]:
"""
Check prepaid card balance for customer
Args:
customer_id: Customer ID
Returns:
Dict with balance info: {
"has_card": bool,
"card_id": int or None,
"card_number": str or None,
"balance_hours": Decimal,
"status": str or None,
"expires_at": datetime or None
}
"""
card = KlippekortService.get_active_card_for_customer(customer_id)
if not card:
return {
"has_card": False,
"card_id": None,
"card_number": None,
"balance_hours": Decimal('0'),
"status": None,
"expires_at": None
}
return {
"has_card": True,
"card_id": card['id'],
"card_number": card['card_number'],
"balance_hours": card['remaining_hours'],
"status": card['status'],
"expires_at": card['expires_at']
}
@staticmethod
def can_deduct(customer_id: int, hours: Decimal) -> tuple[bool, Optional[str]]:
"""
Check if customer has sufficient balance for deduction
Args:
customer_id: Customer ID
hours: Hours to deduct
Returns:
(can_deduct, error_message)
"""
balance_info = KlippekortService.check_balance(customer_id)
if not balance_info['has_card']:
return False, f"Customer {customer_id} has no active prepaid card"
if balance_info['status'] != 'active':
return False, f"Prepaid card is not active (status: {balance_info['status']})"
if balance_info['balance_hours'] < hours:
return False, (
f"Insufficient balance: {balance_info['balance_hours']}h available, "
f"{hours}h required"
)
# Check expiration
if balance_info['expires_at']:
if balance_info['expires_at'] < datetime.now():
return False, f"Prepaid card expired on {balance_info['expires_at']}"
return True, None
@staticmethod
def deduct_hours(
customer_id: int,
hours: Decimal,
worklog_id: int,
user_id: Optional[int] = None,
description: Optional[str] = None
) -> Dict[str, Any]:
"""
Deduct hours from customer's active prepaid card
Args:
customer_id: Customer ID
hours: Hours to deduct
worklog_id: Worklog entry consuming the hours
user_id: User performing deduction
description: Optional description
Returns:
Transaction dict
Raises:
ValueError: If insufficient balance or no active card
"""
# Check if deduction is possible
can_deduct, error = KlippekortService.can_deduct(customer_id, hours)
if not can_deduct:
raise ValueError(error)
# Get active card
card = KlippekortService.get_active_card_for_customer(customer_id)
logger.info(f"⏱️ Deducting {hours}h from card {card['card_number']} for worklog {worklog_id}")
# Update card usage
new_used = Decimal(str(card['used_hours'])) + hours
execute_update(
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
(new_used, card['id'])
)
# Calculate new balance
new_balance = Decimal(str(card['remaining_hours'])) - hours
# Create transaction
transaction_id = execute_insert(
"""
INSERT INTO tticket_prepaid_transactions
(card_id, worklog_id, transaction_type, hours, balance_after, description, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
card['id'],
worklog_id,
'usage',
-hours, # Negative for deduction
new_balance,
description or f"Worklog #{worklog_id}: {hours}h",
user_id
)
)
# Check if card is now depleted
if new_balance <= Decimal('0'):
execute_update(
"UPDATE tticket_prepaid_cards SET status = 'depleted' WHERE id = %s",
(card['id'],)
)
logger.warning(f"💳 Card {card['card_number']} is now depleted")
# Fetch transaction
transaction = execute_query(
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
(transaction_id,),
fetchone=True
)
logger.info(f"✅ Deducted {hours}h from card {card['card_number']}, new balance: {new_balance}h")
return transaction
@staticmethod
def top_up_card(
card_id: int,
hours: Decimal,
user_id: Optional[int] = None,
note: Optional[str] = None
) -> Dict[str, Any]:
"""
Add hours to existing prepaid card
Args:
card_id: Card ID
hours: Hours to add
user_id: User performing top-up
note: Optional note
Returns:
Transaction dict
Raises:
ValueError: If card not found or not active
"""
# Get card
card = KlippekortService.get_card(card_id)
if not card:
raise ValueError(f"Prepaid card {card_id} not found")
if card['status'] not in ['active', 'depleted']:
raise ValueError(f"Cannot top up card with status: {card['status']}")
logger.info(f"💰 Topping up card {card['card_number']} with {hours}h")
# Update purchased hours
new_purchased = Decimal(str(card['purchased_hours'])) + hours
execute_update(
"UPDATE tticket_prepaid_cards SET purchased_hours = %s, status = 'active' WHERE id = %s",
(new_purchased, card_id)
)
# Calculate new balance
new_balance = Decimal(str(card['remaining_hours'])) + hours
# Create transaction
transaction_id = execute_insert(
"""
INSERT INTO tticket_prepaid_transactions
(card_id, transaction_type, hours, balance_after, description, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
card_id,
'top_up',
hours, # Positive for addition
new_balance,
note or f"Top-up: {hours}h added",
user_id
)
)
transaction = execute_query(
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
(transaction_id,),
fetchone=True
)
logger.info(f"✅ Topped up card {card['card_number']} with {hours}h, new balance: {new_balance}h")
return transaction
@staticmethod
def get_transactions(
card_id: int,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Get transaction history for card
Args:
card_id: Card ID
limit: Max number of transactions
Returns:
List of transaction dicts
"""
transactions = execute_query(
"""
SELECT * FROM tticket_prepaid_transactions
WHERE card_id = %s
ORDER BY created_at DESC
LIMIT %s
""",
(card_id, limit)
)
return transactions or []
@staticmethod
def list_cards(
customer_id: Optional[int] = None,
status: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[Dict[str, Any]]:
"""
List prepaid cards with optional filters
Args:
customer_id: Filter by customer
status: Filter by status
limit: Number of results
offset: Offset for pagination
Returns:
List of card dicts
"""
query = "SELECT * FROM tticket_prepaid_cards WHERE 1=1"
params = []
if customer_id:
query += " AND customer_id = %s"
params.append(customer_id)
if status:
query += " AND status = %s"
params.append(status)
query += " ORDER BY purchased_at DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
cards = execute_query(query, tuple(params))
return cards or []
@staticmethod
def cancel_card(
card_id: int,
user_id: Optional[int] = None,
reason: Optional[str] = None
) -> Dict[str, Any]:
"""
Cancel/deactivate a prepaid card
Args:
card_id: Card ID
user_id: User cancelling card
reason: Cancellation reason
Returns:
Updated card dict
Raises:
ValueError: If card not found or already cancelled
"""
card = KlippekortService.get_card(card_id)
if not card:
raise ValueError(f"Prepaid card {card_id} not found")
if card['status'] == 'cancelled':
raise ValueError(f"Card {card['card_number']} is already cancelled")
logger.info(f"❌ Cancelling card {card['card_number']}")
# Update status
execute_update(
"UPDATE tticket_prepaid_cards SET status = 'cancelled' WHERE id = %s",
(card_id,)
)
# Log transaction
execute_insert(
"""
INSERT INTO tticket_prepaid_transactions
(card_id, transaction_type, hours, balance_after, description, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
card_id,
'cancellation',
Decimal('0'),
card['remaining_hours'],
reason or "Card cancelled",
user_id
)
)
# Fetch updated card
updated = execute_query(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
(card_id,),
fetchone=True
)
logger.info(f"✅ Cancelled card {card['card_number']}")
return updated

View File

@ -0,0 +1,497 @@
"""
Pydantic Models for Ticket System & Klippekort Module
======================================================
Alle models repræsenterer data fra tticket_* tabeller.
Modulet er isoleret og har ingen afhængigheder til core Hub-models.
"""
from datetime import date, datetime
from decimal import Decimal
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
from enum import Enum
# ============================================================================
# ENUMS
# ============================================================================
class TicketStatus(str, Enum):
"""Ticket status workflow"""
OPEN = "open"
IN_PROGRESS = "in_progress"
WAITING_CUSTOMER = "waiting_customer"
WAITING_INTERNAL = "waiting_internal"
RESOLVED = "resolved"
CLOSED = "closed"
class TicketPriority(str, Enum):
"""Ticket prioritet"""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
class TicketSource(str, Enum):
"""Hvor ticket blev oprettet fra"""
EMAIL = "email"
PORTAL = "portal"
PHONE = "phone"
MANUAL = "manual"
API = "api"
class WorkType(str, Enum):
"""Type af arbejde"""
SUPPORT = "support"
DEVELOPMENT = "development"
TROUBLESHOOTING = "troubleshooting"
ON_SITE = "on_site"
MEETING = "meeting"
OTHER = "other"
class BillingMethod(str, Enum):
"""Afregningsmetode"""
PREPAID_CARD = "prepaid_card"
INVOICE = "invoice"
INTERNAL = "internal"
WARRANTY = "warranty"
class WorklogStatus(str, Enum):
"""Worklog status"""
DRAFT = "draft"
BILLABLE = "billable"
BILLED = "billed"
NON_BILLABLE = "non_billable"
class PrepaidCardStatus(str, Enum):
"""Klippekort status"""
ACTIVE = "active"
DEPLETED = "depleted"
EXPIRED = "expired"
CANCELLED = "cancelled"
class TransactionType(str, Enum):
"""Klippekort transaction type"""
PURCHASE = "purchase"
TOP_UP = "top_up"
USAGE = "usage"
REFUND = "refund"
EXPIRATION = "expiration"
CANCELLATION = "cancellation"
# ============================================================================
# TICKET MODELS
# ============================================================================
class TTicketBase(BaseModel):
"""Base model for ticket"""
subject: str = Field(..., min_length=1, max_length=500)
description: Optional[str] = None
status: TicketStatus = Field(default=TicketStatus.OPEN)
priority: TicketPriority = Field(default=TicketPriority.NORMAL)
category: Optional[str] = Field(None, max_length=100)
customer_id: Optional[int] = Field(None, description="Reference til customers.id")
contact_id: Optional[int] = Field(None, description="Reference til contacts.id")
assigned_to_user_id: Optional[int] = Field(None, description="Reference til users.user_id")
source: TicketSource = Field(default=TicketSource.MANUAL)
tags: Optional[List[str]] = Field(default_factory=list)
custom_fields: Optional[dict] = Field(default_factory=dict)
class TTicketCreate(TTicketBase):
"""Model for creating a ticket"""
ticket_number: Optional[str] = Field(None, description="Auto-generated hvis ikke angivet")
created_by_user_id: Optional[int] = Field(None, description="User der opretter ticket")
class TTicketUpdate(BaseModel):
"""Model for updating a ticket (partial updates)"""
subject: Optional[str] = Field(None, min_length=1, max_length=500)
description: Optional[str] = None
status: Optional[TicketStatus] = None
priority: Optional[TicketPriority] = None
category: Optional[str] = Field(None, max_length=100)
customer_id: Optional[int] = None
contact_id: Optional[int] = None
assigned_to_user_id: Optional[int] = None
tags: Optional[List[str]] = None
custom_fields: Optional[dict] = None
class TTicket(TTicketBase):
"""Full ticket model with DB fields"""
id: int
ticket_number: str
created_by_user_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
first_response_at: Optional[datetime] = None
resolved_at: Optional[datetime] = None
closed_at: Optional[datetime] = None
class Config:
from_attributes = True
class TTicketWithStats(TTicket):
"""Ticket med statistik (fra view)"""
comment_count: Optional[int] = 0
attachment_count: Optional[int] = 0
pending_hours: Optional[Decimal] = None
billed_hours: Optional[Decimal] = None
last_comment_at: Optional[datetime] = None
age_hours: Optional[float] = None
class Config:
from_attributes = True
# ============================================================================
# COMMENT MODELS
# ============================================================================
class TTicketCommentBase(BaseModel):
"""Base model for comment"""
comment_text: str = Field(..., min_length=1)
is_internal: bool = Field(default=False, description="Intern note (ikke kunde-synlig)")
class TTicketCommentCreate(TTicketCommentBase):
"""Model for creating a comment"""
ticket_id: int = Field(..., gt=0)
user_id: Optional[int] = Field(None, description="User der opretter kommentar")
class TTicketComment(TTicketCommentBase):
"""Full comment model with DB fields"""
id: int
ticket_id: int
user_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# ============================================================================
# ATTACHMENT MODELS
# ============================================================================
class TTicketAttachmentBase(BaseModel):
"""Base model for attachment"""
file_name: str = Field(..., min_length=1, max_length=255)
file_path: str = Field(..., min_length=1, max_length=500)
file_size: Optional[int] = Field(None, ge=0)
mime_type: Optional[str] = Field(None, max_length=100)
class TTicketAttachmentCreate(TTicketAttachmentBase):
"""Model for creating an attachment"""
ticket_id: int = Field(..., gt=0)
uploaded_by_user_id: Optional[int] = None
class TTicketAttachment(TTicketAttachmentBase):
"""Full attachment model with DB fields"""
id: int
ticket_id: int
uploaded_by_user_id: Optional[int] = None
created_at: datetime
class Config:
from_attributes = True
# ============================================================================
# WORKLOG MODELS
# ============================================================================
class TTicketWorklogBase(BaseModel):
"""Base model for worklog entry"""
work_date: date = Field(..., description="Dato arbejdet blev udført")
hours: Decimal = Field(..., gt=0, le=24, description="Timer brugt")
work_type: WorkType = Field(default=WorkType.SUPPORT)
description: Optional[str] = None
billing_method: BillingMethod = Field(default=BillingMethod.INVOICE)
@field_validator('hours')
@classmethod
def validate_hours(cls, v):
"""Validate hours is reasonable (max 24 hours per day)"""
if v <= 0:
raise ValueError('Hours must be greater than 0')
if v > 24:
raise ValueError('Hours cannot exceed 24 per entry')
return v
class TTicketWorklogCreate(TTicketWorklogBase):
"""Model for creating a worklog entry"""
ticket_id: int = Field(..., gt=0)
user_id: Optional[int] = Field(None, description="User der opretter worklog")
prepaid_card_id: Optional[int] = Field(None, description="Klippekort ID hvis billing_method=prepaid_card")
class TTicketWorklogUpdate(BaseModel):
"""Model for updating a worklog entry (partial updates)"""
work_date: Optional[date] = None
hours: Optional[Decimal] = Field(None, gt=0, le=24)
work_type: Optional[WorkType] = None
description: Optional[str] = None
billing_method: Optional[BillingMethod] = None
status: Optional[WorklogStatus] = None
prepaid_card_id: Optional[int] = None
class TTicketWorklog(TTicketWorklogBase):
"""Full worklog model with DB fields"""
id: int
ticket_id: int
user_id: Optional[int] = None
status: WorklogStatus = Field(default=WorklogStatus.DRAFT)
prepaid_card_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
billed_at: Optional[datetime] = None
class Config:
from_attributes = True
class TTicketWorklogWithDetails(TTicketWorklog):
"""Worklog med ticket detaljer (til review UI)"""
ticket_number: Optional[str] = None
ticket_subject: Optional[str] = None
customer_id: Optional[int] = None
ticket_status: Optional[str] = None
has_sufficient_balance: Optional[bool] = True
class Config:
from_attributes = True
# ============================================================================
# PREPAID CARD (KLIPPEKORT) MODELS
# ============================================================================
class TPrepaidCardBase(BaseModel):
"""Base model for prepaid card"""
customer_id: int = Field(..., gt=0, description="Reference til customers.id")
purchased_hours: Decimal = Field(..., gt=0, description="Timer købt")
price_per_hour: Decimal = Field(..., gt=0, description="DKK pr. time")
total_amount: Decimal = Field(..., gt=0, description="Total pris DKK")
expires_at: Optional[datetime] = Field(None, description="Udløbsdato (NULL = ingen udløb)")
notes: Optional[str] = None
@field_validator('total_amount')
@classmethod
def validate_total(cls, v, info):
"""Validate that total_amount matches purchased_hours * price_per_hour"""
# Note: This validator runs after all fields are set in Pydantic v2
# If we need cross-field validation, use model_validator instead
if v <= 0:
raise ValueError('Total amount must be greater than 0')
return v
class TPrepaidCardCreate(TPrepaidCardBase):
"""Model for creating a prepaid card"""
card_number: Optional[str] = Field(None, description="Auto-generated hvis ikke angivet")
created_by_user_id: Optional[int] = Field(None, description="User der opretter kort")
economic_invoice_number: Optional[str] = Field(None, max_length=50)
economic_product_number: Optional[str] = Field(None, max_length=50)
class TPrepaidCardUpdate(BaseModel):
"""Model for updating a prepaid card (partial updates)"""
status: Optional[PrepaidCardStatus] = None
expires_at: Optional[datetime] = None
notes: Optional[str] = None
economic_invoice_number: Optional[str] = Field(None, max_length=50)
economic_product_number: Optional[str] = Field(None, max_length=50)
class TPrepaidCard(TPrepaidCardBase):
"""Full prepaid card model with DB fields"""
id: int
card_number: str
used_hours: Decimal = Field(default=Decimal('0'))
remaining_hours: Decimal # Generated column
status: PrepaidCardStatus = Field(default=PrepaidCardStatus.ACTIVE)
purchased_at: datetime
economic_invoice_number: Optional[str] = None
economic_product_number: Optional[str] = None
created_by_user_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class TPrepaidCardWithStats(TPrepaidCard):
"""Prepaid card med statistik (fra view)"""
usage_count: Optional[int] = 0
total_hours_used: Optional[Decimal] = None
billed_usage_count: Optional[int] = 0
class Config:
from_attributes = True
# ============================================================================
# PREPAID TRANSACTION MODELS
# ============================================================================
class TPrepaidTransactionBase(BaseModel):
"""Base model for prepaid transaction"""
transaction_type: TransactionType
hours: Decimal = Field(..., description="Timer (positiv=tilføj, negativ=træk)")
description: Optional[str] = None
class TPrepaidTransactionCreate(TPrepaidTransactionBase):
"""Model for creating a transaction"""
card_id: int = Field(..., gt=0)
worklog_id: Optional[int] = Field(None, description="Reference til worklog (NULL for køb/top-up)")
balance_after: Decimal = Field(..., ge=0, description="Saldo efter transaction")
created_by_user_id: Optional[int] = None
class TPrepaidTransaction(TPrepaidTransactionBase):
"""Full transaction model with DB fields"""
id: int
card_id: int
worklog_id: Optional[int] = None
balance_after: Decimal
created_at: datetime
created_by_user_id: Optional[int] = None
class Config:
from_attributes = True
# ============================================================================
# EMAIL INTEGRATION MODELS
# ============================================================================
class TTicketEmailLogBase(BaseModel):
"""Base model for email log"""
action: str = Field(..., max_length=50)
class TTicketEmailLogCreate(TTicketEmailLogBase):
"""Model for creating an email log entry"""
ticket_id: Optional[int] = None
email_id: Optional[int] = Field(None, description="Reference til email_messages.id")
email_message_id: Optional[str] = Field(None, max_length=500, description="Email Message-ID header")
class TTicketEmailLog(TTicketEmailLogBase):
"""Full email log model with DB fields"""
id: int
ticket_id: Optional[int] = None
email_id: Optional[int] = None
email_message_id: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# ============================================================================
# AUDIT LOG MODELS
# ============================================================================
class TTicketAuditLogBase(BaseModel):
"""Base model for audit log"""
entity_type: str = Field(..., max_length=50)
action: str = Field(..., max_length=50)
old_value: Optional[str] = None
new_value: Optional[str] = None
details: Optional[dict] = Field(default_factory=dict)
class TTicketAuditLogCreate(TTicketAuditLogBase):
"""Model for creating an audit log entry"""
ticket_id: Optional[int] = None
entity_id: Optional[int] = None
user_id: Optional[int] = None
class TTicketAuditLog(TTicketAuditLogBase):
"""Full audit log model with DB fields"""
id: int
ticket_id: Optional[int] = None
entity_id: Optional[int] = None
user_id: Optional[int] = None
created_at: datetime
class Config:
from_attributes = True
# ============================================================================
# RESPONSE MODELS (for API responses)
# ============================================================================
class TicketListResponse(BaseModel):
"""Response model for ticket lists"""
tickets: List[TTicketWithStats]
total: int
page: int = 1
page_size: int = 50
class WorklogReviewResponse(BaseModel):
"""Response model for worklog review page"""
worklogs: List[TTicketWorklogWithDetails]
total: int
total_hours: Decimal
total_billable_hours: Decimal
class PrepaidCardBalanceResponse(BaseModel):
"""Response model for prepaid card balance check"""
card: TPrepaidCardWithStats
can_deduct: bool
required_hours: Optional[Decimal] = None
message: Optional[str] = None
# ============================================================================
# REQUEST MODELS (for specific actions)
# ============================================================================
class TicketStatusUpdateRequest(BaseModel):
"""Request model for updating ticket status"""
status: TicketStatus
note: Optional[str] = Field(None, description="Note til audit log")
class WorklogBillingRequest(BaseModel):
"""Request model for marking worklogs as billable"""
worklog_ids: List[int] = Field(..., min_length=1)
note: Optional[str] = Field(None, description="Note til audit log")
class PrepaidCardTopUpRequest(BaseModel):
"""Request model for topping up prepaid card"""
hours: Decimal = Field(..., gt=0, description="Timer at tilføje")
note: Optional[str] = Field(None, description="Beskrivelse af top-up")
class PrepaidCardDeductRequest(BaseModel):
"""Request model for deducting hours from prepaid card"""
worklog_id: int = Field(..., gt=0, description="Worklog ID der skal trækkes fra kort")
hours: Decimal = Field(..., gt=0, description="Timer at trække")

View File

@ -0,0 +1,818 @@
"""
Ticket Module API Router
=========================
REST API endpoints for ticket system.
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import JSONResponse
from app.ticket.backend.ticket_service import TicketService
from app.ticket.backend.economic_export import ticket_economic_service
from app.ticket.backend.models import (
TTicket,
TTicketCreate,
TTicketUpdate,
TTicketWithStats,
TTicketComment,
TTicketCommentCreate,
TTicketWorklog,
TTicketWorklogCreate,
TTicketWorklogUpdate,
TTicketWorklogWithDetails,
TicketListResponse,
TicketStatusUpdateRequest,
WorklogReviewResponse,
WorklogBillingRequest
)
from app.core.database import execute_query, execute_insert, execute_update
from datetime import date
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# TICKET ENDPOINTS
# ============================================================================
@router.get("/tickets", response_model=TicketListResponse, tags=["Tickets"])
async def list_tickets(
status: Optional[str] = Query(None, description="Filter by status"),
priority: Optional[str] = Query(None, description="Filter by priority"),
customer_id: Optional[int] = Query(None, description="Filter by customer"),
assigned_to_user_id: Optional[int] = Query(None, description="Filter by assigned user"),
search: Optional[str] = Query(None, description="Search in subject/description"),
limit: int = Query(50, ge=1, le=100, description="Number of results"),
offset: int = Query(0, ge=0, description="Offset for pagination")
):
"""
List tickets with optional filters
- **status**: Filter by ticket status
- **priority**: Filter by priority level
- **customer_id**: Show tickets for specific customer
- **assigned_to_user_id**: Show tickets assigned to user
- **search**: Search in subject and description
"""
try:
tickets = TicketService.list_tickets(
status=status,
priority=priority,
customer_id=customer_id,
assigned_to_user_id=assigned_to_user_id,
search=search,
limit=limit,
offset=offset
)
# Get total count for pagination
total_query = "SELECT COUNT(*) as count FROM tticket_tickets WHERE 1=1"
params = []
if status:
total_query += " AND status = %s"
params.append(status)
if customer_id:
total_query += " AND customer_id = %s"
params.append(customer_id)
total_result = execute_query(total_query, tuple(params), fetchone=True)
total = total_result['count'] if total_result else 0
return TicketListResponse(
tickets=tickets,
total=total,
page=offset // limit + 1,
page_size=limit
)
except Exception as e:
logger.error(f"❌ Error listing tickets: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets/{ticket_id}", response_model=TTicketWithStats, tags=["Tickets"])
async def get_ticket(ticket_id: int):
"""
Get single ticket with statistics
Returns ticket with comment count, worklog hours, etc.
"""
try:
ticket = TicketService.get_ticket_with_stats(ticket_id)
if not ticket:
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
return ticket
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error getting ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets", response_model=TTicket, status_code=status.HTTP_201_CREATED, tags=["Tickets"])
async def create_ticket(
ticket_data: TTicketCreate,
user_id: Optional[int] = Query(None, description="User creating ticket")
):
"""
Create new ticket
Ticket number will be auto-generated if not provided.
"""
try:
ticket = TicketService.create_ticket(ticket_data, user_id=user_id)
logger.info(f"✅ Created ticket {ticket['ticket_number']}")
return ticket
except Exception as e:
logger.error(f"❌ Error creating ticket: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/tickets/{ticket_id}", response_model=TTicket, tags=["Tickets"])
async def update_ticket(
ticket_id: int,
update_data: TTicketUpdate,
user_id: Optional[int] = Query(None, description="User making update")
):
"""
Update ticket (partial update)
Only provided fields will be updated.
"""
try:
ticket = TicketService.update_ticket(ticket_id, update_data, user_id=user_id)
return ticket
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"❌ Error updating ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/tickets/{ticket_id}/status", response_model=TTicket, tags=["Tickets"])
async def update_ticket_status(
ticket_id: int,
request: TicketStatusUpdateRequest,
user_id: Optional[int] = Query(None, description="User changing status")
):
"""
Update ticket status with validation
Status transitions are validated according to workflow rules.
"""
try:
ticket = TicketService.update_ticket_status(
ticket_id,
request.status.value,
user_id=user_id,
note=request.note
)
return ticket
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error updating status for ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/tickets/{ticket_id}/assign", response_model=TTicket, tags=["Tickets"])
async def assign_ticket(
ticket_id: int,
assigned_to_user_id: int = Query(..., description="User to assign to"),
user_id: Optional[int] = Query(None, description="User making assignment")
):
"""
Assign ticket to a user
"""
try:
ticket = TicketService.assign_ticket(ticket_id, assigned_to_user_id, user_id=user_id)
return ticket
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"❌ Error assigning ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# COMMENT ENDPOINTS
# ============================================================================
@router.get("/tickets/{ticket_id}/comments", response_model=List[TTicketComment], tags=["Comments"])
async def list_comments(ticket_id: int):
"""
List all comments for a ticket
"""
try:
comments = execute_query(
"SELECT * FROM tticket_comments WHERE ticket_id = %s ORDER BY created_at ASC",
(ticket_id,)
)
return comments or []
except Exception as e:
logger.error(f"❌ Error listing comments for ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/comments", response_model=TTicketComment, status_code=status.HTTP_201_CREATED, tags=["Comments"])
async def add_comment(
ticket_id: int,
comment_text: str = Query(..., min_length=1, description="Comment text"),
is_internal: bool = Query(False, description="Is internal note"),
user_id: Optional[int] = Query(None, description="User adding comment")
):
"""
Add comment to ticket
- **is_internal**: If true, comment is only visible to staff
"""
try:
comment = TicketService.add_comment(
ticket_id,
comment_text,
user_id=user_id,
is_internal=is_internal
)
return comment
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"❌ Error adding comment to ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# WORKLOG ENDPOINTS
# ============================================================================
@router.get("/tickets/{ticket_id}/worklog", response_model=List[TTicketWorklog], tags=["Worklog"])
async def list_worklog(ticket_id: int):
"""
List all worklog entries for a ticket
"""
try:
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE ticket_id = %s ORDER BY work_date DESC",
(ticket_id,)
)
return worklog or []
except Exception as e:
logger.error(f"❌ Error listing worklog for ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/worklog", response_model=TTicketWorklog, status_code=status.HTTP_201_CREATED, tags=["Worklog"])
async def create_worklog(
ticket_id: int,
worklog_data: TTicketWorklogCreate,
user_id: Optional[int] = Query(None, description="User creating worklog")
):
"""
Create worklog entry for ticket
Creates time entry in draft status.
"""
try:
from psycopg2.extras import Json
worklog_id = execute_insert(
"""
INSERT INTO tticket_worklog
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
ticket_id,
worklog_data.work_date,
worklog_data.hours,
worklog_data.work_type.value,
worklog_data.description,
worklog_data.billing_method.value,
'draft',
user_id or worklog_data.user_id,
worklog_data.prepaid_card_id
)
)
# Log audit
TicketService.log_audit(
ticket_id=ticket_id,
entity_type="worklog",
entity_id=worklog_id,
user_id=user_id,
action="created",
details={"hours": float(worklog_data.hours), "work_type": worklog_data.work_type.value}
)
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,),
fetchone=True
)
logger.info(f"✅ Created worklog entry {worklog_id} for ticket {ticket_id}")
return worklog
except Exception as e:
logger.error(f"❌ Error creating worklog: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/worklog/{worklog_id}", response_model=TTicketWorklog, tags=["Worklog"])
async def update_worklog(
worklog_id: int,
update_data: TTicketWorklogUpdate,
user_id: Optional[int] = Query(None, description="User updating worklog")
):
"""
Update worklog entry (partial update)
Only draft entries can be fully edited.
"""
try:
# Get current worklog
current = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,),
fetchone=True
)
if not current:
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
# Build update query
updates = []
params = []
update_dict = update_data.model_dump(exclude_unset=True)
for field, value in update_dict.items():
if hasattr(value, 'value'):
value = value.value
updates.append(f"{field} = %s")
params.append(value)
if updates:
params.append(worklog_id)
query = f"UPDATE tticket_worklog SET {', '.join(updates)} WHERE id = %s"
execute_update(query, tuple(params))
# Log audit
TicketService.log_audit(
ticket_id=current['ticket_id'],
entity_type="worklog",
entity_id=worklog_id,
user_id=user_id,
action="updated",
details=update_dict
)
# Fetch updated
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,),
fetchone=True
)
return worklog
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating worklog {worklog_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/worklog/review", response_model=WorklogReviewResponse, tags=["Worklog"])
async def review_worklog(
customer_id: Optional[int] = Query(None, description="Filter by customer"),
status: str = Query("draft", description="Filter by status (default: draft)")
):
"""
Get worklog entries for review/billing
Returns entries ready for review with ticket context.
"""
try:
from decimal import Decimal
query = """
SELECT w.*, t.ticket_number, t.subject AS ticket_subject,
t.customer_id, t.status AS ticket_status
FROM tticket_worklog w
JOIN tticket_tickets t ON w.ticket_id = t.id
WHERE w.status = %s
"""
params = [status]
if customer_id:
query += " AND t.customer_id = %s"
params.append(customer_id)
query += " ORDER BY w.work_date DESC, t.customer_id"
worklogs = execute_query(query, tuple(params))
# Calculate totals
total_hours = Decimal('0')
total_billable_hours = Decimal('0')
for w in worklogs or []:
total_hours += Decimal(str(w['hours']))
if w['status'] in ['draft', 'billable']:
total_billable_hours += Decimal(str(w['hours']))
return WorklogReviewResponse(
worklogs=worklogs or [],
total=len(worklogs) if worklogs else 0,
total_hours=total_hours,
total_billable_hours=total_billable_hours
)
except Exception as e:
logger.error(f"❌ Error getting worklog review: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/worklog/mark-billable", tags=["Worklog"])
async def mark_worklog_billable(
request: WorklogBillingRequest,
user_id: Optional[int] = Query(None, description="User marking as billable")
):
"""
Mark worklog entries as billable
Changes status from draft to billable for selected entries.
"""
try:
updated_count = 0
for worklog_id in request.worklog_ids:
# Get worklog
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,),
fetchone=True
)
if not worklog:
logger.warning(f"⚠️ Worklog {worklog_id} not found, skipping")
continue
if worklog['status'] != 'draft':
logger.warning(f"⚠️ Worklog {worklog_id} not in draft status, skipping")
continue
# Update to billable
execute_update(
"UPDATE tticket_worklog SET status = 'billable' WHERE id = %s",
(worklog_id,)
)
# Log audit
TicketService.log_audit(
ticket_id=worklog['ticket_id'],
entity_type="worklog",
entity_id=worklog_id,
user_id=user_id,
action="marked_billable",
old_value="draft",
new_value="billable",
details={"note": request.note} if request.note else None
)
updated_count += 1
logger.info(f"✅ Marked {updated_count} worklog entries as billable")
return JSONResponse(
content={
"success": True,
"updated_count": updated_count,
"message": f"Marked {updated_count} entries as billable"
}
)
except Exception as e:
logger.error(f"❌ Error marking worklog as billable: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# PREPAID CARD (KLIPPEKORT) ENDPOINTS
# ============================================================================
from app.ticket.backend.klippekort_service import KlippekortService
from app.ticket.backend.models import (
TPrepaidCard,
TPrepaidCardCreate,
TPrepaidCardUpdate,
TPrepaidCardWithStats,
TPrepaidTransaction,
PrepaidCardBalanceResponse,
PrepaidCardTopUpRequest
)
@router.get("/prepaid-cards", response_model=List[TPrepaidCard], tags=["Prepaid Cards"])
async def list_prepaid_cards(
customer_id: Optional[int] = Query(None, description="Filter by customer"),
status: Optional[str] = Query(None, description="Filter by status"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0)
):
"""
List prepaid cards with optional filters
"""
try:
cards = KlippekortService.list_cards(
customer_id=customer_id,
status=status,
limit=limit,
offset=offset
)
return cards
except Exception as e:
logger.error(f"❌ Error listing prepaid cards: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prepaid-cards/{card_id}", response_model=TPrepaidCardWithStats, tags=["Prepaid Cards"])
async def get_prepaid_card(card_id: int):
"""
Get prepaid card with usage statistics
"""
try:
card = KlippekortService.get_card_with_stats(card_id)
if not card:
raise HTTPException(status_code=404, detail=f"Prepaid card {card_id} not found")
return card
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error getting prepaid card {card_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/prepaid-cards", response_model=TPrepaidCard, status_code=status.HTTP_201_CREATED, tags=["Prepaid Cards"])
async def purchase_prepaid_card(
card_data: TPrepaidCardCreate,
user_id: Optional[int] = Query(None, description="User purchasing card")
):
"""
Purchase new prepaid card
CONSTRAINT: Only 1 active card allowed per customer.
Will fail if customer already has an active card.
"""
try:
card = KlippekortService.purchase_card(card_data, user_id=user_id)
logger.info(f"✅ Purchased prepaid card {card['card_number']}")
return card
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error purchasing prepaid card: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prepaid-cards/customer/{customer_id}/balance", response_model=PrepaidCardBalanceResponse, tags=["Prepaid Cards"])
async def check_customer_balance(customer_id: int):
"""
Check prepaid card balance for customer
Returns balance info for customer's active card.
"""
try:
balance_info = KlippekortService.check_balance(customer_id)
if not balance_info['has_card']:
return PrepaidCardBalanceResponse(
card=None,
can_deduct=False,
message=f"Customer {customer_id} has no active prepaid card"
)
# Get card details
card = KlippekortService.get_card_with_stats(balance_info['card_id'])
return PrepaidCardBalanceResponse(
card=card,
can_deduct=balance_info['balance_hours'] > 0,
message=f"Balance: {balance_info['balance_hours']}h"
)
except Exception as e:
logger.error(f"❌ Error checking balance for customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/prepaid-cards/{card_id}/top-up", response_model=TPrepaidTransaction, tags=["Prepaid Cards"])
async def top_up_prepaid_card(
card_id: int,
request: PrepaidCardTopUpRequest,
user_id: Optional[int] = Query(None, description="User performing top-up")
):
"""
Top up prepaid card with additional hours
"""
try:
transaction = KlippekortService.top_up_card(
card_id,
request.hours,
user_id=user_id,
note=request.note
)
return transaction
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error topping up card {card_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prepaid-cards/{card_id}/transactions", response_model=List[TPrepaidTransaction], tags=["Prepaid Cards"])
async def get_card_transactions(
card_id: int,
limit: int = Query(100, ge=1, le=500)
):
"""
Get transaction history for prepaid card
"""
try:
transactions = KlippekortService.get_transactions(card_id, limit=limit)
return transactions
except Exception as e:
logger.error(f"❌ Error getting transactions for card {card_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/prepaid-cards/{card_id}", response_model=TPrepaidCard, tags=["Prepaid Cards"])
async def cancel_prepaid_card(
card_id: int,
reason: Optional[str] = Query(None, description="Cancellation reason"),
user_id: Optional[int] = Query(None, description="User cancelling card")
):
"""
Cancel/deactivate prepaid card
"""
try:
card = KlippekortService.cancel_card(card_id, user_id=user_id, reason=reason)
return card
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error cancelling card {card_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# STATISTICS ENDPOINTS
# ============================================================================
@router.get("/tickets/stats/by-status", tags=["Statistics"])
async def get_stats_by_status():
"""
Get ticket statistics grouped by status
"""
try:
stats = execute_query(
"SELECT * FROM tticket_stats_by_status ORDER BY status"
)
return stats or []
except Exception as e:
logger.error(f"❌ Error getting stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets/stats/open", tags=["Statistics"])
async def get_open_tickets_stats():
"""
Get statistics for open tickets
"""
try:
stats = execute_query(
"""
SELECT
COUNT(*) as total_open,
COUNT(*) FILTER (WHERE status = 'open') as new_tickets,
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
COUNT(*) FILTER (WHERE priority = 'urgent') as urgent_count,
AVG(age_hours) as avg_age_hours
FROM tticket_open_tickets
""",
fetchone=True
)
return stats or {}
except Exception as e:
logger.error(f"❌ Error getting open tickets stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# E-CONOMIC EXPORT ENDPOINTS
# ============================================================================
@router.post("/worklog/export/preview", tags=["E-conomic Export"])
async def preview_economic_export(
customer_id: int = Query(..., description="Customer ID"),
worklog_ids: Optional[List[int]] = Query(None, description="Specific worklog IDs to export"),
date_from: Optional[date] = Query(None, description="Start date filter"),
date_to: Optional[date] = Query(None, description="End date filter")
):
"""
Preview what would be exported to e-conomic without actually exporting
**Safety**: This is read-only and safe to call
"""
try:
preview = await ticket_economic_service.get_export_preview(
customer_id=customer_id,
worklog_ids=worklog_ids,
date_from=date_from,
date_to=date_to
)
return preview
except Exception as e:
logger.error(f"❌ Error generating export preview: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/worklog/export/execute", tags=["E-conomic Export"])
async def execute_economic_export(
customer_id: int = Query(..., description="Customer ID"),
worklog_ids: Optional[List[int]] = Query(None, description="Specific worklog IDs to export"),
date_from: Optional[date] = Query(None, description="Start date filter"),
date_to: Optional[date] = Query(None, description="End date filter")
):
"""
Export billable worklog entries to e-conomic as draft invoice
** WARNING**: This creates invoices in e-conomic (subject to safety switches)
**Safety Switches**:
- `TICKET_ECONOMIC_READ_ONLY=true`: Blocks execution
- `TICKET_ECONOMIC_DRY_RUN=true`: Logs but doesn't execute
- Both must be `false` to actually export
**Process**:
1. Validates customer has e-conomic mapping
2. Collects billable worklog entries
3. Creates draft invoice in e-conomic
4. Marks worklog entries as "billed"
"""
try:
result = await ticket_economic_service.export_billable_worklog_batch(
customer_id=customer_id,
worklog_ids=worklog_ids,
date_from=date_from,
date_to=date_to
)
if result['status'] == 'blocked':
return JSONResponse(
status_code=403,
content={
'error': 'Export blocked by safety switches',
'read_only': result.get('read_only'),
'dry_run': result.get('dry_run'),
'message': 'Set TICKET_ECONOMIC_READ_ONLY=false and TICKET_ECONOMIC_DRY_RUN=false to enable'
}
)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error executing export: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,543 @@
"""
Ticket Service
==============
Business logic for ticket operations: status transitions, validation, audit logging.
"""
import logging
from datetime import datetime
from typing import Optional, Dict, Any, List
from decimal import Decimal
from app.core.database import execute_query, execute_insert, execute_update
from app.ticket.backend.models import (
TicketStatus,
TicketPriority,
TTicketCreate,
TTicketUpdate,
TTicket
)
logger = logging.getLogger(__name__)
class TicketService:
"""Service for ticket business logic"""
# Status transition rules (which transitions are allowed)
ALLOWED_TRANSITIONS = {
TicketStatus.OPEN: [TicketStatus.IN_PROGRESS, TicketStatus.CLOSED],
TicketStatus.IN_PROGRESS: [
TicketStatus.WAITING_CUSTOMER,
TicketStatus.WAITING_INTERNAL,
TicketStatus.RESOLVED
],
TicketStatus.WAITING_CUSTOMER: [TicketStatus.IN_PROGRESS, TicketStatus.CLOSED],
TicketStatus.WAITING_INTERNAL: [TicketStatus.IN_PROGRESS, TicketStatus.CLOSED],
TicketStatus.RESOLVED: [TicketStatus.CLOSED, TicketStatus.IN_PROGRESS], # Can reopen
TicketStatus.CLOSED: [] # Cannot transition from closed
}
@staticmethod
def validate_status_transition(current_status: str, new_status: str) -> tuple[bool, Optional[str]]:
"""
Validate if status transition is allowed
Returns:
(is_valid, error_message)
"""
try:
current = TicketStatus(current_status)
new = TicketStatus(new_status)
except ValueError as e:
return False, f"Invalid status: {e}"
if current == new:
return True, None # Same status is OK
allowed = TicketService.ALLOWED_TRANSITIONS.get(current, [])
if new not in allowed:
return False, f"Cannot transition from {current.value} to {new.value}"
return True, None
@staticmethod
def create_ticket(
ticket_data: TTicketCreate,
user_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Create a new ticket with auto-generated ticket_number if not provided
Args:
ticket_data: Ticket creation data
user_id: User creating the ticket
Returns:
Created ticket dict
"""
logger.info(f"🎫 Creating ticket: {ticket_data.subject}")
# Prepare data for PostgreSQL (convert Python types to SQL types)
import json
from psycopg2.extras import Json
# Insert ticket (trigger will auto-generate ticket_number if NULL)
ticket_id = execute_insert(
"""
INSERT INTO tticket_tickets (
ticket_number, subject, description, status, priority, category,
customer_id, contact_id, assigned_to_user_id, created_by_user_id,
source, tags, custom_fields
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
ticket_data.ticket_number,
ticket_data.subject,
ticket_data.description,
ticket_data.status.value,
ticket_data.priority.value,
ticket_data.category,
ticket_data.customer_id,
ticket_data.contact_id,
ticket_data.assigned_to_user_id,
user_id or ticket_data.created_by_user_id,
ticket_data.source.value,
ticket_data.tags or [], # PostgreSQL array
Json(ticket_data.custom_fields or {}) # PostgreSQL JSONB
)
)
# Log creation
TicketService.log_audit(
ticket_id=ticket_id,
entity_type="ticket",
entity_id=ticket_id,
user_id=user_id,
action="created",
details={"subject": ticket_data.subject, "status": ticket_data.status.value}
)
# Fetch created ticket
ticket = execute_query(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
logger.info(f"✅ Created ticket {ticket['ticket_number']} (ID: {ticket_id})")
return ticket
@staticmethod
def update_ticket(
ticket_id: int,
update_data: TTicketUpdate,
user_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Update ticket with partial data
Args:
ticket_id: Ticket ID to update
update_data: Fields to update
user_id: User making the update
Returns:
Updated ticket dict
"""
# Get current ticket
current = execute_query(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
if not current:
raise ValueError(f"Ticket {ticket_id} not found")
# Build UPDATE query dynamically
import json
from psycopg2.extras import Json
updates = []
params = []
update_dict = update_data.model_dump(exclude_unset=True)
for field, value in update_dict.items():
# Convert enums to values
if hasattr(value, 'value'):
value = value.value
# Convert dict to JSON for PostgreSQL JSONB
elif field == 'custom_fields' and isinstance(value, dict):
value = Json(value)
# Arrays are handled automatically by psycopg2
updates.append(f"{field} = %s")
params.append(value)
if not updates:
logger.warning(f"No fields to update for ticket {ticket_id}")
return current
# Add ticket_id to params
params.append(ticket_id)
query = f"UPDATE tticket_tickets SET {', '.join(updates)} WHERE id = %s"
execute_update(query, tuple(params))
# Log update
TicketService.log_audit(
ticket_id=ticket_id,
entity_type="ticket",
entity_id=ticket_id,
user_id=user_id,
action="updated",
details=update_dict
)
# Fetch updated ticket
updated = execute_query(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
logger.info(f"✅ Updated ticket {updated['ticket_number']}")
return updated
@staticmethod
def update_ticket_status(
ticket_id: int,
new_status: str,
user_id: Optional[int] = None,
note: Optional[str] = None
) -> Dict[str, Any]:
"""
Update ticket status with validation
Args:
ticket_id: Ticket ID
new_status: New status value
user_id: User making the change
note: Optional note for audit log
Returns:
Updated ticket dict
Raises:
ValueError: If transition is not allowed
"""
# Get current ticket
current = execute_query(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
if not current:
raise ValueError(f"Ticket {ticket_id} not found")
current_status = current['status']
# Validate transition
is_valid, error = TicketService.validate_status_transition(current_status, new_status)
if not is_valid:
logger.error(f"❌ Invalid status transition: {error}")
raise ValueError(error)
# Update status
execute_update(
"UPDATE tticket_tickets SET status = %s WHERE id = %s",
(new_status, ticket_id)
)
# Update resolved_at timestamp if transitioning to resolved
if new_status == TicketStatus.RESOLVED.value and not current['resolved_at']:
execute_update(
"UPDATE tticket_tickets SET resolved_at = CURRENT_TIMESTAMP WHERE id = %s",
(ticket_id,)
)
# Update closed_at timestamp if transitioning to closed
if new_status == TicketStatus.CLOSED.value and not current['closed_at']:
execute_update(
"UPDATE tticket_tickets SET closed_at = CURRENT_TIMESTAMP WHERE id = %s",
(ticket_id,)
)
# Log status change
TicketService.log_audit(
ticket_id=ticket_id,
entity_type="ticket",
entity_id=ticket_id,
user_id=user_id,
action="status_changed",
old_value=current_status,
new_value=new_status,
details={"note": note} if note else None
)
# Fetch updated ticket
updated = execute_query(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
logger.info(f"✅ Updated ticket {updated['ticket_number']} status: {current_status}{new_status}")
return updated
@staticmethod
def assign_ticket(
ticket_id: int,
assigned_to_user_id: int,
user_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Assign ticket to a user
Args:
ticket_id: Ticket ID
assigned_to_user_id: User to assign to
user_id: User making the assignment
Returns:
Updated ticket dict
"""
# Get current assignment
current = execute_query(
"SELECT assigned_to_user_id FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
if not current:
raise ValueError(f"Ticket {ticket_id} not found")
# Update assignment
execute_update(
"UPDATE tticket_tickets SET assigned_to_user_id = %s WHERE id = %s",
(assigned_to_user_id, ticket_id)
)
# Log assignment
TicketService.log_audit(
ticket_id=ticket_id,
entity_type="ticket",
entity_id=ticket_id,
user_id=user_id,
action="assigned",
old_value=str(current['assigned_to_user_id']) if current['assigned_to_user_id'] else None,
new_value=str(assigned_to_user_id)
)
# Fetch updated ticket
updated = execute_query(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
logger.info(f"✅ Assigned ticket {updated['ticket_number']} to user {assigned_to_user_id}")
return updated
@staticmethod
def add_comment(
ticket_id: int,
comment_text: str,
user_id: Optional[int] = None,
is_internal: bool = False
) -> Dict[str, Any]:
"""
Add a comment to a ticket
Args:
ticket_id: Ticket ID
comment_text: Comment content
user_id: User adding comment
is_internal: Whether comment is internal
Returns:
Created comment dict
"""
# Verify ticket exists
ticket = execute_query(
"SELECT id FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
if not ticket:
raise ValueError(f"Ticket {ticket_id} not found")
# Insert comment
comment_id = execute_insert(
"""
INSERT INTO tticket_comments (ticket_id, user_id, comment_text, is_internal)
VALUES (%s, %s, %s, %s)
""",
(ticket_id, user_id, comment_text, is_internal)
)
# Update ticket's updated_at timestamp
execute_update(
"UPDATE tticket_tickets SET updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(ticket_id,)
)
# Update first_response_at if this is the first non-internal comment
if not is_internal:
ticket = execute_query(
"SELECT first_response_at FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
if not ticket['first_response_at']:
execute_update(
"UPDATE tticket_tickets SET first_response_at = CURRENT_TIMESTAMP WHERE id = %s",
(ticket_id,)
)
# Log comment
TicketService.log_audit(
ticket_id=ticket_id,
entity_type="comment",
entity_id=comment_id,
user_id=user_id,
action="comment_added",
details={"is_internal": is_internal, "length": len(comment_text)}
)
# Fetch created comment
comment = execute_query(
"SELECT * FROM tticket_comments WHERE id = %s",
(comment_id,),
fetchone=True
)
logger.info(f"💬 Added comment to ticket {ticket_id} (internal: {is_internal})")
return comment
@staticmethod
def log_audit(
ticket_id: Optional[int] = None,
entity_type: str = "ticket",
entity_id: Optional[int] = None,
user_id: Optional[int] = None,
action: str = "updated",
old_value: Optional[str] = None,
new_value: Optional[str] = None,
details: Optional[Dict[str, Any]] = None
) -> None:
"""
Log an action to audit log
Args:
ticket_id: Related ticket ID (optional)
entity_type: Type of entity (ticket, comment, worklog, etc.)
entity_id: ID of the entity
user_id: User performing action
action: Action performed
old_value: Old value (for updates)
new_value: New value (for updates)
details: Additional details as JSON
"""
try:
from psycopg2.extras import Json
execute_insert(
"""
INSERT INTO tticket_audit_log
(ticket_id, entity_type, entity_id, user_id, action, old_value, new_value, details)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(ticket_id, entity_type, entity_id, user_id, action, old_value, new_value,
Json(details) if details else None)
)
except Exception as e:
logger.error(f"❌ Failed to log audit entry: {e}")
# Don't raise - audit logging should not break the main operation
@staticmethod
def get_ticket_with_stats(ticket_id: int) -> Optional[Dict[str, Any]]:
"""
Get ticket with statistics from view
Args:
ticket_id: Ticket ID
Returns:
Ticket dict with stats or None if not found
"""
ticket = execute_query(
"SELECT * FROM tticket_open_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
# If not in open_tickets view, fetch from main table
if not ticket:
ticket = execute_query(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
return ticket
@staticmethod
def list_tickets(
status: Optional[str] = None,
priority: Optional[str] = None,
customer_id: Optional[int] = None,
assigned_to_user_id: Optional[int] = None,
search: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[Dict[str, Any]]:
"""
List tickets with filters
Args:
status: Filter by status
priority: Filter by priority
customer_id: Filter by customer
assigned_to_user_id: Filter by assigned user
search: Search in subject/description
limit: Number of results
offset: Offset for pagination
Returns:
List of ticket dicts
"""
query = "SELECT * FROM tticket_tickets WHERE 1=1"
params = []
if status:
query += " AND status = %s"
params.append(status)
if priority:
query += " AND priority = %s"
params.append(priority)
if customer_id:
query += " AND customer_id = %s"
params.append(customer_id)
if assigned_to_user_id:
query += " AND assigned_to_user_id = %s"
params.append(assigned_to_user_id)
if search:
query += " AND (subject ILIKE %s OR description ILIKE %s)"
search_pattern = f"%{search}%"
params.extend([search_pattern, search_pattern])
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
tickets = execute_query(query, tuple(params))
return tickets or []

View File

@ -0,0 +1,6 @@
"""
Ticket Module Frontend
======================
HTML templates og view handlers for ticket-systemet.
"""

View File

@ -0,0 +1,361 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Ticket Dashboard - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.stat-card {
text-align: center;
padding: 2rem 1.5rem;
cursor: pointer;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--accent);
transform: scaleX(0);
transition: transform 0.3s;
}
.stat-card:hover::before {
transform: scaleX(1);
}
.stat-card h3 {
font-size: 3rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.5rem;
line-height: 1;
}
.stat-card p {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .icon {
font-size: 2rem;
opacity: 0.3;
margin-bottom: 1rem;
}
.stat-card.status-open h3 { color: #17a2b8; }
.stat-card.status-in-progress h3 { color: #ffc107; }
.stat-card.status-resolved h3 { color: #28a745; }
.stat-card.status-closed h3 { color: #6c757d; }
.ticket-list {
background: var(--bg-card);
}
.ticket-list th {
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--accent-light);
padding: 1rem 0.75rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ticket-list td {
padding: 1rem 0.75rem;
vertical-align: middle;
border-bottom: 1px solid var(--accent-light);
}
.ticket-row {
transition: background-color 0.2s;
cursor: pointer;
}
.ticket-row:hover {
background-color: var(--accent-light);
}
.badge {
padding: 0.4rem 0.8rem;
font-weight: 500;
border-radius: 6px;
font-size: 0.75rem;
}
.badge-status-open {
background-color: #d1ecf1;
color: #0c5460;
}
.badge-status-in_progress {
background-color: #fff3cd;
color: #856404;
}
.badge-status-pending_customer {
background-color: #e2e3e5;
color: #383d41;
}
.badge-status-resolved {
background-color: #d4edda;
color: #155724;
}
.badge-status-closed {
background-color: #f8d7da;
color: #721c24;
}
.badge-priority-low {
background-color: var(--accent-light);
color: var(--accent);
}
.badge-priority-normal {
background-color: #e2e3e5;
color: #383d41;
}
.badge-priority-high {
background-color: #fff3cd;
color: #856404;
}
.badge-priority-urgent, .badge-priority-critical {
background-color: #f8d7da;
color: #721c24;
}
.ticket-number {
font-family: 'Monaco', 'Courier New', monospace;
background: var(--accent-light);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
color: var(--accent);
font-weight: 600;
}
.worklog-stats {
display: flex;
justify-content: space-around;
padding: 1.5rem;
background: linear-gradient(135deg, var(--accent-light) 0%, var(--bg-card) 100%);
border-radius: var(--border-radius);
margin-bottom: 2rem;
}
.worklog-stat {
text-align: center;
}
.worklog-stat h4 {
font-size: 2rem;
font-weight: 700;
color: var(--accent);
margin: 0;
}
.worklog-stat p {
color: var(--text-secondary);
margin: 0;
font-size: 0.85rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.3;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.section-header h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.quick-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<!-- Page Header -->
<div class="section-header">
<div>
<h1 class="mb-2">
<i class="bi bi-speedometer2"></i> Ticket Dashboard
</h1>
<p class="text-muted">Oversigt over alle tickets og worklog aktivitet</p>
</div>
<div class="quick-actions">
<a href="/ticket/tickets/new" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Ny Ticket
</a>
</div>
</div>
<!-- Ticket Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card stat-card status-open" onclick="filterTickets('open')">
<div class="icon"><i class="bi bi-inbox"></i></div>
<h3>{{ stats.open_count or 0 }}</h3>
<p>Nye Tickets</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card status-in-progress" onclick="filterTickets('in_progress')">
<div class="icon"><i class="bi bi-arrow-repeat"></i></div>
<h3>{{ stats.in_progress_count or 0 }}</h3>
<p>I Gang</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card status-resolved" onclick="filterTickets('resolved')">
<div class="icon"><i class="bi bi-check-circle"></i></div>
<h3>{{ stats.resolved_count or 0 }}</h3>
<p>Løst</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card status-closed" onclick="filterTickets('closed')">
<div class="icon"><i class="bi bi-archive"></i></div>
<h3>{{ stats.closed_count or 0 }}</h3>
<p>Lukket</p>
</div>
</div>
</div>
<!-- Worklog Statistics -->
<div class="worklog-stats">
<div class="worklog-stat">
<h4>{{ worklog_stats.draft_count or 0 }}</h4>
<p>Draft Worklog</p>
</div>
<div class="worklog-stat">
<h4>{{ "%.1f"|format(worklog_stats.draft_hours or 0) }}t</h4>
<p>Udraft Timer</p>
</div>
<div class="worklog-stat">
<h4>{{ worklog_stats.billable_count or 0 }}</h4>
<p>Billable Entries</p>
</div>
<div class="worklog-stat">
<h4>{{ "%.1f"|format(worklog_stats.billable_hours or 0) }}t</h4>
<p>Billable Timer</p>
</div>
</div>
<!-- Recent Tickets -->
<div class="section-header">
<h2>
<i class="bi bi-clock-history"></i> Seneste Tickets
</h2>
<a href="/ticket/tickets" class="btn btn-outline-secondary">
<i class="bi bi-list-ul"></i> Se Alle
</a>
</div>
{% if recent_tickets %}
<div class="card">
<div class="table-responsive">
<table class="table ticket-list mb-0">
<thead>
<tr>
<th>Ticket</th>
<th>Kunde</th>
<th>Status</th>
<th>Prioritet</th>
<th>Oprettet</th>
</tr>
</thead>
<tbody>
{% for ticket in recent_tickets %}
<tr class="ticket-row" onclick="window.location='/ticket/tickets/{{ ticket.id }}'">
<td>
<span class="ticket-number">{{ ticket.ticket_number }}</span>
<br>
<strong>{{ ticket.subject }}</strong>
</td>
<td>
{% if ticket.customer_name %}
{{ ticket.customer_name }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<span class="badge badge-status-{{ ticket.status }}">
{{ ticket.status.replace('_', ' ').title() }}
</span>
</td>
<td>
<span class="badge badge-priority-{{ ticket.priority }}">
{{ ticket.priority.title() }}
</span>
</td>
<td>
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="card">
<div class="empty-state">
<i class="bi bi-inbox"></i>
<h3>Ingen tickets endnu</h3>
<p>Opret din første ticket for at komme i gang</p>
<a href="/ticket/tickets/new" class="btn btn-primary mt-3">
<i class="bi bi-plus-circle"></i> Opret Ticket
</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
// Filter tickets by status
function filterTickets(status) {
window.location.href = `/ticket/tickets?status=${status}`;
}
// Auto-refresh every 5 minutes
setTimeout(() => {
location.reload();
}, 300000);
</script>
{% endblock %}

View File

@ -0,0 +1,265 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Concept 1: Split Screen - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Hide default footer if exists to maximize height */
footer { display: none; }
.split-layout {
display: flex;
height: 100%;
}
/* Left Sidebar: Ticket List */
.ticket-list-sidebar {
width: 350px;
flex-shrink: 0;
border-right: 1px solid rgba(0,0,0,0.1);
background: var(--bg-card);
display: flex;
flex-direction: column;
}
.list-header {
padding: 1rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.ticket-list-scroll {
overflow-y: auto;
flex-grow: 1;
}
.ticket-item {
padding: 1rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
.ticket-item:hover {
background-color: var(--accent-light);
}
.ticket-item.active {
background-color: var(--accent-light);
border-left: 4px solid var(--accent);
}
.ticket-item.unread::after {
content: '';
position: absolute;
top: 1rem;
right: 1rem;
width: 8px;
height: 8px;
background-color: var(--accent);
border-radius: 50%;
}
/* Right Content: Detail View */
.ticket-detail-main {
flex-grow: 1;
background: var(--bg-body);
display: flex;
flex-direction: column;
overflow: hidden;
}
.detail-header {
background: var(--bg-card);
padding: 1rem 1.5rem;
border-bottom: 1px solid rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-scroll {
flex-grow: 1;
overflow-y: auto;
padding: 1.5rem;
}
.detail-footer {
background: var(--bg-card);
padding: 1rem 1.5rem;
border-top: 1px solid rgba(0,0,0,0.1);
}
/* Chat bubbles */
.message-bubble {
background: var(--bg-card);
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
max-width: 85%;
}
.message-bubble.internal {
background: #fff3cd;
border: 1px solid #ffeeba;
}
.message-bubble.me {
background: var(--accent-light);
margin-left: auto;
}
.meta-text {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
</style>
{% endblock %}
{% block content_wrapper %}
<div class="container-fluid p-0" style="height: calc(100vh - 80px); overflow: hidden;">
<div class="split-layout">
<!-- Left Sidebar -->
<div class="ticket-list-sidebar">
<div class="list-header">
<div class="input-group">
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-search"></i></span>
<input type="text" class="form-control border-start-0" placeholder="Søg tickets...">
</div>
<div class="d-flex gap-2 mt-2 overflow-auto pb-1">
<span class="badge bg-primary rounded-pill">Alle</span>
<span class="badge bg-light text-dark border rounded-pill">Mine</span>
<span class="badge bg-light text-dark border rounded-pill">Uløste</span>
</div>
</div>
<div class="ticket-list-scroll">
<!-- Active Item -->
<div class="ticket-item active">
<div class="d-flex justify-content-between mb-1">
<span class="badge bg-warning text-dark">Høj</span>
<small class="text-muted">14:32</small>
</div>
<h6 class="mb-1 text-truncate">Netværksproblem i hovedkontoret</h6>
<div class="d-flex align-items-center gap-2">
<img src="https://ui-avatars.com/api/?name=Tech+Corp&background=random" class="rounded-circle" width="20">
<small class="text-muted">Tech Corp A/S</small>
</div>
</div>
<!-- Other Items -->
<div class="ticket-item unread">
<div class="d-flex justify-content-between mb-1">
<span class="badge bg-info text-white">Ny</span>
<small class="text-muted">12:15</small>
</div>
<h6 class="mb-1 text-truncate">Printer løbet tør for toner</h6>
<div class="d-flex align-items-center gap-2">
<img src="https://ui-avatars.com/api/?name=Advokat+Huset&background=random" class="rounded-circle" width="20">
<small class="text-muted">Advokathuset</small>
</div>
</div>
<div class="ticket-item">
<div class="d-flex justify-content-between mb-1">
<span class="badge bg-success">Løst</span>
<small class="text-muted">I går</small>
</div>
<h6 class="mb-1 text-truncate">Opsætning af ny bruger (Mette)</h6>
<div class="d-flex align-items-center gap-2">
<img src="https://ui-avatars.com/api/?name=Byg+Aps&background=random" class="rounded-circle" width="20">
<small class="text-muted">Byg ApS</small>
</div>
</div>
<!-- More dummy items -->
{% for i in range(5) %}
<div class="ticket-item">
<div class="d-flex justify-content-between mb-1">
<span class="badge bg-secondary">Venter</span>
<small class="text-muted">2 dage siden</small>
</div>
<h6 class="mb-1 text-truncate">Licens fornyelse Office 365</h6>
<div class="d-flex align-items-center gap-2">
<img src="https://ui-avatars.com/api/?name=Kunde+{{i}}&background=random" class="rounded-circle" width="20">
<small class="text-muted">Kunde {{i}}</small>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Right Content -->
<div class="ticket-detail-main">
<div class="detail-header">
<div>
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-light text-dark border">#TKT-20251215-005</span>
<span class="badge bg-warning text-dark">I Gang</span>
</div>
<h5 class="mb-0">Netværksproblem i hovedkontoret</h5>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm"><i class="bi bi-person-plus"></i> Tildel</button>
<button class="btn btn-outline-secondary btn-sm"><i class="bi bi-clock"></i> Log Tid</button>
<button class="btn btn-primary btn-sm"><i class="bi bi-check-lg"></i> Løs Sag</button>
</div>
</div>
<div class="detail-scroll">
<!-- Original Request -->
<div class="message-bubble">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center gap-2">
<img src="https://ui-avatars.com/api/?name=Jens+Jensen&background=random" class="rounded-circle" width="24">
<span class="fw-bold">Jens Jensen</span>
<span class="text-muted small">via Email</span>
</div>
<small class="text-muted">15. dec 14:32</small>
</div>
<p class="mb-0">Hej Support,<br><br>Vi har problemer med internettet på hovedkontoret. Ingen kan komme på Wi-Fi, og kablet forbindelse virker heller ikke. Det haster meget!</p>
</div>
<!-- Internal Note -->
<div class="message-bubble internal">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-shield-lock text-warning"></i>
<span class="fw-bold text-warning-emphasis">Intern Note</span>
</div>
<small class="text-muted">15. dec 14:45</small>
</div>
<p class="mb-0">Jeg har tjekket Unifi controlleren. Switch #3 svarer ikke. Det er nok den der er nede.</p>
</div>
<!-- Reply -->
<div class="message-bubble me">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center gap-2">
<img src="https://ui-avatars.com/api/?name=Christian&background=0f4c75&color=fff" class="rounded-circle" width="24">
<span class="fw-bold">Christian (Support)</span>
</div>
<small class="text-muted">15. dec 14:50</small>
</div>
<p class="mb-0">Hej Jens,<br><br>Jeg kigger på det med det samme. Jeg kan se vi har mistet forbindelsen til en af jeres switche. Jeg prøver at genstarte den remote.</p>
</div>
</div>
<div class="detail-footer">
<div class="input-group">
<button class="btn btn-outline-secondary" type="button"><i class="bi bi-paperclip"></i></button>
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">Svar Kunde</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Svar Kunde</a></li>
<li><a class="dropdown-item" href="#"><i class="bi bi-shield-lock text-warning"></i> Intern Note</a></li>
</ul>
<input type="text" class="form-control" placeholder="Skriv et svar...">
<button class="btn btn-primary" type="button"><i class="bi bi-send"></i> Send</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,251 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Concept 2: Kanban Board - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.kanban-board {
display: flex;
height: 100%;
padding: 1.5rem;
gap: 1.5rem;
overflow-x: auto;
}
.kanban-column {
width: 320px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: transparent;
height: 100%;
}
.column-header {
padding: 0.5rem 0.5rem 1rem 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 0.5px;
}
.column-content {
flex-grow: 1;
overflow-y: auto;
padding-right: 0.5rem; /* Space for scrollbar */
}
.kanban-card {
background: var(--bg-card);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
cursor: grab;
transition: transform 0.2s, box-shadow 0.2s;
border-left: 4px solid transparent;
}
.kanban-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.kanban-card:active {
cursor: grabbing;
}
/* Priority Borders */
.priority-high { border-left-color: var(--warning); }
.priority-critical { border-left-color: var(--danger); }
.priority-normal { border-left-color: var(--info); }
.priority-low { border-left-color: var(--text-secondary); }
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.card-tags {
display: flex;
gap: 0.25rem;
margin-top: 0.5rem;
}
.tag {
background: var(--bg-body);
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.7rem;
color: var(--text-secondary);
}
.add-card-btn {
background: transparent;
border: 2px dashed rgba(0,0,0,0.1);
color: var(--text-secondary);
width: 100%;
padding: 0.75rem;
border-radius: 8px;
margin-top: 0.5rem;
transition: all 0.2s;
}
.add-card-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(15, 76, 117, 0.05);
}
</style>
{% endblock %}
{% block content_wrapper %}
<div class="container-fluid p-0" style="height: calc(100vh - 80px); overflow: hidden;">
<div class="kanban-board">
<!-- Column: Ny -->
<div class="kanban-column">
<div class="column-header">
<span><i class="bi bi-circle text-info me-2"></i>Ny</span>
<span class="badge bg-secondary bg-opacity-10 text-secondary">3</span>
</div>
<div class="column-content">
<div class="kanban-card priority-critical">
<div class="d-flex justify-content-between mb-2">
<small class="text-muted">#TKT-005</small>
<i class="bi bi-three-dots text-muted"></i>
</div>
<h6 class="mb-2">Server nede i produktion</h6>
<div class="card-tags">
<span class="tag">Server</span>
<span class="tag">Kritisk</span>
</div>
<div class="card-meta">
<div class="d-flex align-items-center gap-1">
<img src="https://ui-avatars.com/api/?name=Fabrik+A/S&background=random" class="rounded-circle" width="16">
<span>Fabrik A/S</span>
</div>
<span>10m</span>
</div>
</div>
<div class="kanban-card priority-normal">
<div class="d-flex justify-content-between mb-2">
<small class="text-muted">#TKT-008</small>
<i class="bi bi-three-dots text-muted"></i>
</div>
<h6 class="mb-2">Ny medarbejder oprettelse</h6>
<div class="card-meta">
<div class="d-flex align-items-center gap-1">
<img src="https://ui-avatars.com/api/?name=Kontor+ApS&background=random" class="rounded-circle" width="16">
<span>Kontor ApS</span>
</div>
<span>2t</span>
</div>
</div>
<button class="add-card-btn"><i class="bi bi-plus"></i> Tilføj kort</button>
</div>
</div>
<!-- Column: I Gang -->
<div class="kanban-column">
<div class="column-header">
<span><i class="bi bi-play-circle text-warning me-2"></i>I Gang</span>
<span class="badge bg-secondary bg-opacity-10 text-secondary">2</span>
</div>
<div class="column-content">
<div class="kanban-card priority-high">
<div class="d-flex justify-content-between mb-2">
<small class="text-muted">#TKT-002</small>
<img src="https://ui-avatars.com/api/?name=Christian&background=0f4c75&color=fff" class="rounded-circle" width="20" title="Assigned to Christian">
</div>
<h6 class="mb-2">Netværksproblem hovedkontor</h6>
<div class="card-tags">
<span class="tag">Netværk</span>
</div>
<div class="card-meta">
<div class="d-flex align-items-center gap-1">
<img src="https://ui-avatars.com/api/?name=Tech+Corp&background=random" class="rounded-circle" width="16">
<span>Tech Corp</span>
</div>
<span>1d</span>
</div>
</div>
<div class="kanban-card priority-normal">
<div class="d-flex justify-content-between mb-2">
<small class="text-muted">#TKT-004</small>
<img src="https://ui-avatars.com/api/?name=Morten&background=random" class="rounded-circle" width="20" title="Assigned to Morten">
</div>
<h6 class="mb-2">Opdatering af firewall</h6>
<div class="card-meta">
<div class="d-flex align-items-center gap-1">
<img src="https://ui-avatars.com/api/?name=Sikkerhed+A/S&background=random" class="rounded-circle" width="16">
<span>Sikkerhed A/S</span>
</div>
<span>3d</span>
</div>
</div>
</div>
</div>
<!-- Column: Venter på Kunde -->
<div class="kanban-column">
<div class="column-header">
<span><i class="bi bi-pause-circle text-secondary me-2"></i>Venter</span>
<span class="badge bg-secondary bg-opacity-10 text-secondary">4</span>
</div>
<div class="column-content">
{% for i in range(4) %}
<div class="kanban-card priority-low">
<div class="d-flex justify-content-between mb-2">
<small class="text-muted">#TKT-01{{i}}</small>
<i class="bi bi-three-dots text-muted"></i>
</div>
<h6 class="mb-2">Bestilling af hardware {{i}}</h6>
<div class="card-meta">
<div class="d-flex align-items-center gap-1">
<img src="https://ui-avatars.com/api/?name=Kunde+{{i}}&background=random" class="rounded-circle" width="16">
<span>Kunde {{i}}</span>
</div>
<span>5d</span>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Column: Løst -->
<div class="kanban-column">
<div class="column-header">
<span><i class="bi bi-check-circle text-success me-2"></i>Løst</span>
<span class="badge bg-secondary bg-opacity-10 text-secondary">12</span>
</div>
<div class="column-content">
<div class="kanban-card priority-normal opacity-75">
<div class="d-flex justify-content-between mb-2">
<small class="text-muted">#TKT-001</small>
<i class="bi bi-check-lg text-success"></i>
</div>
<h6 class="mb-2 text-decoration-line-through">Printer installation</h6>
<div class="card-meta">
<div class="d-flex align-items-center gap-1">
<img src="https://ui-avatars.com/api/?name=Advokat&background=random" class="rounded-circle" width="16">
<span>Advokat</span>
</div>
<span>1u</span>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,239 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Concept 3: Power Table - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.power-table-container {
background: var(--bg-card);
border-radius: 8px;
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
overflow: hidden;
}
.filter-bar {
padding: 1rem;
border-bottom: 1px solid rgba(0,0,0,0.1);
background: var(--bg-body);
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.table-responsive {
overflow-x: auto;
}
.power-table {
width: 100%;
font-size: 0.85rem;
white-space: nowrap;
}
.power-table th {
background: var(--bg-body);
padding: 0.75rem 1rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
border-bottom: 2px solid rgba(0,0,0,0.1);
}
.power-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
vertical-align: middle;
}
.power-table tr:hover td {
background-color: var(--accent-light);
cursor: pointer;
}
.col-checkbox { width: 40px; text-align: center; }
.col-id { width: 120px; font-family: monospace; color: var(--accent); }
.col-status { width: 100px; }
.col-priority { width: 100px; }
.col-subject { min-width: 300px; font-weight: 500; }
.col-customer { width: 200px; }
.col-assigned { width: 150px; }
.col-updated { width: 150px; color: var(--text-secondary); }
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-open { background-color: var(--info); }
.status-in-progress { background-color: var(--warning); }
.status-resolved { background-color: var(--success); }
.status-closed { background-color: var(--text-secondary); }
.priority-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.priority-high { background: #fff3cd; color: #856404; }
.priority-critical { background: #f8d7da; color: #721c24; }
.priority-normal { background: #e2e3e5; color: #383d41; }
.avatar-sm {
width: 24px;
height: 24px;
border-radius: 50%;
vertical-align: middle;
margin-right: 6px;
}
.bulk-actions {
display: none;
align-items: center;
gap: 1rem;
background: var(--accent);
color: white;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
}
.bulk-actions.show {
display: flex;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0">Alle Tickets</h2>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary"><i class="bi bi-download"></i> Eksportér</button>
<button class="btn btn-primary"><i class="bi bi-plus-lg"></i> Ny Ticket</button>
</div>
</div>
<div class="power-table-container">
<!-- Filter Bar -->
<div class="filter-bar">
<div class="input-group" style="width: 300px;">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
<input type="text" class="form-control border-start-0" placeholder="Søg...">
</div>
<select class="form-select" style="width: 150px;">
<option selected>Status: Alle</option>
<option>Ny</option>
<option>I Gang</option>
<option>Løst</option>
</select>
<select class="form-select" style="width: 150px;">
<option selected>Prioritet: Alle</option>
<option>Kritisk</option>
<option>Høj</option>
<option>Normal</option>
</select>
<select class="form-select" style="width: 150px;">
<option selected>Tildelt: Alle</option>
<option>Mig</option>
<option>Ufordelt</option>
</select>
<div class="ms-auto text-muted small">
Viser 1-50 af 142 tickets
</div>
</div>
<!-- Bulk Actions Bar (Hidden by default) -->
<div class="bg-light p-2 border-bottom d-none" id="bulkActions">
<div class="d-flex align-items-center gap-3 px-2">
<span class="fw-bold text-primary">3 valgt</span>
<div class="vr"></div>
<button class="btn btn-sm btn-outline-secondary">Sæt Status</button>
<button class="btn btn-sm btn-outline-secondary">Tildel</button>
<button class="btn btn-sm btn-outline-danger">Slet</button>
</div>
</div>
<!-- Table -->
<div class="table-responsive">
<table class="power-table">
<thead>
<tr>
<th class="col-checkbox"><input type="checkbox" class="form-check-input"></th>
<th class="col-id">ID</th>
<th class="col-subject">Emne</th>
<th class="col-customer">Kunde</th>
<th class="col-status">Status</th>
<th class="col-priority">Prioritet</th>
<th class="col-assigned">Tildelt</th>
<th class="col-updated">Opdateret</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-checkbox"><input type="checkbox" class="form-check-input"></td>
<td class="col-id">#TKT-005</td>
<td class="col-subject">Netværksproblem i hovedkontoret</td>
<td class="col-customer">
<img src="https://ui-avatars.com/api/?name=Tech+Corp&background=random" class="avatar-sm">
Tech Corp A/S
</td>
<td class="col-status"><span class="status-dot status-in-progress"></span>I Gang</td>
<td class="col-priority"><span class="priority-badge priority-high">Høj</span></td>
<td class="col-assigned">
<img src="https://ui-avatars.com/api/?name=Christian&background=0f4c75&color=fff" class="avatar-sm">
Christian
</td>
<td class="col-updated">10 min siden</td>
</tr>
<tr>
<td class="col-checkbox"><input type="checkbox" class="form-check-input"></td>
<td class="col-id">#TKT-004</td>
<td class="col-subject">Server nede i produktion</td>
<td class="col-customer">
<img src="https://ui-avatars.com/api/?name=Fabrik+A/S&background=random" class="avatar-sm">
Fabrik A/S
</td>
<td class="col-status"><span class="status-dot status-open"></span>Ny</td>
<td class="col-priority"><span class="priority-badge priority-critical">Kritisk</span></td>
<td class="col-assigned"><span class="text-muted fst-italic">Ufordelt</span></td>
<td class="col-updated">1 time siden</td>
</tr>
{% for i in range(10) %}
<tr>
<td class="col-checkbox"><input type="checkbox" class="form-check-input"></td>
<td class="col-id">#TKT-00{{i}}</td>
<td class="col-subject">Support sag vedrørende faktura {{i}}</td>
<td class="col-customer">
<img src="https://ui-avatars.com/api/?name=Kunde+{{i}}&background=random" class="avatar-sm">
Kunde {{i}} ApS
</td>
<td class="col-status"><span class="status-dot status-resolved"></span>Løst</td>
<td class="col-priority"><span class="priority-badge priority-normal">Normal</span></td>
<td class="col-assigned">
<img src="https://ui-avatars.com/api/?name=Morten&background=random" class="avatar-sm">
Morten
</td>
<td class="col-updated">{{i}} dage siden</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,409 @@
{% extends "shared/frontend/base.html" %}
{% block title %}{{ ticket.ticket_number }} - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.ticket-header {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%);
padding: 2rem;
border-radius: var(--border-radius);
color: white;
margin-bottom: 2rem;
}
.ticket-number {
font-family: 'Monaco', 'Courier New', monospace;
font-size: 1rem;
opacity: 0.9;
}
.ticket-title {
font-size: 2rem;
font-weight: 700;
margin: 0.5rem 0;
}
.badge {
padding: 0.4rem 0.8rem;
font-weight: 500;
border-radius: 6px;
font-size: 0.75rem;
}
.badge-status-open { background-color: #d1ecf1; color: #0c5460; }
.badge-status-in_progress { background-color: #fff3cd; color: #856404; }
.badge-status-pending_customer { background-color: #e2e3e5; color: #383d41; }
.badge-status-resolved { background-color: #d4edda; color: #155724; }
.badge-status-closed { background-color: #f8d7da; color: #721c24; }
.badge-priority-low { background-color: var(--accent-light); color: var(--accent); }
.badge-priority-normal { background-color: #e2e3e5; color: #383d41; }
.badge-priority-high { background-color: #fff3cd; color: #856404; }
.badge-priority-urgent, .badge-priority-critical { background-color: #f8d7da; color: #721c24; }
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.info-item {
background: var(--bg-card);
padding: 1rem;
border-radius: var(--border-radius);
border: 1px solid var(--accent-light);
}
.info-item label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.info-item .value {
font-weight: 600;
color: var(--text-primary);
}
.comment {
padding: 1.5rem;
margin-bottom: 1rem;
background: var(--accent-light);
border-radius: var(--border-radius);
border-left: 4px solid var(--accent);
}
.comment.internal {
background: #fff3cd;
border-left-color: #ffc107;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.comment-author {
font-weight: 600;
color: var(--accent);
}
.comment-date {
font-size: 0.85rem;
color: var(--text-secondary);
}
.worklog-table th {
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--accent-light);
padding: 0.75rem;
font-size: 0.85rem;
}
.worklog-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--accent-light);
}
.attachment {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
background: var(--accent-light);
border-radius: var(--border-radius);
margin-right: 0.5rem;
margin-bottom: 0.5rem;
text-decoration: none;
color: var(--accent);
transition: all 0.2s;
}
.attachment:hover {
background: var(--accent);
color: white;
}
.attachment i {
margin-right: 0.5rem;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.empty-state i {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.3;
}
.action-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.description-box {
background: var(--bg-body);
padding: 1.5rem;
border-radius: var(--border-radius);
white-space: pre-wrap;
line-height: 1.6;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<!-- Ticket Header -->
<div class="ticket-header">
<div class="ticket-number">{{ ticket.ticket_number }}</div>
<div class="ticket-title">{{ ticket.subject }}</div>
<div class="mt-3">
<span class="badge badge-status-{{ ticket.status }}">
{{ ticket.status.replace('_', ' ').title() }}
</span>
<span class="badge badge-priority-{{ ticket.priority }}">
{{ ticket.priority.title() }} Priority
</span>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons mb-4">
<a href="/api/v1/tickets/{{ ticket.id }}" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Rediger
</a>
<button class="btn btn-outline-secondary" onclick="addComment()">
<i class="bi bi-chat"></i> Tilføj Kommentar
</button>
<button class="btn btn-outline-secondary" onclick="addWorklog()">
<i class="bi bi-clock"></i> Log Tid
</button>
<a href="/ticket/tickets" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Tilbage
</a>
</div>
<div class="row">
<!-- Main Content -->
<div class="col-lg-8">
<!-- Description -->
<div class="card">
<div class="card-body">
<div class="section-title">
<i class="bi bi-file-text"></i> Beskrivelse
</div>
<div class="description-box">
{{ ticket.description or 'Ingen beskrivelse' }}
</div>
</div>
</div>
<!-- Comments -->
<div class="card">
<div class="card-body">
<div class="section-title">
<i class="bi bi-chat-dots"></i> Kommentarer ({{ comments|length }})
</div>
{% if comments %}
{% for comment in comments %}
<div class="comment {% if comment.internal_note %}internal{% endif %}">
<div class="comment-header">
<span class="comment-author">
<i class="bi bi-person-circle"></i>
{{ comment.user_name or 'System' }}
{% if comment.internal_note %}
<span class="badge bg-warning text-dark ms-2">Internal</span>
{% endif %}
</span>
<span class="comment-date">
{{ comment.created_at.strftime('%d-%m-%Y %H:%M') if comment.created_at else '-' }}
</span>
</div>
<div class="comment-text">
{{ comment.comment_text }}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<i class="bi bi-chat"></i>
<p>Ingen kommentarer endnu</p>
</div>
{% endif %}
</div>
</div>
<!-- Worklog -->
<div class="card">
<div class="card-body">
<div class="section-title">
<i class="bi bi-clock-history"></i> Worklog ({{ worklog|length }})
</div>
{% if worklog %}
<div class="table-responsive">
<table class="table worklog-table">
<thead>
<tr>
<th>Dato</th>
<th>Timer</th>
<th>Type</th>
<th>Beskrivelse</th>
<th>Status</th>
<th>Medarbejder</th>
</tr>
</thead>
<tbody>
{% for entry in worklog %}
<tr>
<td>{{ entry.work_date.strftime('%d-%m-%Y') if entry.work_date else '-' }}</td>
<td><strong>{{ "%.2f"|format(entry.hours) }}t</strong></td>
<td>{{ entry.work_type }}</td>
<td>{{ entry.description or '-' }}</td>
<td>
<span class="badge {% if entry.status == 'billable' %}bg-success{% elif entry.status == 'draft' %}bg-warning{% else %}bg-secondary{% endif %}">
{{ entry.status }}
</span>
</td>
<td>{{ entry.user_name or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-clock"></i>
<p>Ingen worklog entries endnu</p>
</div>
{% endif %}
</div>
</div>
<!-- Attachments -->
{% if attachments %}
<div class="card">
<div class="card-body">
<div class="section-title">
<i class="bi bi-paperclip"></i> Vedhæftninger ({{ attachments|length }})
</div>
{% for attachment in attachments %}
<a href="/api/v1/attachments/{{ attachment.id }}/download" class="attachment">
<i class="bi bi-file-earmark"></i>
{{ attachment.filename }}
<small class="ms-2">({{ (attachment.file_size / 1024)|round(1) }} KB)</small>
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Ticket Info -->
<div class="card">
<div class="card-body">
<div class="section-title">
<i class="bi bi-info-circle"></i> Ticket Information
</div>
<div class="info-item mb-3">
<label>Kunde</label>
<div class="value">
{% if ticket.customer_name %}
<a href="/customers/{{ ticket.customer_id }}" style="text-decoration: none; color: var(--accent);">
{{ ticket.customer_name }}
</a>
{% else %}
<span class="text-muted">Ikke angivet</span>
{% endif %}
</div>
</div>
<div class="info-item mb-3">
<label>Tildelt til</label>
<div class="value">
{{ ticket.assigned_to_name or 'Ikke tildelt' }}
</div>
</div>
<div class="info-item mb-3">
<label>Oprettet</label>
<div class="value">
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
</div>
</div>
<div class="info-item mb-3">
<label>Senest opdateret</label>
<div class="value">
{{ ticket.updated_at.strftime('%d-%m-%Y %H:%M') if ticket.updated_at else '-' }}
</div>
</div>
{% if ticket.resolved_at %}
<div class="info-item mb-3">
<label>Løst</label>
<div class="value">
{{ ticket.resolved_at.strftime('%d-%m-%Y %H:%M') }}
</div>
</div>
{% endif %}
{% if ticket.first_response_at %}
<div class="info-item mb-3">
<label>Første svar</label>
<div class="value">
{{ ticket.first_response_at.strftime('%d-%m-%Y %H:%M') }}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Tags -->
{% if ticket.tags %}
<div class="card">
<div class="card-body">
<div class="section-title">
<i class="bi bi-tags"></i> Tags
</div>
{% for tag in ticket.tags %}
<span class="badge bg-secondary me-1 mb-1">#{{ tag }}</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Add comment (placeholder - integrate with API)
function addComment() {
alert('Add comment functionality - integrate with POST /api/v1/tickets/{{ ticket.id }}/comments');
}
// Add worklog (placeholder - integrate with API)
function addWorklog() {
alert('Add worklog functionality - integrate with POST /api/v1/tickets/{{ ticket.id }}/worklog');
}
</script>
{% endblock %}

View File

@ -0,0 +1,272 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Alle Tickets - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.filter-bar {
background: var(--bg-card);
padding: 1.5rem;
border-radius: var(--border-radius);
margin-bottom: 1.5rem;
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
}
.ticket-table th {
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--accent-light);
padding: 1rem 0.75rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ticket-table td {
padding: 1rem 0.75rem;
vertical-align: middle;
border-bottom: 1px solid var(--accent-light);
}
.ticket-row {
transition: background-color 0.2s;
cursor: pointer;
}
.ticket-row:hover {
background-color: var(--accent-light);
}
.badge {
padding: 0.4rem 0.8rem;
font-weight: 500;
border-radius: 6px;
font-size: 0.75rem;
}
.badge-status-open { background-color: #d1ecf1; color: #0c5460; }
.badge-status-in_progress { background-color: #fff3cd; color: #856404; }
.badge-status-pending_customer { background-color: #e2e3e5; color: #383d41; }
.badge-status-resolved { background-color: #d4edda; color: #155724; }
.badge-status-closed { background-color: #f8d7da; color: #721c24; }
.badge-priority-low { background-color: var(--accent-light); color: var(--accent); }
.badge-priority-normal { background-color: #e2e3e5; color: #383d41; }
.badge-priority-high { background-color: #fff3cd; color: #856404; }
.badge-priority-urgent, .badge-priority-critical { background-color: #f8d7da; color: #721c24; }
.ticket-number {
font-family: 'Monaco', 'Courier New', monospace;
background: var(--accent-light);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
color: var(--accent);
font-weight: 600;
}
.search-box {
position: relative;
}
.search-box input {
padding-left: 2.5rem;
background: var(--bg-body);
border: 1px solid var(--accent-light);
color: var(--text-primary);
}
.search-box i {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.3;
}
.meta-info {
font-size: 0.85rem;
color: var(--text-secondary);
}
.meta-info i {
margin-right: 0.25rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-2">
<i class="bi bi-ticket-detailed"></i> Alle Tickets
</h1>
<p class="text-muted">Oversigt over alle tickets i systemet</p>
</div>
<a href="/api/v1/tickets" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Ny Ticket
</a>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<form method="get" action="/ticket/tickets" class="row g-3">
<div class="col-md-3">
<label for="status" class="form-label">Status:</label>
<select name="status" id="status" class="form-select" onchange="this.form.submit()">
<option value="">Alle</option>
<option value="open" {% if selected_status == 'open' %}selected{% endif %}>Open</option>
<option value="in_progress" {% if selected_status == 'in_progress' %}selected{% endif %}>In Progress</option>
<option value="pending_customer" {% if selected_status == 'pending_customer' %}selected{% endif %}>Pending Customer</option>
<option value="resolved" {% if selected_status == 'resolved' %}selected{% endif %}>Resolved</option>
<option value="closed" {% if selected_status == 'closed' %}selected{% endif %}>Closed</option>
</select>
</div>
<div class="col-md-3">
<label for="priority" class="form-label">Prioritet:</label>
<select name="priority" id="priority" class="form-select" onchange="this.form.submit()">
<option value="">Alle</option>
<option value="low" {% if selected_priority == 'low' %}selected{% endif %}>Low</option>
<option value="normal" {% if selected_priority == 'normal' %}selected{% endif %}>Normal</option>
<option value="high" {% if selected_priority == 'high' %}selected{% endif %}>High</option>
<option value="urgent" {% if selected_priority == 'urgent' %}selected{% endif %}>Urgent</option>
<option value="critical" {% if selected_priority == 'critical' %}selected{% endif %}>Critical</option>
</select>
</div>
<div class="col-md-3">
<label for="customer_id" class="form-label">Kunde:</label>
<select name="customer_id" id="customer_id" class="form-select" onchange="this.form.submit()">
<option value="">Alle kunder</option>
{% for customer in customers %}
<option value="{{ customer.id }}" {% if customer.id == selected_customer_id %}selected{% endif %}>
{{ customer.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="search" class="form-label">Søg:</label>
<div class="search-box">
<i class="bi bi-search"></i>
<input
type="text"
name="search"
id="search"
class="form-control"
placeholder="Ticket nummer eller emne..."
value="{{ search_query or '' }}">
</div>
</div>
</form>
</div>
<!-- Tickets Table -->
{% if tickets %}
<div class="card">
<div class="table-responsive">
<table class="table ticket-table mb-0">
<thead>
<tr>
<th>Ticket</th>
<th>Kunde</th>
<th>Status</th>
<th>Prioritet</th>
<th>Tildelt</th>
<th>Aktivitet</th>
<th>Oprettet</th>
</tr>
</thead>
<tbody>
{% for ticket in tickets %}
<tr class="ticket-row" onclick="window.location='/ticket/tickets/{{ ticket.id }}'">
<td>
<span class="ticket-number">{{ ticket.ticket_number }}</span>
<br>
<strong>{{ ticket.subject }}</strong>
</td>
<td>
{% if ticket.customer_name %}
{{ ticket.customer_name }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<span class="badge badge-status-{{ ticket.status }}">
{{ ticket.status.replace('_', ' ').title() }}
</span>
</td>
<td>
<span class="badge badge-priority-{{ ticket.priority }}">
{{ ticket.priority.title() }}
</span>
</td>
<td>
{% if ticket.assigned_to_name %}
{{ ticket.assigned_to_name }}
{% else %}
<span class="text-muted">Ikke tildelt</span>
{% endif %}
</td>
<td>
<div class="meta-info">
<i class="bi bi-chat"></i> {{ ticket.comment_count }}
<i class="bi bi-clock ms-2"></i> {{ ticket.worklog_count }}
</div>
</td>
<td>
{{ ticket.created_at.strftime('%d-%m-%Y') if ticket.created_at else '-' }}
<br>
<small class="text-muted">{{ ticket.created_at.strftime('%H:%M') if ticket.created_at else '' }}</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="text-muted mt-3">
<small>Viser {{ tickets|length }} tickets</small>
</div>
{% else %}
<div class="card">
<div class="empty-state">
<i class="bi bi-inbox"></i>
<h3>Ingen tickets fundet</h3>
<p>Prøv at justere dine filtre eller opret en ny ticket</p>
{% if selected_status or selected_priority or selected_customer_id or search_query %}
<a href="/ticket/tickets" class="btn btn-outline-secondary mt-3">
<i class="bi bi-x-circle"></i> Ryd filtre
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
// Submit search on Enter
document.getElementById('search').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
this.form.submit();
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,451 @@
"""
Ticket System Frontend Views
HTML template routes for ticket management UI
"""
import logging
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from typing import Optional
from datetime import date
from app.core.database import execute_query, execute_update
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
# ============================================================================
# MOCKUP ROUTES (TEMPORARY)
# ============================================================================
@router.get("/mockups/1", response_class=HTMLResponse)
async def mockup_option1(request: Request):
"""Mockup: Split Screen Concept"""
return templates.TemplateResponse("ticket/frontend/mockups/option1_splitscreen.html", {"request": request})
@router.get("/mockups/2", response_class=HTMLResponse)
async def mockup_option2(request: Request):
"""Mockup: Kanban Board Concept"""
return templates.TemplateResponse("ticket/frontend/mockups/option2_kanban.html", {"request": request})
@router.get("/mockups/3", response_class=HTMLResponse)
async def mockup_option3(request: Request):
"""Mockup: Power Table Concept"""
return templates.TemplateResponse("ticket/frontend/mockups/option3_powertable.html", {"request": request})
@router.get("/worklog/review", response_class=HTMLResponse)
async def worklog_review_page(
request: Request,
customer_id: Optional[int] = None,
status: str = "draft"
):
"""
Worklog review page with single-entry approval
Query params:
customer_id: Filter by customer (optional)
status: Filter by status (default: draft)
"""
try:
# Build query with filters
query = """
SELECT
w.id,
w.ticket_id,
w.user_id,
w.work_date,
w.hours,
w.work_type,
w.description,
w.billing_method,
w.status,
w.prepaid_card_id,
w.created_at,
t.ticket_number,
t.subject AS ticket_subject,
t.customer_id,
t.status AS ticket_status,
c.name AS customer_name,
u.username AS user_name,
pc.card_number,
pc.remaining_hours AS card_remaining_hours
FROM tticket_worklog w
INNER JOIN tticket_tickets t ON t.id = w.ticket_id
LEFT JOIN customers c ON c.id = t.customer_id
LEFT JOIN users u ON u.user_id = w.user_id
LEFT JOIN tticket_prepaid_cards pc ON pc.id = w.prepaid_card_id
WHERE w.status = %s
"""
params = [status]
if customer_id:
query += " AND t.customer_id = %s"
params.append(customer_id)
query += " ORDER BY w.work_date DESC, w.created_at DESC"
worklogs = execute_query(query, tuple(params))
# Get customer list for filter dropdown
customers_query = """
SELECT DISTINCT c.id, c.name
FROM customers c
INNER JOIN tticket_tickets t ON t.customer_id = c.id
INNER JOIN tticket_worklog w ON w.ticket_id = t.id
WHERE w.status = %s
ORDER BY c.name
"""
customers = execute_query(customers_query, (status,))
# Calculate totals
total_hours = sum(float(w['hours']) for w in worklogs)
total_billable = sum(
float(w['hours'])
for w in worklogs
if w['billing_method'] == 'invoice'
)
return templates.TemplateResponse(
"ticket/frontend/worklog_review.html",
{
"request": request,
"worklogs": worklogs,
"customers": customers,
"selected_customer_id": customer_id,
"selected_status": status,
"total_hours": total_hours,
"total_billable_hours": total_billable,
"total_entries": len(worklogs)
}
)
except Exception as e:
logger.error(f"❌ Failed to load worklog review page: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/worklog/{worklog_id}/approve")
async def approve_worklog_entry(
worklog_id: int,
redirect_to: Optional[str] = Form(default="/ticket/worklog/review")
):
"""
Approve single worklog entry (change status draft billable)
Form params:
redirect_to: URL to redirect after approval
"""
try:
# Check entry exists and is draft
check_query = """
SELECT id, status, billing_method
FROM tticket_worklog
WHERE id = %s
"""
entry = execute_query(check_query, (worklog_id,), fetchone=True)
if not entry:
raise HTTPException(status_code=404, detail="Worklog entry not found")
if entry['status'] != 'draft':
raise HTTPException(
status_code=400,
detail=f"Cannot approve entry with status '{entry['status']}'"
)
# Approve entry
update_query = """
UPDATE tticket_worklog
SET status = 'billable',
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
"""
execute_update(update_query, (worklog_id,))
logger.info(f"✅ Approved worklog entry {worklog_id}")
return RedirectResponse(url=redirect_to, status_code=303)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to approve worklog entry: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/worklog/{worklog_id}/reject")
async def reject_worklog_entry(
worklog_id: int,
reason: Optional[str] = Form(default=None),
redirect_to: Optional[str] = Form(default="/ticket/worklog/review")
):
"""
Reject single worklog entry (change status draft rejected)
Form params:
reason: Rejection reason (optional)
redirect_to: URL to redirect after rejection
"""
try:
# Check entry exists and is draft
check_query = """
SELECT id, status
FROM tticket_worklog
WHERE id = %s
"""
entry = execute_query(check_query, (worklog_id,), fetchone=True)
if not entry:
raise HTTPException(status_code=404, detail="Worklog entry not found")
if entry['status'] != 'draft':
raise HTTPException(
status_code=400,
detail=f"Cannot reject entry with status '{entry['status']}'"
)
# Reject entry (store reason in description)
update_query = """
UPDATE tticket_worklog
SET status = 'rejected',
description = COALESCE(description, '') ||
CASE WHEN %s IS NOT NULL
THEN E'\n\n[REJECTED: ' || %s || ']'
ELSE E'\n\n[REJECTED]'
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
"""
execute_update(update_query, (reason, reason, worklog_id))
logger.info(f"❌ Rejected worklog entry {worklog_id}" + (f": {reason}" if reason else ""))
return RedirectResponse(url=redirect_to, status_code=303)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to reject worklog entry: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dashboard", response_class=HTMLResponse)
async def ticket_dashboard(request: Request):
"""
Ticket system dashboard with statistics
"""
try:
# Get ticket statistics
stats_query = """
SELECT
COUNT(*) FILTER (WHERE status = 'open') AS open_count,
COUNT(*) FILTER (WHERE status = 'in_progress') AS in_progress_count,
COUNT(*) FILTER (WHERE status = 'pending_customer') AS pending_count,
COUNT(*) FILTER (WHERE status = 'resolved') AS resolved_count,
COUNT(*) FILTER (WHERE status = 'closed') AS closed_count,
COUNT(*) AS total_count
FROM tticket_tickets
"""
stats = execute_query(stats_query, fetchone=True)
# Get recent tickets
recent_query = """
SELECT
t.id,
t.ticket_number,
t.subject,
t.status,
t.priority,
t.created_at,
c.name AS customer_name
FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id
ORDER BY t.created_at DESC
LIMIT 10
"""
recent_tickets = execute_query(recent_query)
# Get worklog statistics
worklog_stats_query = """
SELECT
COUNT(*) FILTER (WHERE status = 'draft') AS draft_count,
COALESCE(SUM(hours) FILTER (WHERE status = 'draft'), 0) AS draft_hours,
COUNT(*) FILTER (WHERE status = 'billable') AS billable_count,
COALESCE(SUM(hours) FILTER (WHERE status = 'billable'), 0) AS billable_hours
FROM tticket_worklog
"""
worklog_stats = execute_query(worklog_stats_query, fetchone=True)
return templates.TemplateResponse(
"ticket/frontend/dashboard.html",
{
"request": request,
"stats": stats,
"recent_tickets": recent_tickets,
"worklog_stats": worklog_stats
}
)
except Exception as e:
logger.error(f"❌ Failed to load dashboard: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets", response_class=HTMLResponse)
async def ticket_list_page(
request: Request,
status: Optional[str] = None,
priority: Optional[str] = None,
customer_id: Optional[int] = None,
search: Optional[str] = None
):
"""
Ticket list page with filters
"""
try:
# Build query with filters
query = """
SELECT
t.id,
t.ticket_number,
t.subject,
t.status,
t.priority,
t.created_at,
t.updated_at,
c.name AS customer_name,
u.username AS assigned_to_name,
(SELECT COUNT(*) FROM tticket_comments WHERE ticket_id = t.id) AS comment_count,
(SELECT COUNT(*) FROM tticket_worklog WHERE ticket_id = t.id) AS worklog_count
FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id
LEFT JOIN users u ON u.user_id = t.assigned_to_user_id
WHERE 1=1
"""
params = []
if status:
query += " AND t.status = %s"
params.append(status)
if priority:
query += " AND t.priority = %s"
params.append(priority)
if customer_id:
query += " AND t.customer_id = %s"
params.append(customer_id)
if search:
query += " AND (t.subject ILIKE %s OR t.description ILIKE %s OR t.ticket_number ILIKE %s)"
search_pattern = f"%{search}%"
params.extend([search_pattern, search_pattern, search_pattern])
query += " ORDER BY t.created_at DESC LIMIT 100"
tickets = execute_query(query, tuple(params)) if params else execute_query(query)
# Get filter options
customers = execute_query(
"""SELECT DISTINCT c.id, c.name
FROM customers c
INNER JOIN tticket_tickets t ON t.customer_id = c.id
ORDER BY c.name"""
)
return templates.TemplateResponse(
"ticket/frontend/ticket_list.html",
{
"request": request,
"tickets": tickets,
"customers": customers,
"selected_status": status,
"selected_priority": priority,
"selected_customer_id": customer_id,
"search_query": search
}
)
except Exception as e:
logger.error(f"❌ Failed to load ticket list: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets/{ticket_id}", response_class=HTMLResponse)
async def ticket_detail_page(request: Request, ticket_id: int):
"""
Ticket detail page with comments and worklog
"""
try:
# Get ticket details
ticket_query = """
SELECT
t.*,
c.name AS customer_name,
c.email AS customer_email,
u.username AS assigned_to_name
FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id
LEFT JOIN users u ON u.user_id = t.assigned_to_user_id
WHERE t.id = %s
"""
ticket = execute_query(ticket_query, (ticket_id,), fetchone=True)
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Get comments
comments_query = """
SELECT
c.*,
u.username AS user_name
FROM tticket_comments c
LEFT JOIN users u ON u.user_id = c.user_id
WHERE c.ticket_id = %s
ORDER BY c.created_at ASC
"""
comments = execute_query(comments_query, (ticket_id,))
# Get worklog
worklog_query = """
SELECT
w.*,
u.username AS user_name
FROM tticket_worklog w
LEFT JOIN users u ON u.user_id = w.user_id
WHERE w.ticket_id = %s
ORDER BY w.work_date DESC, w.created_at DESC
"""
worklog = execute_query(worklog_query, (ticket_id,))
# Get attachments
attachments_query = """
SELECT * FROM tticket_attachments
WHERE ticket_id = %s
ORDER BY created_at DESC
"""
attachments = execute_query(attachments_query, (ticket_id,))
return templates.TemplateResponse(
"ticket/frontend/ticket_detail.html",
{
"request": request,
"ticket": ticket,
"comments": comments,
"worklog": worklog,
"attachments": attachments
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to load ticket detail: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,560 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Worklog Godkendelse - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root {
--bg-body: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #2c3e50;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--border-radius: 12px;
--success: #28a745;
--danger: #dc3545;
--warning: #ffc107;
}
[data-theme="dark"] {
--bg-body: #1a1d23;
--bg-card: #252a31;
--text-primary: #e4e6eb;
--text-secondary: #b0b3b8;
--accent: #4a9eff;
--accent-light: #2d3748;
--success: #48bb78;
--danger: #f56565;
--warning: #ed8936;
}
body {
background-color: var(--bg-body);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px;
transition: background-color 0.3s, color 0.3s;
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.08);
padding: 1rem 0;
border-bottom: 1px solid rgba(0,0,0,0.05);
transition: background-color 0.3s;
}
.navbar-brand {
font-weight: 700;
color: var(--accent);
font-size: 1.25rem;
}
.nav-link {
color: var(--text-secondary);
padding: 0.6rem 1.2rem !important;
border-radius: var(--border-radius);
transition: all 0.2s;
font-weight: 500;
margin: 0 0.2rem;
}
.nav-link:hover, .nav-link.active {
background-color: var(--accent-light);
color: var(--accent);
}
.card {
border: none;
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
background: var(--bg-card);
margin-bottom: 1.5rem;
transition: background-color 0.3s;
}
.stats-row {
margin-bottom: 2rem;
}
.stat-card {
text-align: center;
padding: 1.5rem;
}
.stat-card h3 {
font-size: 2.5rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.5rem;
}
.stat-card p {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
}
.worklog-table {
background: var(--bg-card);
}
.worklog-table th {
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--accent-light);
padding: 1rem 0.75rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.worklog-table td {
padding: 1rem 0.75rem;
vertical-align: middle;
border-bottom: 1px solid var(--accent-light);
}
.worklog-row {
transition: background-color 0.2s;
}
.worklog-row:hover {
background-color: var(--accent-light);
}
.badge {
padding: 0.4rem 0.8rem;
font-weight: 500;
border-radius: 6px;
font-size: 0.75rem;
}
.badge-invoice {
background-color: var(--accent-light);
color: var(--accent);
}
.badge-prepaid {
background-color: #d4edda;
color: #155724;
}
.badge-support {
background-color: #cce5ff;
color: #004085;
}
.badge-development {
background-color: #f8d7da;
color: #721c24;
}
.btn-approve {
background-color: var(--success);
color: white;
border: none;
padding: 0.4rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
transition: all 0.2s;
}
.btn-approve:hover {
background-color: #218838;
transform: translateY(-1px);
}
.btn-reject {
background-color: var(--danger);
color: white;
border: none;
padding: 0.4rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
transition: all 0.2s;
}
.btn-reject:hover {
background-color: #c82333;
transform: translateY(-1px);
}
.theme-toggle {
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
background: var(--accent-light);
color: var(--accent);
transition: all 0.2s;
border: none;
font-size: 1.2rem;
}
.theme-toggle:hover {
background: var(--accent);
color: white;
}
.filter-bar {
background: var(--bg-card);
padding: 1.5rem;
border-radius: var(--border-radius);
margin-bottom: 1.5rem;
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
}
.hours-display {
font-weight: 700;
color: var(--accent);
font-size: 1.1rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.3;
}
.ticket-number {
font-family: 'Monaco', 'Courier New', monospace;
background: var(--accent-light);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
color: var(--accent);
}
.customer-name {
font-weight: 600;
color: var(--text-primary);
}
.work-description {
color: var(--text-secondary);
font-size: 0.9rem;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-group-actions {
white-space: nowrap;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<i class="bi bi-boxes"></i> BMC Hub
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/ticket/dashboard">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/api/v1/tickets">
<i class="bi bi-ticket-detailed"></i> Tickets
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/ticket/worklog/review">
<i class="bi bi-clock-history"></i> Worklog Godkendelse
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/api/v1/prepaid-cards">
<i class="bi bi-credit-card"></i> Klippekort
</a>
</li>
</ul>
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">
<i class="bi bi-moon-stars-fill"></i>
</button>
</div>
</div>
</nav>
<div class="container-fluid px-4">
<!-- Page Header -->
<div class="row mb-4">
<div class="col">
<h1 class="mb-2">
<i class="bi bi-clock-history"></i> Worklog Godkendelse
</h1>
<p class="text-muted">Godkend eller afvis enkelt-entries fra draft worklog</p>
</div>
</div>
<!-- Statistics Row -->
<div class="row stats-row">
<div class="col-md-4">
<div class="card stat-card">
<h3>{{ total_entries }}</h3>
<p>Entries til godkendelse</p>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card">
<h3>{{ "%.2f"|format(total_hours) }}t</h3>
<p>Total timer</p>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card">
<h3>{{ "%.2f"|format(total_billable_hours) }}t</h3>
<p>Fakturerbare timer</p>
</div>
</div>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<form method="get" action="/ticket/worklog/review" class="row g-3">
<div class="col-md-4">
<label for="customer_id" class="form-label">Filtrer efter kunde:</label>
<select name="customer_id" id="customer_id" class="form-select" onchange="this.form.submit()">
<option value="">Alle kunder</option>
{% for customer in customers %}
<option value="{{ customer.id }}" {% if customer.id == selected_customer_id %}selected{% endif %}>
{{ customer.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label for="status" class="form-label">Status:</label>
<select name="status" id="status" class="form-select" onchange="this.form.submit()">
<option value="draft" {% if selected_status == 'draft' %}selected{% endif %}>Draft</option>
<option value="billable" {% if selected_status == 'billable' %}selected{% endif %}>Billable</option>
<option value="rejected" {% if selected_status == 'rejected' %}selected{% endif %}>Rejected</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="submit" class="btn btn-primary">
<i class="bi bi-funnel"></i> Filtrer
</button>
<a href="/ticket/worklog/review" class="btn btn-outline-secondary ms-2">
<i class="bi bi-x-circle"></i> Ryd filtre
</a>
</div>
</form>
</div>
<!-- Worklog Table -->
{% if worklogs %}
<div class="card">
<div class="table-responsive">
<table class="table worklog-table mb-0">
<thead>
<tr>
<th>Ticket</th>
<th>Kunde</th>
<th>Dato</th>
<th>Timer</th>
<th>Type</th>
<th>Fakturering</th>
<th>Beskrivelse</th>
<th>Medarbejder</th>
<th style="text-align: right;">Handlinger</th>
</tr>
</thead>
<tbody>
{% for worklog in worklogs %}
<tr class="worklog-row">
<td>
<span class="ticket-number">{{ worklog.ticket_number }}</span>
<br>
<small class="text-muted">{{ worklog.ticket_subject[:30] }}...</small>
</td>
<td>
{% if worklog.customer_name %}
<span class="customer-name">{{ worklog.customer_name }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{{ worklog.work_date.strftime('%d-%m-%Y') if worklog.work_date else '-' }}
</td>
<td>
<span class="hours-display">{{ "%.2f"|format(worklog.hours) }}t</span>
</td>
<td>
{% if worklog.work_type == 'support' %}
<span class="badge badge-support">Support</span>
{% elif worklog.work_type == 'development' %}
<span class="badge badge-development">Udvikling</span>
{% else %}
<span class="badge">{{ worklog.work_type }}</span>
{% endif %}
</td>
<td>
{% if worklog.billing_method == 'invoice' %}
<span class="badge badge-invoice">
<i class="bi bi-file-earmark-text"></i> Faktura
</span>
{% elif worklog.billing_method == 'prepaid' %}
<span class="badge badge-prepaid">
<i class="bi bi-credit-card"></i> Klippekort
</span>
{% if worklog.card_number %}
<br><small class="text-muted">{{ worklog.card_number }}</small>
{% endif %}
{% endif %}
</td>
<td>
<div class="work-description" title="{{ worklog.description or '-' }}">
{{ worklog.description or '-' }}
</div>
</td>
<td>
{{ worklog.user_name or 'N/A' }}
</td>
<td>
{% if worklog.status == 'draft' %}
<div class="btn-group-actions">
<form method="post" action="/ticket/worklog/{{ worklog.id }}/approve" style="display: inline;">
<input type="hidden" name="redirect_to" value="{{ request.url }}">
<button type="submit" class="btn btn-approve btn-sm">
<i class="bi bi-check-circle"></i> Godkend
</button>
</form>
<button
type="button"
class="btn btn-reject btn-sm ms-1"
onclick="rejectWorklog({{ worklog.id }}, '{{ request.url }}')">
<i class="bi bi-x-circle"></i> Afvis
</button>
</div>
{% else %}
<span class="badge">{{ worklog.status }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="card">
<div class="empty-state">
<i class="bi bi-inbox"></i>
<h3>Ingen worklog entries</h3>
<p>Der er ingen entries med status "{{ selected_status }}" {% if selected_customer_id %}for denne kunde{% endif %}.</p>
</div>
</div>
{% endif %}
</div>
<!-- Reject Modal -->
<div class="modal fade" id="rejectModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background: var(--bg-card); color: var(--text-primary);">
<div class="modal-header" style="border-bottom: 1px solid var(--accent-light);">
<h5 class="modal-title">Afvis Worklog Entry</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="rejectForm" method="post">
<div class="modal-body">
<div class="mb-3">
<label for="rejectReason" class="form-label">Årsag til afvisning (valgfrit):</label>
<textarea
class="form-control"
id="rejectReason"
name="reason"
rows="3"
placeholder="Forklar hvorfor denne entry afvises..."
style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);"></textarea>
</div>
<input type="hidden" name="redirect_to" id="rejectRedirectTo">
</div>
<div class="modal-footer" style="border-top: 1px solid var(--accent-light);">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="submit" class="btn btn-reject">
<i class="bi bi-x-circle"></i> Afvis Entry
</button>
</div>
</form>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Theme Toggle
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
// Update icon
const icon = document.querySelector('.theme-toggle i');
if (newTheme === 'dark') {
icon.className = 'bi bi-sun-fill';
} else {
icon.className = 'bi bi-moon-stars-fill';
}
}
// Load saved theme
document.addEventListener('DOMContentLoaded', function() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
const icon = document.querySelector('.theme-toggle i');
if (savedTheme === 'dark') {
icon.className = 'bi bi-sun-fill';
}
});
// Reject worklog with modal
function rejectWorklog(worklogId, redirectUrl) {
const form = document.getElementById('rejectForm');
form.action = `/ticket/worklog/${worklogId}/reject`;
document.getElementById('rejectRedirectTo').value = redirectUrl;
const modal = new bootstrap.Modal(document.getElementById('rejectModal'));
modal.show();
}
// Auto-refresh indicator (optional)
let lastRefresh = Date.now();
setInterval(() => {
const elapsed = Math.floor((Date.now() - lastRefresh) / 1000);
if (elapsed > 300) { // 5 minutes
const badge = document.createElement('span');
badge.className = 'badge bg-warning position-fixed';
badge.style.bottom = '20px';
badge.style.right = '20px';
badge.style.cursor = 'pointer';
badge.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Opdater siden';
badge.onclick = () => location.reload();
document.body.appendChild(badge);
}
}, 60000);
</script>
</body>
</html>

View File

@ -0,0 +1,423 @@
# Ticket System - Email Integration
## Overview
The ticket system integrates with BMC Hub's email workflow engine to automatically create tickets from incoming emails. Uses **Option B: Message-ID Threading** for robust email-to-ticket linkage.
## Architecture
```
Incoming Email → Email Classifier → Email Workflow Engine → Ticket Integration
create_ticket action
EmailTicketIntegration.process_email_for_ticket()
┌───────┴───────┐
↓ ↓
New Ticket Reply to Existing
(creates TKT-XXX) (links via Message-ID)
```
## Email Threading Logic
### Message-ID Based Threading (Option B)
The system uses email headers to detect if an email is part of an existing ticket thread:
1. **In-Reply-To Header**: Check if contains `TKT-YYYYMMDD-XXX` pattern
2. **References Header**: Check all message IDs in chain for ticket number
3. **Subject Line**: Check for `[TKT-YYYYMMDD-XXX]` or `Re: TKT-YYYYMMDD-XXX`
If ticket number found → **Link to existing ticket**
If NOT found → **Create new ticket**
### Ticket Number Format
Pattern: `TKT-YYYYMMDD-XXX`
- `TKT`: Prefix
- `YYYYMMDD`: Date (e.g., 20251215)
- `XXX`: Sequential number (001-999)
Example: `TKT-20251215-001`
## Workflow Actions
### 1. `create_ticket`
Creates new ticket OR links to existing ticket (smart routing).
**Workflow Definition:**
```json
{
"name": "Support Request → Ticket",
"classification_trigger": "support_request",
"workflow_steps": [
{
"action": "create_ticket",
"params": {
"customer_id": 123,
"assigned_to_user_id": 5
}
}
]
}
```
**Parameters:**
- `customer_id` (optional): BMC customer ID to link ticket to
- `assigned_to_user_id` (optional): User ID to assign ticket to
- `priority` (optional): Override priority detection (low/normal/high/critical)
**Returns:**
```json
{
"ticket_id": 42,
"ticket_number": "TKT-20251215-001",
"created": true,
"linked": false
}
```
### 2. `link_email_to_ticket`
Explicitly link email to a specific ticket (manual routing).
**Workflow Definition:**
```json
{
"action": "link_email_to_ticket",
"params": {
"ticket_number": "TKT-20251215-001"
}
}
```
**Parameters:**
- `ticket_number` (required): Target ticket number
**Returns:**
```json
{
"ticket_id": 42,
"ticket_number": "TKT-20251215-001",
"linked": true
}
```
## Automatic Features
### Tag Extraction
Extracts `#hashtags` from email body and adds as ticket tags:
```
Email body: "We need help with #billing #urgent issue"
→ Ticket tags: ["billing", "urgent"]
```
- Max 10 tags per ticket
- Minimum 3 characters
- Converted to lowercase
### Priority Detection
Automatically determines ticket priority from email content:
**Critical**: kritisk, critical, down, nede, urgent, akut
**High**: høj, high, vigtig, important, haster
**Low**: lav, low, spørgsmål, question, info
**Normal**: Everything else (default)
Checks both subject line and extracted tags.
### Email Metadata
Stores rich metadata in ticket:
- **Description**: Formatted with sender email, received date, original body
- **Custom Fields**: `email_from`, `email_message_id`, `created_from_email`
- **Email Log**: Full threading data in `tticket_email_log` table
## Database Storage
### `tticket_email_log` Table
Stores all email-ticket linkages for audit trail:
```sql
CREATE TABLE tticket_email_log (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id),
email_message_id TEXT NOT NULL,
email_subject TEXT,
email_from TEXT NOT NULL,
email_received_at TIMESTAMP,
is_reply BOOLEAN DEFAULT FALSE,
thread_data JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**`thread_data` JSONB structure:**
```json
{
"message_id": "<abc123@example.com>",
"in_reply_to": "<TKT-20251215-001@bmcnetworks.dk>",
"references": "<ref1@example.com> <ref2@example.com>"
}
```
## Usage Examples
### Example 1: Basic Support Request
**Incoming Email:**
```
From: customer@example.com
Subject: Help with network issue
Body: Our internet is down. Can you help?
```
**Workflow Configuration:**
```json
{
"name": "Support Email → Ticket",
"classification_trigger": "support_request",
"confidence_threshold": 0.7,
"workflow_steps": [
{
"action": "create_ticket",
"params": {
"assigned_to_user_id": 5
}
}
]
}
```
**Result:**
- Creates ticket `TKT-20251215-001`
- Priority: `normal` (no urgent keywords)
- Tags: [] (no hashtags found)
- Assigned to user 5
- Email logged in `tticket_email_log`
### Example 2: Email Reply to Ticket
**Incoming Email:**
```
From: customer@example.com
Subject: Re: TKT-20251215-001
In-Reply-To: <TKT-20251215-001@bmcnetworks.dk>
Body: Thanks, issue is resolved now
```
**Result:**
- **Detects existing ticket** via In-Reply-To header
- **Adds comment** to ticket `TKT-20251215-001`
- Does NOT create new ticket
- Logs as reply (`is_reply=true`)
### Example 3: Tagged Urgent Request
**Incoming Email:**
```
From: vip@example.com
Subject: URGENT: Server down!
Body: Production server is down #critical #server
```
**Result:**
- Creates ticket with priority `critical` (subject keyword)
- Tags: `["critical", "server"]`
- Custom field: `created_from_email=true`
## API Endpoints
### Get Ticket Email Thread
```http
GET /api/v1/tickets/{ticket_id}/emails
```
Returns chronological list of all emails linked to ticket.
**Response:**
```json
{
"ticket_id": 42,
"ticket_number": "TKT-20251215-001",
"emails": [
{
"id": 1,
"email_message_id": "<abc123@example.com>",
"email_from": "customer@example.com",
"email_subject": "Help with issue",
"email_received_at": "2025-12-15T10:00:00Z",
"is_reply": false,
"created_at": "2025-12-15T10:01:00Z"
},
{
"id": 2,
"email_message_id": "<def456@example.com>",
"email_from": "customer@example.com",
"email_subject": "Re: TKT-20251215-001",
"email_received_at": "2025-12-15T11:00:00Z",
"is_reply": true,
"created_at": "2025-12-15T11:01:00Z"
}
]
}
```
### Find Tickets by Email Address
```http
GET /api/v1/tickets/by-email/{email_address}
```
Returns all tickets associated with an email address.
**Response:**
```json
{
"email_address": "customer@example.com",
"tickets": [
{
"id": 42,
"ticket_number": "TKT-20251215-001",
"subject": "Network issue",
"status": "open",
"created_at": "2025-12-15T10:00:00Z"
}
]
}
```
## Configuration
### Settings (`.env`)
```bash
# Ticket system enabled
TICKET_ENABLED=true
# Email integration enabled (requires email system)
TICKET_EMAIL_INTEGRATION=true
# Auto-assign new tickets (requires user_id)
TICKET_AUTO_ASSIGN=false
TICKET_DEFAULT_ASSIGNEE_ID=5
# Default priority for new tickets
TICKET_DEFAULT_PRIORITY=normal
```
## Testing
### Test Email-to-Ticket Creation
```bash
curl -X POST http://localhost:8001/api/v1/test/email-to-ticket \
-H "Content-Type: application/json" \
-d '{
"email_data": {
"message_id": "<test123@example.com>",
"subject": "Test ticket",
"from_address": "test@example.com",
"body": "This is a test #testing",
"received_at": "2025-12-15T10:00:00Z"
}
}'
```
### Test Email Reply Linking
```bash
curl -X POST http://localhost:8001/api/v1/test/email-to-ticket \
-H "Content-Type: application/json" \
-d '{
"email_data": {
"message_id": "<reply456@example.com>",
"subject": "Re: TKT-20251215-001",
"from_address": "test@example.com",
"body": "Reply to existing ticket",
"received_at": "2025-12-15T11:00:00Z",
"in_reply_to": "<TKT-20251215-001@bmcnetworks.dk>"
}
}'
```
## Troubleshooting
### Tickets Not Created
**Check:**
1. Email workflow engine enabled (`EMAIL_WORKFLOWS_ENABLED=true`)
2. Workflow exists for classification trigger
3. Confidence threshold met
4. Workflow action is `create_ticket` (NOT old `create_ticket` stub)
**Debug:**
```sql
-- Check workflow executions
SELECT * FROM email_workflow_executions
WHERE status = 'failed'
ORDER BY created_at DESC
LIMIT 10;
```
### Email Not Linked to Ticket
**Check:**
1. Ticket number format correct (`TKT-YYYYMMDD-XXX`)
2. Ticket exists in database
3. Email headers contain ticket number (In-Reply-To, References, Subject)
**Debug:**
```sql
-- Check email logs
SELECT * FROM tticket_email_log
WHERE ticket_id = 42
ORDER BY created_at DESC;
```
### Duplicate Tickets Created
**Check:**
1. Email reply headers missing ticket number
2. Subject line doesn't match pattern (e.g., `Re: Ticket 123` instead of `Re: TKT-20251215-001`)
**Solution:**
- Ensure outgoing ticket emails include ticket number in subject: `[TKT-20251215-001]`
- Add ticket number to Message-ID: `<TKT-20251215-001-reply@bmcnetworks.dk>`
## Best Practices
1. **Include Ticket Number in Replies**: Always include `[TKT-YYYYMMDD-XXX]` in subject line
2. **Use Message-ID with Ticket Number**: Format: `<TKT-YYYYMMDD-XXX@bmcnetworks.dk>`
3. **Set Customer ID in Workflow**: Improves ticket organization and reporting
4. **Monitor Workflow Executions**: Check `email_workflow_executions` table regularly
5. **Review Failed Actions**: Alert on repeated workflow failures
## Security Considerations
1. **No Email Body Storage**: Only stores metadata and body in ticket description
2. **Sender Validation**: Consider implementing sender verification (SPF/DKIM)
3. **Spam Prevention**: Email classifier should filter spam before workflow execution
4. **Customer Isolation**: Ensure `customer_id` properly set to prevent data leakage
## Future Enhancements
- **Attachment Handling**: Link email attachments to ticket attachments
- **Email Templates**: Auto-reply with ticket number
- **SLA Integration**: Start SLA timer on ticket creation from email
- **Multi-Ticket Threading**: Support one email creating multiple tickets
- **Smart Customer Detection**: Auto-detect customer from sender domain
## Related Documentation
- [Ticket System Architecture](TICKET_SYSTEM_ARCHITECTURE.md)
- [Email Workflow System](EMAIL_WORKFLOWS.md)
- [Database Schema](../migrations/025_ticket_module.sql)

View File

@ -42,6 +42,8 @@ from app.emails.frontend import views as emails_views
from app.backups.backend import router as backups_api
from app.backups.frontend import views as backups_views
from app.backups.backend.scheduler import backup_scheduler
from app.ticket.backend import router as ticket_api
from app.ticket.frontend import views as ticket_views
# Configure logging
logging.basicConfig(
@ -131,6 +133,7 @@ app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV
app.include_router(timetracking_api, prefix="/api/v1/timetracking", tags=["Time Tracking"])
app.include_router(backups_api.router, prefix="/api/v1", tags=["Backup System"])
app.include_router(emails_api.router, prefix="/api/v1", tags=["Email System"])
app.include_router(ticket_api.router, prefix="/api/v1", tags=["Ticket System"])
# Frontend Routers
app.include_router(auth_views.router, tags=["Frontend"])
@ -144,6 +147,7 @@ app.include_router(devportal_views.router, tags=["Frontend"])
app.include_router(backups_views.router, tags=["Frontend"])
app.include_router(timetracking_views.router, tags=["Frontend"])
app.include_router(emails_views.router, tags=["Frontend"])
app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend - Tickets"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")

View File

@ -106,6 +106,17 @@ INSERT INTO permissions (code, description, category) VALUES
('billing.edit', 'Edit billing information', 'billing'),
('billing.approve', 'Approve billing items', 'billing'),
-- Tickets
('tickets.view', 'View tickets', 'tickets'),
('tickets.create', 'Create tickets', 'tickets'),
('tickets.edit', 'Edit tickets', 'tickets'),
('tickets.delete', 'Delete tickets', 'tickets'),
('tickets.assign', 'Assign tickets to users', 'tickets'),
('tickets.worklog.create', 'Create worklog entries', 'tickets'),
('tickets.worklog.approve', 'Approve worklog for billing', 'tickets'),
('tickets.billing.export', 'Export billable worklog to e-conomic', 'tickets'),
('tickets.prepaid.manage', 'Manage prepaid cards (klippekort)', 'tickets'),
-- System
('system.view', 'View system settings', 'system'),
('system.edit', 'Edit system settings', 'system'),
@ -148,6 +159,8 @@ WHERE g.name = 'Managers' AND p.code IN (
'customers.view', 'customers.edit', 'customers.create',
'hardware.view',
'billing.view', 'billing.edit', 'billing.approve',
'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.assign',
'tickets.worklog.approve', 'tickets.billing.export', 'tickets.prepaid.manage',
'reports.view', 'reports.export'
)
ON CONFLICT DO NOTHING;
@ -160,6 +173,7 @@ CROSS JOIN permissions p
WHERE g.name = 'Technicians' AND p.code IN (
'customers.view',
'hardware.view', 'hardware.edit', 'hardware.create', 'hardware.assign',
'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.worklog.create',
'reports.view'
)
ON CONFLICT DO NOTHING;
@ -173,6 +187,7 @@ WHERE g.name = 'Viewers' AND p.code IN (
'customers.view',
'hardware.view',
'billing.view',
'tickets.view',
'reports.view'
)
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,484 @@
-- ============================================================================
-- Migration 025: Ticket System & Klippekort Modul (Isoleret)
-- ============================================================================
-- Dette modul er 100% isoleret og kan slettes uden at påvirke eksisterende data.
-- Alle tabeller har prefix 'tticket_' for at markere tilhørsforhold til modulet.
-- Ved uninstall køres DROP-scriptet i bunden af denne fil.
-- ============================================================================
-- Metadata tabel til at tracke modulets tilstand
CREATE TABLE IF NOT EXISTS tticket_metadata (
id SERIAL PRIMARY KEY,
module_version VARCHAR(20) NOT NULL DEFAULT '1.0.0',
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
installed_by INTEGER, -- Reference til users.user_id (read-only, ingen FK)
last_sync_at TIMESTAMP,
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb
);
-- Indsæt initial metadata
INSERT INTO tticket_metadata (module_version, is_active)
VALUES ('1.0.0', true)
ON CONFLICT DO NOTHING;
-- ============================================================================
-- TICKETS (hovedtabel)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_tickets (
id SERIAL PRIMARY KEY,
ticket_number VARCHAR(50) UNIQUE NOT NULL, -- Format: TKT-YYYYMMDD-XXX
-- Core felter
subject VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'waiting_customer', 'waiting_internal', 'resolved', 'closed')),
priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
category VARCHAR(100), -- support, bug, feature_request, question, etc.
-- Relationer (read-only references - INGEN FK til core tables)
customer_id INTEGER, -- Reference til customers.id
contact_id INTEGER, -- Reference til contacts.id
assigned_to_user_id INTEGER, -- Reference til users.user_id
created_by_user_id INTEGER, -- Reference til users.user_id
-- Kilde
source VARCHAR(50) DEFAULT 'manual' CHECK (source IN ('email', 'portal', 'phone', 'manual', 'api')),
-- Metadata
tags TEXT[],
custom_fields JSONB,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
first_response_at TIMESTAMP, -- For SLA tracking (future)
resolved_at TIMESTAMP,
closed_at TIMESTAMP
);
CREATE INDEX idx_tticket_tickets_number ON tticket_tickets(ticket_number);
CREATE INDEX idx_tticket_tickets_customer ON tticket_tickets(customer_id);
CREATE INDEX idx_tticket_tickets_assigned ON tticket_tickets(assigned_to_user_id);
CREATE INDEX idx_tticket_tickets_status ON tticket_tickets(status);
CREATE INDEX idx_tticket_tickets_priority ON tticket_tickets(priority);
CREATE INDEX idx_tticket_tickets_created ON tticket_tickets(created_at DESC);
-- ============================================================================
-- COMMENTS (beskeder på tickets)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_comments (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
user_id INTEGER, -- Reference til users.user_id (read-only, ingen FK)
comment_text TEXT NOT NULL,
is_internal BOOLEAN DEFAULT false, -- Intern note vs. kunde-synlig
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE INDEX idx_tticket_comments_ticket ON tticket_comments(ticket_id);
CREATE INDEX idx_tticket_comments_created ON tticket_comments(created_at);
-- ============================================================================
-- ATTACHMENTS (filer på tickets)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_attachments (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
file_name VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size INTEGER,
mime_type VARCHAR(100),
uploaded_by_user_id INTEGER, -- Reference til users.user_id (read-only)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tticket_attachments_ticket ON tticket_attachments(ticket_id);
-- ============================================================================
-- WORKLOG (tidsregistrering på tickets)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_worklog (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
-- Arbejdsdata
work_date DATE NOT NULL,
hours DECIMAL(5,2) NOT NULL CHECK (hours > 0),
work_type VARCHAR(50) DEFAULT 'support' CHECK (work_type IN ('support', 'development', 'troubleshooting', 'on_site', 'meeting', 'other')),
description TEXT,
-- Fakturering
billing_method VARCHAR(20) DEFAULT 'invoice' CHECK (billing_method IN ('prepaid_card', 'invoice', 'internal', 'warranty')),
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'billable', 'billed', 'non_billable')),
prepaid_card_id INTEGER, -- Reference til tticket_prepaid_cards (read-only)
-- Metadata
user_id INTEGER, -- Reference til users.user_id (read-only)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
billed_at TIMESTAMP
);
CREATE INDEX idx_tticket_worklog_ticket ON tticket_worklog(ticket_id);
CREATE INDEX idx_tticket_worklog_status ON tticket_worklog(status);
CREATE INDEX idx_tticket_worklog_date ON tticket_worklog(work_date);
CREATE INDEX idx_tticket_worklog_card ON tticket_worklog(prepaid_card_id);
-- ============================================================================
-- PREPAID CARDS (klippekort - kun 1 aktivt per virksomhed)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_prepaid_cards (
id SERIAL PRIMARY KEY,
card_number VARCHAR(50) UNIQUE NOT NULL, -- Format: CARD-YYYYMMDD-XXX
-- Tilknytning (kun 1 aktivt kort per customer)
customer_id INTEGER NOT NULL, -- Reference til customers.id (read-only, ingen FK)
-- Saldo
purchased_hours DECIMAL(8,2) NOT NULL CHECK (purchased_hours > 0),
used_hours DECIMAL(8,2) DEFAULT 0 CHECK (used_hours >= 0),
remaining_hours DECIMAL(8,2) GENERATED ALWAYS AS (purchased_hours - used_hours) STORED,
-- Priser
price_per_hour DECIMAL(10,2) NOT NULL,
total_amount DECIMAL(12,2) NOT NULL,
-- Lifecycle
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'depleted', 'expired', 'cancelled')),
purchased_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP, -- NULL = ingen udløb
-- e-conomic integration
economic_invoice_number VARCHAR(50),
economic_product_number VARCHAR(50),
-- Metadata
notes TEXT,
created_by_user_id INTEGER, -- Reference til users.user_id (read-only)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
-- CRITICAL: Kun 1 aktivt kort per customer
CREATE UNIQUE INDEX idx_tticket_prepaid_unique_active ON tticket_prepaid_cards(customer_id)
WHERE status = 'active';
CREATE INDEX idx_tticket_prepaid_customer ON tticket_prepaid_cards(customer_id);
CREATE INDEX idx_tticket_prepaid_status ON tticket_prepaid_cards(status);
CREATE INDEX idx_tticket_prepaid_expires ON tticket_prepaid_cards(expires_at);
-- ============================================================================
-- PREPAID TRANSACTIONS (immutable log af forbrug)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_prepaid_transactions (
id SERIAL PRIMARY KEY,
card_id INTEGER NOT NULL REFERENCES tticket_prepaid_cards(id) ON DELETE CASCADE,
worklog_id INTEGER, -- Reference til tticket_worklog (read-only, NULL for purchases/top-ups)
-- Transaction type
transaction_type VARCHAR(20) NOT NULL CHECK (transaction_type IN ('purchase', 'top_up', 'usage', 'refund', 'expiration', 'cancellation')),
-- Beløb (positiv = tilføj, negativ = træk)
hours DECIMAL(8,2) NOT NULL,
balance_after DECIMAL(8,2) NOT NULL,
-- Beskrivelse
description TEXT,
-- Metadata (immutable)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_user_id INTEGER -- Reference til users.user_id (read-only)
);
CREATE INDEX idx_tticket_transactions_card ON tticket_prepaid_transactions(card_id);
CREATE INDEX idx_tticket_transactions_worklog ON tticket_prepaid_transactions(worklog_id);
CREATE INDEX idx_tticket_transactions_created ON tticket_prepaid_transactions(created_at DESC);
-- ============================================================================
-- EMAIL INTEGRATION LOG (email → ticket mapping)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_email_log (
id SERIAL PRIMARY KEY,
ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE CASCADE,
email_id INTEGER, -- Reference til email_messages.id (read-only, ingen FK)
email_message_id VARCHAR(500), -- Email Message-ID header for threading
action VARCHAR(50) NOT NULL, -- created|comment_added|updated|linked
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tticket_email_log_ticket ON tticket_email_log(ticket_id);
CREATE INDEX idx_tticket_email_log_email ON tticket_email_log(email_id);
CREATE INDEX idx_tticket_email_log_message_id ON tticket_email_log(email_message_id);
-- ============================================================================
-- AUDIT LOG (alle ændringer)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_audit_log (
id SERIAL PRIMARY KEY,
ticket_id INTEGER, -- NULL for system-level events
entity_type VARCHAR(50) NOT NULL, -- ticket, comment, worklog, prepaid_card, etc.
entity_id INTEGER,
user_id INTEGER, -- Reference til users.user_id (read-only)
action VARCHAR(50) NOT NULL, -- created, updated, deleted, status_changed, assigned, etc.
old_value TEXT,
new_value TEXT,
details JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
CREATE INDEX idx_tticket_audit_entity ON tticket_audit_log(entity_type, entity_id);
CREATE INDEX idx_tticket_audit_created ON tticket_audit_log(created_at DESC);
-- ============================================================================
-- FUNCTIONS
-- ============================================================================
-- Auto-update timestamp function
CREATE OR REPLACE FUNCTION tticket_update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Generate ticket number function
CREATE OR REPLACE FUNCTION tticket_generate_ticket_number()
RETURNS VARCHAR AS $$
DECLARE
today_str VARCHAR(8);
last_number INTEGER;
next_number INTEGER;
new_ticket_number VARCHAR(50);
BEGIN
-- Format: TKT-YYYYMMDD-XXX
today_str := TO_CHAR(CURRENT_DATE, 'YYYYMMDD');
-- Find last ticket number for today
SELECT COALESCE(
MAX(CAST(SPLIT_PART(ticket_number, '-', 3) AS INTEGER)),
0
) INTO last_number
FROM tticket_tickets
WHERE ticket_number LIKE 'TKT-' || today_str || '-%';
next_number := last_number + 1;
new_ticket_number := 'TKT-' || today_str || '-' || LPAD(next_number::TEXT, 3, '0');
RETURN new_ticket_number;
END;
$$ LANGUAGE plpgsql;
-- Generate prepaid card number function
CREATE OR REPLACE FUNCTION tticket_generate_card_number()
RETURNS VARCHAR AS $$
DECLARE
today_str VARCHAR(8);
last_number INTEGER;
next_number INTEGER;
new_card_number VARCHAR(50);
BEGIN
-- Format: CARD-YYYYMMDD-XXX
today_str := TO_CHAR(CURRENT_DATE, 'YYYYMMDD');
-- Find last card number for today
SELECT COALESCE(
MAX(CAST(SPLIT_PART(card_number, '-', 3) AS INTEGER)),
0
) INTO last_number
FROM tticket_prepaid_cards
WHERE card_number LIKE 'CARD-' || today_str || '-%';
next_number := last_number + 1;
new_card_number := 'CARD-' || today_str || '-' || LPAD(next_number::TEXT, 3, '0');
RETURN new_card_number;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Auto-update timestamps
CREATE TRIGGER tticket_tickets_update
BEFORE UPDATE ON tticket_tickets
FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
CREATE TRIGGER tticket_comments_update
BEFORE UPDATE ON tticket_comments
FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
CREATE TRIGGER tticket_worklog_update
BEFORE UPDATE ON tticket_worklog
FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
CREATE TRIGGER tticket_prepaid_cards_update
BEFORE UPDATE ON tticket_prepaid_cards
FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
-- Auto-generate ticket number if not provided
CREATE OR REPLACE FUNCTION tticket_auto_generate_ticket_number()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.ticket_number IS NULL OR NEW.ticket_number = '' THEN
NEW.ticket_number := tticket_generate_ticket_number();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tticket_tickets_generate_number
BEFORE INSERT ON tticket_tickets
FOR EACH ROW EXECUTE FUNCTION tticket_auto_generate_ticket_number();
-- Auto-generate card number if not provided
CREATE OR REPLACE FUNCTION tticket_auto_generate_card_number()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.card_number IS NULL OR NEW.card_number = '' THEN
NEW.card_number := tticket_generate_card_number();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tticket_prepaid_cards_generate_number
BEFORE INSERT ON tticket_prepaid_cards
FOR EACH ROW EXECUTE FUNCTION tticket_auto_generate_card_number();
-- ============================================================================
-- VIEWS (common queries)
-- ============================================================================
-- View: Open tickets with statistics
CREATE OR REPLACE VIEW tticket_open_tickets AS
SELECT
t.*,
COUNT(DISTINCT c.id) AS comment_count,
COUNT(DISTINCT a.id) AS attachment_count,
SUM(w.hours) FILTER (WHERE w.status IN ('draft', 'billable')) AS pending_hours,
SUM(w.hours) FILTER (WHERE w.status = 'billed') AS billed_hours,
MAX(c.created_at) AS last_comment_at,
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - t.created_at))/3600 AS age_hours
FROM tticket_tickets t
LEFT JOIN tticket_comments c ON t.id = c.ticket_id
LEFT JOIN tticket_attachments a ON t.id = a.ticket_id
LEFT JOIN tticket_worklog w ON t.id = w.ticket_id
WHERE t.status IN ('open', 'in_progress', 'waiting_customer', 'waiting_internal')
GROUP BY t.id;
-- View: Worklog entries ready for billing
CREATE OR REPLACE VIEW tticket_billable_worklog AS
SELECT
w.*,
t.ticket_number,
t.subject AS ticket_subject,
t.customer_id,
t.status AS ticket_status,
CASE
WHEN w.billing_method = 'prepaid_card' THEN pc.remaining_hours >= w.hours
ELSE true
END AS has_sufficient_balance
FROM tticket_worklog w
JOIN tticket_tickets t ON w.ticket_id = t.id
LEFT JOIN tticket_prepaid_cards pc ON w.prepaid_card_id = pc.id
WHERE w.status = 'billable';
-- View: Prepaid card balances
CREATE OR REPLACE VIEW tticket_prepaid_balances AS
SELECT
pc.*,
COUNT(w.id) AS usage_count,
SUM(w.hours) AS total_hours_used,
COUNT(w.id) FILTER (WHERE w.status = 'billed') AS billed_usage_count
FROM tticket_prepaid_cards pc
LEFT JOIN tticket_worklog w ON pc.id = w.prepaid_card_id
GROUP BY pc.id;
-- View: Ticket statistics by status
CREATE OR REPLACE VIEW tticket_stats_by_status AS
SELECT
status,
priority,
COUNT(*) AS ticket_count,
AVG(EXTRACT(EPOCH FROM (COALESCE(resolved_at, CURRENT_TIMESTAMP) - created_at))/3600) AS avg_age_hours
FROM tticket_tickets
GROUP BY status, priority;
-- ============================================================================
-- INITIAL DATA
-- ============================================================================
-- Log installation in audit log
INSERT INTO tticket_audit_log (entity_type, action, details)
VALUES (
'system',
'module_installed',
jsonb_build_object(
'version', '1.0.0',
'timestamp', CURRENT_TIMESTAMP,
'tables_created', ARRAY[
'tticket_metadata', 'tticket_tickets', 'tticket_comments',
'tticket_attachments', 'tticket_worklog', 'tticket_prepaid_cards',
'tticket_prepaid_transactions', 'tticket_email_log', 'tticket_audit_log'
]
)
);
-- ============================================================================
-- UNINSTALL SCRIPT (bruges ved modul-sletning)
-- ============================================================================
-- ADVARSEL: Dette script sletter ALLE data i modulet!
-- Kør kun hvis modulet skal fjernes fuldstændigt.
--
-- For at uninstalle, kør følgende kommandoer i rækkefølge:
--
-- -- Drop views
-- DROP VIEW IF EXISTS tticket_stats_by_status CASCADE;
-- DROP VIEW IF EXISTS tticket_prepaid_balances CASCADE;
-- DROP VIEW IF EXISTS tticket_billable_worklog CASCADE;
-- DROP VIEW IF EXISTS tticket_open_tickets CASCADE;
--
-- -- Drop triggers
-- DROP TRIGGER IF EXISTS tticket_prepaid_cards_generate_number ON tticket_prepaid_cards;
-- DROP TRIGGER IF EXISTS tticket_tickets_generate_number ON tticket_tickets;
-- DROP TRIGGER IF EXISTS tticket_prepaid_cards_update ON tticket_prepaid_cards;
-- DROP TRIGGER IF EXISTS tticket_worklog_update ON tticket_worklog;
-- DROP TRIGGER IF EXISTS tticket_comments_update ON tticket_comments;
-- DROP TRIGGER IF EXISTS tticket_tickets_update ON tticket_tickets;
--
-- -- Drop functions
-- DROP FUNCTION IF EXISTS tticket_generate_card_number() CASCADE;
-- DROP FUNCTION IF EXISTS tticket_generate_ticket_number() CASCADE;
-- DROP FUNCTION IF EXISTS tticket_auto_generate_card_number() CASCADE;
-- DROP FUNCTION IF EXISTS tticket_auto_generate_ticket_number() CASCADE;
-- DROP FUNCTION IF EXISTS tticket_update_timestamp() CASCADE;
--
-- -- Drop tables (reverse dependency order)
-- DROP TABLE IF EXISTS tticket_audit_log CASCADE;
-- DROP TABLE IF EXISTS tticket_email_log CASCADE;
-- DROP TABLE IF EXISTS tticket_prepaid_transactions CASCADE;
-- DROP TABLE IF EXISTS tticket_prepaid_cards CASCADE;
-- DROP TABLE IF EXISTS tticket_worklog CASCADE;
-- DROP TABLE IF EXISTS tticket_attachments CASCADE;
-- DROP TABLE IF EXISTS tticket_comments CASCADE;
-- DROP TABLE IF EXISTS tticket_tickets CASCADE;
-- DROP TABLE IF EXISTS tticket_metadata CASCADE;
--
-- -- Log uninstall
-- -- (Dette vil fejle hvis tticket_audit_log er droppet, men det er OK)
-- DO $$
-- BEGIN
-- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tticket_audit_log') THEN
-- INSERT INTO tticket_audit_log (entity_type, action, details)
-- VALUES ('system', 'module_uninstalled', jsonb_build_object('timestamp', CURRENT_TIMESTAMP));
-- END IF;
-- EXCEPTION WHEN OTHERS THEN
-- NULL; -- Ignorer fejl
-- END $$;
--
-- ============================================================================

View File

@ -0,0 +1,59 @@
-- Migration 026: Add Ticket System Permissions
-- Description: Adds ticket-related permissions to auth system
-- Date: 2025-12-15
-- Add ticket permissions
INSERT INTO permissions (code, description, category) VALUES
('tickets.view', 'View tickets', 'tickets'),
('tickets.create', 'Create tickets', 'tickets'),
('tickets.edit', 'Edit tickets', 'tickets'),
('tickets.delete', 'Delete tickets', 'tickets'),
('tickets.assign', 'Assign tickets to users', 'tickets'),
('tickets.worklog.create', 'Create worklog entries', 'tickets'),
('tickets.worklog.approve', 'Approve worklog for billing', 'tickets'),
('tickets.billing.export', 'Export billable worklog to e-conomic', 'tickets'),
('tickets.prepaid.manage', 'Manage prepaid cards (klippekort)', 'tickets')
ON CONFLICT (code) DO NOTHING;
-- Assign ticket permissions to Administrators group (all permissions)
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
CROSS JOIN permissions p
WHERE g.name = 'Administrators' AND p.category = 'tickets'
ON CONFLICT DO NOTHING;
-- Assign ticket permissions to Managers group
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
CROSS JOIN permissions p
WHERE g.name = 'Managers' AND p.code IN (
'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.assign',
'tickets.worklog.approve', 'tickets.billing.export', 'tickets.prepaid.manage'
)
ON CONFLICT DO NOTHING;
-- Assign ticket permissions to Technicians group
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
CROSS JOIN permissions p
WHERE g.name = 'Technicians' AND p.code IN (
'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.worklog.create'
)
ON CONFLICT DO NOTHING;
-- Assign ticket view permission to Viewers group
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
CROSS JOIN permissions p
WHERE g.name = 'Viewers' AND p.code = 'tickets.view'
ON CONFLICT DO NOTHING;
-- Verify migration
SELECT 'Ticket permissions added successfully' AS status,
COUNT(*) AS ticket_permissions_count
FROM permissions
WHERE category = 'tickets';

490
test_ticket_module.py Normal file
View File

@ -0,0 +1,490 @@
"""
Test script for Ticket Module
==============================
Tester database schema, funktioner, constraints og services.
"""
import asyncio
import sys
from datetime import date, datetime
from decimal import Decimal
# Add parent directory to path
sys.path.insert(0, '/app')
# Initialize database pool before importing services
from app.core.database import init_db, execute_query, execute_insert, execute_update
from app.ticket.backend.ticket_service import TicketService
from app.ticket.backend.models import (
TTicketCreate,
TicketStatus,
TicketPriority,
TicketSource,
WorkType,
BillingMethod
)
def print_test(name: str, passed: bool, details: str = ""):
"""Print test result"""
emoji = "" if passed else ""
print(f"{emoji} {name}")
if details:
print(f" {details}")
print()
def test_tables_exist():
"""Test 1: Check all tables are created"""
print("=" * 60)
print("TEST 1: Database Tables")
print("=" * 60)
tables = [
'tticket_metadata',
'tticket_tickets',
'tticket_comments',
'tticket_attachments',
'tticket_worklog',
'tticket_prepaid_cards',
'tticket_prepaid_transactions',
'tticket_email_log',
'tticket_audit_log'
]
for table in tables:
result = execute_query(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = %s
)
""",
(table,),
fetchone=True
)
print_test(f"Table '{table}' exists", result['exists'])
# Check views
views = [
'tticket_open_tickets',
'tticket_billable_worklog',
'tticket_prepaid_balances',
'tticket_stats_by_status'
]
for view in views:
result = execute_query(
"""
SELECT EXISTS (
SELECT FROM information_schema.views
WHERE table_name = %s
)
""",
(view,),
fetchone=True
)
print_test(f"View '{view}' exists", result['exists'])
def test_ticket_number_generation():
"""Test 2: Auto-generate ticket number"""
print("=" * 60)
print("TEST 2: Ticket Number Generation")
print("=" * 60)
# Insert ticket without ticket_number (should auto-generate)
ticket_id = execute_insert(
"""
INSERT INTO tticket_tickets (subject, description, status)
VALUES (%s, %s, %s)
""",
("Test Ticket", "Testing auto-generation", "open")
)
ticket = execute_query(
"SELECT ticket_number FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
# Check format: TKT-YYYYMMDD-XXX
import re
pattern = r'TKT-\d{8}-\d{3}'
matches = re.match(pattern, ticket['ticket_number'])
print_test(
"Ticket number auto-generated",
matches is not None,
f"Generated: {ticket['ticket_number']}"
)
# Test generating multiple tickets
ticket_id2 = execute_insert(
"""
INSERT INTO tticket_tickets (subject, status)
VALUES (%s, %s)
""",
("Test Ticket 2", "open")
)
ticket2 = execute_query(
"SELECT ticket_number FROM tticket_tickets WHERE id = %s",
(ticket_id2,),
fetchone=True
)
print_test(
"Sequential ticket numbers",
ticket2['ticket_number'] > ticket['ticket_number'],
f"First: {ticket['ticket_number']}, Second: {ticket2['ticket_number']}"
)
def test_prepaid_card_constraints():
"""Test 3: Prepaid card constraints (1 active per customer)"""
print("=" * 60)
print("TEST 3: Prepaid Card Constraints")
print("=" * 60)
# Create first active card for customer 1
card1_id = execute_insert(
"""
INSERT INTO tticket_prepaid_cards
(customer_id, purchased_hours, price_per_hour, total_amount, status)
VALUES (%s, %s, %s, %s, %s)
""",
(1, Decimal('10.0'), Decimal('850.0'), Decimal('8500.0'), 'active')
)
card1 = execute_query(
"SELECT card_number, remaining_hours FROM tticket_prepaid_cards WHERE id = %s",
(card1_id,),
fetchone=True
)
print_test(
"First prepaid card created",
card1 is not None,
f"Card: {card1['card_number']}, Balance: {card1['remaining_hours']} hours"
)
# Try to create second active card for same customer (should fail due to UNIQUE constraint)
try:
card2_id = execute_insert(
"""
INSERT INTO tticket_prepaid_cards
(customer_id, purchased_hours, price_per_hour, total_amount, status)
VALUES (%s, %s, %s, %s, %s)
""",
(1, Decimal('20.0'), Decimal('850.0'), Decimal('17000.0'), 'active')
)
print_test(
"Cannot create 2nd active card (UNIQUE constraint)",
False,
"ERROR: Constraint not enforced!"
)
except Exception as e:
print_test(
"Cannot create 2nd active card (UNIQUE constraint)",
"unique" in str(e).lower() or "duplicate" in str(e).lower(),
f"Constraint enforced: {str(e)[:80]}"
)
# Create inactive card for same customer (should work)
card3_id = execute_insert(
"""
INSERT INTO tticket_prepaid_cards
(customer_id, purchased_hours, price_per_hour, total_amount, status)
VALUES (%s, %s, %s, %s, %s)
""",
(1, Decimal('5.0'), Decimal('850.0'), Decimal('4250.0'), 'depleted')
)
print_test(
"Can create inactive card for same customer",
card3_id is not None,
"Multiple inactive cards allowed"
)
def test_generated_column():
"""Test 4: Generated column (remaining_hours)"""
print("=" * 60)
print("TEST 4: Generated Column (remaining_hours)")
print("=" * 60)
# Create card
card_id = execute_insert(
"""
INSERT INTO tticket_prepaid_cards
(customer_id, purchased_hours, price_per_hour, total_amount, status)
VALUES (%s, %s, %s, %s, %s)
""",
(2, Decimal('20.0'), Decimal('850.0'), Decimal('17000.0'), 'active')
)
card = execute_query(
"SELECT purchased_hours, used_hours, remaining_hours FROM tticket_prepaid_cards WHERE id = %s",
(card_id,),
fetchone=True
)
print_test(
"Initial remaining_hours calculated",
card['remaining_hours'] == Decimal('20.0'),
f"Purchased: {card['purchased_hours']}, Used: {card['used_hours']}, Remaining: {card['remaining_hours']}"
)
# Use some hours
execute_update(
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
(Decimal('5.5'), card_id)
)
card = execute_query(
"SELECT purchased_hours, used_hours, remaining_hours FROM tticket_prepaid_cards WHERE id = %s",
(card_id,),
fetchone=True
)
expected = Decimal('14.5')
print_test(
"remaining_hours auto-updates",
card['remaining_hours'] == expected,
f"After using 5.5h: {card['remaining_hours']}h (expected: {expected}h)"
)
def test_ticket_service():
"""Test 5: Ticket Service business logic"""
print("=" * 60)
print("TEST 5: Ticket Service")
print("=" * 60)
# Test create ticket
ticket_data = TTicketCreate(
subject="Test Service Ticket",
description="Testing TicketService",
priority=TicketPriority.HIGH,
customer_id=1,
source=TicketSource.MANUAL
)
ticket = TicketService.create_ticket(ticket_data, user_id=1)
print_test(
"TicketService.create_ticket() works",
ticket is not None and 'id' in ticket,
f"Created ticket: {ticket['ticket_number']}"
)
# Test status transition validation
is_valid, error = TicketService.validate_status_transition('open', 'in_progress')
print_test(
"Valid status transition (open → in_progress)",
is_valid and error is None,
"Transition allowed"
)
is_valid, error = TicketService.validate_status_transition('closed', 'open')
print_test(
"Invalid status transition (closed → open)",
not is_valid and error is not None,
f"Transition blocked: {error}"
)
# Test update status
try:
updated = TicketService.update_ticket_status(
ticket['id'],
'in_progress',
user_id=1,
note="Starting work"
)
print_test(
"TicketService.update_ticket_status() works",
updated['status'] == 'in_progress',
f"Status changed: {ticket['status']}{updated['status']}"
)
except Exception as e:
print_test(
"TicketService.update_ticket_status() works",
False,
f"ERROR: {e}"
)
# Test add comment
try:
comment = TicketService.add_comment(
ticket['id'],
"Test comment from service",
user_id=1,
is_internal=False
)
print_test(
"TicketService.add_comment() works",
comment is not None and 'id' in comment,
f"Comment added (ID: {comment['id']})"
)
except Exception as e:
print_test(
"TicketService.add_comment() works",
False,
f"ERROR: {e}"
)
def test_worklog_creation():
"""Test 6: Worklog creation"""
print("=" * 60)
print("TEST 6: Worklog")
print("=" * 60)
# Get a ticket
ticket = execute_query(
"SELECT id FROM tticket_tickets LIMIT 1",
fetchone=True
)
if not ticket:
print_test("Worklog creation", False, "No tickets found to test worklog")
return
# Create worklog entry
worklog_id = execute_insert(
"""
INSERT INTO tticket_worklog
(ticket_id, work_date, hours, work_type, billing_method, status, user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(ticket['id'], date.today(), Decimal('2.5'), 'support', 'invoice', 'draft', 1)
)
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,),
fetchone=True
)
print_test(
"Worklog entry created",
worklog is not None,
f"ID: {worklog_id}, Hours: {worklog['hours']}, Status: {worklog['status']}"
)
# Check view
billable_count = execute_query(
"SELECT COUNT(*) as count FROM tticket_billable_worklog WHERE status = 'billable'",
fetchone=True
)
print_test(
"Billable worklog view accessible",
billable_count is not None,
f"Found {billable_count['count']} billable entries"
)
def test_audit_logging():
"""Test 7: Audit logging"""
print("=" * 60)
print("TEST 7: Audit Logging")
print("=" * 60)
# Check if audit entries were created
audit_count = execute_query(
"SELECT COUNT(*) as count FROM tticket_audit_log",
fetchone=True
)
print_test(
"Audit log has entries",
audit_count['count'] > 0,
f"Found {audit_count['count']} audit entries"
)
# Check recent audit entries
recent = execute_query(
"""
SELECT action, entity_type, created_at
FROM tticket_audit_log
ORDER BY created_at DESC
LIMIT 5
"""
)
if recent:
print("Recent audit entries:")
for entry in recent:
print(f" - {entry['action']} on {entry['entity_type']} at {entry['created_at']}")
print()
def test_views():
"""Test 8: Database views"""
print("=" * 60)
print("TEST 8: Database Views")
print("=" * 60)
# Test tticket_open_tickets view
open_tickets = execute_query(
"SELECT ticket_number, comment_count, age_hours FROM tticket_open_tickets LIMIT 5"
)
print_test(
"tticket_open_tickets view works",
open_tickets is not None,
f"Found {len(open_tickets)} open tickets"
)
# Test tticket_stats_by_status view
stats = execute_query(
"SELECT status, ticket_count FROM tticket_stats_by_status"
)
if stats:
print("Ticket statistics by status:")
for stat in stats:
print(f" - {stat['status']}: {stat['ticket_count']} tickets")
print()
def main():
"""Run all tests"""
print("\n")
print("🎫 TICKET MODULE TEST SUITE")
print("=" * 60)
print()
# Initialize database pool
print("🔌 Initializing database connection...")
init_db()
print("✅ Database connected\n")
try:
test_tables_exist()
test_ticket_number_generation()
test_prepaid_card_constraints()
test_generated_column()
test_ticket_service()
test_worklog_creation()
test_audit_logging()
test_views()
print("=" * 60)
print("✅ ALL TESTS COMPLETED")
print("=" * 60)
print()
except Exception as e:
print(f"\n❌ TEST SUITE FAILED: {e}\n")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()