-
-
Leverandør Fordeling
-
-
-{% endblock %}
-
-{% block extra_js %}
-
-{% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/app/devportal/backend/router.py b/app/devportal/backend/router.py
index 4912e64..b884468 100644
--- a/app/devportal/backend/router.py
+++ b/app/devportal/backend/router.py
@@ -76,14 +76,14 @@ async def get_features(version: Optional[str] = None, status: Optional[str] = No
params.append(status)
query += " ORDER BY priority DESC, expected_date ASC"
- result = execute_query(query, tuple(params) if params else None)
+ result = execute_query_single(query, tuple(params) if params else None)
return result or []
@router.get("/features/{feature_id}", response_model=Feature)
async def get_feature(feature_id: int):
"""Get a specific feature"""
- result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,), fetchone=True)
+ result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,))
if not result:
raise HTTPException(status_code=404, detail="Feature not found")
return result
@@ -97,10 +97,10 @@ async def create_feature(feature: FeatureCreate):
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
"""
- result = execute_query(query, (
+ result = execute_query_single(query, (
feature.title, feature.description, feature.version,
feature.status, feature.priority, feature.expected_date
- ), fetchone=True)
+ ))
logger.info(f"✅ Created feature: {feature.title}")
return result
@@ -116,10 +116,10 @@ async def update_feature(feature_id: int, feature: FeatureCreate):
WHERE id = %s
RETURNING *
"""
- result = execute_query(query, (
+ result = execute_query_single(query, (
feature.title, feature.description, feature.version,
feature.status, feature.priority, feature.expected_date, feature_id
- ), fetchone=True)
+ ))
if not result:
raise HTTPException(status_code=404, detail="Feature not found")
@@ -131,7 +131,7 @@ async def update_feature(feature_id: int, feature: FeatureCreate):
@router.delete("/features/{feature_id}")
async def delete_feature(feature_id: int):
"""Delete a roadmap feature"""
- result = execute_query("DELETE FROM dev_features WHERE id = %s RETURNING id", (feature_id,), fetchone=True)
+ result = execute_query_single("DELETE FROM dev_features WHERE id = %s RETURNING id", (feature_id,))
if not result:
raise HTTPException(status_code=404, detail="Feature not found")
@@ -151,7 +151,7 @@ async def get_ideas(category: Optional[str] = None):
params.append(category)
query += " ORDER BY votes DESC, created_at DESC"
- result = execute_query(query, tuple(params) if params else None)
+ result = execute_query_single(query, tuple(params) if params else None)
return result or []
@@ -163,7 +163,7 @@ async def create_idea(idea: IdeaCreate):
VALUES (%s, %s, %s)
RETURNING *
"""
- result = execute_query(query, (idea.title, idea.description, idea.category), fetchone=True)
+ result = execute_query(query, (idea.title, idea.description, idea.category))
logger.info(f"✅ Created idea: {idea.title}")
return result
@@ -178,7 +178,7 @@ async def vote_idea(idea_id: int):
WHERE id = %s
RETURNING *
"""
- result = execute_query(query, (idea_id,), fetchone=True)
+ result = execute_query_single(query, (idea_id,))
if not result:
raise HTTPException(status_code=404, detail="Idea not found")
@@ -189,7 +189,7 @@ async def vote_idea(idea_id: int):
@router.delete("/ideas/{idea_id}")
async def delete_idea(idea_id: int):
"""Delete an idea"""
- result = execute_query("DELETE FROM dev_ideas WHERE id = %s RETURNING id", (idea_id,), fetchone=True)
+ result = execute_query_single("DELETE FROM dev_ideas WHERE id = %s RETURNING id", (idea_id,))
if not result:
raise HTTPException(status_code=404, detail="Idea not found")
@@ -209,14 +209,14 @@ async def get_workflows(category: Optional[str] = None):
params.append(category)
query += " ORDER BY created_at DESC"
- result = execute_query(query, tuple(params) if params else None)
+ result = execute_query_single(query, tuple(params) if params else None)
return result or []
@router.get("/workflows/{workflow_id}", response_model=Workflow)
async def get_workflow(workflow_id: int):
"""Get a specific workflow"""
- result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,), fetchone=True)
+ result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,))
if not result:
raise HTTPException(status_code=404, detail="Workflow not found")
return result
@@ -230,9 +230,9 @@ async def create_workflow(workflow: WorkflowCreate):
VALUES (%s, %s, %s, %s)
RETURNING *
"""
- result = execute_query(query, (
+ result = execute_query_single(query, (
workflow.title, workflow.description, workflow.category, workflow.diagram_xml
- ), fetchone=True)
+ ))
logger.info(f"✅ Created workflow: {workflow.title}")
return result
@@ -247,10 +247,10 @@ async def update_workflow(workflow_id: int, workflow: WorkflowCreate):
WHERE id = %s
RETURNING *
"""
- result = execute_query(query, (
+ result = execute_query_single(query, (
workflow.title, workflow.description, workflow.category,
workflow.diagram_xml, workflow_id
- ), fetchone=True)
+ ))
if not result:
raise HTTPException(status_code=404, detail="Workflow not found")
@@ -262,7 +262,7 @@ async def update_workflow(workflow_id: int, workflow: WorkflowCreate):
@router.delete("/workflows/{workflow_id}")
async def delete_workflow(workflow_id: int):
"""Delete a workflow"""
- result = execute_query("DELETE FROM dev_workflows WHERE id = %s RETURNING id", (workflow_id,), fetchone=True)
+ result = execute_query_single("DELETE FROM dev_workflows WHERE id = %s RETURNING id", (workflow_id,))
if not result:
raise HTTPException(status_code=404, detail="Workflow not found")
@@ -274,9 +274,9 @@ async def delete_workflow(workflow_id: int):
@router.get("/stats")
async def get_devportal_stats():
"""Get DEV Portal statistics"""
- features_count = execute_query("SELECT COUNT(*) as count FROM dev_features", fetchone=True)
- ideas_count = execute_query("SELECT COUNT(*) as count FROM dev_ideas", fetchone=True)
- workflows_count = execute_query("SELECT COUNT(*) as count FROM dev_workflows", fetchone=True)
+ features_count = execute_query_single("SELECT COUNT(*) as count FROM dev_features")
+ ideas_count = execute_query_single("SELECT COUNT(*) as count FROM dev_ideas")
+ workflows_count = execute_query_single("SELECT COUNT(*) as count FROM dev_workflows")
features_by_status = execute_query("""
SELECT status, COUNT(*) as count
diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py
index 8d48105..40bf9ba 100644
--- a/app/emails/backend/router.py
+++ b/app/emails/backend/router.py
@@ -183,7 +183,7 @@ async def list_emails(
"""
params.extend([limit, offset])
- result = execute_query(query, tuple(params))
+ result = execute_query_single(query, tuple(params))
return result
@@ -241,7 +241,7 @@ async def mark_email_processed(email_id: int):
WHERE id = %s AND deleted_at IS NULL
RETURNING id, folder, status
"""
- result = execute_query(update_query, (email_id,), fetchone=True)
+ result = execute_query(update_query, (email_id,))
if not result:
raise HTTPException(status_code=404, detail="Email not found")
@@ -274,7 +274,7 @@ async def download_attachment(email_id: int, attachment_id: int):
JOIN email_messages e ON e.id = a.email_id
WHERE a.id = %s AND a.email_id = %s AND e.deleted_at IS NULL
"""
- result = execute_query(query, (attachment_id, email_id))
+ result = execute_query_single(query, (attachment_id, email_id))
if not result:
raise HTTPException(status_code=404, detail="Attachment not found")
@@ -717,7 +717,7 @@ async def get_workflow(workflow_id: int):
"""Get specific workflow by ID"""
try:
query = "SELECT * FROM email_workflows WHERE id = %s"
- result = execute_query(query, (workflow_id,), fetchone=True)
+ result = execute_query(query, (workflow_id,))
if not result:
raise HTTPException(status_code=404, detail="Workflow not found")
@@ -745,7 +745,7 @@ async def create_workflow(workflow: EmailWorkflow):
RETURNING *
"""
- result = execute_query(query, (
+ result = execute_query_single(query, (
workflow.name,
workflow.description,
workflow.classification_trigger,
@@ -756,7 +756,7 @@ async def create_workflow(workflow: EmailWorkflow):
workflow.priority,
workflow.enabled,
workflow.stop_on_match
- ), fetchone=True)
+ ))
if result:
logger.info(f"✅ Created workflow: {workflow.name}")
@@ -791,7 +791,7 @@ async def update_workflow(workflow_id: int, workflow: EmailWorkflow):
RETURNING *
"""
- result = execute_query(query, (
+ result = execute_query_single(query, (
workflow.name,
workflow.description,
workflow.classification_trigger,
@@ -803,7 +803,7 @@ async def update_workflow(workflow_id: int, workflow: EmailWorkflow):
workflow.enabled,
workflow.stop_on_match,
workflow_id
- ), fetchone=True)
+ ))
if result:
logger.info(f"✅ Updated workflow {workflow_id}")
@@ -841,7 +841,7 @@ async def toggle_workflow(workflow_id: int):
WHERE id = %s
RETURNING enabled
"""
- result = execute_query(query, (workflow_id,), fetchone=True)
+ result = execute_query_single(query, (workflow_id,))
if not result:
raise HTTPException(status_code=404, detail="Workflow not found")
@@ -873,7 +873,7 @@ async def execute_workflows_for_email(email_id: int):
FROM email_messages
WHERE id = %s AND deleted_at IS NULL
"""
- email_data = execute_query(query, (email_id,), fetchone=True)
+ email_data = execute_query_single(query, (email_id,))
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
diff --git a/app/models/schemas.py b/app/models/schemas.py
index 9a9968c..a412364 100644
--- a/app/models/schemas.py
+++ b/app/models/schemas.py
@@ -3,11 +3,10 @@ Pydantic Models and Schemas
"""
from pydantic import BaseModel
-from typing import Optional, List
+from typing import Optional
from datetime import datetime
-# Customer Schemas
class CustomerBase(BaseModel):
"""Base customer schema"""
name: str
@@ -16,31 +15,9 @@ class CustomerBase(BaseModel):
address: Optional[str] = None
-class CustomerCreate(BaseModel):
+class CustomerCreate(CustomerBase):
"""Schema for creating a customer"""
- name: str
- cvr_number: Optional[str] = None
- email: Optional[str] = None
- phone: Optional[str] = None
- address: Optional[str] = None
- postal_code: Optional[str] = None
- city: Optional[str] = None
- website: Optional[str] = None
- is_active: bool = True
-
-
-class CustomerUpdate(BaseModel):
- """Schema for updating a customer"""
- name: Optional[str] = None
- cvr_number: Optional[str] = None
- email: Optional[str] = None
- phone: Optional[str] = None
- address: Optional[str] = None
- postal_code: Optional[str] = None
- city: Optional[str] = None
- website: Optional[str] = None
- is_active: Optional[bool] = None
- subscriptions_locked: Optional[bool] = None
+ pass
class Customer(CustomerBase):
@@ -53,70 +30,6 @@ class Customer(CustomerBase):
from_attributes = True
-# Contact Schemas
-class ContactBase(BaseModel):
- """Base contact schema"""
- first_name: str
- last_name: str
- email: Optional[str] = None
- phone: Optional[str] = None
- mobile: Optional[str] = None
- title: Optional[str] = None
- department: Optional[str] = None
-
-
-class ContactCreate(ContactBase):
- """Schema for creating a contact"""
- company_ids: List[int] = [] # List of customer IDs to link to
- is_primary: bool = False # Whether this is the primary contact for first company
- role: Optional[str] = None
- notes: Optional[str] = None
- is_active: bool = True
-
-
-class ContactUpdate(BaseModel):
- """Schema for updating a contact"""
- first_name: Optional[str] = None
- last_name: Optional[str] = None
- email: Optional[str] = None
- phone: Optional[str] = None
- mobile: Optional[str] = None
- title: Optional[str] = None
- department: Optional[str] = None
- is_active: Optional[bool] = None
-
-
-class ContactCompanyLink(BaseModel):
- """Schema for linking/unlinking a contact to a company"""
- customer_id: int
- is_primary: bool = False
- role: Optional[str] = None
- notes: Optional[str] = None
-
-
-class CompanyInfo(BaseModel):
- """Schema for company information in contact context"""
- id: int
- name: str
- is_primary: bool
- role: Optional[str] = None
- notes: Optional[str] = None
-
-
-class Contact(ContactBase):
- """Full contact schema"""
- id: int
- is_active: bool
- vtiger_id: Optional[str] = None
- created_at: datetime
- updated_at: Optional[datetime] = None
- companies: List[CompanyInfo] = [] # List of linked companies
-
- class Config:
- from_attributes = True
-
-
-# Hardware Schemas
class HardwareBase(BaseModel):
"""Base hardware schema"""
serial_number: str
@@ -138,46 +51,32 @@ class Hardware(HardwareBase):
from_attributes = True
-# Vendor Schemas
class VendorBase(BaseModel):
"""Base vendor schema"""
name: str
- email: Optional[str] = None
- phone: Optional[str] = None
-
-
-class VendorCreate(BaseModel):
- """Schema for creating a vendor"""
- name: str
cvr_number: Optional[str] = None
+ domain: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
- address: Optional[str] = None
- postal_code: Optional[str] = None
- city: Optional[str] = None
- website: Optional[str] = None
- domain: Optional[str] = None
- email_pattern: Optional[str] = None
- category: str = 'general'
- priority: int = 50
+ contact_person: Optional[str] = None
+ category: Optional[str] = None
notes: Optional[str] = None
- is_active: bool = True
+
+
+class VendorCreate(VendorBase):
+ """Schema for creating a vendor"""
+ pass
class VendorUpdate(BaseModel):
"""Schema for updating a vendor"""
name: Optional[str] = None
cvr_number: Optional[str] = None
+ domain: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
- address: Optional[str] = None
- postal_code: Optional[str] = None
- city: Optional[str] = None
- website: Optional[str] = None
- domain: Optional[str] = None
- email_pattern: Optional[str] = None
+ contact_person: Optional[str] = None
category: Optional[str] = None
- priority: Optional[int] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
@@ -185,20 +84,9 @@ class VendorUpdate(BaseModel):
class Vendor(VendorBase):
"""Full vendor schema"""
id: int
- cvr_number: Optional[str] = None
- address: Optional[str] = None
- postal_code: Optional[str] = None
- city: Optional[str] = None
- country: Optional[str] = None
- website: Optional[str] = None
- domain: Optional[str] = None
- category: str
- priority: int
- notes: Optional[str] = None
- is_active: bool
+ is_active: bool = True
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
-
diff --git a/app/modules/_template/backend/router.py b/app/modules/_template/backend/router.py
index 7623f19..ce8b446 100644
--- a/app/modules/_template/backend/router.py
+++ b/app/modules/_template/backend/router.py
@@ -29,7 +29,7 @@ async def get_items():
read_only = get_module_config("template_module", "READ_ONLY", "true") == "true"
# Hent items (bemærk table_prefix)
- items = execute_query(
+ items = execute_query_single(
"SELECT * FROM template_items ORDER BY created_at DESC"
)
@@ -58,9 +58,7 @@ async def get_item(item_id: int):
try:
item = execute_query(
"SELECT * FROM template_items WHERE id = %s",
- (item_id,),
- fetchone=True
- )
+ (item_id,))
if not item:
raise HTTPException(status_code=404, detail="Item not found")
@@ -245,7 +243,7 @@ async def health_check():
"""
try:
# Test database connectivity
- result = execute_query("SELECT 1 as test", fetchone=True)
+ result = execute_query_single("SELECT 1 as test")
return {
"status": "healthy",
diff --git a/app/modules/test_module/backend/router.py b/app/modules/test_module/backend/router.py
index 1e5f3e9..a17448b 100644
--- a/app/modules/test_module/backend/router.py
+++ b/app/modules/test_module/backend/router.py
@@ -29,7 +29,7 @@ async def get_items():
read_only = get_module_config("test_module", "READ_ONLY", "true") == "true"
# Hent items (bemærk table_prefix)
- items = execute_query(
+ items = execute_query_single(
"SELECT * FROM test_module_items ORDER BY created_at DESC"
)
@@ -58,9 +58,7 @@ async def get_item(item_id: int):
try:
item = execute_query(
"SELECT * FROM test_module_items WHERE id = %s",
- (item_id,),
- fetchone=True
- )
+ (item_id,))
if not item:
raise HTTPException(status_code=404, detail="Item not found")
@@ -245,7 +243,7 @@ async def health_check():
"""
try:
# Test database connectivity
- result = execute_query("SELECT 1 as test", fetchone=True)
+ result = execute_query_single("SELECT 1 as test")
return {
"status": "healthy",
diff --git a/app/prepaid/backend/router.py b/app/prepaid/backend/router.py
new file mode 100644
index 0000000..5264f43
--- /dev/null
+++ b/app/prepaid/backend/router.py
@@ -0,0 +1,273 @@
+from fastapi import APIRouter, HTTPException
+from app.core.database import execute_query
+from typing import List, Optional, Dict, Any
+from pydantic import BaseModel
+from datetime import datetime, date
+import logging
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+# Pydantic Models
+class PrepaidCard(BaseModel):
+ id: Optional[int] = None
+ card_number: str
+ customer_id: int
+ purchased_hours: float
+ used_hours: float
+ remaining_hours: float
+ price_per_hour: float
+ total_amount: float
+ status: str
+ purchased_at: Optional[datetime] = None
+ expires_at: Optional[datetime] = None
+ economic_invoice_number: Optional[str] = None
+ economic_product_number: Optional[str] = None
+ notes: Optional[str] = None
+ created_at: Optional[datetime] = None
+ updated_at: Optional[datetime] = None
+
+class PrepaidCardCreate(BaseModel):
+ customer_id: int
+ purchased_hours: float
+ price_per_hour: float
+ expires_at: Optional[date] = None
+ notes: Optional[str] = None
+
+
+@router.get("/prepaid-cards", response_model=List[Dict[str, Any]])
+async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[int] = None):
+ """
+ Get all prepaid cards with customer information
+ """
+ try:
+ query = """
+ SELECT
+ pc.*,
+ c.name as customer_name,
+ c.email as customer_email,
+ (SELECT COUNT(*) FROM tticket_prepaid_transactions WHERE card_id = pc.id) as transaction_count
+ FROM tticket_prepaid_cards pc
+ LEFT JOIN customers c ON pc.customer_id = c.id
+ WHERE 1=1
+ """
+ params = []
+
+ if status:
+ query += " AND pc.status = %s"
+ params.append(status)
+
+ if customer_id:
+ query += " AND pc.customer_id = %s"
+ params.append(customer_id)
+
+ query += " ORDER BY pc.created_at DESC"
+
+ cards = execute_query(query, tuple(params) if params else None)
+ logger.info(f"✅ Retrieved {len(cards) if cards else 0} prepaid cards")
+ return cards or []
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching prepaid cards: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/prepaid-cards/{card_id}", response_model=Dict[str, Any])
+async def get_prepaid_card(card_id: int):
+ """
+ Get a specific prepaid card with transactions
+ """
+ try:
+ result = execute_query("""
+ SELECT
+ pc.*,
+ c.name as customer_name,
+ c.email as customer_email
+ FROM tticket_prepaid_cards pc
+ LEFT JOIN customers c ON pc.customer_id = c.id
+ WHERE pc.id = %s
+ """, (card_id,))
+
+ if not result or len(result) == 0:
+ raise HTTPException(status_code=404, detail="Prepaid card not found")
+
+ card = result[0]
+
+ # Get transactions
+ transactions = execute_query("""
+ SELECT
+ pt.*,
+ w.ticket_id,
+ t.subject as ticket_title
+ FROM tticket_prepaid_transactions pt
+ LEFT JOIN tticket_worklog w ON pt.worklog_id = w.id
+ LEFT JOIN tticket_tickets t ON w.ticket_id = t.id
+ WHERE pt.card_id = %s
+ ORDER BY pt.created_at DESC
+ """, (card_id,))
+
+ card['transactions'] = transactions or []
+
+ return card
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error fetching prepaid card {card_id}: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/prepaid-cards", response_model=Dict[str, Any])
+async def create_prepaid_card(card: PrepaidCardCreate):
+ """
+ Create a new prepaid card
+ """
+ try:
+ # Calculate total amount
+ total_amount = card.purchased_hours * card.price_per_hour
+
+ # Check if customer already has active card
+ existing = execute_query("""
+ SELECT id FROM tticket_prepaid_cards
+ WHERE customer_id = %s AND status = 'active'
+ """, (card.customer_id,))
+
+ if existing and len(existing) > 0:
+ raise HTTPException(
+ status_code=400,
+ detail="Customer already has an active prepaid card"
+ )
+
+ # Create card (need to use fetch=False for INSERT RETURNING)
+ conn = None
+ try:
+ from app.core.database import get_db_connection, release_db_connection
+ from psycopg2.extras import RealDictCursor
+
+ conn = get_db_connection()
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute("""
+ INSERT INTO tticket_prepaid_cards
+ (customer_id, purchased_hours, price_per_hour, total_amount, expires_at, notes)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ RETURNING *
+ """, (
+ card.customer_id,
+ card.purchased_hours,
+ card.price_per_hour,
+ total_amount,
+ card.expires_at,
+ card.notes
+ ))
+ conn.commit()
+ result = cursor.fetchone()
+
+ logger.info(f"✅ Created prepaid card: {result['card_number']}")
+ return result
+ finally:
+ if conn:
+ release_db_connection(conn)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error creating prepaid card: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.put("/prepaid-cards/{card_id}/status")
+async def update_card_status(card_id: int, status: str):
+ """
+ Update prepaid card status (cancel, reactivate)
+ """
+ try:
+ if status not in ['active', 'cancelled']:
+ raise HTTPException(status_code=400, detail="Invalid status")
+
+ conn = None
+ try:
+ from app.core.database import get_db_connection, release_db_connection
+ from psycopg2.extras import RealDictCursor
+
+ conn = get_db_connection()
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute("""
+ UPDATE tticket_prepaid_cards
+ SET status = %s
+ WHERE id = %s
+ RETURNING *
+ """, (status, card_id))
+ conn.commit()
+ result = cursor.fetchone()
+
+ if not result:
+ raise HTTPException(status_code=404, detail="Card not found")
+
+ logger.info(f"✅ Updated card {card_id} status to {status}")
+ return result
+ finally:
+ if conn:
+ release_db_connection(conn)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error updating card status: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/prepaid-cards/{card_id}")
+async def delete_prepaid_card(card_id: int):
+ """
+ Delete a prepaid card (only if no transactions)
+ """
+ try:
+ # Check for transactions
+ transactions = execute_query("""
+ SELECT COUNT(*) as count FROM tticket_prepaid_transactions
+ WHERE card_id = %s
+ """, (card_id,))
+
+ if transactions and len(transactions) > 0 and transactions[0]['count'] > 0:
+ raise HTTPException(
+ status_code=400,
+ detail="Cannot delete card with transactions"
+ )
+
+ execute_query("DELETE FROM tticket_prepaid_cards WHERE id = %s", (card_id,), fetch=False)
+
+ logger.info(f"✅ Deleted prepaid card {card_id}")
+ return {"message": "Card deleted successfully"}
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error deleting card: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/prepaid-cards/stats/summary", response_model=Dict[str, Any])
+async def get_prepaid_stats():
+ """
+ Get prepaid cards statistics
+ """
+ try:
+ result = execute_query("""
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'active') as active_count,
+ COUNT(*) FILTER (WHERE status = 'depleted') as depleted_count,
+ COUNT(*) FILTER (WHERE status = 'expired') as expired_count,
+ COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count,
+ COALESCE(SUM(remaining_hours) FILTER (WHERE status = 'active'), 0) as total_remaining_hours,
+ COALESCE(SUM(used_hours), 0) as total_used_hours,
+ COALESCE(SUM(purchased_hours), 0) as total_purchased_hours,
+ COALESCE(SUM(total_amount), 0) as total_revenue
+ FROM tticket_prepaid_cards
+ """)
+
+ return result[0] if result and len(result) > 0 else {}
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching prepaid stats: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/app/prepaid/backend/views.py b/app/prepaid/backend/views.py
new file mode 100644
index 0000000..64e8966
--- /dev/null
+++ b/app/prepaid/backend/views.py
@@ -0,0 +1,34 @@
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+import logging
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+templates = Jinja2Templates(directory=["app/prepaid/frontend", "app/shared/frontend"])
+
+
+@router.get("/prepaid-cards", response_class=HTMLResponse)
+async def prepaid_cards_page(request: Request):
+ """
+ Prepaid cards overview page
+ """
+ logger.info("🔍 Rendering prepaid cards page")
+ return templates.TemplateResponse("index.html", {
+ "request": request,
+ "page_title": "Prepaid Cards"
+ })
+
+
+@router.get("/prepaid-cards/{card_id}", response_class=HTMLResponse)
+async def prepaid_card_detail(request: Request, card_id: int):
+ """
+ Prepaid card detail page
+ """
+ logger.info(f"🔍 Rendering prepaid card detail: {card_id}")
+ return templates.TemplateResponse("detail.html", {
+ "request": request,
+ "page_title": "Card Details",
+ "card_id": card_id
+ })
diff --git a/app/prepaid/frontend/detail.html b/app/prepaid/frontend/detail.html
new file mode 100644
index 0000000..0eeae94
--- /dev/null
+++ b/app/prepaid/frontend/detail.html
@@ -0,0 +1,313 @@
+{% extends "base.html" %}
+
+{% block title %}Card Details - BMC Hub{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Købte Timer
+
-
+
+
+ Brugte Timer
+
-
+
+
+ Tilbage
+
-
+
+
+ Total Beløb
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Dato |
+ Ticket |
+ Beskrivelse |
+ Timer |
+ Beløb |
+
+
+
+
+ |
+
+ Loading...
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/prepaid/frontend/index.html b/app/prepaid/frontend/index.html
new file mode 100644
index 0000000..7e68d10
--- /dev/null
+++ b/app/prepaid/frontend/index.html
@@ -0,0 +1,461 @@
+{% extends "base.html" %}
+
+{% block title %}Prepaid Cards - BMC Hub{% endblock %}
+
+{% block content %}
+
+
+
+
+
💳 Prepaid Cards
+
Oversigt og kontrol af kunders timekort
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Tilbageværende Timer
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Kortnummer |
+ Kunde |
+ Købte Timer |
+ Brugte Timer |
+ Tilbage |
+ Pris/Time |
+ Total |
+ Status |
+ Udløber |
+ Handlinger |
+
+
+
+
+ |
+
+ Loading...
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/services/email_processor_service.py b/app/services/email_processor_service.py
index 487a121..e00735a 100644
--- a/app/services/email_processor_service.py
+++ b/app/services/email_processor_service.py
@@ -97,10 +97,9 @@ class EmailProcessorService:
# Step 5: Match against rules (legacy support) - skip if workflow already processed
if self.rules_enabled and not workflow_processed:
# Check if workflow already processed this email
- existing_execution = execute_query(
+ existing_execution = execute_query_single(
"SELECT id FROM email_workflow_executions WHERE email_id = %s AND status = 'completed' LIMIT 1",
- (email_id,), fetchone=True
- )
+ (email_id,))
if existing_execution:
logger.info(f"⏭️ Email {email_id} already processed by workflow, skipping rules")
diff --git a/app/services/email_workflow_service.py b/app/services/email_workflow_service.py
index 00e3de3..f0cad7e 100644
--- a/app/services/email_workflow_service.py
+++ b/app/services/email_workflow_service.py
@@ -104,7 +104,7 @@ class EmailWorkflowService:
ORDER BY priority ASC
"""
- workflows = execute_query(query, (classification, confidence))
+ workflows = execute_query_single(query, (classification, confidence))
# Filter by additional patterns
matching = []
@@ -400,16 +400,15 @@ class EmailWorkflowService:
# Find vendor by email
query = "SELECT id, name FROM vendors WHERE email = %s LIMIT 1"
- result = execute_query(query, (sender_email,), fetchone=True)
+ result = execute_query(query, (sender_email,))
if result:
vendor_id = result['id']
# Check if already linked to avoid duplicate updates
- current_vendor = execute_query(
+ current_vendor = execute_query_single(
"SELECT supplier_id FROM email_messages WHERE id = %s",
- (email_data['id'],), fetchone=True
- )
+ (email_data['id'],))
if current_vendor and current_vendor.get('supplier_id') == vendor_id:
logger.info(f"⏭️ Email already linked to vendor {vendor_id}, skipping duplicate update")
@@ -458,7 +457,7 @@ class EmailWorkflowService:
vendor_id = email_data.get('supplier_id')
# Get PDF attachments from email
- attachments = execute_query(
+ attachments = execute_query_single(
"""SELECT filename, file_path, size_bytes, content_type
FROM email_attachments
WHERE email_id = %s AND content_type = 'application/pdf'""",
@@ -515,9 +514,7 @@ class EmailWorkflowService:
# Check if file already exists
existing = execute_query(
"SELECT file_id FROM incoming_files WHERE checksum = %s",
- (checksum,),
- fetchone=True
- )
+ (checksum,))
if existing:
logger.info(f"⚠️ File already exists: {attachment['filename']}")
diff --git a/app/services/ollama_service.py b/app/services/ollama_service.py
index 66808c9..c3dc6b1 100644
--- a/app/services/ollama_service.py
+++ b/app/services/ollama_service.py
@@ -12,7 +12,7 @@ from datetime import datetime
import re
from app.core.config import settings
-from app.core.database import execute_insert, execute_query, execute_update
+from app.core.database import execute_insert, execute_query, execute_update, execute_query_single
logger = logging.getLogger(__name__)
@@ -582,11 +582,9 @@ Output: {
return None
# Search vendors table
- vendor = execute_query(
+ vendor = execute_query_single(
"SELECT * FROM vendors WHERE cvr_number = %s",
- (cvr_clean,),
- fetchone=True
- )
+ (cvr_clean,))
if vendor:
logger.info(f"✅ Matched vendor: {vendor['name']} (CVR: {cvr_clean})")
diff --git a/app/services/simplycrm_service.py b/app/services/simplycrm_service.py
index cd44634..409b1b3 100644
--- a/app/services/simplycrm_service.py
+++ b/app/services/simplycrm_service.py
@@ -22,16 +22,16 @@ class SimplyCRMService:
"""Service for integrating with Simply-CRM via webservice.php (VTiger fork)"""
def __init__(self):
- # Simply-CRM bruger OLD_VTIGER settings
- self.base_url = getattr(settings, 'OLD_VTIGER_URL', None)
- self.username = getattr(settings, 'OLD_VTIGER_USERNAME', None)
- self.access_key = getattr(settings, 'OLD_VTIGER_ACCESS_KEY', None)
+ # Try SIMPLYCRM_* first, fallback to OLD_VTIGER_* for backward compatibility
+ self.base_url = getattr(settings, 'SIMPLYCRM_URL', None) or getattr(settings, 'OLD_VTIGER_URL', None)
+ self.username = getattr(settings, 'SIMPLYCRM_USERNAME', None) or getattr(settings, 'OLD_VTIGER_USERNAME', None)
+ self.access_key = getattr(settings, 'SIMPLYCRM_API_KEY', None) or getattr(settings, 'OLD_VTIGER_API_KEY', None)
self.session_name: Optional[str] = None
self.session: Optional[aiohttp.ClientSession] = None
if not all([self.base_url, self.username, self.access_key]):
- logger.warning("⚠️ Simply-CRM credentials not configured (OLD_VTIGER_* settings)")
+ logger.warning("⚠️ Simply-CRM credentials not configured (SIMPLYCRM_* or OLD_VTIGER_* settings)")
async def __aenter__(self):
"""Context manager entry - create session and login"""
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index 9552ef0..db9d0a2 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -208,9 +208,10 @@
📦 Modul System
Dynamisk feature loading - udvikl moduler isoleret fra core systemet
-
+
+ API ikke implementeret endnu
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index 78d3262..e118dd2 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -236,7 +236,7 @@
Godkend Worklog
Ny Ticket
-
Klippekort
+
Prepaid Cards
Knowledge Base
@@ -1054,8 +1054,16 @@
function checkMaintenanceMode() {
fetch('/api/v1/backups/maintenance')
- .then(response => response.json())
+ .then(response => {
+ if (!response.ok) {
+ // Silently ignore 404 - maintenance endpoint not implemented yet
+ return null;
+ }
+ return response.json();
+ })
.then(data => {
+ if (!data) return; // Skip if endpoint doesn't exist
+
const overlay = document.getElementById('maintenance-overlay');
const messageEl = document.getElementById('maintenance-message');
const etaEl = document.getElementById('maintenance-eta');
@@ -1092,11 +1100,11 @@
}
})
.catch(error => {
- console.error('Maintenance check error:', error);
+ // Silently ignore errors - maintenance check is not critical
});
}
- // Check on page load
+ // Check on page load (optional feature, don't block if not available)
checkMaintenanceMode();
// Check periodically (every 30 seconds when not in maintenance)
diff --git a/app/ticket/backend/economic_export.py b/app/ticket/backend/economic_export.py
index 8d3bb5b..cd3ce37 100644
--- a/app/ticket/backend/economic_export.py
+++ b/app/ticket/backend/economic_export.py
@@ -19,7 +19,7 @@ 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.database import execute_query, execute_update, execute_query_single
from app.core.config import settings
from app.services.economic_service import EconomicService
from psycopg2.extras import Json
@@ -164,7 +164,7 @@ class TicketEconomicExportService:
WHERE id = %s
"""
- customer = execute_query(query, (customer_id,), fetchone=True)
+ customer = execute_query_single(query, (customer_id,))
if not customer:
logger.error(f"❌ Customer {customer_id} not found")
diff --git a/app/ticket/backend/email_integration.py b/app/ticket/backend/email_integration.py
index 10e1701..4cf2f9e 100644
--- a/app/ticket/backend/email_integration.py
+++ b/app/ticket/backend/email_integration.py
@@ -14,7 +14,7 @@ import re
from typing import Dict, Any, Optional, List
from datetime import datetime
-from app.core.database import execute_query, execute_insert
+from app.core.database import execute_query, execute_insert, execute_query_single
from app.ticket.backend.ticket_service import TicketService
from app.ticket.backend.models import TTicketCreate, TicketPriority
from psycopg2.extras import Json
@@ -122,7 +122,7 @@ class EmailTicketIntegration:
# Find ticket by ticket_number
query = "SELECT id FROM tticket_tickets WHERE ticket_number = %s"
- result = execute_query(query, (ticket_number,), fetchone=True)
+ result = execute_query_single(query, (ticket_number,))
if not result:
logger.warning(f"⚠️ Ticket {ticket_number} not found - creating new ticket instead")
diff --git a/app/ticket/backend/klippekort_service.py b/app/ticket/backend/klippekort_service.py
index 9a13297..7e242f5 100644
--- a/app/ticket/backend/klippekort_service.py
+++ b/app/ticket/backend/klippekort_service.py
@@ -12,7 +12,7 @@ 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.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.ticket.backend.models import (
TPrepaidCard,
TPrepaidCardCreate,
@@ -54,14 +54,12 @@ class KlippekortService:
from psycopg2.extras import Json
# Check if customer already has an active card
- existing = execute_query(
+ existing = execute_query_single(
"""
SELECT id, card_number FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
""",
- (card_data.customer_id,),
- fetchone=True
- )
+ (card_data.customer_id,))
if existing:
raise ValueError(
@@ -113,11 +111,9 @@ class KlippekortService:
)
# Fetch created card
- card = execute_query(
+ card = execute_query_single(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
- (card_id,),
- fetchone=True
- )
+ (card_id,))
logger.info(f"✅ Created prepaid card {card['card_number']} (ID: {card_id})")
return card
@@ -125,20 +121,16 @@ class KlippekortService:
@staticmethod
def get_card(card_id: int) -> Optional[Dict[str, Any]]:
"""Get prepaid card by ID"""
- return execute_query(
+ return execute_query_single(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
- (card_id,),
- fetchone=True
- )
+ (card_id,))
@staticmethod
def get_card_with_stats(card_id: int) -> Optional[Dict[str, Any]]:
"""Get prepaid card with usage statistics"""
- return execute_query(
+ return execute_query_single(
"SELECT * FROM tticket_prepaid_balances WHERE id = %s",
- (card_id,),
- fetchone=True
- )
+ (card_id,))
@staticmethod
def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]:
@@ -147,14 +139,12 @@ class KlippekortService:
Returns None if no active card exists.
"""
- return execute_query(
+ return execute_query_single(
"""
SELECT * FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
""",
- (customer_id,),
- fetchone=True
- )
+ (customer_id,))
@staticmethod
def check_balance(customer_id: int) -> Dict[str, Any]:
@@ -299,11 +289,9 @@ class KlippekortService:
logger.warning(f"💳 Card {card['card_number']} is now depleted")
# Fetch transaction
- transaction = execute_query(
+ transaction = execute_query_single(
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
- (transaction_id,),
- fetchone=True
- )
+ (transaction_id,))
logger.info(f"✅ Deducted {hours}h from card {card['card_number']}, new balance: {new_balance}h")
return transaction
@@ -368,11 +356,9 @@ class KlippekortService:
)
)
- transaction = execute_query(
+ transaction = execute_query_single(
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
- (transaction_id,),
- fetchone=True
- )
+ (transaction_id,))
logger.info(f"✅ Topped up card {card['card_number']} with {hours}h, new balance: {new_balance}h")
return transaction
@@ -392,7 +378,7 @@ class KlippekortService:
Returns:
List of transaction dicts
"""
- transactions = execute_query(
+ transactions = execute_query_single(
"""
SELECT * FROM tticket_prepaid_transactions
WHERE card_id = %s
@@ -496,9 +482,7 @@ class KlippekortService:
# Fetch updated card
updated = execute_query(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
- (card_id,),
- fetchone=True
- )
+ (card_id,))
logger.info(f"✅ Cancelled card {card['card_number']}")
return updated
diff --git a/app/ticket/backend/models.py b/app/ticket/backend/models.py
index b84ef9a..7255b1e 100644
--- a/app/ticket/backend/models.py
+++ b/app/ticket/backend/models.py
@@ -495,3 +495,289 @@ 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")
+
+
+# ============================================================================
+# TICKET RELATIONS MODELS (Migration 026)
+# ============================================================================
+
+class TicketRelationType(str, Enum):
+ """Ticket relation types"""
+ MERGED_INTO = "merged_into"
+ SPLIT_FROM = "split_from"
+ PARENT_OF = "parent_of"
+ CHILD_OF = "child_of"
+ RELATED_TO = "related_to"
+
+
+class TTicketRelationBase(BaseModel):
+ """Base model for ticket relation"""
+ ticket_id: int
+ related_ticket_id: int
+ relation_type: TicketRelationType
+ reason: Optional[str] = None
+
+
+class TTicketRelationCreate(TTicketRelationBase):
+ """Create ticket relation"""
+ pass
+
+
+class TTicketRelation(TTicketRelationBase):
+ """Full ticket relation model"""
+ id: int
+ created_by_user_id: Optional[int] = None
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+# ============================================================================
+# CALENDAR EVENTS MODELS
+# ============================================================================
+
+class CalendarEventType(str, Enum):
+ """Calendar event types"""
+ APPOINTMENT = "appointment"
+ DEADLINE = "deadline"
+ MILESTONE = "milestone"
+ REMINDER = "reminder"
+ FOLLOW_UP = "follow_up"
+
+
+class CalendarEventStatus(str, Enum):
+ """Calendar event status"""
+ PENDING = "pending"
+ CONFIRMED = "confirmed"
+ COMPLETED = "completed"
+ CANCELLED = "cancelled"
+
+
+class TTicketCalendarEventBase(BaseModel):
+ """Base model for calendar event"""
+ ticket_id: int
+ title: str = Field(..., min_length=1, max_length=200)
+ description: Optional[str] = None
+ event_type: CalendarEventType = Field(default=CalendarEventType.APPOINTMENT)
+ event_date: date
+ event_time: Optional[str] = None
+ duration_minutes: Optional[int] = None
+ all_day: bool = False
+ status: CalendarEventStatus = Field(default=CalendarEventStatus.PENDING)
+
+
+class TTicketCalendarEventCreate(TTicketCalendarEventBase):
+ """Create calendar event"""
+ suggested_by_ai: bool = False
+ ai_confidence: Optional[Decimal] = None
+ ai_source_text: Optional[str] = None
+
+
+class TTicketCalendarEvent(TTicketCalendarEventBase):
+ """Full calendar event model"""
+ id: int
+ suggested_by_ai: bool = False
+ ai_confidence: Optional[Decimal] = None
+ ai_source_text: Optional[str] = None
+ created_by_user_id: Optional[int] = None
+ created_at: datetime
+ updated_at: Optional[datetime] = None
+ completed_at: Optional[datetime] = None
+
+ class Config:
+ from_attributes = True
+
+
+# ============================================================================
+# TEMPLATES MODELS
+# ============================================================================
+
+class TTicketTemplateBase(BaseModel):
+ """Base model for template"""
+ name: str = Field(..., min_length=1, max_length=200)
+ description: Optional[str] = None
+ category: Optional[str] = None
+ subject_template: Optional[str] = Field(None, max_length=500)
+ body_template: str = Field(..., min_length=1)
+ available_placeholders: Optional[List[str]] = None
+ default_attachments: Optional[dict] = None
+ is_active: bool = True
+ requires_approval: bool = False
+
+
+class TTicketTemplateCreate(TTicketTemplateBase):
+ """Create template"""
+ pass
+
+
+class TTicketTemplate(TTicketTemplateBase):
+ """Full template model"""
+ id: int
+ created_by_user_id: Optional[int] = None
+ created_at: datetime
+ updated_at: Optional[datetime] = None
+ last_used_at: Optional[datetime] = None
+ usage_count: int = 0
+
+ class Config:
+ from_attributes = True
+
+
+class TemplateRenderRequest(BaseModel):
+ """Request to render template with data"""
+ template_id: int
+ ticket_id: int
+ custom_data: Optional[dict] = None
+
+
+class TemplateRenderResponse(BaseModel):
+ """Rendered template"""
+ subject: Optional[str] = None
+ body: str
+ placeholders_used: List[str]
+
+
+# ============================================================================
+# AI SUGGESTIONS MODELS
+# ============================================================================
+
+class AISuggestionType(str, Enum):
+ """AI suggestion types"""
+ CONTACT_UPDATE = "contact_update"
+ NEW_CONTACT = "new_contact"
+ CATEGORY = "category"
+ TAG = "tag"
+ PRIORITY = "priority"
+ DEADLINE = "deadline"
+ CALENDAR_EVENT = "calendar_event"
+ TEMPLATE = "template"
+ MERGE = "merge"
+ RELATED_TICKET = "related_ticket"
+
+
+class AISuggestionStatus(str, Enum):
+ """AI suggestion status"""
+ PENDING = "pending"
+ ACCEPTED = "accepted"
+ REJECTED = "rejected"
+ AUTO_EXPIRED = "auto_expired"
+
+
+class TTicketAISuggestionBase(BaseModel):
+ """Base model for AI suggestion"""
+ ticket_id: int
+ suggestion_type: AISuggestionType
+ suggestion_data: dict # Struktureret data om forslaget
+ confidence: Optional[Decimal] = None
+ reasoning: Optional[str] = None
+ source_text: Optional[str] = None
+ source_comment_id: Optional[int] = None
+
+
+class TTicketAISuggestionCreate(TTicketAISuggestionBase):
+ """Create AI suggestion"""
+ expires_at: Optional[datetime] = None
+
+
+class TTicketAISuggestion(TTicketAISuggestionBase):
+ """Full AI suggestion model"""
+ id: int
+ status: AISuggestionStatus = Field(default=AISuggestionStatus.PENDING)
+ reviewed_by_user_id: Optional[int] = None
+ reviewed_at: Optional[datetime] = None
+ created_at: datetime
+ expires_at: Optional[datetime] = None
+
+ class Config:
+ from_attributes = True
+
+
+class AISuggestionReviewRequest(BaseModel):
+ """Request to accept/reject AI suggestion"""
+ action: str = Field(..., pattern="^(accept|reject)$")
+ note: Optional[str] = None
+
+
+# ============================================================================
+# EMAIL METADATA MODELS
+# ============================================================================
+
+class TTicketEmailMetadataBase(BaseModel):
+ """Base model for email metadata"""
+ ticket_id: int
+ message_id: Optional[str] = None
+ in_reply_to: Optional[str] = None
+ references: Optional[str] = None
+ from_email: str
+ from_name: Optional[str] = None
+ from_signature: Optional[str] = None
+
+
+class TTicketEmailMetadataCreate(TTicketEmailMetadataBase):
+ """Create email metadata"""
+ matched_contact_id: Optional[int] = None
+ match_confidence: Optional[Decimal] = None
+ match_method: Optional[str] = None
+ suggested_contacts: Optional[dict] = None
+ extracted_phone: Optional[str] = None
+ extracted_address: Optional[str] = None
+ extracted_company: Optional[str] = None
+ extracted_title: Optional[str] = None
+
+
+class TTicketEmailMetadata(TTicketEmailMetadataCreate):
+ """Full email metadata model"""
+ id: int
+ created_at: datetime
+ updated_at: Optional[datetime] = None
+
+ class Config:
+ from_attributes = True
+
+
+# ============================================================================
+# AUDIT LOG MODELS
+# ============================================================================
+
+class TTicketAuditLog(BaseModel):
+ """Audit log entry"""
+ id: int
+ ticket_id: int
+ action: str
+ field_name: Optional[str] = None
+ old_value: Optional[str] = None
+ new_value: Optional[str] = None
+ user_id: Optional[int] = None
+ performed_at: datetime
+ reason: Optional[str] = None
+ metadata: Optional[dict] = None
+
+ class Config:
+ from_attributes = True
+
+
+# ============================================================================
+# EXTENDED REQUEST MODELS
+# ============================================================================
+
+class TicketMergeRequest(BaseModel):
+ """Request to merge tickets"""
+ source_ticket_ids: List[int] = Field(..., min_length=1, description="Tickets at lægge sammen")
+ target_ticket_id: int = Field(..., description="Primær ticket der skal beholdes")
+ reason: Optional[str] = Field(None, description="Hvorfor lægges de sammen")
+
+
+class TicketSplitRequest(BaseModel):
+ """Request to split ticket"""
+ source_ticket_id: int = Field(..., description="Ticket at splitte")
+ comment_ids: List[int] = Field(..., min_length=1, description="Kommentarer til ny ticket")
+ new_subject: str = Field(..., min_length=1, description="Emne på ny ticket")
+ new_description: Optional[str] = Field(None, description="Beskrivelse på ny ticket")
+ reason: Optional[str] = Field(None, description="Hvorfor splittes ticketen")
+
+
+class TicketDeadlineUpdateRequest(BaseModel):
+ """Request to update ticket deadline"""
+ deadline: Optional[datetime] = None
+ reason: Optional[str] = None
diff --git a/app/ticket/backend/router.py b/app/ticket/backend/router.py
index ce30937..5d08c82 100644
--- a/app/ticket/backend/router.py
+++ b/app/ticket/backend/router.py
@@ -26,9 +26,28 @@ from app.ticket.backend.models import (
TicketListResponse,
TicketStatusUpdateRequest,
WorklogReviewResponse,
- WorklogBillingRequest
+ WorklogBillingRequest,
+ # Migration 026 models
+ TTicketRelation,
+ TTicketRelationCreate,
+ TTicketCalendarEvent,
+ TTicketCalendarEventCreate,
+ CalendarEventStatus,
+ TTicketTemplate,
+ TTicketTemplateCreate,
+ TemplateRenderRequest,
+ TemplateRenderResponse,
+ TTicketAISuggestion,
+ TTicketAISuggestionCreate,
+ AISuggestionStatus,
+ AISuggestionType,
+ AISuggestionReviewRequest,
+ TTicketAuditLog,
+ TicketMergeRequest,
+ TicketSplitRequest,
+ TicketDeadlineUpdateRequest
)
-from app.core.database import execute_query, execute_insert, execute_update
+from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from datetime import date
logger = logging.getLogger(__name__)
@@ -81,7 +100,7 @@ async def list_tickets(
total_query += " AND customer_id = %s"
params.append(customer_id)
- total_result = execute_query(total_query, tuple(params), fetchone=True)
+ total_result = execute_query_single(total_query, tuple(params))
total = total_result['count'] if total_result else 0
return TicketListResponse(
@@ -217,7 +236,7 @@ async def list_comments(ticket_id: int):
List all comments for a ticket
"""
try:
- comments = execute_query(
+ comments = execute_query_single(
"SELECT * FROM tticket_comments WHERE ticket_id = %s ORDER BY created_at ASC",
(ticket_id,)
)
@@ -322,9 +341,7 @@ async def create_worklog(
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s",
- (worklog_id,),
- fetchone=True
- )
+ (worklog_id,))
logger.info(f"✅ Created worklog entry {worklog_id} for ticket {ticket_id}")
return worklog
@@ -347,11 +364,9 @@ async def update_worklog(
"""
try:
# Get current worklog
- current = execute_query(
+ current = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s",
- (worklog_id,),
- fetchone=True
- )
+ (worklog_id,))
if not current:
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
@@ -384,11 +399,9 @@ async def update_worklog(
)
# Fetch updated
- worklog = execute_query(
+ worklog = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s",
- (worklog_id,),
- fetchone=True
- )
+ (worklog_id,))
return worklog
@@ -427,7 +440,7 @@ async def review_worklog(
query += " ORDER BY w.work_date DESC, t.customer_id"
- worklogs = execute_query(query, tuple(params))
+ worklogs = execute_query_single(query, tuple(params))
# Calculate totals
total_hours = Decimal('0')
@@ -467,9 +480,7 @@ async def mark_worklog_billable(
# Get worklog
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s",
- (worklog_id,),
- fetchone=True
- )
+ (worklog_id,))
if not worklog:
logger.warning(f"⚠️ Worklog {worklog_id} not found, skipping")
@@ -700,7 +711,7 @@ async def get_stats_by_status():
Get ticket statistics grouped by status
"""
try:
- stats = execute_query(
+ stats = execute_query_single(
"SELECT * FROM tticket_stats_by_status ORDER BY status"
)
return stats or []
@@ -725,9 +736,7 @@ async def get_open_tickets_stats():
COUNT(*) FILTER (WHERE priority = 'urgent') as urgent_count,
AVG(age_hours) as avg_age_hours
FROM tticket_open_tickets
- """,
- fetchone=True
- )
+ """)
return stats or {}
@@ -816,3 +825,586 @@ async def execute_economic_export(
except Exception as e:
logger.error(f"❌ Error executing export: {e}")
raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================================
+# TICKET RELATIONS ENDPOINTS (Migration 026)
+# ============================================================================
+
+@router.post("/tickets/{ticket_id}/merge", tags=["Ticket Relations"])
+async def merge_tickets(ticket_id: int, request: TicketMergeRequest):
+ """
+ Flet flere tickets sammen til én primær ticket
+
+ **Process**:
+ 1. Validerer at alle source tickets eksisterer
+ 2. Kopierer kommentarer og worklogs til target ticket
+ 3. Opretter relation records
+ 4. Markerer source tickets som merged
+ 5. Logger i audit trail
+ """
+ try:
+ # Validate target ticket exists
+ target_ticket = execute_query_single(
+ "SELECT id, ticket_number, subject FROM tticket_tickets WHERE id = %s",
+ (request.target_ticket_id,)
+ )
+ if not target_ticket:
+ raise HTTPException(status_code=404, detail=f"Target ticket {request.target_ticket_id} not found")
+
+ merged_count = 0
+ for source_id in request.source_ticket_ids:
+ # Validate source ticket
+ source_ticket = execute_query_single(
+ "SELECT id, ticket_number FROM tticket_tickets WHERE id = %s",
+ (source_id,)
+ )
+ if not source_ticket:
+ logger.warning(f"⚠️ Source ticket {source_id} not found, skipping")
+ continue
+
+ # Create relation
+ execute_query(
+ """INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
+ VALUES (%s, %s, 'merged_into', %s, 1)
+ ON CONFLICT (ticket_id, related_ticket_id, relation_type) DO NOTHING""",
+ (source_id, request.target_ticket_id, request.reason)
+ )
+
+ # Mark source as merged
+ execute_query(
+ """UPDATE tticket_tickets
+ SET is_merged = true, merged_into_ticket_id = %s, status = 'closed'
+ WHERE id = %s""",
+ (request.target_ticket_id, source_id),
+ fetch=False
+ )
+
+ # Log audit
+ execute_query(
+ """INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
+ VALUES (%s, 'merged_into', %s, %s)""",
+ (source_id, str(request.target_ticket_id), request.reason)
+ )
+
+ merged_count += 1
+ logger.info(f"✅ Merged ticket {source_id} into {request.target_ticket_id}")
+
+ return {
+ "status": "success",
+ "merged_count": merged_count,
+ "target_ticket": target_ticket,
+ "message": f"Successfully merged {merged_count} ticket(s)"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error merging tickets: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/tickets/{ticket_id}/split", tags=["Ticket Relations"])
+async def split_ticket(ticket_id: int, request: TicketSplitRequest):
+ """
+ Opdel en ticket i to - flyt kommentarer til ny ticket
+
+ **Process**:
+ 1. Opretter ny ticket med nyt subject
+ 2. Flytter valgte kommentarer til ny ticket
+ 3. Opretter relation
+ 4. Logger i audit trail
+ """
+ try:
+ # Validate source ticket
+ source_ticket = execute_query_single(
+ "SELECT * FROM tticket_tickets WHERE id = %s",
+ (request.source_ticket_id,)
+ )
+ if not source_ticket:
+ raise HTTPException(status_code=404, detail=f"Source ticket {request.source_ticket_id} not found")
+
+ # Create new ticket (inherit customer, contact, priority)
+ new_ticket_id = execute_insert(
+ """INSERT INTO tticket_tickets
+ (subject, description, status, priority, customer_id, contact_id, source, created_by_user_id)
+ VALUES (%s, %s, 'open', %s, %s, %s, 'manual', 1)
+ RETURNING id""",
+ (request.new_subject, request.new_description, source_ticket['priority'],
+ source_ticket['customer_id'], source_ticket['contact_id'])
+ )
+
+ new_ticket_number = execute_query_single(
+ "SELECT ticket_number FROM tticket_tickets WHERE id = %s",
+ (new_ticket_id,)
+ )['ticket_number']
+
+ # Move comments
+ moved_comments = 0
+ for comment_id in request.comment_ids:
+ result = execute_query(
+ "UPDATE tticket_comments SET ticket_id = %s WHERE id = %s AND ticket_id = %s",
+ (new_ticket_id, comment_id, request.source_ticket_id),
+ fetch=False
+ )
+ if result:
+ moved_comments += 1
+
+ # Create relation
+ execute_query(
+ """INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
+ VALUES (%s, %s, 'split_from', %s, 1)""",
+ (new_ticket_id, request.source_ticket_id, request.reason)
+ )
+
+ # Log audit
+ execute_query(
+ """INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
+ VALUES (%s, 'split_into', %s, %s)""",
+ (request.source_ticket_id, str(new_ticket_id), request.reason)
+ )
+
+ logger.info(f"✅ Split ticket {request.source_ticket_id} into {new_ticket_id}, moved {moved_comments} comments")
+
+ return {
+ "status": "success",
+ "new_ticket_id": new_ticket_id,
+ "new_ticket_number": new_ticket_number,
+ "moved_comments": moved_comments,
+ "message": f"Successfully split ticket into {new_ticket_number}"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error splitting ticket: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/tickets/{ticket_id}/relations", tags=["Ticket Relations"])
+async def get_ticket_relations(ticket_id: int):
+ """Hent alle relationer for en ticket (begge retninger)"""
+ try:
+ relations = execute_query(
+ """SELECT r.*,
+ t.ticket_number as related_ticket_number,
+ t.subject as related_subject,
+ t.status as related_status
+ FROM tticket_all_relations r
+ LEFT JOIN tticket_tickets t ON r.related_ticket_id = t.id
+ WHERE r.ticket_id = %s
+ ORDER BY r.created_at DESC""",
+ (ticket_id,)
+ )
+
+ return {"relations": relations, "total": len(relations)}
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching relations: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/tickets/{ticket_id}/relations", tags=["Ticket Relations"])
+async def create_ticket_relation(ticket_id: int, relation: TTicketRelationCreate):
+ """Opret en relation mellem to tickets"""
+ try:
+ # Validate both tickets exist
+ for tid in [relation.ticket_id, relation.related_ticket_id]:
+ ticket = execute_query_single("SELECT id FROM tticket_tickets WHERE id = %s", (tid,))
+ if not ticket:
+ raise HTTPException(status_code=404, detail=f"Ticket {tid} not found")
+
+ execute_query(
+ """INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
+ VALUES (%s, %s, %s, %s, 1)""",
+ (relation.ticket_id, relation.related_ticket_id, relation.relation_type, relation.reason)
+ )
+
+ return {"status": "success", "message": "Relation created"}
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error creating relation: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================================
+# CALENDAR EVENTS ENDPOINTS
+# ============================================================================
+
+@router.get("/tickets/{ticket_id}/calendar-events", tags=["Calendar"])
+async def get_calendar_events(ticket_id: int):
+ """Hent alle kalender events for en ticket"""
+ try:
+ events = execute_query(
+ """SELECT * FROM tticket_calendar_events
+ WHERE ticket_id = %s
+ ORDER BY event_date DESC, event_time DESC NULLS LAST""",
+ (ticket_id,)
+ )
+
+ return {"events": events, "total": len(events)}
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching calendar events: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/tickets/{ticket_id}/calendar-events", tags=["Calendar"])
+async def create_calendar_event(ticket_id: int, event: TTicketCalendarEventCreate):
+ """Opret kalender event (manual eller AI-foreslået)"""
+ try:
+ event_id = execute_insert(
+ """INSERT INTO tticket_calendar_events
+ (ticket_id, title, description, event_type, event_date, event_time,
+ duration_minutes, all_day, status, suggested_by_ai, ai_confidence,
+ ai_source_text, created_by_user_id)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1)
+ RETURNING id""",
+ (ticket_id, event.title, event.description, event.event_type,
+ event.event_date, event.event_time, event.duration_minutes,
+ event.all_day, event.status, event.suggested_by_ai,
+ event.ai_confidence, event.ai_source_text)
+ )
+
+ logger.info(f"✅ Created calendar event {event_id} for ticket {ticket_id}")
+
+ return {"status": "success", "event_id": event_id, "message": "Calendar event created"}
+
+ except Exception as e:
+ logger.error(f"❌ Error creating calendar event: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.put("/tickets/{ticket_id}/calendar-events/{event_id}", tags=["Calendar"])
+async def update_calendar_event(ticket_id: int, event_id: int, status: CalendarEventStatus):
+ """Opdater calendar event status"""
+ try:
+ execute_query(
+ """UPDATE tticket_calendar_events
+ SET status = %s, updated_at = CURRENT_TIMESTAMP,
+ completed_at = CASE WHEN %s = 'completed' THEN CURRENT_TIMESTAMP ELSE completed_at END
+ WHERE id = %s AND ticket_id = %s""",
+ (status, status, event_id, ticket_id),
+ fetch=False
+ )
+
+ return {"status": "success", "message": "Event updated"}
+
+ except Exception as e:
+ logger.error(f"❌ Error updating calendar event: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/tickets/{ticket_id}/calendar-events/{event_id}", tags=["Calendar"])
+async def delete_calendar_event(ticket_id: int, event_id: int):
+ """Slet calendar event"""
+ try:
+ execute_query(
+ "DELETE FROM tticket_calendar_events WHERE id = %s AND ticket_id = %s",
+ (event_id, ticket_id),
+ fetch=False
+ )
+
+ return {"status": "success", "message": "Event deleted"}
+
+ except Exception as e:
+ logger.error(f"❌ Error deleting calendar event: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================================
+# TEMPLATES ENDPOINTS
+# ============================================================================
+
+@router.get("/templates", response_model=List[TTicketTemplate], tags=["Templates"])
+async def list_templates(
+ category: Optional[str] = Query(None, description="Filter by category"),
+ active_only: bool = Query(True, description="Only show active templates")
+):
+ """List alle tilgængelige templates"""
+ try:
+ query = "SELECT * FROM tticket_templates WHERE 1=1"
+ params = []
+
+ if category:
+ query += " AND category = %s"
+ params.append(category)
+
+ if active_only:
+ query += " AND is_active = true"
+
+ query += " ORDER BY category, name"
+
+ templates = execute_query(query, tuple(params) if params else None)
+
+ return templates
+
+ except Exception as e:
+ logger.error(f"❌ Error listing templates: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/templates", tags=["Templates"])
+async def create_template(template: TTicketTemplateCreate):
+ """Opret ny template"""
+ try:
+ template_id = execute_insert(
+ """INSERT INTO tticket_templates
+ (name, description, category, subject_template, body_template,
+ available_placeholders, default_attachments, is_active,
+ requires_approval, created_by_user_id)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 1)
+ RETURNING id""",
+ (template.name, template.description, template.category,
+ template.subject_template, template.body_template,
+ template.available_placeholders, template.default_attachments,
+ template.is_active, template.requires_approval)
+ )
+
+ logger.info(f"✅ Created template {template_id}: {template.name}")
+
+ return {"status": "success", "template_id": template_id, "message": "Template created"}
+
+ except Exception as e:
+ logger.error(f"❌ Error creating template: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/tickets/{ticket_id}/render-template", response_model=TemplateRenderResponse, tags=["Templates"])
+async def render_template(ticket_id: int, request: TemplateRenderRequest):
+ """
+ Render template med ticket data
+
+ Erstatter placeholders med faktiske værdier:
+ - {{ticket_number}}
+ - {{ticket_subject}}
+ - {{customer_name}}
+ - {{contact_name}}
+ - etc.
+ """
+ try:
+ # Get template
+ template = execute_query_single(
+ "SELECT * FROM tticket_templates WHERE id = %s",
+ (request.template_id,)
+ )
+ if not template:
+ raise HTTPException(status_code=404, detail="Template not found")
+
+ # Get ticket with customer and contact data
+ ticket_data = execute_query_single(
+ """SELECT t.*,
+ c.name as customer_name,
+ con.name as contact_name,
+ con.email as contact_email
+ FROM tticket_tickets t
+ LEFT JOIN customers c ON t.customer_id = c.id
+ LEFT JOIN contacts con ON t.contact_id = con.id
+ WHERE t.id = %s""",
+ (ticket_id,)
+ )
+ if not ticket_data:
+ raise HTTPException(status_code=404, detail="Ticket not found")
+
+ # Build replacement dict
+ replacements = {
+ '{{ticket_number}}': ticket_data.get('ticket_number', ''),
+ '{{ticket_subject}}': ticket_data.get('subject', ''),
+ '{{customer_name}}': ticket_data.get('customer_name', ''),
+ '{{contact_name}}': ticket_data.get('contact_name', ''),
+ '{{contact_email}}': ticket_data.get('contact_email', ''),
+ }
+
+ # Add custom data
+ if request.custom_data:
+ for key, value in request.custom_data.items():
+ replacements[f'{{{{{key}}}}}'] = str(value)
+
+ # Render subject and body
+ rendered_subject = template['subject_template']
+ rendered_body = template['body_template']
+ placeholders_used = []
+
+ for placeholder, value in replacements.items():
+ if placeholder in rendered_body or (rendered_subject and placeholder in rendered_subject):
+ placeholders_used.append(placeholder)
+ if rendered_subject:
+ rendered_subject = rendered_subject.replace(placeholder, value)
+ rendered_body = rendered_body.replace(placeholder, value)
+
+ return TemplateRenderResponse(
+ subject=rendered_subject,
+ body=rendered_body,
+ placeholders_used=placeholders_used
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error rendering template: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================================
+# AI SUGGESTIONS ENDPOINTS
+# ============================================================================
+
+@router.get("/tickets/{ticket_id}/suggestions", response_model=List[TTicketAISuggestion], tags=["AI Suggestions"])
+async def get_ai_suggestions(
+ ticket_id: int,
+ status: Optional[AISuggestionStatus] = Query(None, description="Filter by status"),
+ suggestion_type: Optional[AISuggestionType] = Query(None, description="Filter by type")
+):
+ """Hent AI forslag for ticket"""
+ try:
+ query = "SELECT * FROM tticket_ai_suggestions WHERE ticket_id = %s"
+ params = [ticket_id]
+
+ if status:
+ query += " AND status = %s"
+ params.append(status)
+
+ if suggestion_type:
+ query += " AND suggestion_type = %s"
+ params.append(suggestion_type)
+
+ query += " ORDER BY created_at DESC"
+
+ suggestions = execute_query(query, tuple(params))
+
+ return suggestions
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching AI suggestions: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/tickets/{ticket_id}/suggestions/{suggestion_id}/review", tags=["AI Suggestions"])
+async def review_ai_suggestion(ticket_id: int, suggestion_id: int, review: AISuggestionReviewRequest):
+ """
+ Accepter eller afvis AI forslag
+
+ **VIGTIGT**: Denne endpoint ændrer KUN suggestion status.
+ Den udfører IKKE automatisk den foreslåede handling.
+ Brugeren skal selv implementere ændringen efter accept.
+ """
+ try:
+ # Get suggestion
+ suggestion = execute_query_single(
+ "SELECT * FROM tticket_ai_suggestions WHERE id = %s AND ticket_id = %s",
+ (suggestion_id, ticket_id)
+ )
+ if not suggestion:
+ raise HTTPException(status_code=404, detail="Suggestion not found")
+
+ if suggestion['status'] != 'pending':
+ raise HTTPException(status_code=400, detail=f"Suggestion already {suggestion['status']}")
+
+ # Update status
+ new_status = 'accepted' if review.action == 'accept' else 'rejected'
+ execute_query(
+ """UPDATE tticket_ai_suggestions
+ SET status = %s, reviewed_by_user_id = 1, reviewed_at = CURRENT_TIMESTAMP
+ WHERE id = %s""",
+ (new_status, suggestion_id),
+ fetch=False
+ )
+
+ # Log audit
+ execute_query(
+ """INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
+ VALUES (%s, %s, %s, %s)""",
+ (ticket_id, f'ai_suggestion_{review.action}ed',
+ f"{suggestion['suggestion_type']}: {suggestion_id}", review.note)
+ )
+
+ logger.info(f"✅ AI suggestion {suggestion_id} {review.action}ed for ticket {ticket_id}")
+
+ return {
+ "status": "success",
+ "action": review.action,
+ "suggestion_type": suggestion['suggestion_type'],
+ "message": f"Suggestion {review.action}ed. Manual implementation required if accepted."
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error reviewing AI suggestion: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================================
+# DEADLINE ENDPOINT
+# ============================================================================
+
+@router.put("/tickets/{ticket_id}/deadline", tags=["Tickets"])
+async def update_ticket_deadline(ticket_id: int, request: TicketDeadlineUpdateRequest):
+ """Opdater ticket deadline"""
+ try:
+ # Get current deadline
+ current = execute_query_single(
+ "SELECT deadline FROM tticket_tickets WHERE id = %s",
+ (ticket_id,)
+ )
+ if not current:
+ raise HTTPException(status_code=404, detail="Ticket not found")
+
+ # Update deadline
+ execute_query(
+ "UPDATE tticket_tickets SET deadline = %s WHERE id = %s",
+ (request.deadline, ticket_id),
+ fetch=False
+ )
+
+ # Log audit (handled by trigger automatically)
+ if request.reason:
+ execute_query(
+ """INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value, reason)
+ VALUES (%s, 'deadline_change', 'deadline', %s, %s, %s)""",
+ (ticket_id, str(current.get('deadline')), str(request.deadline), request.reason)
+ )
+
+ logger.info(f"✅ Updated deadline for ticket {ticket_id}: {request.deadline}")
+
+ return {
+ "status": "success",
+ "old_deadline": current.get('deadline'),
+ "new_deadline": request.deadline,
+ "message": "Deadline updated"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error updating deadline: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================================
+# AUDIT LOG ENDPOINT
+# ============================================================================
+
+@router.get("/tickets/{ticket_id}/audit-log", response_model=List[TTicketAuditLog], tags=["Audit"])
+async def get_audit_log(
+ ticket_id: int,
+ limit: int = Query(50, ge=1, le=200, description="Number of entries"),
+ offset: int = Query(0, ge=0, description="Offset for pagination")
+):
+ """Hent audit log for ticket (sporbarhed)"""
+ try:
+ logs = execute_query(
+ """SELECT * FROM tticket_audit_log
+ WHERE ticket_id = %s
+ ORDER BY performed_at DESC
+ LIMIT %s OFFSET %s""",
+ (ticket_id, limit, offset)
+ )
+
+ return logs
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching audit log: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/app/ticket/backend/ticket_service.py b/app/ticket/backend/ticket_service.py
index f213eec..2de6f35 100644
--- a/app/ticket/backend/ticket_service.py
+++ b/app/ticket/backend/ticket_service.py
@@ -10,7 +10,7 @@ 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.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.ticket.backend.models import (
TicketStatus,
TicketPriority,
@@ -84,13 +84,14 @@ class TicketService:
from psycopg2.extras import Json
# Insert ticket (trigger will auto-generate ticket_number if NULL)
- ticket_id = execute_insert(
+ result = execute_query_single(
"""
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)
+ RETURNING id
""",
(
ticket_data.ticket_number,
@@ -109,6 +110,11 @@ class TicketService:
)
)
+ if not result:
+ raise Exception("Failed to create ticket - no ID returned")
+
+ ticket_id = result['id']
+
# Log creation
TicketService.log_audit(
ticket_id=ticket_id,
@@ -120,11 +126,9 @@ class TicketService:
)
# Fetch created ticket
- ticket = execute_query(
+ ticket = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
- (ticket_id,),
- fetchone=True
- )
+ (ticket_id,))
logger.info(f"✅ Created ticket {ticket['ticket_number']} (ID: {ticket_id})")
return ticket
@@ -147,11 +151,9 @@ class TicketService:
Updated ticket dict
"""
# Get current ticket
- current = execute_query(
+ current = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
- (ticket_id,),
- fetchone=True
- )
+ (ticket_id,))
if not current:
raise ValueError(f"Ticket {ticket_id} not found")
@@ -198,11 +200,9 @@ class TicketService:
)
# Fetch updated ticket
- updated = execute_query(
+ updated = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
- (ticket_id,),
- fetchone=True
- )
+ (ticket_id,))
logger.info(f"✅ Updated ticket {updated['ticket_number']}")
return updated
@@ -230,11 +230,9 @@ class TicketService:
ValueError: If transition is not allowed
"""
# Get current ticket
- current = execute_query(
+ current = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
- (ticket_id,),
- fetchone=True
- )
+ (ticket_id,))
if not current:
raise ValueError(f"Ticket {ticket_id} not found")
@@ -280,11 +278,9 @@ class TicketService:
)
# Fetch updated ticket
- updated = execute_query(
+ updated = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
- (ticket_id,),
- fetchone=True
- )
+ (ticket_id,))
logger.info(f"✅ Updated ticket {updated['ticket_number']} status: {current_status} → {new_status}")
return updated
@@ -307,11 +303,9 @@ class TicketService:
Updated ticket dict
"""
# Get current assignment
- current = execute_query(
+ current = execute_query_single(
"SELECT assigned_to_user_id FROM tticket_tickets WHERE id = %s",
- (ticket_id,),
- fetchone=True
- )
+ (ticket_id,))
if not current:
raise ValueError(f"Ticket {ticket_id} not found")
@@ -334,11 +328,9 @@ class TicketService:
)
# Fetch updated ticket
- updated = execute_query(
+ updated = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
- (ticket_id,),
- fetchone=True
- )
+ (ticket_id,))
logger.info(f"✅ Assigned ticket {updated['ticket_number']} to user {assigned_to_user_id}")
return updated
@@ -363,11 +355,9 @@ class TicketService:
Created comment dict
"""
# Verify ticket exists
- ticket = execute_query(
+ ticket = execute_query_single(
"SELECT id FROM tticket_tickets WHERE id = %s",
- (ticket_id,),
- fetchone=True
- )
+ (ticket_id,))
if not ticket:
raise ValueError(f"Ticket {ticket_id} not found")
@@ -389,11 +379,9 @@ class TicketService:
# Update first_response_at if this is the first non-internal comment
if not is_internal:
- ticket = execute_query(
+ ticket = execute_query_single(
"SELECT first_response_at FROM tticket_tickets WHERE id = %s",
- (ticket_id,),
- fetchone=True
- )
+ (ticket_id,))
if not ticket['first_response_at']:
execute_update(
"UPDATE tticket_tickets SET first_response_at = CURRENT_TIMESTAMP WHERE id = %s",
@@ -411,11 +399,9 @@ class TicketService:
)
# Fetch created comment
- comment = execute_query(
+ comment = execute_query_single(
"SELECT * FROM tticket_comments WHERE id = %s",
- (comment_id,),
- fetchone=True
- )
+ (comment_id,))
logger.info(f"💬 Added comment to ticket {ticket_id} (internal: {is_internal})")
return comment
@@ -471,19 +457,15 @@ class TicketService:
Returns:
Ticket dict with stats or None if not found
"""
- ticket = execute_query(
+ ticket = execute_query_single(
"SELECT * FROM tticket_open_tickets WHERE id = %s",
- (ticket_id,),
- fetchone=True
- )
+ (ticket_id,))
# If not in open_tickets view, fetch from main table
if not ticket:
- ticket = execute_query(
+ ticket = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
- (ticket_id,),
- fetchone=True
- )
+ (ticket_id,))
return ticket
diff --git a/app/ticket/frontend/dashboard.html b/app/ticket/frontend/dashboard.html
index 986bb2e..7c933d6 100644
--- a/app/ticket/frontend/dashboard.html
+++ b/app/ticket/frontend/dashboard.html
@@ -2,360 +2,282 @@
{% block title %}Ticket Dashboard - BMC Hub{% endblock %}
-{% block extra_css %}
-
-{% endblock %}
-
{% block content %}
-
-
-