From 0502a7b080d1175e7d5756c9ba94fd7c7d3747bf Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 17 Dec 2025 07:56:33 +0100 Subject: [PATCH] feat: Implement central tagging system with CRUD operations, entity tagging, and workflow management - Added API endpoints for tag management (create, read, update, delete). - Implemented entity tagging functionality to associate tags with various entities. - Created workflow management for tag-triggered actions. - Developed frontend views for tag administration using FastAPI and Jinja2. - Designed HTML template for tag management interface with Bootstrap styling. - Added JavaScript for tag picker component with keyboard shortcuts and dynamic tag filtering. - Created database migration scripts for tags, entity_tags, and tag_workflows tables. - Included default tags for initial setup in the database. --- app/modules/_template/README.md | 39 +- app/modules/_template/migrations/001_init.sql | 46 +++ app/shared/frontend/base.html | 3 +- app/system/backend/router.py | 61 +++ app/tags/backend/models.py | 86 ++++ app/tags/backend/router.py | 226 +++++++++++ app/tags/backend/views.py | 14 + app/tags/frontend/tags_admin.html | 378 +++++++++++++++++ app/ticket/frontend/ticket_detail.html | 122 ++++++ app/ticket/frontend/views.py | 2 +- app/timetracking/backend/economic_export.py | 4 +- app/timetracking/backend/order_service.py | 4 +- app/timetracking/frontend/orders.html | 22 +- docs/MODULE_SYSTEM.md | 66 +++ main.py | 4 + migrations/027_tag_system.sql | 79 ++++ .../028_auto_link_tmodule_customers.sql | 84 ++++ static/js/tag-picker.js | 379 ++++++++++++++++++ 18 files changed, 1608 insertions(+), 11 deletions(-) create mode 100644 app/tags/backend/models.py create mode 100644 app/tags/backend/router.py create mode 100644 app/tags/backend/views.py create mode 100644 app/tags/frontend/tags_admin.html create mode 100644 migrations/027_tag_system.sql create mode 100644 migrations/028_auto_link_tmodule_customers.sql create mode 100644 static/js/tag-picker.js diff --git a/app/modules/_template/README.md b/app/modules/_template/README.md index 7c2ea43..a2e75e3 100644 --- a/app/modules/_template/README.md +++ b/app/modules/_template/README.md @@ -32,7 +32,7 @@ Alle tabeller SKAL bruge `table_prefix` fra module.json: ```sql -- Hvis table_prefix = "mymod_" -CREATE TABLE mymod_customers ( +CREATE TABLE mymod_items ( id SERIAL PRIMARY KEY, name VARCHAR(255) ); @@ -40,6 +40,43 @@ CREATE TABLE mymod_customers ( Dette sikrer at moduler ikke kolliderer med core eller andre moduler. +### Customer Linking (Hvis nødvendigt) + +Hvis dit modul skal have sin egen kunde-tabel (f.eks. ved sync fra eksternt system): + +**SKAL altid linke til core customers:** + +```sql +CREATE TABLE mymod_customers ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + external_id VARCHAR(100), -- ID fra eksternt system + hub_customer_id INTEGER REFERENCES customers(id), -- VIGTIG! + active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Auto-link trigger (se migrations/001_init.sql for komplet eksempel) +CREATE TRIGGER trigger_auto_link_mymod_customer + BEFORE INSERT OR UPDATE OF name + ON mymod_customers + FOR EACH ROW + EXECUTE FUNCTION auto_link_mymod_customer(); +``` + +**Hvorfor?** Dette sikrer at: +- ✅ E-conomic export virker automatisk +- ✅ Billing integration fungerer +- ✅ Ingen manuel linking nødvendig + +**Alternativ:** Hvis modulet kun har simple kunde-relationer, brug direkte FK: +```sql +CREATE TABLE mymod_orders ( + id SERIAL PRIMARY KEY, + customer_id INTEGER REFERENCES customers(id) -- Direkte link +); +``` + ## Konfiguration Modul-specifikke miljøvariable følger mønsteret: diff --git a/app/modules/_template/migrations/001_init.sql b/app/modules/_template/migrations/001_init.sql index d266086..88a95d0 100644 --- a/app/modules/_template/migrations/001_init.sql +++ b/app/modules/_template/migrations/001_init.sql @@ -11,6 +11,19 @@ CREATE TABLE IF NOT EXISTS template_items ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Optional: Customers tabel hvis modulet har egne kunder (f.eks. sync fra eksternt system) +-- Kun nødvendigt hvis modulet har mange custom felter eller external sync +-- Ellers brug direkte foreign key til customers.id +CREATE TABLE IF NOT EXISTS template_customers ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + external_id VARCHAR(100), -- ID fra eksternt system hvis relevant + hub_customer_id INTEGER REFERENCES customers(id), -- VIGTIG: Link til core customers + active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + -- Index for performance CREATE INDEX IF NOT EXISTS idx_template_items_active ON template_items(active); CREATE INDEX IF NOT EXISTS idx_template_items_created ON template_items(created_at DESC); @@ -29,6 +42,39 @@ BEFORE UPDATE ON template_items FOR EACH ROW EXECUTE FUNCTION update_template_items_updated_at(); +-- Trigger for auto-linking customers (hvis template_customers tabel oprettes) +-- Dette linker automatisk nye kunder til core customers baseret på navn match +CREATE OR REPLACE FUNCTION auto_link_template_customer() +RETURNS TRIGGER AS $$ +DECLARE + matched_hub_id INTEGER; +BEGIN + -- Hvis hub_customer_id allerede er sat, skip + IF NEW.hub_customer_id IS NOT NULL THEN + RETURN NEW; + END IF; + + -- Find matching hub customer baseret på navn + SELECT id INTO matched_hub_id + FROM customers + WHERE LOWER(TRIM(name)) = LOWER(TRIM(NEW.name)) + LIMIT 1; + + -- Hvis match fundet, sæt hub_customer_id + IF matched_hub_id IS NOT NULL THEN + NEW.hub_customer_id := matched_hub_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_auto_link_template_customer + BEFORE INSERT OR UPDATE OF name + ON template_customers + FOR EACH ROW + EXECUTE FUNCTION auto_link_template_customer(); + -- Indsæt test data (optional) INSERT INTO template_items (name, description) VALUES diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html index e118dd2..29585ee 100644 --- a/app/shared/frontend/base.html +++ b/app/shared/frontend/base.html @@ -502,6 +502,7 @@ {% endblock %} + + + + diff --git a/app/ticket/frontend/ticket_detail.html b/app/ticket/frontend/ticket_detail.html index a760da7..e67b427 100644 --- a/app/ticket/frontend/ticket_detail.html +++ b/app/ticket/frontend/ticket_detail.html @@ -135,6 +135,60 @@ margin-right: 0.5rem; } + .tags-container { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + } + + .tag-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 500; + color: white; + transition: all 0.2s; + cursor: default; + } + + .tag-badge:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + } + + .tag-badge .btn-close { + font-size: 0.6rem; + opacity: 0.7; + } + + .tag-badge .btn-close:hover { + opacity: 1; + } + + .add-tag-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border: 2px dashed var(--accent-light); + border-radius: 8px; + background: transparent; + color: var(--accent); + cursor: pointer; + transition: all 0.2s; + font-size: 0.85rem; + font-weight: 500; + } + + .add-tag-btn:hover { + border-color: var(--accent); + background: var(--accent-light); + } + .section-title { font-size: 1.25rem; font-weight: 600; @@ -186,6 +240,12 @@ {{ ticket.priority.title() }} Priority +
+ +
+ @@ -405,5 +465,67 @@ function addWorklog() { alert('Add worklog functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/worklog'); } + + // Load and render ticket tags + async function loadTicketTags() { + try { + const response = await fetch('/api/v1/tags/entity/ticket/{{ ticket.id }}'); + if (!response.ok) return; + + const tags = await response.json(); + const container = document.getElementById('ticketTags'); + + if (tags.length === 0) { + container.innerHTML = ' Ingen tags endnu'; + return; + } + + container.innerHTML = tags.map(tag => ` + + ${tag.icon ? `` : ''} + ${tag.name} + + + `).join(''); + } catch (error) { + console.error('Error loading tags:', error); + } + } + + async function removeTag(tagId, tagName) { + if (!confirm(`Fjern tag "${tagName}"?`)) return; + + try { + const response = await fetch(`/api/v1/tags/entity?entity_type=ticket&entity_id={{ ticket.id }}&tag_id=${tagId}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Failed to remove tag'); + await loadTicketTags(); + } catch (error) { + console.error('Error removing tag:', error); + alert('Fejl ved fjernelse af tag'); + } + } + + function reloadTags() { + loadTicketTags(); + } + + // Load tags on page load + document.addEventListener('DOMContentLoaded', loadTicketTags); + + // Override global tag picker to auto-reload after adding + if (window.tagPicker) { + const originalShow = window.tagPicker.show.bind(window.tagPicker); + window.showTagPicker = function(entityType, entityId, onSelect) { + window.tagPicker.show(entityType, entityId, () => { + loadTicketTags(); + if (onSelect) onSelect(); + }); + }; + } {% endblock %} diff --git a/app/ticket/frontend/views.py b/app/ticket/frontend/views.py index b59c6d5..ef56166 100644 --- a/app/ticket/frontend/views.py +++ b/app/ticket/frontend/views.py @@ -10,7 +10,7 @@ from fastapi.templating import Jinja2Templates from typing import Optional from datetime import date -from app.core.database import execute_query, execute_update +from app.core.database import execute_query, execute_update, execute_query_single logger = logging.getLogger(__name__) diff --git a/app/timetracking/backend/economic_export.py b/app/timetracking/backend/economic_export.py index e33f68f..7a5b3d3 100644 --- a/app/timetracking/backend/economic_export.py +++ b/app/timetracking/backend/economic_export.py @@ -187,7 +187,7 @@ class EconomicExportService: WHERE order_id = %s ORDER BY line_number """ - lines = execute_query_single(lines_query, (request.order_id,)) + lines = execute_query(lines_query, (request.order_id,)) if not lines: raise HTTPException( @@ -244,7 +244,7 @@ class EconomicExportService: LEFT JOIN customers c ON tc.hub_customer_id = c.id WHERE tc.id = %s """ - customer_data = execute_query(customer_number_query, (order['customer_id'],)) + customer_data = execute_query_single(customer_number_query, (order['customer_id'],)) if not customer_data or not customer_data.get('economic_customer_number'): raise HTTPException( diff --git a/app/timetracking/backend/order_service.py b/app/timetracking/backend/order_service.py index e3b0e17..70fd8d0 100644 --- a/app/timetracking/backend/order_service.py +++ b/app/timetracking/backend/order_service.py @@ -314,7 +314,7 @@ class OrderService: LEFT JOIN tmodule_customers c ON o.customer_id = c.id WHERE o.id = %s """ - order = execute_query(order_query, (order_id,)) + order = execute_query_single(order_query, (order_id,)) if not order: raise HTTPException(status_code=404, detail="Order not found") @@ -334,7 +334,7 @@ class OrderService: ol.product_number, ol.account_number, ol.created_at ORDER BY ol.line_number """ - lines = execute_query_single(lines_query, (order_id,)) + lines = execute_query(lines_query, (order_id,)) return TModuleOrderWithLines( **order, diff --git a/app/timetracking/frontend/orders.html b/app/timetracking/frontend/orders.html index af1e6e3..86c8e4b 100644 --- a/app/timetracking/frontend/orders.html +++ b/app/timetracking/frontend/orders.html @@ -99,8 +99,8 @@ - -
+ +