feat(sag): Add Varekøb & Salg module with database migration and frontend template

- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
This commit is contained in:
Christian 2026-02-02 20:23:56 +01:00
parent d5dd958bf9
commit 56d6d45aa2
62 changed files with 7987 additions and 553 deletions

View File

@ -22,6 +22,14 @@ ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Dock
SECRET_KEY=change-this-in-production-use-random-string SECRET_KEY=change-this-in-production-use-random-string
CORS_ORIGINS=http://localhost:8000,http://localhost:3000 CORS_ORIGINS=http://localhost:8000,http://localhost:3000
# Shadow Admin (Emergency Access)
SHADOW_ADMIN_ENABLED=false
SHADOW_ADMIN_USERNAME=shadowadmin
SHADOW_ADMIN_PASSWORD=
SHADOW_ADMIN_TOTP_SECRET=
SHADOW_ADMIN_EMAIL=shadowadmin@bmcnetworks.dk
SHADOW_ADMIN_FULL_NAME=Shadow Administrator
# ===================================================== # =====================================================
# LOGGING # LOGGING
# ===================================================== # =====================================================
@ -45,6 +53,16 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer # 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
# =====================================================
# Nextcloud Integration (Optional)
# =====================================================
NEXTCLOUD_READ_ONLY=true
NEXTCLOUD_DRY_RUN=true
NEXTCLOUD_TIMEOUT_SECONDS=15
NEXTCLOUD_CACHE_TTL_SECONDS=300
# Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
NEXTCLOUD_ENCRYPTION_KEY=
# ===================================================== # =====================================================
# vTiger Cloud Integration (Required for Subscriptions) # vTiger Cloud Integration (Required for Subscriptions)
# ===================================================== # =====================================================

View File

@ -1,5 +1,28 @@
--- ---
description: 'Describe what this custom agent does and when to use it.' name: hub-sales-and-aggregation-agent
tools: []
description: "Planlægger og specificerer varekøb og salg i BMC Hub som en simpel sag-baseret funktion, inklusiv aggregering af varer og tid op gennem sagstræet."
scope:
- Sag-modul
- Vare- og ydelsessalg
- Aggregering i sagstræ
constraints:
- Ingen ERP-kompleksitet
- Ingen lagerstyring
- Ingen selvstændig ordre-entitet i v1
- Alt salg er knyttet til en Sag
- Aggregering er læsevisning, ikke datakopiering
inputs:
- Eksisterende Sag-model med parent/child-relationer
- Eksisterende Tidsmodul
- Varekatalog (internt og leverandørvarer)
outputs:
- Datamodelforslag
- UI-struktur for Varer-fanen
- Aggregeringslogik
- Faktureringsforberedelse
--- ---
Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help.

241
NEXTCLOUD_MODULE_PLAN.md Normal file
View File

@ -0,0 +1,241 @@
# Nextcloud-modul BMC Hub
## 1. Formål og rolle i Hubben
Nextcloud-modulet gør det muligt at sælge, administrere og supportere kunders Nextcloudløsninger direkte i Hubben.
Hubben er styrende system. Nextcloud er et eksternt drifts og brugersystem, som Hubben taler med direkte (ingen gateway).
## 2. Aktivering af modulet
Modulet er kontekstbaseret og aktiveres via tag:
- Når Firma, Kontakt eller Sag har tagget `nextcloud`, vises en Nextcloudfane i UI.
- Uden tag vises ingen Nextcloudfunktioner.
## 3. Kunde → Nextcloudfane (overblik)
Fanen indeholder:
1. Drifts og systeminformation (readonly)
2. Handlinger relateret til brugere
3. Historik (hvad Hubben har gjort mod instansen)
Fanen må aldrig blokere kundevisningen, selv hvis Nextcloud er utilgængelig.
## 4. Systemstatus og driftsinformation
**Datakilde**: Nextcloud Serverinfo API
- `GET /ocs/v2.php/apps/serverinfo/api/v1/info`
- Direkte kald til Nextcloud
- Autentificeret
- Readonly
- Cached i DB med global TTL = 5 min
### 4.1 Overblik
Vises øverst i fanen:
- Instansstatus (Online / Offline / Ukendt)
- Sidst opdateret
- Nextcloudversion
- PHPversion
- Databasetype og version
### 4.2 Ressourceforbrug
Vises som simple værdier/badges:
- CPU
- Load average (1 / 5 / 15 min)
- Antal kerner
- RAM (total + brug i %)
- Disk (total + brug i % + fri plads)
Ved kritiske værdier vises advarsel.
### 4.3 Nextcloudnøgletal
Hvor API tillader det:
- Antal brugere
- Aktive brugere
- Antal filer
- Samlet datamængde
- Status på: database, cache/Redis, cron/background jobs
## 5. Handlinger i Nextcloudfanen
Knapper:
- Tilføj ny bruger
- Reset password
- Luk bruger
- Gensend guide
Alle handlinger:
- udføres direkte mod Nextcloud
- logges i Hub
- kan spores i historik
- kan knyttes til sag
## 6. Tilføj ny bruger (primær funktion)
### 6.1 Start af flow
- Ved “Tilføj ny bruger” oprettes automatisk en ny Sag
- Sagstype: **Nextcloud Brugeroprettelse**
- Ingen Nextcloudhandling udføres uden en sag
### 6.2 Sag felter og logik
**Firma**
- Vælg eksisterende firma
- Hub slår tilknyttet Nextcloudinstans op i DB og vælger automatisk
- Instans kan ikke ændres manuelt
**Kontaktperson**
- Vælg eksisterende kontakt eller opret ny
- Bruges til kommunikation, velkomstmail og ejerskab af sag
**Grupper**
- Multiselect
- Hentes live fra Nextcloud (OCS groups API)
- Kun gyldige grupper kan vælges
**Velkomstbrev**
- Checkbox: skal velkomstbrev sendes?
- Hvis ja: bruger oprettes, password genereres, guide + logininfo sendes
- Hvis nej: bruger oprettes uden mail, sag forbliver åben til manuel opfølgning
## 7. Øvrige handlinger
**Reset password**
- Vælg eksisterende Nextcloudbruger
- Nyt password genereres
- Valg: send mail til kontakt eller kun log i sag
**Luk bruger**
- Bruger deaktiveres i Nextcloud
- Data bevares
- Kræver eksplicit bekræftelse
- Logges i sag og historik
**Gensend guide**
- Gensender velkomstmail og guide
- Password ændres ikke
- Kan udføres uden ny sag, men logges
## 8. Arkitekturprincipper
- Hub ejer: firma, kontakt, sag, historik
- Nextcloud ejer: brugere, filer, rettigheder
- Integration er direkte (ingen gateway)
- Perinstans auth ligger krypteret i DB
- Global DBcache (5 min) for readonly statusdata
## 9. Logning og sporbarhed
For hver handling gemmes:
- tidspunkt
- handlingstype
- udførende bruger
- mål (bruger/instans)
- teknisk resultat (success/fejl)
Auditlog er **separat pr. kunde**, med **manuel retention** og **tidsbaseret partitionering**.
## 10. Afgrænsninger (v1)
Modulet indeholder ikke:
- ændring af serverkonfiguration
- håndtering af apps
- ændring af kvoter
- direkte adminlogin
## 11. Klar til udvidelse
Modulet er designet til senere udvidelser:
- overvågning → automatisk sag
- historiske grafer
- offboardingflows
- kvotestyring
- SLArapportering
## 12. Sikkerhed og drift
- Credentials krypteres med `settings.NEXTCLOUD_ENCRYPTION_KEY`
- Safety switches: `NEXTCLOUD_READ_ONLY` og `NEXTCLOUD_DRY_RUN` (default true)
- Ingen credentials i UI eller logs
- TLSonly base URLs
## 13. Backendstruktur (plan)
Placering: `app/modules/nextcloud/`
- `backend/router.py`
- `backend/service.py`
- `backend/models.py`
Alle eksterne kald går via servicelaget, som:
- loader instans fra DB
- dekrypterer credentials
- bruger global DBcache (5 min)
- skriver auditlog pr. kunde
## 14. Databasemodel (plan)
### `nextcloud_instances`
- `customer_id` FK
- `base_url`
- `auth_type`
- `username`
- `password_encrypted`
- `is_enabled`, `disabled_at`
- `created_at`, `updated_at`, `deleted_at`
### `nextcloud_cache`
- `cache_key` (PK)
- `payload` (JSONB)
- `expires_at`
- `created_at`
### `nextcloud_audit_log`
- `customer_id`, `instance_id`
- `event_type`
- `request_meta`, `response_meta`
- `actor_user_id`
- `created_at`
Partitionering: månedlig range på `created_at`. Retention er manuel via adminUI.
## 15. APIendpoints (v1)
### Instanser (admin)
- `GET /api/v1/nextcloud/instances`
- `POST /api/v1/nextcloud/instances`
- `PATCH /api/v1/nextcloud/instances/{id}`
- `POST /api/v1/nextcloud/instances/{id}/disable`
- `POST /api/v1/nextcloud/instances/{id}/enable`
- `POST /api/v1/nextcloud/instances/{id}/rotate-credentials`
### Status + grupper
- `GET /api/v1/nextcloud/instances/{id}/status`
- `GET /api/v1/nextcloud/instances/{id}/groups`
### Brugere (handlinger)
- `POST /api/v1/nextcloud/instances/{id}/users` (opret)
- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/reset-password`
- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/disable`
- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/resend-guide`
Alle endpoints skal:
- validere `is_enabled = true`
- håndhæve kundeejerskab
- skrive auditlog
- respektere `READ_ONLY`/`DRY_RUN`
## 16. UIkrav (plan)
Nextcloudfanen i kundevisning skal vise:
- Systemstatus
- Nøgletal
- Handlinger
- Historik
AdminUI (Settings) skal give:
- Liste over instanser
- Enable/disable
- Rotation af credentials
- Retentionstyring af auditlog pr. kunde
## 17. Migrations (plan)
1. `migrations/0XX_nextcloud_instances.sql`
2. `migrations/0XX_nextcloud_cache.sql`
3. `migrations/0XX_nextcloud_audit_log.sql` (partitioneret)
## 18. Næste skridt
1. Opret migrationsfiler
2. Implementer kryptering helper
3. Implementer servicelag
4. Implementer routere og schemas
5. Implementer UIfanen + adminUI
6. Implementer auditlog viewer/export

View File

@ -0,0 +1,50 @@
from app.core.database import get_db_connection, release_db_connection, init_db
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def run_migration():
init_db() # Initialize the pool
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# Files linked to a Case
cursor.execute("""
CREATE TABLE IF NOT EXISTS sag_files (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
size_bytes INTEGER,
stored_name TEXT NOT NULL,
uploaded_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sag_files_sag_id ON sag_files(sag_id);")
cursor.execute("COMMENT ON TABLE sag_files IS 'Files uploaded directly to the Case.';")
# Emails linked to a Case (Many-to-Many)
cursor.execute("""
CREATE TABLE IF NOT EXISTS sag_emails (
sag_id INTEGER REFERENCES sag_sager(id) ON DELETE CASCADE,
email_id INTEGER REFERENCES email_messages(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (sag_id, email_id)
);
""")
cursor.execute("COMMENT ON TABLE sag_emails IS 'Emails linked to the Case.';")
conn.commit()
logger.info("Migration 084 applied successfully.")
except Exception as e:
conn.rollback()
logger.error(f"Migration failed: {e}")
finally:
release_db_connection(conn)
if __name__ == "__main__":
run_migration()

View File

@ -0,0 +1,69 @@
import logging
import os
import sys
# Ensure we can import app modules
sys.path.append("/app")
from app.core.database import execute_query, init_db
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
SQL_MIGRATION = """
CREATE TABLE IF NOT EXISTS sag_solutions (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
solution_type VARCHAR(50),
result VARCHAR(50),
created_by_user_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_sag_solutions_sag_id UNIQUE (sag_id)
);
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS solution_id INTEGER REFERENCES sag_solutions(id) ON DELETE SET NULL;
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL;
ALTER TABLE tmodule_times ALTER COLUMN vtiger_id DROP NOT NULL;
ALTER TABLE tmodule_times ALTER COLUMN case_id DROP NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sag_solutions_sag_id ON sag_solutions(sag_id);
CREATE INDEX IF NOT EXISTS idx_tmodule_times_solution_id ON tmodule_times(solution_id);
CREATE INDEX IF NOT EXISTS idx_tmodule_times_sag_id ON tmodule_times(sag_id);
"""
def run_migration():
logger.info("Initializing DB connection...")
try:
init_db()
except Exception as e:
logger.error(f"Failed to init db: {e}")
return
logger.info("Applying migration 085...")
commands = [cmd.strip() for cmd in SQL_MIGRATION.split(";") if cmd.strip()]
for cmd in commands:
# Skip empty lines or pure comments
if not cmd or cmd.startswith("--"):
continue
logger.info(f"Executing: {cmd[:50]}...")
try:
execute_query(cmd, ())
except Exception as e:
logger.warning(f"Error executing command: {e}")
logger.info("✅ Migration applied successfully")
if __name__ == "__main__":
run_migration()

150
app/auth/backend/admin.py Normal file
View File

@ -0,0 +1,150 @@
"""
Auth Admin API - Users, Groups, Permissions management
"""
from fastapi import APIRouter, HTTPException, status, Depends
from app.core.auth_dependencies import require_superadmin
from app.core.auth_service import AuthService
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
from app.models.schemas import UserAdminCreate, UserGroupsUpdate, GroupCreate, GroupPermissionsUpdate
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/admin/users", dependencies=[Depends(require_superadmin)])
async def list_users():
users = execute_query(
"""
SELECT u.user_id, u.username, u.email, u.full_name,
u.is_active, u.is_superadmin, u.is_2fa_enabled,
COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups
FROM users u
LEFT JOIN user_groups ug ON u.user_id = ug.user_id
LEFT JOIN groups g ON ug.group_id = g.id
GROUP BY u.user_id
ORDER BY u.user_id
"""
)
return users
@router.post("/admin/users", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superadmin)])
async def create_user(payload: UserAdminCreate):
existing = execute_query_single(
"SELECT user_id FROM users WHERE username = %s OR email = %s",
(payload.username, payload.email)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username or email already exists"
)
password_hash = AuthService.hash_password(payload.password)
user_id = execute_insert(
"""
INSERT INTO users (username, email, password_hash, full_name, is_superadmin, is_active)
VALUES (%s, %s, %s, %s, %s, %s) RETURNING user_id
""",
(payload.username, payload.email, password_hash, payload.full_name, payload.is_superadmin, payload.is_active)
)
if payload.group_ids:
for group_id in payload.group_ids:
execute_insert(
"""
INSERT INTO user_groups (user_id, group_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING
""",
(user_id, group_id)
)
logger.info("✅ User created via admin: %s (ID: %s)", payload.username, user_id)
return {"user_id": user_id}
@router.put("/admin/users/{user_id}/groups", dependencies=[Depends(require_superadmin)])
async def update_user_groups(user_id: int, payload: UserGroupsUpdate):
user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
execute_update("DELETE FROM user_groups WHERE user_id = %s", (user_id,))
for group_id in payload.group_ids:
execute_insert(
"""
INSERT INTO user_groups (user_id, group_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING
""",
(user_id, group_id)
)
return {"message": "Groups updated"}
@router.get("/admin/groups", dependencies=[Depends(require_superadmin)])
async def list_groups():
groups = execute_query(
"""
SELECT g.id, g.name, g.description,
COALESCE(array_remove(array_agg(p.code), NULL), ARRAY[]::varchar[]) AS permissions
FROM groups g
LEFT JOIN group_permissions gp ON g.id = gp.group_id
LEFT JOIN permissions p ON gp.permission_id = p.id
GROUP BY g.id
ORDER BY g.id
"""
)
return groups
@router.post("/admin/groups", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superadmin)])
async def create_group(payload: GroupCreate):
existing = execute_query_single("SELECT id FROM groups WHERE name = %s", (payload.name,))
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Group already exists")
group_id = execute_insert(
"""
INSERT INTO groups (name, description)
VALUES (%s, %s) RETURNING id
""",
(payload.name, payload.description)
)
return {"group_id": group_id}
@router.put("/admin/groups/{group_id}/permissions", dependencies=[Depends(require_superadmin)])
async def update_group_permissions(group_id: int, payload: GroupPermissionsUpdate):
group = execute_query_single("SELECT id FROM groups WHERE id = %s", (group_id,))
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
execute_update("DELETE FROM group_permissions WHERE group_id = %s", (group_id,))
for permission_id in payload.permission_ids:
execute_insert(
"""
INSERT INTO group_permissions (group_id, permission_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING
""",
(group_id, permission_id)
)
return {"message": "Permissions updated"}
@router.get("/admin/permissions", dependencies=[Depends(require_superadmin)])
async def list_permissions():
permissions = execute_query(
"""
SELECT id, code, description, category
FROM permissions
ORDER BY category, code
"""
)
return permissions

View File

@ -1,8 +1,9 @@
""" """
Auth API Router - Login, Logout, Me endpoints Auth API Router - Login, Logout, Me endpoints
""" """
from fastapi import APIRouter, HTTPException, status, Request, Depends from fastapi import APIRouter, HTTPException, status, Request, Depends, Response
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
from app.core.auth_dependencies import get_current_user from app.core.auth_dependencies import get_current_user
import logging import logging
@ -15,6 +16,7 @@ router = APIRouter()
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
otp_code: Optional[str] = None
class LoginResponse(BaseModel): class LoginResponse(BaseModel):
@ -27,18 +29,30 @@ class LogoutRequest(BaseModel):
token_jti: str token_jti: str
class TwoFactorCodeRequest(BaseModel):
otp_code: str
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
async def login(request: Request, credentials: LoginRequest): async def login(request: Request, credentials: LoginRequest, response: Response):
""" """
Authenticate user and return JWT token Authenticate user and return JWT token
""" """
ip_address = request.client.host if request.client else None ip_address = request.client.host if request.client else None
# Authenticate user # Authenticate user
user = AuthService.authenticate_user( user, error_detail = AuthService.authenticate_user(
username=credentials.username, username=credentials.username,
password=credentials.password, password=credentials.password,
ip_address=ip_address ip_address=ip_address,
otp_code=credentials.otp_code
)
if error_detail:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=error_detail,
headers={"WWW-Authenticate": "Bearer"},
) )
if not user: if not user:
@ -52,7 +66,16 @@ async def login(request: Request, credentials: LoginRequest):
access_token = AuthService.create_access_token( access_token = AuthService.create_access_token(
user_id=user['user_id'], user_id=user['user_id'],
username=user['username'], username=user['username'],
is_superadmin=user['is_superadmin'] is_superadmin=user['is_superadmin'],
is_shadow_admin=user.get('is_shadow_admin', False)
)
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
samesite="lax",
secure=False
) )
return LoginResponse( return LoginResponse(
@ -62,11 +85,21 @@ async def login(request: Request, credentials: LoginRequest):
@router.post("/logout") @router.post("/logout")
async def logout(request: LogoutRequest, current_user: dict = Depends(get_current_user)): async def logout(
request: LogoutRequest,
response: Response,
current_user: dict = Depends(get_current_user)
):
""" """
Revoke JWT token (logout) Revoke JWT token (logout)
""" """
AuthService.revoke_token(request.token_jti, current_user['id']) AuthService.revoke_token(
request.token_jti,
current_user['id'],
current_user.get('is_shadow_admin', False)
)
response.delete_cookie("access_token")
return {"message": "Successfully logged out"} return {"message": "Successfully logged out"}
@ -82,5 +115,75 @@ async def get_me(current_user: dict = Depends(get_current_user)):
"email": current_user['email'], "email": current_user['email'],
"full_name": current_user['full_name'], "full_name": current_user['full_name'],
"is_superadmin": current_user['is_superadmin'], "is_superadmin": current_user['is_superadmin'],
"is_2fa_enabled": current_user.get('is_2fa_enabled', False),
"permissions": current_user['permissions'] "permissions": current_user['permissions']
} }
@router.post("/2fa/setup")
async def setup_2fa(current_user: dict = Depends(get_current_user)):
"""Generate and store TOTP secret (requires verification to enable)"""
if current_user.get("is_shadow_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Shadow admin cannot configure 2FA",
)
result = AuthService.setup_user_2fa(
user_id=current_user["id"],
username=current_user["username"]
)
return result
@router.post("/2fa/enable")
async def enable_2fa(
request: TwoFactorCodeRequest,
current_user: dict = Depends(get_current_user)
):
"""Enable 2FA after verifying the provided code"""
if current_user.get("is_shadow_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Shadow admin cannot configure 2FA",
)
ok = AuthService.enable_user_2fa(
user_id=current_user["id"],
otp_code=request.otp_code
)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid 2FA code or missing setup",
)
return {"message": "2FA enabled"}
@router.post("/2fa/disable")
async def disable_2fa(
request: TwoFactorCodeRequest,
current_user: dict = Depends(get_current_user)
):
"""Disable 2FA after verifying the provided code"""
if current_user.get("is_shadow_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Shadow admin cannot configure 2FA",
)
ok = AuthService.disable_user_2fa(
user_id=current_user["id"],
otp_code=request.otp_code
)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid 2FA code or missing setup",
)
return {"message": "2FA disabled"}

View File

@ -39,6 +39,18 @@
> >
</div> </div>
<div class="mb-3">
<label for="otp_code" class="form-label">2FA-kode</label>
<input
type="text"
class="form-control"
id="otp_code"
name="otp_code"
placeholder="Indtast 2FA-kode"
autocomplete="one-time-code"
>
</div>
<div class="mb-3 form-check"> <div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="rememberMe"> <input type="checkbox" class="form-check-input" id="rememberMe">
<label class="form-check-label" for="rememberMe"> <label class="form-check-label" for="rememberMe">
@ -80,6 +92,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
const username = document.getElementById('username').value; const username = document.getElementById('username').value;
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
const otp_code = document.getElementById('otp_code').value;
const errorMessage = document.getElementById('errorMessage'); const errorMessage = document.getElementById('errorMessage');
const errorText = document.getElementById('errorText'); const errorText = document.getElementById('errorText');
const submitBtn = e.target.querySelector('button[type="submit"]'); const submitBtn = e.target.querySelector('button[type="submit"]');
@ -97,7 +110,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password, otp_code })
}); });
const data = await response.json(); const data = await response.json();

View File

@ -148,11 +148,13 @@ async def get_contact(contact_id: int):
FROM contacts FROM contacts
WHERE id = %s WHERE id = %s
""" """
contact = execute_query(contact_query, (contact_id,)) contact_result = execute_query(contact_query, (contact_id,))
if not contact: if not contact_result:
raise HTTPException(status_code=404, detail="Contact not found") raise HTTPException(status_code=404, detail="Contact not found")
contact = contact_result[0]
# Get linked companies # Get linked companies
companies_query = """ companies_query = """
SELECT SELECT
@ -163,7 +165,7 @@ async def get_contact(contact_id: int):
WHERE cc.contact_id = %s WHERE cc.contact_id = %s
ORDER BY cc.is_primary DESC, cu.name ORDER BY cc.is_primary DESC, cu.name
""" """
companies = execute_query_single(companies_query, (contact_id,)) # Default is fetchall companies = execute_query(companies_query, (contact_id,))
contact['companies'] = companies or [] contact['companies'] = companies or []
return contact return contact

View File

@ -6,16 +6,18 @@ from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Optional from typing import Optional
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
from app.core.config import settings
from app.core.database import execute_query_single
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
security = HTTPBearer() security = HTTPBearer(auto_error=False)
async def get_current_user( async def get_current_user(
request: Request, request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security) credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
) -> dict: ) -> dict:
""" """
Dependency to get current authenticated user from JWT token Dependency to get current authenticated user from JWT token
@ -25,7 +27,13 @@ async def get_current_user(
async def my_endpoint(current_user: dict = Depends(get_current_user)): async def my_endpoint(current_user: dict = Depends(get_current_user)):
... ...
""" """
token = credentials.credentials token = credentials.credentials if credentials else request.cookies.get("access_token")
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify token # Verify token
payload = AuthService.verify_token(token) payload = AuthService.verify_token(token)
@ -41,14 +49,27 @@ async def get_current_user(
user_id = int(payload.get("sub")) user_id = int(payload.get("sub"))
username = payload.get("username") username = payload.get("username")
is_superadmin = payload.get("is_superadmin", False) is_superadmin = payload.get("is_superadmin", False)
is_shadow_admin = payload.get("shadow_admin", False)
# Add IP address to user info # Add IP address to user info
ip_address = request.client.host if request.client else None ip_address = request.client.host if request.client else None
if is_shadow_admin:
return {
"id": user_id,
"username": username,
"email": settings.SHADOW_ADMIN_EMAIL,
"full_name": settings.SHADOW_ADMIN_FULL_NAME,
"is_superadmin": True,
"is_shadow_admin": True,
"is_2fa_enabled": True,
"ip_address": ip_address,
"permissions": AuthService.get_all_permissions()
}
# Get additional user details from database # Get additional user details from database
from app.core.database import execute_query
user_details = execute_query_single( user_details = execute_query_single(
"SELECT email, full_name FROM users WHERE id = %s", "SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,)) (user_id,))
return { return {
@ -57,6 +78,8 @@ async def get_current_user(
"email": user_details.get('email') if user_details else None, "email": user_details.get('email') if user_details else None,
"full_name": user_details.get('full_name') if user_details else None, "full_name": user_details.get('full_name') if user_details else None,
"is_superadmin": is_superadmin, "is_superadmin": is_superadmin,
"is_shadow_admin": False,
"is_2fa_enabled": user_details.get('is_2fa_enabled') if user_details else False,
"ip_address": ip_address, "ip_address": ip_address,
"permissions": AuthService.get_user_permissions(user_id) "permissions": AuthService.get_user_permissions(user_id)
} }
@ -70,7 +93,7 @@ async def get_optional_user(
Dependency to get current user if authenticated, None otherwise Dependency to get current user if authenticated, None otherwise
Allows endpoints that work both with and without authentication Allows endpoints that work both with and without authentication
""" """
if not credentials: if not credentials and not request.cookies.get("access_token"):
return None return None
try: try:

View File

@ -2,12 +2,14 @@
Authentication Service - Håndterer login, JWT tokens, password hashing Authentication Service - Håndterer login, JWT tokens, password hashing
Adapted from OmniSync for BMC Hub Adapted from OmniSync for BMC Hub
""" """
from typing import Optional, Dict, List from typing import Optional, Dict, List, Tuple
from datetime import datetime, timedelta from datetime import datetime, timedelta
import hashlib import hashlib
import secrets import secrets
import jwt import jwt
from app.core.database import execute_query, execute_insert, execute_update import pyotp
from passlib.context import CryptContext
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
from app.core.config import settings from app.core.config import settings
import logging import logging
@ -18,6 +20,8 @@ SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-prod
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class AuthService: class AuthService:
"""Service for authentication and authorization""" """Service for authentication and authorization"""
@ -25,18 +29,124 @@ class AuthService:
@staticmethod @staticmethod
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
""" """
Hash password using SHA256 Hash password using bcrypt
I produktion: Brug bcrypt eller argon2!
""" """
return hashlib.sha256(password.encode()).hexdigest() return pwd_context.hash(password)
@staticmethod @staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify password against hash""" """Verify password against hash"""
return AuthService.hash_password(plain_password) == hashed_password if not hashed_password:
return False
if not hashed_password.startswith("$2"):
return False
try:
return pwd_context.verify(plain_password, hashed_password)
except Exception:
return False
@staticmethod @staticmethod
def create_access_token(user_id: int, username: str, is_superadmin: bool = False) -> str: def verify_legacy_sha256(plain_password: str, hashed_password: str) -> bool:
"""Verify legacy SHA256 hash and upgrade when used"""
if not hashed_password or len(hashed_password) != 64:
return False
try:
return hashlib.sha256(plain_password.encode()).hexdigest() == hashed_password
except Exception:
return False
@staticmethod
def upgrade_password_hash(user_id: int, plain_password: str):
"""Upgrade legacy password hash to bcrypt"""
new_hash = AuthService.hash_password(plain_password)
execute_update(
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(new_hash, user_id)
)
@staticmethod
def verify_totp_code(secret: str, code: str) -> bool:
"""Verify TOTP code"""
if not secret or not code:
return False
try:
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
except Exception:
return False
@staticmethod
def generate_2fa_secret() -> str:
"""Generate a new TOTP secret"""
return pyotp.random_base32()
@staticmethod
def get_2fa_provisioning_uri(username: str, secret: str) -> str:
"""Generate provisioning URI for authenticator apps"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=username, issuer_name="BMC Hub")
@staticmethod
def setup_user_2fa(user_id: int, username: str) -> Dict:
"""Create and store a new TOTP secret (not enabled until verified)"""
secret = AuthService.generate_2fa_secret()
execute_update(
"UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(secret, user_id)
)
return {
"secret": secret,
"provisioning_uri": AuthService.get_2fa_provisioning_uri(username, secret)
}
@staticmethod
def enable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Enable 2FA after verifying TOTP code"""
user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,)
)
if not user or not user.get("totp_secret"):
return False
if not AuthService.verify_totp_code(user["totp_secret"], otp_code):
return False
execute_update(
"UPDATE users SET is_2fa_enabled = TRUE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user_id,)
)
return True
@staticmethod
def disable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Disable 2FA after verifying TOTP code"""
user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,)
)
if not user or not user.get("totp_secret"):
return False
if not AuthService.verify_totp_code(user["totp_secret"], otp_code):
return False
execute_update(
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user_id,)
)
return True
@staticmethod
def create_access_token(
user_id: int,
username: str,
is_superadmin: bool = False,
is_shadow_admin: bool = False
) -> str:
""" """
Create JWT access token Create JWT access token
@ -55,6 +165,7 @@ class AuthService:
"sub": str(user_id), "sub": str(user_id),
"username": username, "username": username,
"is_superadmin": is_superadmin, "is_superadmin": is_superadmin,
"shadow_admin": is_shadow_admin,
"exp": expire, "exp": expire,
"iat": datetime.utcnow(), "iat": datetime.utcnow(),
"jti": jti "jti": jti
@ -62,7 +173,8 @@ class AuthService:
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
# Store session for token revocation # Store session for token revocation (skip for shadow admin)
if not is_shadow_admin:
execute_insert( execute_insert(
"""INSERT INTO sessions (user_id, token_jti, expires_at) """INSERT INTO sessions (user_id, token_jti, expires_at)
VALUES (%s, %s, %s)""", VALUES (%s, %s, %s)""",
@ -82,6 +194,9 @@ class AuthService:
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("shadow_admin"):
return payload
# Check if token is revoked # Check if token is revoked
jti = payload.get('jti') jti = payload.get('jti')
if jti: if jti:
@ -102,7 +217,12 @@ class AuthService:
return None return None
@staticmethod @staticmethod
def authenticate_user(username: str, password: str, ip_address: Optional[str] = None) -> Optional[Dict]: def authenticate_user(
username: str,
password: str,
ip_address: Optional[str] = None,
otp_code: Optional[str] = None
) -> Tuple[Optional[Dict], Optional[str]]:
""" """
Authenticate user with username/password Authenticate user with username/password
@ -114,38 +234,70 @@ class AuthService:
Returns: Returns:
User dict if successful, None otherwise User dict if successful, None otherwise
""" """
# Shadow Admin shortcut
if settings.SHADOW_ADMIN_ENABLED and username == settings.SHADOW_ADMIN_USERNAME:
if not settings.SHADOW_ADMIN_PASSWORD or not settings.SHADOW_ADMIN_TOTP_SECRET:
logger.error("❌ Shadow admin enabled but not configured")
return None, "Shadow admin not configured"
if not secrets.compare_digest(password, settings.SHADOW_ADMIN_PASSWORD):
logger.warning(f"❌ Shadow admin login failed from IP: {ip_address}")
return None, "Invalid username or password"
if not otp_code:
return None, "2FA code required"
if not AuthService.verify_totp_code(settings.SHADOW_ADMIN_TOTP_SECRET, otp_code):
logger.warning(f"❌ Shadow admin 2FA failed from IP: {ip_address}")
return None, "Invalid 2FA code"
logger.warning(f"⚠️ Shadow admin login used from IP: {ip_address}")
return {
"user_id": 0,
"username": settings.SHADOW_ADMIN_USERNAME,
"email": settings.SHADOW_ADMIN_EMAIL,
"full_name": settings.SHADOW_ADMIN_FULL_NAME,
"is_superadmin": True,
"is_shadow_admin": True
}, None
# Get user # Get user
user = execute_query_single( user = execute_query_single(
"""SELECT id, username, email, password_hash, full_name, """SELECT user_id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until is_active, is_superadmin, failed_login_attempts, locked_until,
is_2fa_enabled, totp_secret
FROM users FROM users
WHERE username = %s OR email = %s""", WHERE username = %s OR email = %s""",
(username, username)) (username, username))
if not user: if not user:
logger.warning(f"❌ Login failed: User not found - {username}") logger.warning(f"❌ Login failed: User not found - {username}")
return None return None, "Invalid username or password"
# Check if account is active # Check if account is active
if not user['is_active']: if not user['is_active']:
logger.warning(f"❌ Login failed: Account disabled - {username}") logger.warning(f"❌ Login failed: Account disabled - {username}")
return None return None, "Account disabled"
# Check if account is locked # Check if account is locked
if user['locked_until']: if user['locked_until']:
locked_until = user['locked_until'] locked_until = user['locked_until']
if datetime.now() < locked_until: if datetime.now() < locked_until:
logger.warning(f"❌ Login failed: Account locked - {username}") logger.warning(f"❌ Login failed: Account locked - {username}")
return None return None, "Account locked"
else: else:
# Unlock account # Unlock account
execute_update( execute_update(
"UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE id = %s", "UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE user_id = %s",
(user['id'],) (user['user_id'],)
) )
# Verify password # Verify password
if not AuthService.verify_password(password, user['password_hash']): if AuthService.verify_password(password, user['password_hash']):
pass
elif AuthService.verify_legacy_sha256(password, user['password_hash']):
AuthService.upgrade_password_hash(user['user_id'], password)
else:
# Increment failed attempts # Increment failed attempts
failed_attempts = user['failed_login_attempts'] + 1 failed_attempts = user['failed_login_attempts'] + 1
@ -155,18 +307,30 @@ class AuthService:
execute_update( execute_update(
"""UPDATE users """UPDATE users
SET failed_login_attempts = %s, locked_until = %s SET failed_login_attempts = %s, locked_until = %s
WHERE id = %s""", WHERE user_id = %s""",
(failed_attempts, locked_until, user['id']) (failed_attempts, locked_until, user['user_id'])
) )
logger.warning(f"🔒 Account locked due to failed attempts: {username}") logger.warning(f"🔒 Account locked due to failed attempts: {username}")
else: else:
execute_update( execute_update(
"UPDATE users SET failed_login_attempts = %s WHERE id = %s", "UPDATE users SET failed_login_attempts = %s WHERE user_id = %s",
(failed_attempts, user['id']) (failed_attempts, user['user_id'])
) )
logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})") logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})")
return None return None, "Invalid username or password"
# 2FA check
if user.get('is_2fa_enabled'):
if not user.get('totp_secret'):
return None, "2FA not configured"
if not otp_code:
return None, "2FA code required"
if not AuthService.verify_totp_code(user['totp_secret'], otp_code):
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
return None, "Invalid 2FA code"
# Success! Reset failed attempts and update last login # Success! Reset failed attempts and update last login
execute_update( execute_update(
@ -174,29 +338,48 @@ class AuthService:
SET failed_login_attempts = 0, SET failed_login_attempts = 0,
locked_until = NULL, locked_until = NULL,
last_login_at = CURRENT_TIMESTAMP last_login_at = CURRENT_TIMESTAMP
WHERE id = %s""", WHERE user_id = %s""",
(user['id'],) (user['user_id'],)
) )
logger.info(f"✅ User logged in: {username} from IP: {ip_address}") logger.info(f"✅ User logged in: {username} from IP: {ip_address}")
return { return {
'user_id': user['id'], 'user_id': user['user_id'],
'username': user['username'], 'username': user['username'],
'email': user['email'], 'email': user['email'],
'full_name': user['full_name'], 'full_name': user['full_name'],
'is_superadmin': bool(user['is_superadmin']) 'is_superadmin': bool(user['is_superadmin']),
} 'is_shadow_admin': False
}, None
@staticmethod @staticmethod
def revoke_token(jti: str, user_id: int): def revoke_token(jti: str, user_id: int, is_shadow_admin: bool = False):
"""Revoke a JWT token""" """Revoke a JWT token"""
if is_shadow_admin:
logger.info("🔒 Shadow admin logout - no session to revoke")
return
execute_update( execute_update(
"UPDATE sessions SET revoked = TRUE WHERE token_jti = %s AND user_id = %s", "UPDATE sessions SET revoked = TRUE WHERE token_jti = %s AND user_id = %s",
(jti, user_id) (jti, user_id)
) )
logger.info(f"🔒 Token revoked for user {user_id}") logger.info(f"🔒 Token revoked for user {user_id}")
@staticmethod
def get_all_permissions() -> List[str]:
"""Get all permission codes"""
perms = execute_query("SELECT code FROM permissions")
return [p['code'] for p in perms] if perms else []
@staticmethod
def is_user_2fa_enabled(user_id: int) -> bool:
"""Check if user has 2FA enabled"""
user = execute_query_single(
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,)
)
return bool(user and user.get("is_2fa_enabled"))
@staticmethod @staticmethod
def get_user_permissions(user_id: int) -> List[str]: def get_user_permissions(user_id: int) -> List[str]:
""" """
@ -210,13 +393,12 @@ class AuthService:
""" """
# Check if user is superadmin first # Check if user is superadmin first
user = execute_query_single( user = execute_query_single(
"SELECT is_superadmin FROM users WHERE id = %s", "SELECT is_superadmin FROM users WHERE user_id = %s",
(user_id,)) (user_id,))
# Superadmins have all permissions # Superadmins have all permissions
if user and user['is_superadmin']: if user and user['is_superadmin']:
all_perms = execute_query_single("SELECT code FROM permissions") return AuthService.get_all_permissions()
return [p['code'] for p in all_perms] if all_perms else []
# Get permissions through groups # Get permissions through groups
perms = execute_query(""" perms = execute_query("""
@ -242,8 +424,8 @@ class AuthService:
True if user has permission True if user has permission
""" """
# Superadmins have all permissions # Superadmins have all permissions
user = execute_query( user = execute_query_single(
"SELECT is_superadmin FROM users WHERE id = %s", "SELECT is_superadmin FROM users WHERE user_id = %s",
(user_id,)) (user_id,))
if user and user['is_superadmin']: if user and user['is_superadmin']:
@ -279,7 +461,7 @@ class AuthService:
user_id = execute_insert( user_id = execute_insert(
"""INSERT INTO users """INSERT INTO users
(username, email, password_hash, full_name, is_superadmin) (username, email, password_hash, full_name, is_superadmin)
VALUES (%s, %s, %s, %s, %s) RETURNING id""", VALUES (%s, %s, %s, %s, %s) RETURNING user_id""",
(username, email, password_hash, full_name, is_superadmin) (username, email, password_hash, full_name, is_superadmin)
) )
@ -292,7 +474,7 @@ class AuthService:
password_hash = AuthService.hash_password(new_password) password_hash = AuthService.hash_password(new_password)
execute_update( execute_update(
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", "UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(password_hash, user_id) (password_hash, user_id)
) )

View File

@ -30,6 +30,14 @@ class Settings(BaseSettings):
ALLOWED_ORIGINS: List[str] = ["http://localhost:8000", "http://localhost:3000"] ALLOWED_ORIGINS: List[str] = ["http://localhost:8000", "http://localhost:3000"]
CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000" CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000"
# Shadow Admin (emergency access)
SHADOW_ADMIN_ENABLED: bool = False
SHADOW_ADMIN_USERNAME: str = "shadowadmin"
SHADOW_ADMIN_PASSWORD: str = ""
SHADOW_ADMIN_TOTP_SECRET: str = ""
SHADOW_ADMIN_EMAIL: str = "shadowadmin@bmcnetworks.dk"
SHADOW_ADMIN_FULL_NAME: str = "Shadow Administrator"
# Logging # Logging
LOG_LEVEL: str = "INFO" LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/app.log" LOG_FILE: str = "logs/app.log"
@ -42,6 +50,13 @@ class Settings(BaseSettings):
ECONOMIC_READ_ONLY: bool = True ECONOMIC_READ_ONLY: bool = True
ECONOMIC_DRY_RUN: bool = True ECONOMIC_DRY_RUN: bool = True
# Nextcloud Integration
NEXTCLOUD_READ_ONLY: bool = True
NEXTCLOUD_DRY_RUN: bool = True
NEXTCLOUD_TIMEOUT_SECONDS: int = 15
NEXTCLOUD_CACHE_TTL_SECONDS: int = 300
NEXTCLOUD_ENCRYPTION_KEY: str = ""
# Ollama LLM # Ollama LLM
OLLAMA_ENDPOINT: str = "http://localhost:11434" OLLAMA_ENDPOINT: str = "http://localhost:11434"
OLLAMA_MODEL: str = "llama3.2:3b" OLLAMA_MODEL: str = "llama3.2:3b"

31
app/core/crypto.py Normal file
View File

@ -0,0 +1,31 @@
"""
Crypto helpers for encrypting/decrypting secrets at rest.
"""
import logging
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
logger = logging.getLogger(__name__)
def _get_fernet() -> Fernet:
if not settings.NEXTCLOUD_ENCRYPTION_KEY:
raise ValueError("NEXTCLOUD_ENCRYPTION_KEY not configured")
return Fernet(settings.NEXTCLOUD_ENCRYPTION_KEY.encode())
def encrypt_secret(value: str) -> str:
fernet = _get_fernet()
return fernet.encrypt(value.encode()).decode()
def decrypt_secret(value: str) -> Optional[str]:
try:
fernet = _get_fernet()
return fernet.decrypt(value.encode()).decode()
except (InvalidToken, ValueError) as exc:
logger.error("❌ Nextcloud credential decryption failed: %s", exc)
return None

View File

@ -68,19 +68,18 @@ def execute_query(query: str, params: tuple = None, fetch: bool = True):
cursor.execute(query, params) cursor.execute(query, params)
# Auto-detect write operations and commit # Auto-detect write operations and commit
query_upper = query.strip().upper() # Robust detection handling comments and whitespace
is_write = query_upper.startswith(('INSERT', 'UPDATE', 'DELETE')) clean_query = "\n".join([line for line in query.split("\n") if not line.strip().startswith("--")]).strip().upper()
is_write = clean_query.startswith(('INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'COMMENT'))
if is_write: if is_write:
conn.commit() conn.commit()
# Only fetch if there are results to fetch # Only fetch if there are results to fetch (cursor.description is not None)
# (SELECT queries or INSERT/UPDATE/DELETE with RETURNING clause) if cursor.description:
if fetch and (not is_write or 'RETURNING' in query_upper):
return cursor.fetchall() return cursor.fetchall()
elif is_write:
return cursor.rowcount return cursor.rowcount
return []
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
logger.error(f"Query error: {e}") logger.error(f"Query error: {e}")

View File

@ -316,6 +316,11 @@
<i class="bi bi-hdd"></i>Hardware <i class="bi bi-hdd"></i>Hardware
</a> </a>
</li> </li>
<li class="nav-item d-none" id="nextcloudTabNav">
<a class="nav-link" data-bs-toggle="tab" href="#nextcloud">
<i class="bi bi-cloud"></i>Nextcloud
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#activity"> <a class="nav-link" data-bs-toggle="tab" href="#activity">
<i class="bi bi-clock-history"></i>Aktivitet <i class="bi bi-clock-history"></i>Aktivitet
@ -430,6 +435,19 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12">
<div class="info-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0">Tags</h5>
<button class="btn btn-sm btn-outline-primary" onclick="openCustomerTagModal()">
<i class="bi bi-tag me-1"></i>Tilføj tag
</button>
</div>
<div id="customerTagsContainer" class="d-flex flex-wrap gap-2"></div>
<div id="customerTagsEmpty" class="text-muted small">Ingen tags tilføjet endnu.</div>
</div>
</div>
</div> </div>
</div> </div>
@ -600,6 +618,11 @@
</div> </div>
</div> </div>
<!-- Nextcloud Tab -->
<div class="tab-pane fade d-none" id="nextcloud">
{% include "modules/nextcloud/templates/tab.html" %}
</div>
<!-- Activity Tab --> <!-- Activity Tab -->
<div class="tab-pane fade" id="activity"> <div class="tab-pane fade" id="activity">
<h5 class="fw-bold mb-4">Aktivitet</h5> <h5 class="fw-bold mb-4">Aktivitet</h5>
@ -750,6 +773,28 @@
</div> </div>
</div> </div>
<!-- Customer Tag Modal -->
<div class="modal fade" id="customerTagModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title"><i class="bi bi-tag me-2"></i>Tilføj tag</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Vælg tag</label>
<select class="form-select" id="customerTagSelect"></select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="addCustomerTag()">Tilføj</button>
</div>
</div>
</div>
</div>
<!-- Subscription Modal --> <!-- Subscription Modal -->
<div class="modal fade" id="subscriptionModal" tabindex="-1"> <div class="modal fade" id="subscriptionModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
@ -867,6 +912,7 @@
const customerId = parseInt(window.location.pathname.split('/').pop()); const customerId = parseInt(window.location.pathname.split('/').pop());
let customerData = null; let customerData = null;
let pipelineStages = []; let pipelineStages = [];
let allTagsCache = [];
let eventListenersAdded = false; let eventListenersAdded = false;
@ -919,6 +965,14 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false }); }, { once: false });
} }
// Load Nextcloud status when tab is shown
const nextcloudTab = document.querySelector('a[href="#nextcloud"]');
if (nextcloudTab) {
nextcloudTab.addEventListener('shown.bs.tab', () => {
loadNextcloudStatus();
}, { once: false });
}
eventListenersAdded = true; eventListenersAdded = true;
}); });
@ -933,6 +987,7 @@ async function loadCustomer() {
displayCustomer(customerData); displayCustomer(customerData);
await loadUtilityCompany(); await loadUtilityCompany();
await loadCustomerTags();
// Check data consistency // Check data consistency
await checkDataConsistency(); await checkDataConsistency();
@ -1025,6 +1080,280 @@ function displayCustomer(customer) {
document.getElementById('createdAt').textContent = new Date(customer.created_at).toLocaleString('da-DK'); document.getElementById('createdAt').textContent = new Date(customer.created_at).toLocaleString('da-DK');
} }
async function loadCustomerTags() {
try {
const response = await fetch(`/api/v1/tags/entity/customer/${customerId}`);
if (!response.ok) return;
const tags = await response.json();
const hasNextcloud = (tags || []).some(tag => (tag.name || '').toLowerCase() === 'nextcloud');
const nextcloudNav = document.getElementById('nextcloudTabNav');
const nextcloudPane = document.getElementById('nextcloud');
if (hasNextcloud) {
nextcloudNav?.classList.remove('d-none');
nextcloudPane?.classList.remove('d-none');
} else {
nextcloudNav?.classList.add('d-none');
nextcloudPane?.classList.add('d-none');
}
renderCustomerTags(tags || []);
} catch (error) {
console.error('Failed to load customer tags:', error);
}
}
function renderCustomerTags(tags) {
const container = document.getElementById('customerTagsContainer');
const emptyState = document.getElementById('customerTagsEmpty');
if (!container || !emptyState) return;
if (!tags.length) {
container.innerHTML = '';
emptyState.classList.remove('d-none');
return;
}
emptyState.classList.add('d-none');
container.innerHTML = tags.map(tag => `
<span class="badge" style="background:${tag.color || '#0f4c75'}; color: white;">
${escapeHtml(tag.name)}
<button class="btn btn-sm btn-link text-white p-0 ms-2" onclick="removeCustomerTag(${tag.id})" title="Fjern tag">
<i class="bi bi-x"></i>
</button>
</span>
`).join('');
}
async function openCustomerTagModal() {
const modal = new bootstrap.Modal(document.getElementById('customerTagModal'));
await loadAllTags();
modal.show();
}
async function loadAllTags() {
try {
const response = await fetch('/api/v1/tags?is_active=true');
if (!response.ok) return;
allTagsCache = await response.json();
const currentTagsResponse = await fetch(`/api/v1/tags/entity/customer/${customerId}`);
const currentTags = currentTagsResponse.ok ? await currentTagsResponse.json() : [];
const currentTagIds = new Set(currentTags.map(tag => tag.id));
const select = document.getElementById('customerTagSelect');
if (!select) return;
const options = allTagsCache
.filter(tag => !currentTagIds.has(tag.id))
.map(tag => `<option value="${tag.id}">${escapeHtml(tag.name)}</option>`)
.join('');
select.innerHTML = options || '<option value="">Ingen flere tags</option>';
} catch (error) {
console.error('Failed to load tags:', error);
}
}
async function addCustomerTag() {
const select = document.getElementById('customerTagSelect');
if (!select || !select.value) return;
const payload = {
entity_type: 'customer',
entity_id: customerId,
tag_id: parseInt(select.value, 10)
};
try {
const response = await fetch('/api/v1/tags/entity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json();
alert(error.detail || 'Kunne ikke tilføje tag');
return;
}
bootstrap.Modal.getInstance(document.getElementById('customerTagModal')).hide();
await loadCustomerTags();
} catch (error) {
console.error('Failed to add tag:', error);
}
}
async function removeCustomerTag(tagId) {
try {
const response = await fetch(`/api/v1/tags/entity?entity_type=customer&entity_id=${customerId}&tag_id=${tagId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
alert(error.detail || 'Kunne ikke fjerne tag');
return;
}
await loadCustomerTags();
} catch (error) {
console.error('Failed to remove tag:', error);
}
}
async function loadNextcloudStatus() {
const statusBadge = document.getElementById('ncStatusBadge');
const lastUpdated = document.getElementById('ncLastUpdated');
const cpuLoad = document.getElementById('ncCpuLoad');
const freeDisk = document.getElementById('ncFreeDisk');
const ramUsage = document.getElementById('ncRamUsage');
const opcache = document.getElementById('ncOpcache');
const fileGrowth = document.getElementById('ncFileGrowth');
const publicShares = document.getElementById('ncPublicShares');
const activeUsers = document.getElementById('ncActiveUsers');
const alerts = document.getElementById('ncAlerts');
if (!statusBadge || !lastUpdated) return;
statusBadge.className = 'badge bg-secondary';
statusBadge.textContent = 'Henter...';
lastUpdated.textContent = '-';
try {
const instanceResponse = await fetch(`/api/v1/nextcloud/customers/${customerId}/instance`);
if (!instanceResponse.ok) {
statusBadge.textContent = 'Ukendt';
return;
}
const instance = await instanceResponse.json();
if (!instance?.id) {
statusBadge.textContent = 'Ikke konfigureret';
return;
}
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/status?customer_id=${customerId}`);
if (!response.ok) {
statusBadge.textContent = 'Ukendt';
return;
}
const payload = await response.json();
const isOnline = payload.status === 'online';
statusBadge.className = `badge ${isOnline ? 'bg-success' : 'bg-warning text-dark'}`;
statusBadge.textContent = isOnline ? 'Online' : 'Ukendt';
lastUpdated.textContent = new Date().toLocaleString('da-DK');
const info = payload.raw?.payload?.ocs?.data || {};
const system = info?.nextcloud?.system || {};
const sharesInfo = info?.nextcloud?.shares || {};
const storageInfo = info?.nextcloud?.storage || {};
const activeUsersInfo = info?.activeUsers || {};
const phpInfo = info?.server?.php || {};
const opcacheStats = phpInfo?.opcache?.opcache_statistics || {};
const loadAvg = Array.isArray(system?.cpuload) ? system.cpuload : [];
const cpuCores = system?.cpucount || system?.cpu_count || system?.num_cores || 0;
const loadValue = loadAvg.length ? loadAvg[0] : null;
const loadText = loadAvg.length ? loadAvg.map(v => Number(v).toFixed(2)).join(' / ') : '-';
const memTotal = system?.mem_total || null;
const memFree = system?.mem_free || null;
const freeRamPct = memTotal && memFree ? (memFree / memTotal) * 100 : null;
const ramUsageText = freeRamPct !== null ? `${(100 - freeRamPct).toFixed(1)} %` : '-';
const freeDiskBytes = system?.freespace ?? null;
const freeDiskText = freeDiskBytes !== null ? formatBytes(freeDiskBytes) : '-';
const opcacheHitRate = opcacheStats?.opcache_hit_rate ?? null;
const opcacheText = opcacheHitRate !== null ? `${Number(opcacheHitRate).toFixed(2)} %` : '-';
if (cpuLoad) cpuLoad.textContent = loadText;
if (freeDisk) freeDisk.textContent = freeDiskText;
if (ramUsage) ramUsage.textContent = ramUsageText;
if (opcache) opcache.textContent = opcacheText;
if (activeUsers) activeUsers.textContent = activeUsersInfo?.last24hours ?? '-';
// File count growth (localStorage diff)
const fileCount = storageInfo?.num_files ?? null;
if (fileGrowth) {
if (fileCount === null) {
fileGrowth.textContent = '-';
} else {
const key = `nextcloud_file_count_${instance.id}`;
const prev = parseInt(localStorage.getItem(key) || '0', 10);
const diff = prev ? fileCount - prev : 0;
fileGrowth.textContent = prev ? `${fileCount} (${diff >= 0 ? '+' : ''}${diff})` : `${fileCount}`;
localStorage.setItem(key, `${fileCount}`);
}
}
// Public shares without password
if (publicShares) {
if (typeof sharesInfo?.num_shares_link_no_password !== 'undefined') {
publicShares.textContent = `${sharesInfo.num_shares_link_no_password}`;
} else {
const sharesResponse = await fetch(`/api/v1/nextcloud/instances/${instance.id}/shares?customer_id=${customerId}`);
if (sharesResponse.ok) {
const sharesPayload = await sharesResponse.json();
const list = sharesPayload?.payload?.ocs?.data || [];
const withoutPassword = list.filter(s => s.share_type === 3 && (!s.password || s.password === '')).length;
publicShares.textContent = `${withoutPassword}`;
} else {
publicShares.textContent = '-';
}
}
}
if (alerts) {
const items = [];
if (freeDiskBytes !== null && freeDiskBytes <= 0) items.push('⚠️ Free disk kritisk');
if (loadValue !== null && cpuCores && loadValue > cpuCores) items.push('⚠️ CPU load > cores');
if (freeRamPct !== null && freeRamPct < 10) items.push(' Free RAM < 10%');
if (opcacheHitRate !== null && opcacheHitRate < 95) items.push(' OPCache hit rate < 95%');
const sharesText = publicShares?.textContent;
if (sharesText && parseInt(sharesText, 10) > 0) items.push('⚠️ Public shares uden password');
alerts.innerHTML = items.length
? items.map(text => `<span class="badge bg-warning text-dark">${text}</span>`).join('')
: '<span class="badge bg-success">✅ Ingen alarmer</span>';
}
} catch (error) {
console.error('Failed to load Nextcloud status:', error);
statusBadge.textContent = 'Ukendt';
}
}
function formatBytes(value) {
if (value === null || typeof value === 'undefined') return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = Number(value);
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
function openNextcloudCreateUser() {
alert('Opret bruger: kommer snart');
}
function openNextcloudResetPassword() {
alert('Reset password: kommer snart');
}
function openNextcloudDisableUser() {
alert('Luk bruger: kommer snart');
}
async function loadUtilityCompany() { async function loadUtilityCompany() {
const nameEl = document.getElementById('utilityCompanyName'); const nameEl = document.getElementById('utilityCompanyName');
const contactEl = document.getElementById('utilityCompanyContact'); const contactEl = document.getElementById('utilityCompanyContact');

View File

@ -3,7 +3,7 @@ Pydantic Models and Schemas
""" """
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from typing import Optional from typing import Optional, List
from datetime import datetime from datetime import datetime
@ -139,3 +139,56 @@ class Conversation(ConversationBase):
deleted_at: Optional[datetime] = None deleted_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class SolutionBase(BaseModel):
"""Base schema for Case Solutions"""
title: str
description: Optional[str] = None
solution_type: Optional[str] = None # Support, Drift, Konsulent, etc.
result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst
class SolutionCreate(SolutionBase):
"""Schema for creating a solution"""
sag_id: int
created_by_user_id: Optional[int] = None
class SolutionUpdate(BaseModel):
"""Schema for updating a solution"""
title: Optional[str] = None
description: Optional[str] = None
solution_type: Optional[str] = None
result: Optional[str] = None
class Solution(SolutionBase):
"""Full solution schema"""
id: int
sag_id: int
created_by_user_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class UserAdminCreate(BaseModel):
username: str
email: str
password: str
full_name: Optional[str] = None
is_superadmin: bool = False
is_active: bool = True
group_ids: Optional[List[int]] = None
class UserGroupsUpdate(BaseModel):
group_ids: List[int]
class GroupCreate(BaseModel):
name: str
description: Optional[str] = None
class GroupPermissionsUpdate(BaseModel):
permission_ids: List[int]

View File

@ -0,0 +1 @@
"""Nextcloud module backend."""

View File

@ -0,0 +1,272 @@
"""
Nextcloud Module - API Router
"""
import json
import logging
import secrets
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from app.core.crypto import encrypt_secret
from app.core.database import execute_query
from app.modules.nextcloud.backend.service import NextcloudService
from app.modules.nextcloud.models.schemas import (
NextcloudInstanceCreate,
NextcloudInstanceUpdate,
NextcloudUserCreate,
NextcloudPasswordReset,
)
logger = logging.getLogger(__name__)
router = APIRouter()
service = NextcloudService()
def _audit(customer_id: int, instance_id: int, event_type: str, request_meta: dict, response_meta: dict):
query = """
INSERT INTO nextcloud_audit_log
(customer_id, instance_id, event_type, request_meta, response_meta)
VALUES (%s, %s, %s, %s, %s)
"""
execute_query(
query,
(
customer_id,
instance_id,
event_type,
json.dumps(request_meta),
json.dumps(response_meta),
),
)
@router.get("/instances")
async def list_instances(customer_id: Optional[int] = Query(None)):
query = "SELECT * FROM nextcloud_instances WHERE deleted_at IS NULL"
params: List[int] = []
if customer_id is not None:
query += " AND customer_id = %s"
params.append(customer_id)
return execute_query(query, tuple(params)) or []
@router.get("/customers/{customer_id}/instance")
async def get_instance_for_customer(customer_id: int):
query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL"
result = execute_query(query, (customer_id,))
if not result:
return None
return result[0]
@router.post("/instances")
async def create_instance(payload: NextcloudInstanceCreate):
try:
password_encrypted = encrypt_secret(payload.password)
query = """
INSERT INTO nextcloud_instances
(customer_id, base_url, auth_type, username, password_encrypted)
VALUES (%s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query(
query,
(
payload.customer_id,
payload.base_url,
payload.auth_type,
payload.username,
password_encrypted,
),
)
return result[0] if result else None
except Exception as exc:
logger.error("❌ Failed to create Nextcloud instance: %s", exc)
raise HTTPException(status_code=500, detail="Failed to create instance")
@router.patch("/instances/{instance_id}")
async def update_instance(instance_id: int, payload: NextcloudInstanceUpdate):
updates = []
params = []
if payload.base_url is not None:
updates.append("base_url = %s")
params.append(payload.base_url)
if payload.auth_type is not None:
updates.append("auth_type = %s")
params.append(payload.auth_type)
if payload.username is not None:
updates.append("username = %s")
params.append(payload.username)
if payload.password is not None:
updates.append("password_encrypted = %s")
params.append(encrypt_secret(payload.password))
if payload.is_enabled is not None:
updates.append("is_enabled = %s")
params.append(payload.is_enabled)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updates.append("updated_at = NOW()")
params.append(instance_id)
query = f"UPDATE nextcloud_instances SET {', '.join(updates)} WHERE id = %s RETURNING *"
result = execute_query(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Instance not found")
return result[0]
@router.post("/instances/{instance_id}/disable")
async def disable_instance(instance_id: int):
query = """
UPDATE nextcloud_instances
SET is_enabled = false, disabled_at = NOW(), updated_at = NOW()
WHERE id = %s
RETURNING *
"""
result = execute_query(query, (instance_id,))
if not result:
raise HTTPException(status_code=404, detail="Instance not found")
return result[0]
@router.post("/instances/{instance_id}/enable")
async def enable_instance(instance_id: int):
query = """
UPDATE nextcloud_instances
SET is_enabled = true, disabled_at = NULL, updated_at = NOW()
WHERE id = %s
RETURNING *
"""
result = execute_query(query, (instance_id,))
if not result:
raise HTTPException(status_code=404, detail="Instance not found")
return result[0]
@router.post("/instances/{instance_id}/rotate-credentials")
async def rotate_credentials(instance_id: int, payload: NextcloudInstanceUpdate):
if not payload.password:
raise HTTPException(status_code=400, detail="Password is required")
query = """
UPDATE nextcloud_instances
SET password_encrypted = %s, updated_at = NOW()
WHERE id = %s
RETURNING *
"""
result = execute_query(query, (encrypt_secret(payload.password), instance_id))
if not result:
raise HTTPException(status_code=404, detail="Instance not found")
return result[0]
@router.get("/instances/{instance_id}/status")
async def get_status(instance_id: int, customer_id: Optional[int] = Query(None)):
response = await service.get_status(instance_id, customer_id)
if customer_id is not None:
_audit(customer_id, instance_id, "status", {"instance_id": instance_id}, response)
return response
@router.get("/instances/{instance_id}/groups")
async def list_groups(instance_id: int, customer_id: Optional[int] = Query(None)):
response = await service.list_groups(instance_id, customer_id)
if customer_id is not None:
_audit(customer_id, instance_id, "groups", {"instance_id": instance_id}, response)
return response
@router.get("/instances/{instance_id}/shares")
async def list_shares(instance_id: int, customer_id: Optional[int] = Query(None)):
response = await service.list_public_shares(instance_id, customer_id)
if customer_id is not None:
_audit(customer_id, instance_id, "shares", {"instance_id": instance_id}, response)
return response
@router.post("/instances/{instance_id}/users")
async def create_user(instance_id: int, payload: NextcloudUserCreate, customer_id: Optional[int] = Query(None)):
password = secrets.token_urlsafe(12)
request_payload = {
"userid": payload.uid,
"password": password,
"email": payload.email,
"displayName": payload.display_name,
"groups[]": payload.groups,
}
response = await service.create_user(instance_id, customer_id, request_payload)
if customer_id is not None:
_audit(customer_id, instance_id, "create_user", {"uid": payload.uid}, response)
return {"result": response, "generated_password": password if payload.send_welcome else None}
@router.post("/instances/{instance_id}/users/{uid}/reset-password")
async def reset_password(
instance_id: int,
uid: str,
payload: NextcloudPasswordReset,
customer_id: Optional[int] = Query(None),
):
password = secrets.token_urlsafe(12)
response = await service.reset_password(instance_id, customer_id, uid, password)
if customer_id is not None:
_audit(customer_id, instance_id, "reset_password", {"uid": uid}, response)
return {"result": response, "generated_password": password if payload.send_email else None}
@router.post("/instances/{instance_id}/users/{uid}/disable")
async def disable_user(instance_id: int, uid: str, customer_id: Optional[int] = Query(None)):
response = await service.disable_user(instance_id, customer_id, uid)
if customer_id is not None:
_audit(customer_id, instance_id, "disable_user", {"uid": uid}, response)
return response
@router.post("/instances/{instance_id}/users/{uid}/resend-guide")
async def resend_guide(instance_id: int, uid: str, customer_id: Optional[int] = Query(None)):
response = {"status": "queued", "uid": uid}
if customer_id is not None:
_audit(customer_id, instance_id, "resend_guide", {"uid": uid}, response)
return response
@router.get("/audit")
async def list_audit(
customer_id: int = Query(...),
instance_id: Optional[int] = Query(None),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
):
query = """
SELECT * FROM nextcloud_audit_log
WHERE customer_id = %s
"""
params: List[object] = [customer_id]
if instance_id is not None:
query += " AND instance_id = %s"
params.append(instance_id)
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
return execute_query(query, tuple(params)) or []
@router.post("/audit/purge")
async def purge_audit(data: dict):
customer_id = data.get("customer_id")
before_date = data.get("before_date")
if not customer_id or not before_date:
raise HTTPException(status_code=400, detail="customer_id and before_date are required")
query = """
DELETE FROM nextcloud_audit_log
WHERE customer_id = %s AND created_at < %s
"""
deleted = execute_query(query, (customer_id, before_date))
return {"deleted": deleted}

View File

@ -0,0 +1,240 @@
"""
Nextcloud Integration Service
Direct OCS API calls with DB cache and audit logging.
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, Optional
import aiohttp
from app.core.config import settings
from app.core.crypto import decrypt_secret
from app.core.database import execute_query
logger = logging.getLogger(__name__)
class NextcloudService:
def __init__(self) -> None:
self.read_only = settings.NEXTCLOUD_READ_ONLY
self.dry_run = settings.NEXTCLOUD_DRY_RUN
self.timeout = settings.NEXTCLOUD_TIMEOUT_SECONDS
self.cache_ttl = settings.NEXTCLOUD_CACHE_TTL_SECONDS
if self.read_only:
logger.warning("🔒 Nextcloud READ_ONLY MODE ENABLED")
elif self.dry_run:
logger.warning("🏃 Nextcloud DRY_RUN MODE ENABLED")
else:
logger.warning("⚠️ Nextcloud WRITE MODE ACTIVE")
def _get_instance(self, instance_id: int, customer_id: Optional[int] = None) -> Optional[dict]:
query = "SELECT * FROM nextcloud_instances WHERE id = %s AND deleted_at IS NULL"
params = [instance_id]
if customer_id is not None:
query += " AND customer_id = %s"
params.append(customer_id)
result = execute_query(query, tuple(params))
return result[0] if result else None
def _get_auth(self, instance: dict) -> Optional[aiohttp.BasicAuth]:
password = decrypt_secret(instance["password_encrypted"])
if not password:
return None
return aiohttp.BasicAuth(instance["username"], password)
def _cache_get(self, cache_key: str) -> Optional[dict]:
query = "SELECT payload FROM nextcloud_cache WHERE cache_key = %s AND expires_at > NOW()"
result = execute_query(query, (cache_key,))
if result:
return result[0]["payload"]
return None
def _cache_set(self, cache_key: str, payload: dict) -> None:
expires_at = datetime.utcnow() + timedelta(seconds=self.cache_ttl)
query = """
INSERT INTO nextcloud_cache (cache_key, payload, expires_at)
VALUES (%s, %s, %s)
ON CONFLICT (cache_key) DO UPDATE
SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at
"""
execute_query(query, (cache_key, json.dumps(payload), expires_at))
def _audit(
self,
customer_id: int,
instance_id: int,
event_type: str,
request_meta: dict,
response_meta: dict,
actor_user_id: Optional[int] = None,
) -> None:
query = """
INSERT INTO nextcloud_audit_log
(customer_id, instance_id, event_type, request_meta, response_meta, actor_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
"""
execute_query(
query,
(
customer_id,
instance_id,
event_type,
json.dumps(request_meta),
json.dumps(response_meta),
actor_user_id,
),
)
def _check_write_permission(self, operation: str) -> bool:
if self.read_only:
logger.error("🚫 BLOCKED: %s - READ_ONLY mode is enabled", operation)
return False
if self.dry_run:
logger.warning("🏃 DRY_RUN: %s - Operation will not be executed", operation)
return False
logger.warning("⚠️ EXECUTING WRITE OPERATION: %s", operation)
return True
async def _ocs_request(
self,
instance: dict,
endpoint: str,
method: str = "GET",
params: Optional[dict] = None,
data: Optional[dict] = None,
use_cache: bool = True,
) -> dict:
cache_key = None
if use_cache and method.upper() == "GET":
cache_key = f"nextcloud:{instance['id']}:{endpoint}:{json.dumps(params or {}, sort_keys=True)}"
cached = self._cache_get(cache_key)
if cached:
cached["cache_hit"] = True
return cached
auth = self._get_auth(instance)
if not auth:
return {"error": "credentials_invalid"}
base_url = instance["base_url"].rstrip("/")
url = f"{base_url}/{endpoint.lstrip('/')}"
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout)) as session:
async with session.request(
method=method.upper(),
url=url,
headers=headers,
auth=auth,
params=params,
data=data,
) as resp:
try:
payload = await resp.json()
except Exception:
payload = {"raw": await resp.text()}
response = {
"status": resp.status,
"payload": payload,
"cache_hit": False,
}
if cache_key and resp.status == 200:
self._cache_set(cache_key, response)
return response
async def get_status(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"status": "offline", "checked_at": datetime.utcnow().isoformat()}
response = await self._ocs_request(
instance,
"/ocs/v2.php/apps/serverinfo/api/v1/info",
method="GET",
use_cache=True,
)
return {
"status": "online" if response.get("status") == 200 else "unknown",
"checked_at": datetime.utcnow().isoformat(),
"raw": response,
}
async def list_groups(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"groups": []}
return await self._ocs_request(
instance,
"/ocs/v1.php/cloud/groups",
method="GET",
use_cache=True,
)
async def list_public_shares(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"payload": {"ocs": {"data": []}}}
return await self._ocs_request(
instance,
"/ocs/v1.php/apps/files_sharing/api/v1/shares",
method="GET",
params={"share_type": 3},
use_cache=True,
)
async def create_user(self, instance_id: int, customer_id: Optional[int], payload: dict) -> dict:
if not self._check_write_permission("create_nextcloud_user"):
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"error": "instance_unavailable"}
return await self._ocs_request(
instance,
"/ocs/v1.php/cloud/users",
method="POST",
data=payload,
use_cache=False,
)
async def reset_password(self, instance_id: int, customer_id: Optional[int], uid: str, password: str) -> dict:
if not self._check_write_permission("reset_nextcloud_password"):
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"error": "instance_unavailable"}
return await self._ocs_request(
instance,
f"/ocs/v1.php/cloud/users/{uid}",
method="PUT",
data={"password": password},
use_cache=False,
)
async def disable_user(self, instance_id: int, customer_id: Optional[int], uid: str) -> dict:
if not self._check_write_permission("disable_nextcloud_user"):
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"error": "instance_unavailable"}
return await self._ocs_request(
instance,
f"/ocs/v1.php/cloud/users/{uid}/disable",
method="PUT",
use_cache=False,
)

View File

@ -0,0 +1 @@
"""Nextcloud module frontend."""

View File

@ -0,0 +1 @@
"""Nextcloud module models."""

View File

@ -0,0 +1,63 @@
from datetime import datetime
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
class NextcloudInstanceBase(BaseModel):
customer_id: int
base_url: str
auth_type: str = "basic"
username: str
class NextcloudInstanceCreate(NextcloudInstanceBase):
password: str = Field(..., min_length=1)
class NextcloudInstanceUpdate(BaseModel):
base_url: Optional[str] = None
auth_type: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
is_enabled: Optional[bool] = None
class NextcloudInstance(NextcloudInstanceBase):
id: int
is_enabled: bool
disabled_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class NextcloudStatus(BaseModel):
status: str
checked_at: datetime
version: Optional[str] = None
php: Optional[str] = None
db: Optional[str] = None
metrics: Dict[str, Optional[str]] = {}
class NextcloudUserCreate(BaseModel):
uid: str
display_name: Optional[str] = None
email: Optional[str] = None
groups: List[str] = []
send_welcome: bool = True
class NextcloudPasswordReset(BaseModel):
send_email: bool = True
class NextcloudAuditLogEntry(BaseModel):
id: int
customer_id: int
instance_id: Optional[int] = None
event_type: str
request_meta: Optional[Dict] = None
response_meta: Optional[Dict] = None
actor_user_id: Optional[int] = None
created_at: datetime

View File

@ -0,0 +1,19 @@
{
"name": "nextcloud",
"version": "1.0.0",
"description": "Nextcloud integration: status, users, and audit log",
"author": "BMC Networks",
"enabled": true,
"dependencies": [],
"table_prefix": "nextcloud_",
"api_prefix": "/api/v1/nextcloud",
"tags": [
"Nextcloud"
],
"config": {
"safety_switches": {
"read_only": true,
"dry_run": true
}
}
}

View File

@ -0,0 +1,38 @@
<div class="row g-4" id="nextcloudTabContent">
<div class="col-lg-6">
<div class="info-card">
<h5 class="fw-bold mb-3">Systemstatus</h5>
<div id="ncStatusBadge" class="badge bg-secondary">Ukendt</div>
<div class="mt-2 small text-muted" id="ncLastUpdated">-</div>
<div class="mt-3">
<div class="info-row"><span class="info-label">CPU load</span><span class="info-value" id="ncCpuLoad">-</span></div>
<div class="info-row"><span class="info-label">Free disk</span><span class="info-value" id="ncFreeDisk">-</span></div>
<div class="info-row"><span class="info-label">RAM usage</span><span class="info-value" id="ncRamUsage">-</span></div>
<div class="info-row"><span class="info-label">OPCache hit rate</span><span class="info-value" id="ncOpcache">-</span></div>
</div>
<div class="mt-3 d-flex flex-wrap gap-2" id="ncAlerts"></div>
</div>
</div>
<div class="col-lg-6">
<div class="info-card">
<h5 class="fw-bold mb-3">Handlinger</h5>
<button class="btn btn-primary w-100 mb-2" onclick="openNextcloudCreateUser()">Tilføj ny bruger</button>
<button class="btn btn-outline-secondary w-100 mb-2" onclick="openNextcloudResetPassword()">Reset password</button>
<button class="btn btn-outline-danger w-100" onclick="openNextcloudDisableUser()">Luk bruger</button>
</div>
</div>
<div class="col-lg-6">
<div class="info-card">
<h5 class="fw-bold mb-3">Nøgletal</h5>
<div class="info-row"><span class="info-label">File count growth</span><span class="info-value" id="ncFileGrowth">-</span></div>
<div class="info-row"><span class="info-label">Public shares uden password</span><span class="info-value" id="ncPublicShares">-</span></div>
<div class="info-row"><span class="info-label">Active users</span><span class="info-value" id="ncActiveUsers">-</span></div>
</div>
</div>
<div class="col-12">
<div class="info-card">
<h5 class="fw-bold mb-3">Historik</h5>
<div id="ncHistory">Ingen events endnu.</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,108 @@
import logging
from fastapi import APIRouter, HTTPException, Depends
from typing import Optional
from app.core.database import execute_query
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
async def get_solution(sag_id: int):
"""Get the solution associated with a case."""
try:
query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
result = execute_query(query, (sag_id,))
if not result:
return None
return result[0]
except Exception as e:
logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to get solution")
@router.post("/sag/{sag_id}/solution", response_model=Solution)
async def create_solution(sag_id: int, solution: SolutionCreate):
"""Create a solution for a case."""
try:
# Check if case exists
case_check = execute_query("SELECT id FROM sag_sager WHERE id = %s", (sag_id,))
if not case_check:
raise HTTPException(status_code=404, detail="Case not found")
# Check if solution already exists
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
if check:
raise HTTPException(status_code=400, detail="Solution already exists for this case")
query = """
INSERT INTO sag_solutions
(sag_id, title, description, solution_type, result, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
sag_id,
solution.title,
solution.description,
solution.solution_type,
solution.result,
solution.created_by_user_id
)
result = execute_query(query, params)
if result:
logger.info("✅ Solution created for case: %s", sag_id)
return result[0]
raise HTTPException(status_code=500, detail="Failed to create solution")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating solution: %s", e)
raise HTTPException(status_code=500, detail="Failed to create solution")
@router.patch("/sag/{sag_id}/solution", response_model=Solution)
async def update_solution(sag_id: int, updates: SolutionUpdate):
"""Update a solution."""
try:
# Check if solution exists
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Solution not found")
# Build dynamic update query
set_clauses = []
params = []
# Helper to check and add params
if updates.title is not None:
set_clauses.append("title = %s")
params.append(updates.title)
if updates.description is not None:
set_clauses.append("description = %s")
params.append(updates.description)
if updates.solution_type is not None:
set_clauses.append("solution_type = %s")
params.append(updates.solution_type)
if updates.result is not None:
set_clauses.append("result = %s")
params.append(updates.result)
if not set_clauses:
raise HTTPException(status_code=400, detail="No fields to update")
set_clauses.append("updated_at = NOW()")
params.append(sag_id)
query = f"UPDATE sag_solutions SET {', '.join(set_clauses)} WHERE sag_id = %s RETURNING *"
result = execute_query(query, tuple(params))
if result:
logger.info("✅ Solution updated for case: %s", sag_id)
return result[0]
raise HTTPException(status_code=500, detail="Failed to update solution")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating solution: %s", e)
raise HTTPException(status_code=500, detail="Failed to update solution")

View File

@ -109,6 +109,18 @@ async def sager_liste(
logger.error("❌ Error displaying case list: %s", e) logger.error("❌ Error displaying case list: %s", e)
raise HTTPException(status_code=500, detail="Failed to load case list") raise HTTPException(status_code=500, detail="Failed to load case list")
@router.get("/sag/new", response_class=HTMLResponse)
async def opret_sag_side(request: Request):
"""Show create case form."""
return templates.TemplateResponse("modules/sag/templates/create.html", {"request": request})
@router.get("/sag/varekob-salg", response_class=HTMLResponse)
async def sag_varekob_salg(request: Request):
"""Display orders overview for all purchases and sales."""
return templates.TemplateResponse("modules/sag/templates/varekob_salg.html", {
"request": request,
})
@router.get("/sag/{sag_id}", response_class=HTMLResponse) @router.get("/sag/{sag_id}", response_class=HTMLResponse)
async def sag_detaljer(request: Request, sag_id: int): async def sag_detaljer(request: Request, sag_id: int):
"""Display case details.""" """Display case details."""
@ -122,10 +134,21 @@ async def sag_detaljer(request: Request, sag_id: int):
sag = sag_result[0] sag = sag_result[0]
# Fetch tags # Fetch tags (Support both Legacy sag_tags and New entity_tags)
tags_query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" # First try the new system (entity_tags) which the valid frontend uses
tags_query = """
SELECT t.name as tag_navn
FROM tags t
JOIN entity_tags et ON t.id = et.tag_id
WHERE et.entity_type = 'case' AND et.entity_id = %s
"""
tags = execute_query(tags_query, (sag_id,)) tags = execute_query(tags_query, (sag_id,))
# If empty, try legacy table fallback
if not tags:
tags_query_legacy = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
tags = execute_query(tags_query_legacy, (sag_id,))
# Fetch relations # Fetch relations
relationer_query = """ relationer_query = """
SELECT sr.*, SELECT sr.*,
@ -140,6 +163,92 @@ async def sag_detaljer(request: Request, sag_id: int):
""" """
relationer = execute_query(relationer_query, (sag_id, sag_id)) relationer = execute_query(relationer_query, (sag_id, sag_id))
# --- Relation Tree Construction ---
relation_tree = []
try:
# 1. Get all connected case IDs (Recursive CTE)
tree_ids_query = """
WITH RECURSIVE CaseTree AS (
SELECT id FROM sag_sager WHERE id = %s
UNION
SELECT CASE WHEN sr.kilde_sag_id = ct.id THEN sr.målsag_id ELSE sr.kilde_sag_id END
FROM sag_relationer sr
JOIN CaseTree ct ON sr.kilde_sag_id = ct.id OR sr.målsag_id = ct.id
WHERE sr.deleted_at IS NULL
)
SELECT id FROM CaseTree LIMIT 50;
"""
tree_ids_rows = execute_query(tree_ids_query, (sag_id,))
tree_ids = [r['id'] for r in tree_ids_rows]
if tree_ids:
# 2. Fetch details
placeholders = ','.join(['%s'] * len(tree_ids))
tree_cases_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders})"
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
# 3. Fetch edges
tree_edges_query = f"""
SELECT id, kilde_sag_id, målsag_id, relationstype
FROM sag_relationer
WHERE deleted_at IS NULL
AND kilde_sag_id IN ({placeholders})
AND målsag_id IN ({placeholders})
"""
tree_edges = execute_query(tree_edges_query, tuple(tree_ids) * 2)
# 4. Build Graph
children_map = {cid: [] for cid in tree_ids}
parents_map = {cid: [] for cid in tree_ids}
for edge in tree_edges:
k, m, rtype = edge['kilde_sag_id'], edge['målsag_id'], edge['relationstype'].lower()
parent, child = k, m # Default (e.g. Relateret til)
if rtype == 'afledt af': # m is parent of k
parent, child = m, k
elif rtype == 'årsag til': # k is parent of m
parent, child = k, m
if parent in children_map:
children_map[parent].append({
'id': child,
'type': edge['relationstype'],
'rel_id': edge['id']
})
if child in parents_map:
parents_map[child].append(parent)
# 5. Identify Roots and Build
roots = [cid for cid in tree_ids if not parents_map[cid]]
if not roots and tree_ids: roots = [min(tree_ids)] # Fallback
def build_tree_node(cid, visited):
if cid in visited: return None
visited.add(cid)
node_case = tree_cases.get(cid)
if not node_case: return None
children_nodes = []
for child_info in children_map.get(cid, []):
c_node = build_tree_node(child_info['id'], visited.copy())
if c_node:
c_node['relation_type'] = child_info['type']
c_node['relation_id'] = child_info['rel_id']
children_nodes.append(c_node)
return {
'case': node_case,
'children': children_nodes,
'is_current': cid == sag_id
}
relation_tree = [build_tree_node(r, set()) for r in roots]
relation_tree = [n for n in relation_tree if n]
except Exception as e:
logger.error(f"Error building relation tree: {e}")
relation_tree = []
# Fetch customer info if customer_id exists # Fetch customer info if customer_id exists
customer = None customer = None
hovedkontakt = None hovedkontakt = None
@ -162,16 +271,110 @@ async def sag_detaljer(request: Request, sag_id: int):
if kontakt_result: if kontakt_result:
hovedkontakt = kontakt_result[0] hovedkontakt = kontakt_result[0]
# Fetch prepaid cards for customer
# Cast remaining_hours to float to avoid Jinja formatting issues with Decimal
# DEBUG: Logging customer ID
cid = sag.get('customer_id')
logger.info(f"🔎 Looking up prepaid cards for Sag {sag_id}, Customer ID: {cid} (Type: {type(cid)})")
pc_query = """
SELECT id, card_number, CAST(remaining_hours AS FLOAT) as remaining_hours
FROM tticket_prepaid_cards
WHERE customer_id = %s
AND status = 'active'
AND remaining_hours > 0
ORDER BY created_at DESC
"""
prepaid_cards = execute_query(pc_query, (cid,))
logger.info(f"💳 Found {len(prepaid_cards)} prepaid cards for customer {cid}")
else:
prepaid_cards = []
# Fetch Nextcloud Instance for this customer
nextcloud_instance = None
if customer:
nc_query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL"
nc_result = execute_query(nc_query, (customer['id'],))
if nc_result:
nextcloud_instance = nc_result[0]
# Fetch linked contacts
contacts_query = """
SELECT sk.*, c.first_name || ' ' || c.last_name as contact_name, c.email as contact_email
FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
"""
contacts = execute_query(contacts_query, (sag_id,))
# Fetch linked customers
customers_query = """
SELECT sk.*, c.name as customer_name, c.email as customer_email
FROM sag_kunder sk
JOIN customers c ON sk.customer_id = c.id
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
"""
customers = execute_query(customers_query, (sag_id,))
# Fetch comments
comments_query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at ASC"
comments = execute_query(comments_query, (sag_id,))
# Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None
# Fetch Time Entries
time_query = "SELECT * FROM tmodule_times WHERE sag_id = %s ORDER BY worked_date DESC"
time_entries = execute_query(time_query, (sag_id,))
# Check for nextcloud integration (case-insensitive, insensitive to whitespace)
logger.info(f"Checking tags for Nextcloud on case {sag_id}: {tags}")
is_nextcloud = any(t['tag_navn'] and t['tag_navn'].strip().lower() == 'nextcloud' for t in tags)
logger.info(f"is_nextcloud result: {is_nextcloud}")
return templates.TemplateResponse("modules/sag/templates/detail.html", { return templates.TemplateResponse("modules/sag/templates/detail.html", {
"request": request, "request": request,
"case": sag, "case": sag,
"customer": customer, "customer": customer,
"hovedkontakt": hovedkontakt, "hovedkontakt": hovedkontakt,
"contacts": contacts,
"customers": customers,
"prepaid_cards": prepaid_cards,
"tags": tags, "tags": tags,
"relationer": relationer, "relationer": relationer,
"relation_tree": relation_tree,
"comments": comments,
"solution": solution,
"time_entries": time_entries,
"is_nextcloud": is_nextcloud,
"nextcloud_instance": nextcloud_instance,
}) })
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("❌ Error displaying case details: %s", e) logger.error("❌ Error displaying case details: %s", e)
raise HTTPException(status_code=500, detail="Failed to load case details") raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/edit", response_class=HTMLResponse)
async def sag_rediger(request: Request, sag_id: int):
"""Display edit case form."""
try:
sag_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL"
sag_result = execute_query(sag_query, (sag_id,))
if not sag_result:
raise HTTPException(status_code=404, detail="Case not found")
return templates.TemplateResponse("modules/sag/templates/edit.html", {
"request": request,
"case": sag_result[0],
})
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error loading edit case page: %s", e)
raise HTTPException(status_code=500, detail="Failed to load edit case page")

View File

@ -0,0 +1,36 @@
-- Sag Module: Varekøb & Salg (case-linked sales items)
CREATE TABLE IF NOT EXISTS sag_salgsvarer (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL DEFAULT 'sale', -- sale | purchase
description TEXT NOT NULL,
quantity NUMERIC(12, 2),
unit VARCHAR(50),
unit_price NUMERIC(12, 2),
amount NUMERIC(12, 2) NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'DKK',
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft | confirmed | cancelled
line_date DATE,
external_ref VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_sag_id ON sag_salgsvarer(sag_id);
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_type ON sag_salgsvarer(type);
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_status ON sag_salgsvarer(status);
CREATE OR REPLACE FUNCTION update_sag_salgsvarer_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_sag_salgsvarer_updated_at ON sag_salgsvarer;
CREATE TRIGGER trigger_sag_salgsvarer_updated_at
BEFORE UPDATE ON sag_salgsvarer
FOR EACH ROW
EXECUTE FUNCTION update_sag_salgsvarer_updated_at();

View File

@ -4,106 +4,37 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.form-container { /* Gradient Header for the Card */
max-width: 600px; .card-header-custom {
margin: 2rem auto; background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%);
color: white;
padding: 1.5rem;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
} }
.form-container h1 { .card-custom {
margin-bottom: 2rem;
}
.card {
border: none; border: none;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05); box-shadow: 0 10px 30px rgba(0,0,0,0.08);
border: 1px solid rgba(0,0,0,0.1); overflow: visible; /* Changed from hidden to visible for shadows/tooltips */
} }
.card-body { .form-label {
padding: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
font-weight: 600; font-weight: 600;
color: var(--accent); color: var(--text-primary);
font-size: 0.9rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
display: block;
} }
.form-control, .form-select { .form-control:focus, .form-select:focus {
border-radius: 8px; border-color: #3282b8;
border: 1px solid rgba(0,0,0,0.1); box-shadow: 0 0 0 0.25rem rgba(50, 130, 184, 0.25);
} }
.btn-submit { /* Search Results Dropdown */
background-color: var(--accent); .search-position-relative {
color: white; position: relative;
padding: 0.7rem 2rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-submit:hover {
background-color: #0056b3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
}
.btn-cancel {
background-color: #6c757d;
color: white;
padding: 0.7rem 2rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.btn-cancel:hover {
background-color: #5a6268;
}
.button-group {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.error {
display: none;
padding: 1rem;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 8px;
color: #721c24;
margin-bottom: 1rem;
}
[data-bs-theme="dark"] .error {
background-color: #5c2b2f;
border-color: #8c3b3f;
color: #f8a5ac;
}
.success {
display: none;
padding: 1rem;
background-color: #d4edda;
border: 1px solid #c3e6cb;
border-radius: 8px;
color: #155724;
margin-bottom: 1rem;
} }
.search-results { .search-results {
@ -111,25 +42,25 @@
top: 100%; top: 100%;
left: 0; left: 0;
right: 0; right: 0;
background: var(--bg-body); background: var(--bg-surface, #ffffff);
border: 1px solid rgba(0,0,0,0.1); border: 1px solid var(--border-color, rgba(0,0,0,0.1));
border-radius: 8px; border-radius: 8px;
max-height: 300px; max-height: 250px;
overflow-y: auto; overflow-y: auto;
z-index: 1000; z-index: 1050;
margin-top: 0.5rem; margin-top: 5px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); box-shadow: 0 4px 15px rgba(0,0,0,0.1);
} }
.search-result-item { .search-result-item {
padding: 0.8rem 1rem; padding: 10px 15px;
border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.05));
cursor: pointer; cursor: pointer;
border-bottom: 1px solid rgba(0,0,0,0.05); transition: background-color 0.15s ease;
transition: background 0.2s;
} }
.search-result-item:hover { .search-result-item:hover {
background: var(--accent-light); background-color: var(--bg-hover, #f8f9fa);
} }
.search-result-item:last-child { .search-result-item:last-child {
@ -138,102 +69,190 @@
.search-result-name { .search-result-name {
font-weight: 600; font-weight: 600;
color: var(--accent); color: var(--text-primary);
font-size: 0.95rem;
} }
.search-result-meta { .search-result-meta {
font-size: 0.85rem; font-size: 0.8rem;
color: var(--text-secondary); color: var(--text-secondary, #6c757d);
} }
/* Selected Items (Tags) */
.selected-item { .selected-item {
display: inline-block; display: inline-flex;
background: var(--accent-light); align-items: center;
color: var(--accent); background-color: #e3f2fd;
padding: 0.4rem 0.8rem; color: #0f4c75;
padding: 6px 12px;
border-radius: 20px; border-radius: 20px;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
margin-right: 0.5rem; margin-right: 8px;
margin-bottom: 0.5rem; margin-bottom: 8px;
border: 1px solid #bbdefb;
} }
.selected-item button { .selected-item button {
background: none; background: none;
border: none; border: none;
color: var(--accent); color: #0f4c75;
margin-left: 8px;
padding: 0;
line-height: 1;
font-size: 1.1rem;
cursor: pointer; cursor: pointer;
margin-left: 0.4rem; opacity: 0.6;
font-weight: bold; transition: opacity 0.2s;
}
.selected-item button:hover {
opacity: 1;
}
/* Dark Mode Adjustments */
[data-bs-theme="dark"] .card-custom {
background-color: var(--bg-surface);
}
[data-bs-theme="dark"] .selected-item {
background-color: rgba(50, 130, 184, 0.2);
color: #a6d5fa;
border-color: rgba(50, 130, 184, 0.4);
}
[data-bs-theme="dark"] .selected-item button {
color: #a6d5fa;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="form-container"> <div class="container py-4">
<div class="card"> <div class="row justify-content-center">
<div class="card-body"> <div class="col-lg-8">
<h1 style="color: var(--accent); margin-bottom: 2rem;">📝 Opret Ny Sag</h1> <!-- Main Card -->
<div class="card card-custom">
<div id="error" class="error"></div> <div class="card-header-custom">
<div id="success" class="success"></div> <h2 class="mb-0 fs-4 fw-bold"><i class="bi bi-plus-circle me-2"></i>Opret Ny Sag</h2>
<p class="mb-0 opacity-75 small mt-1">Udfyld formularen for at oprette en ny sag i systemet.</p>
<form id="createForm">
<div class="form-group">
<label for="titel">Titel *</label>
<input type="text" class="form-control" id="titel" placeholder="Indtast sagens titel" required>
</div> </div>
<div class="form-group"> <div class="card-body p-4">
<label for="beskrivelse">Beskrivelse</label> <!-- Notifications -->
<textarea class="form-control" id="beskrivelse" rows="4" placeholder="Optionalt: Detaljeret beskrivelse af sagen"></textarea> <div id="error" class="alert alert-danger d-none shadow-sm" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i><span id="error-text"></span>
</div>
<div id="success" class="alert alert-success d-none shadow-sm" role="alert">
<i class="bi bi-check-circle-fill me-2"></i><span id="success-text"></span>
</div> </div>
<div class="form-group"> <form id="createForm" novalidate>
<label for="status">Status *</label> <!-- Section: Basic Info -->
<div class="row g-4 mb-4">
<div class="col-md-12">
<label for="titel" class="form-label">Titel <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-type-h1"></i></span>
<input type="text" class="form-control border-start-0 ps-0" id="titel" placeholder="Kort og præcis titel (f.eks. 'Netværksproblemer hos X')" required>
</div>
</div>
<div class="col-md-12">
<label for="beskrivelse" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
<div class="form-text text-end" id="charCount">0 tegn</div>
</div>
</div>
<hr class="my-4 opacity-25">
<!-- Section: Relations -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
<div class="row g-4 mb-4">
<!-- Contact Search -->
<div class="col-md-6">
<label class="form-label">Kontaktpersoner</label>
<div class="search-position-relative">
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-person-plus"></i></span>
<input type="text" id="contactSearch" class="form-control border-start-0 ps-0" placeholder="Søg kontakt...">
</div>
<div id="contactResults" class="search-results shadow-sm d-none"></div>
</div>
<div id="selectedContacts" class="mt-2 text-wrap"></div>
</div>
<!-- Customer Search -->
<div class="col-md-6">
<label class="form-label">Kunde</label>
<div class="search-position-relative">
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-building"></i></span>
<input type="text" id="customerSearch" class="form-control border-start-0 ps-0" placeholder="Søg kunde (min. 2 tegn)...">
</div>
<div id="customerResults" class="search-results shadow-sm d-none"></div>
</div>
<div id="selectedCustomer" class="mt-2 min-vh-20"></div>
<input type="hidden" id="customer_id" name="customer_id">
</div>
</div>
<hr class="my-4 opacity-25">
<!-- Section: Metadata -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
<div class="row g-4 mb-4">
<div class="col-md-4">
<label for="type" class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="type" required>
<option value="ticket" selected>🎫 Ticket</option>
<option value="opgave">🧩 Opgave</option>
<option value="ordre">🧾 Ordre</option>
<option value="projekt">📁 Projekt</option>
<option value="service">🛠️ Service</option>
</select>
</div>
<div class="col-md-4">
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
<select class="form-select" id="status" required> <select class="form-select" id="status" required>
<option value="">Vælg status</option> <option value="åben" selected>🟢 Åben</option>
<option value="åben">Åben</option> <option value="afventer">🟡 Afventer</option>
<option value="lukket">Lukket</option> <option value="lukket">🔴 Lukket</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="col-md-4">
<label>Kunde (valgfrit)</label> <label for="ansvarlig_bruger_id" class="form-label">Ansvarlig (ID)</label>
<div style="position: relative; margin-bottom: 1rem;"> <div class="input-group">
<input type="text" id="customerSearch" class="form-control" placeholder="Søg efter kunde..."> <span class="input-group-text bg-light border-end-0"><i class="bi bi-person-badge"></i></span>
<div id="customerResults" class="search-results" style="display: none;"></div> <input type="number" class="form-control border-start-0 ps-0" id="ansvarlig_bruger_id" placeholder="Bruger ID">
</div> </div>
<div id="selectedCustomer" style="min-height: 1.5rem;"></div>
<input type="hidden" id="customer_id" name="customer_id">
</div> </div>
<div class="form-group"> <div class="col-md-4">
<label>Kontakter (valgfrit)</label> <label for="deadline" class="form-label">Deadline</label>
<div style="position: relative; margin-bottom: 1rem;"> <div class="input-group">
<input type="text" id="contactSearch" class="form-control" placeholder="Søg efter kontakt..."> <span class="input-group-text bg-light border-end-0"><i class="bi bi-calendar-event"></i></span>
<div id="contactResults" class="search-results" style="display: none;"></div> <input type="datetime-local" class="form-control border-start-0 ps-0" id="deadline">
</div>
</div> </div>
<div id="selectedContacts" style="min-height: 1.5rem;"></div>
</div> </div>
<div class="form-group"> <!-- Action Buttons -->
<label for="ansvarlig_bruger_id">Ansvarlig Bruger (valgfrit)</label> <div class="d-flex justify-content-end gap-3 mt-5">
<input type="number" class="form-control" id="ansvarlig_bruger_id" placeholder="Brugers ID"> <a href="/sag" class="btn btn-light border px-4 fw-bold">Annuller</a>
</div> <button type="submit" class="btn btn-primary px-5 fw-bold shadow-sm" id="submitBtn">
<span class="d-flex align-items-center">
<div class="form-group"> <i class="bi bi-check-lg me-2"></i>Opret Sag
<label for="deadline">Deadline (valgfrit)</label> </span>
<input type="datetime-local" class="form-control" id="deadline"> </button>
</div>
<div class="button-group">
<button type="submit" class="btn-submit">Opret Sag</button>
<a href="/cases" class="btn-cancel">Annuller</a>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
<script> <script>
@ -242,208 +261,285 @@
let customerSearchTimeout; let customerSearchTimeout;
let contactSearchTimeout; let contactSearchTimeout;
// Initialize search functionality // --- Character Counter ---
const beskrInput = document.getElementById('beskrivelse');
if (beskrInput) {
beskrInput.addEventListener('input', function(e) {
document.getElementById('charCount').textContent = e.target.value.length + " tegn";
});
}
// --- Search Logic ---
function initializeSearch() { function initializeSearch() {
const customerSearchInput = document.getElementById('customerSearch'); // Customer Search
const contactSearchInput = document.getElementById('contactSearch'); const customerInput = document.getElementById('customerSearch');
if (customerInput) {
if (customerSearchInput) { customerInput.addEventListener('input', (e) => handleSearch(e, 'customer'));
customerSearchInput.addEventListener('input', function(e) { // Close dropdown when clicking outside
clearTimeout(customerSearchTimeout); document.addEventListener('click', (e) => {
const query = e.target.value.trim(); if (!e.target.closest('.search-position-relative')) {
const cr = document.getElementById('customerResults');
if (query.length < 2) { if(cr) cr.classList.add('d-none');
document.getElementById('customerResults').style.display = 'none';
return;
} }
customerSearchTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
const customers = await response.json();
const resultsDiv = document.getElementById('customerResults');
if (customers.length === 0) {
resultsDiv.innerHTML = '<div style="padding: 10px; color: #999;">Ingen kunder fundet</div>';
} else {
resultsDiv.innerHTML = customers.map(c => `
<div class="search-result-item" onclick="selectCustomer(${c.id}, '${c.name.replace(/'/g, "\\'")}')">
<div class="search-result-name">${c.name}</div>
<div class="search-result-meta">${c.email || 'Ingen email'}</div>
</div>
`).join('');
}
resultsDiv.style.display = 'block';
} catch (err) {
console.error('Error searching customers:', err);
}
}, 300);
}); });
} }
if (contactSearchInput) { // Contact Search
contactSearchInput.addEventListener('input', function(e) { const contactInput = document.getElementById('contactSearch');
clearTimeout(contactSearchTimeout); if (contactInput) {
const query = e.target.value.trim(); contactInput.addEventListener('input', (e) => handleSearch(e, 'contact'));
document.addEventListener('click', (e) => {
if (query.length < 2) { if (!e.target.closest('.search-position-relative')) {
document.getElementById('contactResults').style.display = 'none'; const cr = document.getElementById('contactResults');
return; if(cr) cr.classList.add('d-none');
} }
contactSearchTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(query)}`);
const contacts = await response.json();
const resultsDiv = document.getElementById('contactResults');
if (contacts.length === 0) {
resultsDiv.innerHTML = '<div style="padding: 10px; color: #999;">Ingen kontakter fundet</div>';
} else {
resultsDiv.innerHTML = contacts.map(c => `
<div class="search-result-item" onclick="selectContact(${c.id}, '${(c.first_name + ' ' + c.last_name).replace(/'/g, "\\'")}')">
<div class="search-result-name">${c.first_name} ${c.last_name}</div>
<div class="search-result-meta">${c.email || 'Ingen email'}</div>
</div>
`).join('');
}
resultsDiv.style.display = 'block';
} catch (err) {
console.error('Error searching contacts:', err);
}
}, 300);
}); });
} }
} }
// Initialize when DOM is ready function handleSearch(event, type) {
if (document.readyState === 'loading') { const query = event.target.value.trim();
document.addEventListener('DOMContentLoaded', initializeSearch); const resultsId = type === 'customer' ? 'customerResults' : 'contactResults';
const timeoutVar = type === 'customer' ? customerSearchTimeout : contactSearchTimeout;
const resultsDiv = document.getElementById(resultsId);
clearTimeout(timeoutVar);
if (query.length < 2) {
resultsDiv.classList.add('d-none');
return;
}
const timeout = setTimeout(async () => {
try {
// Show loading state
resultsDiv.classList.remove('d-none');
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
const endpoint = type === 'customer' ? '/api/v1/search/customers' : '/api/v1/search/contacts';
const response = await fetch(`${endpoint}?q=${encodeURIComponent(query)}`);
if (!response.ok) {
const errorText = await response.text();
resultsDiv.innerHTML = `<div class="p-3 text-danger small">Fejl ved søgning: ${errorText}</div>`;
return;
}
const data = await response.json();
if (!Array.isArray(data)) {
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
return;
}
if (data.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-muted small">Ingen fundet</div>';
} else { } else {
initializeSearch(); resultsDiv.innerHTML = data.map(item => {
} const name = type === 'customer' ? item.name : `${item.first_name} ${item.last_name}`;
const meta = item.email || (type === 'customer' ? 'CVR: ' + (item.cvr_nummer || '-') : '-');
function selectCustomer(customerId, customerName) { // Handle escaping for JS function call
selectedCustomer = { id: customerId, name: customerName }; const safeName = name.replace(/'/g, "\\'");
document.getElementById('customer_id').value = customerId; const fn = type === 'customer' ? `selectCustomer(${item.id}, '${safeName}')` : `selectContact(${item.id}, '${safeName}')`;
document.getElementById('customerSearch').value = '';
document.getElementById('customerResults').style.display = 'none';
updateSelectedCustomer();
}
function updateSelectedCustomer() { return `
const div = document.getElementById('selectedCustomer'); <div class="search-result-item" onclick="${fn}">
if (selectedCustomer) { <div class="search-result-name">${name}</div>
div.innerHTML = ` <div class="search-result-meta">${meta}</div>
<span class="selected-item"> </div>
🏢 ${selectedCustomer.name}
<button type="button" onclick="removeCustomer()">×</button>
</span>
`; `;
} else { }).join('');
div.innerHTML = '';
} }
} catch (err) {
console.error('Search error:', err);
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
}
}, 300);
if (type === 'customer') customerSearchTimeout = timeout;
else contactSearchTimeout = timeout;
}
// --- Selection Logic ---
function selectCustomer(id, name) {
selectedCustomer = { id, name };
document.getElementById('customer_id').value = id;
document.getElementById('customerSearch').value = '';
document.getElementById('customerResults').classList.add('d-none');
renderSelections();
} }
function removeCustomer() { function removeCustomer() {
selectedCustomer = null; selectedCustomer = null;
document.getElementById('customer_id').value = ''; document.getElementById('customer_id').value = '';
updateSelectedCustomer(); renderSelections();
} }
function selectContact(contactId, contactName) { async function selectContact(id, name) {
if (!selectedContacts[contactId]) { if (!selectedContacts[id]) {
selectedContacts[contactId] = { id: contactId, name: contactName }; selectedContacts[id] = { id, name };
} }
document.getElementById('contactSearch').value = ''; document.getElementById('contactSearch').value = '';
document.getElementById('contactResults').style.display = 'none'; document.getElementById('contactResults').classList.add('d-none');
updateSelectedContacts(); renderSelections();
// Check for associated company (auto-select if single match)
try {
const response = await fetch(`/api/v1/contacts/${id}`);
if (response.ok) {
const data = await response.json();
if (data.companies && data.companies.length === 1) {
const company = data.companies[0];
if (!selectedCustomer) {
selectCustomer(company.id, company.name);
// Show brief notification
const successDiv = document.getElementById('success');
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = `Valgte automatisk kunde: ${company.name}`;
setTimeout(() => successDiv.classList.add('d-none'), 3000);
}
}
}
} catch (e) {
console.error("Auto-select company failed", e);
}
} }
function updateSelectedContacts() { function removeContact(id) {
const div = document.getElementById('selectedContacts'); delete selectedContacts[id];
const items = Object.values(selectedContacts).map(c => ` renderSelections();
<span class="selected-item"> }
👥 ${c.name}
<button type="button" onclick="removeContact(${c.id})">×</button> function renderSelections() {
</span> // Customer
const custDiv = document.getElementById('selectedCustomer');
if (selectedCustomer) {
custDiv.innerHTML = `
<div class="selected-item border-primary bg-primary bg-opacity-10 text-primary">
<i class="bi bi-building me-2"></i>${selectedCustomer.name}
<button type="button" onclick="removeCustomer()" class="text-primary hover-opacity"><i class="bi bi-x"></i></button>
</div>`;
} else {
custDiv.innerHTML = '';
}
// Contacts
const contDiv = document.getElementById('selectedContacts');
contDiv.innerHTML = Object.values(selectedContacts).map(c => `
<div class="selected-item">
<i class="bi bi-person me-2"></i>${c.name}
<button type="button" onclick="removeContact(${c.id})"><i class="bi bi-x"></i></button>
</div>
`).join(''); `).join('');
div.innerHTML = items;
} }
function removeContact(contactId) { async function loadCaseTypesSelect() {
delete selectedContacts[contactId]; const select = document.getElementById('type');
updateSelectedContacts(); if (!select) return;
try {
const res = await fetch('/api/v1/settings/case_types');
if (!res.ok) return;
const setting = await res.json();
const types = JSON.parse(setting.value || '[]');
if (!Array.isArray(types) || types.length === 0) return;
select.innerHTML = types
.map((type) => `<option value="${type}">${type}</option>`)
.join('');
} catch (err) {
console.error('Failed to load case types', err);
}
} }
// --- Initialization ---
document.addEventListener('DOMContentLoaded', () => {
initializeSearch();
loadCaseTypesSelect();
});
// --- Form Submission ---
document.getElementById('createForm').addEventListener('submit', async (e) => { document.getElementById('createForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const titel = document.getElementById('titel').value; // UI Reset
const errorDiv = document.getElementById('error');
const successDiv = document.getElementById('success');
const btn = document.getElementById('submitBtn');
errorDiv.classList.add('d-none');
successDiv.classList.add('d-none');
// Basic Validation
const titelInput = document.getElementById('titel');
const titel = titelInput.value;
const status = document.getElementById('status').value; const status = document.getElementById('status').value;
if (!titel || !status) { if (!titel.trim()) {
document.getElementById('error').textContent = `❌ Udfyld alle påkrævede felter`; titelInput.classList.add('is-invalid');
document.getElementById('error').style.display = 'block'; errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = "Titel er påkrævet";
return; return;
} else {
titelInput.classList.remove('is-invalid');
} }
// Loading State
btn.disabled = true;
const originalBtnText = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
const data = { const data = {
titel: titel, titel: titel,
beskrivelse: document.getElementById('beskrivelse').value || '', beskrivelse: document.getElementById('beskrivelse').value || '',
type: document.getElementById('type').value,
status: status, status: status,
customer_id: document.getElementById('customer_id').value ? parseInt(document.getElementById('customer_id').value) : null, customer_id: selectedCustomer ? selectedCustomer.id : null,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null, ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
created_by_user_id: 1, created_by_user_id: 1, // HARDCODED for now, should come from auth
deadline: document.getElementById('deadline').value || null deadline: document.getElementById('deadline').value || null
}; };
console.log('Sending data:', data);
try { try {
const response = await fetch('/api/v1/cases', { const response = await fetch('/api/v1/sag', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json'
},
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
console.log('Response status:', response.status);
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
console.log('Created case:', result);
// Add selected contacts to the case // Add contacts if any
for (const contactId of Object.keys(selectedContacts)) { const contactPromises = Object.keys(selectedContacts).map(cid =>
await fetch(`/api/v1/cases/${result.id}/contacts`, { fetch(`/api/v1/sag/${result.id}/contacts`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({contact_id: parseInt(contactId), role: 'Kontakt'}) body: JSON.stringify({contact_id: parseInt(cid), role: 'Kontakt'})
}); })
} );
await Promise.all(contactPromises);
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
document.getElementById('success').textContent = `✅ Sag oprettet! Omdirigerer...`;
document.getElementById('success').style.display = 'block';
setTimeout(() => { setTimeout(() => {
window.location.href = `/cases/${result.id}`; window.location.href = `/sag/${result.id}`;
}, 1000); }, 1000);
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
console.error('Error response:', errorText); let errMsg = "Kunne ikke oprette sag";
try { try {
const error = JSON.parse(errorText); const json = JSON.parse(errorText);
document.getElementById('error').textContent = `❌ Fejl: ${error.detail || errorText}`; errMsg = json.detail || errMsg;
} catch { } catch(e) {}
document.getElementById('error').textContent = `❌ Fejl: ${errorText}`;
} throw new Error(errMsg);
document.getElementById('error').style.display = 'block';
} }
} catch (err) { } catch (err) {
console.error('Exception:', err); console.error('Submit error:', err);
document.getElementById('error').textContent = `❌ Fejl: ${err.message}`; errorDiv.classList.remove('d-none');
document.getElementById('error').style.display = 'block'; document.getElementById('error-text').textContent = err.message;
btn.disabled = false;
btn.innerHTML = originalBtnText;
} }
}); });
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@ -189,11 +189,23 @@
<textarea class="form-control" id="beskrivelse" rows="4" placeholder="Optionalt: Detaljeret beskrivelse af sagen">{{ case.beskrivelse or '' }}</textarea> <textarea class="form-control" id="beskrivelse" rows="4" placeholder="Optionalt: Detaljeret beskrivelse af sagen">{{ case.beskrivelse or '' }}</textarea>
</div> </div>
<div class="form-group">
<label for="type">Type *</label>
<select class="form-select" id="type" required>
<option value="ticket" {% if case.type == 'ticket' %}selected{% endif %}>🎫 Ticket</option>
<option value="opgave" {% if case.type == 'opgave' %}selected{% endif %}>🧩 Opgave</option>
<option value="ordre" {% if case.type == 'ordre' %}selected{% endif %}>🧾 Ordre</option>
<option value="projekt" {% if case.type == 'projekt' %}selected{% endif %}>📁 Projekt</option>
<option value="service" {% if case.type == 'service' %}selected{% endif %}>🛠️ Service</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="status">Status *</label> <label for="status">Status *</label>
<select class="form-select" id="status" required> <select class="form-select" id="status" required>
<option value="">Vælg status</option> <option value="">Vælg status</option>
<option value="åben" {% if case.status == 'åben' %}selected{% endif %}>Åben</option> <option value="åben" {% if case.status == 'åben' %}selected{% endif %}>Åben</option>
<option value="afventer" {% if case.status == 'afventer' %}selected{% endif %}>Afventer</option>
<option value="lukket" {% if case.status == 'lukket' %}selected{% endif %}>Lukket</option> <option value="lukket" {% if case.status == 'lukket' %}selected{% endif %}>Lukket</option>
</select> </select>
</div> </div>
@ -210,7 +222,7 @@
<div class="button-group"> <div class="button-group">
<button type="submit" class="btn-submit">Gem Ændringer</button> <button type="submit" class="btn-submit">Gem Ændringer</button>
<a href="/cases/{{ case.id }}" class="btn-cancel">Annuller</a> <a href="/sag/{{ case.id }}" class="btn-cancel">Annuller</a>
</div> </div>
</form> </form>
</div> </div>
@ -219,14 +231,36 @@
<script> <script>
const caseId = {{ case.id }}; const caseId = {{ case.id }};
const currentType = "{{ case.type or 'ticket' }}";
async function loadCaseTypesSelect() {
const select = document.getElementById('type');
if (!select) return;
try {
const res = await fetch('/api/v1/settings/case_types');
if (!res.ok) return;
const setting = await res.json();
const types = JSON.parse(setting.value || '[]');
if (!Array.isArray(types) || types.length === 0) return;
select.innerHTML = types
.map((type) => `<option value="${type}" ${type === currentType ? 'selected' : ''}>${type}</option>`)
.join('');
} catch (err) {
console.error('Failed to load case types', err);
}
}
document.addEventListener('DOMContentLoaded', loadCaseTypesSelect);
document.getElementById('editForm').addEventListener('submit', async (e) => { document.getElementById('editForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const titel = document.getElementById('titel').value; const titel = document.getElementById('titel').value;
const type = document.getElementById('type').value;
const status = document.getElementById('status').value; const status = document.getElementById('status').value;
if (!titel || !status) { if (!titel || !type || !status) {
document.getElementById('error').textContent = `❌ Udfyld alle påkrævede felter`; document.getElementById('error').textContent = `❌ Udfyld alle påkrævede felter`;
document.getElementById('error').style.display = 'block'; document.getElementById('error').style.display = 'block';
return; return;
@ -235,6 +269,7 @@
const data = { const data = {
titel: titel, titel: titel,
beskrivelse: document.getElementById('beskrivelse').value || '', beskrivelse: document.getElementById('beskrivelse').value || '',
type: type,
status: status, status: status,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null, ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
deadline: document.getElementById('deadline').value || null deadline: document.getElementById('deadline').value || null
@ -243,7 +278,7 @@
console.log('Updating case with data:', data); console.log('Updating case with data:', data);
try { try {
const response = await fetch(`/api/v1/cases/${caseId}`, { const response = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -260,7 +295,7 @@
document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`; document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
document.getElementById('success').style.display = 'block'; document.getElementById('success').style.display = 'block';
setTimeout(() => { setTimeout(() => {
window.location.href = `/cases/${caseId}`; window.location.href = `/sag/${caseId}`;
}, 1000); }, 1000);
} else { } else {
const errorText = await response.text(); const errorText = await response.text();

View File

@ -252,10 +252,15 @@
<h1 style="margin: 0; color: var(--accent);"> <h1 style="margin: 0; color: var(--accent);">
<i class="bi bi-list-check me-2"></i>Sager <i class="bi bi-list-check me-2"></i>Sager
</h1> </h1>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
</button>
<button class="btn btn-primary" style="background: var(--accent); border: none;" onclick="window.location.href='/sag/new'"> <button class="btn btn-primary" style="background: var(--accent); border: none;" onclick="window.location.href='/sag/new'">
<i class="bi bi-plus-lg me-2"></i>Ny Sag <i class="bi bi-plus-lg me-2"></i>Ny Sag
</button> </button>
</div> </div>
</div>
<!-- Stats Bar --> <!-- Stats Bar -->
<div class="stats-bar"> <div class="stats-bar">
@ -282,11 +287,18 @@
autocomplete="off"> autocomplete="off">
</div> </div>
<div class="d-flex flex-wrap align-items-center gap-3 mb-3">
<div class="filter-pills"> <div class="filter-pills">
<div class="filter-pill active" data-filter="all">Alle</div> <div class="filter-pill active" data-filter="all">Alle</div>
<div class="filter-pill" data-filter="åben">Åbne</div> <div class="filter-pill" data-filter="åben">Åbne</div>
<div class="filter-pill" data-filter="lukket">Lukkede</div> <div class="filter-pill" data-filter="lukket">Lukkede</div>
</div> </div>
<div style="min-width: 200px;">
<select class="form-select" id="typeFilter">
<option value="all">Alle typer</option>
</select>
</div>
</div>
<!-- Table --> <!-- Table -->
<div class="table-wrapper"> <div class="table-wrapper">
@ -296,6 +308,7 @@
<tr> <tr>
<th style="width: 90px;">ID</th> <th style="width: 90px;">ID</th>
<th>Titel & Beskrivelse</th> <th>Titel & Beskrivelse</th>
<th style="width: 120px;">Type</th>
<th style="width: 180px;">Kunde</th> <th style="width: 180px;">Kunde</th>
<th style="width: 150px;">Hovedkontakt</th> <th style="width: 150px;">Hovedkontakt</th>
<th style="width: 100px;">Status</th> <th style="width: 100px;">Status</th>
@ -309,7 +322,8 @@
{% set has_relations = sag.id in relations_map and relations_map[sag.id]|length > 0 %} {% set has_relations = sag.id in relations_map and relations_map[sag.id]|length > 0 %}
<tr class="tree-row {% if has_relations %}has-children{% endif %}" <tr class="tree-row {% if has_relations %}has-children{% endif %}"
data-sag-id="{{ sag.id }}" data-sag-id="{{ sag.id }}"
data-status="{{ sag.status }}"> data-status="{{ sag.status }}"
data-type="{{ sag.type or 'ticket' }}">
<td> <td>
{% if has_relations %} {% if has_relations %}
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span> <span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
@ -322,6 +336,9 @@
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div> <div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
{% endif %} {% endif %}
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'">
<span class="badge bg-light text-dark border">{{ sag.type or 'ticket' }}</span>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.customer_name if sag.customer_name else '-' }} {{ sag.customer_name if sag.customer_name else '-' }}
</td> </td>
@ -345,7 +362,7 @@
{% if related_sag and rel.target_id not in seen_targets %} {% if related_sag and rel.target_id not in seen_targets %}
{% set _ = seen_targets.append(rel.target_id) %} {% set _ = seen_targets.append(rel.target_id) %}
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %} {% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" style="display: none;"> <tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.type or 'ticket' }}" style="display: none;">
<td> <td>
<span class="sag-id">#{{ related_sag.id }}</span> <span class="sag-id">#{{ related_sag.id }}</span>
</td> </td>
@ -358,6 +375,9 @@
<div class="sag-beskrivelse">{{ related_sag.beskrivelse }}</div> <div class="sag-beskrivelse">{{ related_sag.beskrivelse }}</div>
{% endif %} {% endif %}
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<span class="badge bg-light text-dark border">{{ related_sag.type or 'ticket' }}</span>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.customer_name if related_sag.customer_name else '-' }} {{ related_sag.customer_name if related_sag.customer_name else '-' }}
</td> </td>
@ -417,6 +437,7 @@
const allRows = document.querySelectorAll('.tree-row'); const allRows = document.querySelectorAll('.tree-row');
let currentSearch = ''; let currentSearch = '';
let currentFilter = 'all'; let currentFilter = 'all';
let currentType = 'all';
function applyFilters() { function applyFilters() {
const search = currentSearch; const search = currentSearch;
@ -424,9 +445,11 @@
allRows.forEach(row => { allRows.forEach(row => {
const text = row.textContent.toLowerCase(); const text = row.textContent.toLowerCase();
const status = row.dataset.status; const status = row.dataset.status;
const type = row.dataset.type || 'ticket';
const matchesSearch = text.includes(search); const matchesSearch = text.includes(search);
const matchesFilter = currentFilter === 'all' || status === currentFilter; const matchesFilter = currentFilter === 'all' || status === currentFilter;
const visible = matchesSearch && matchesFilter; const matchesType = currentType === 'all' || type === currentType;
const visible = matchesSearch && matchesFilter && matchesType;
row.style.display = visible ? '' : 'none'; row.style.display = visible ? '' : 'none';
@ -436,9 +459,11 @@
children.forEach(child => { children.forEach(child => {
const childText = child.textContent.toLowerCase(); const childText = child.textContent.toLowerCase();
const childStatus = child.dataset.status; const childStatus = child.dataset.status;
const childType = child.dataset.type || 'ticket';
const childMatchesSearch = childText.includes(search); const childMatchesSearch = childText.includes(search);
const childMatchesFilter = currentFilter === 'all' || childStatus === currentFilter; const childMatchesFilter = currentFilter === 'all' || childStatus === currentFilter;
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter; const childMatchesType = currentType === 'all' || childType === currentType;
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType;
child.style.display = childVisible ? '' : 'none'; child.style.display = childVisible ? '' : 'none';
}); });
} }
@ -465,5 +490,31 @@
applyFilters(); applyFilters();
}); });
}); });
const typeFilter = document.getElementById('typeFilter');
if (typeFilter) {
typeFilter.addEventListener('change', function() {
currentType = this.value || 'all';
applyFilters();
});
}
async function loadTypeFilters() {
if (!typeFilter) return;
try {
const res = await fetch('/api/v1/settings/case_types');
if (!res.ok) return;
const setting = await res.json();
const types = JSON.parse(setting.value || '[]');
if (!Array.isArray(types) || types.length === 0) return;
typeFilter.innerHTML = `<option value="all">Alle typer</option>` +
types.map(type => `<option value="${type}">${type}</option>`).join('');
} catch (err) {
console.error('Failed to load case types', err);
}
}
loadTypeFilters();
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,282 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Varekøb & Salg - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.summary-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem 1.25rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
border: 1px solid rgba(0,0,0,0.05);
}
.summary-title {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-secondary);
margin-bottom: 0.35rem;
}
.summary-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.table-wrapper {
background: var(--bg-card);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.table thead th {
background: var(--accent);
color: white;
border: none;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
border: 1px solid rgba(0,0,0,0.1);
background: var(--bg-light);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-basket3 me-2"></i>Varekøb & Salg</h2>
<div class="text-muted">Samlet oversigt over alle varelinjer på tværs af sager</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-primary" href="/sag"><i class="bi bi-arrow-left me-1"></i>Tilbage til sager</a>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="summary-card">
<div class="summary-title">Total salg</div>
<div class="summary-value" id="summarySalesTotal">-</div>
</div>
</div>
<div class="col-md-3">
<div class="summary-card">
<div class="summary-title">Total køb</div>
<div class="summary-value" id="summaryPurchaseTotal">-</div>
</div>
</div>
<div class="col-md-3">
<div class="summary-card">
<div class="summary-title">Netto</div>
<div class="summary-value" id="summaryNetTotal">-</div>
</div>
</div>
<div class="col-md-3">
<div class="summary-card">
<div class="summary-title">Linjer (total)</div>
<div class="summary-value" id="summaryLinesTotal">-</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label">Søg</label>
<input type="text" class="form-control" id="ordersSearch" placeholder="Søg i beskrivelse, sag eller kunde">
</div>
<div class="col-md-3">
<label class="form-label">Status</label>
<select class="form-select" id="ordersStatus">
<option value="">Alle</option>
<option value="draft">Kladde</option>
<option value="confirmed">Bekræftet</option>
<option value="cancelled">Annulleret</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">Sag ID</label>
<input type="number" class="form-control" id="ordersCaseId" placeholder="F.eks. 12">
</div>
<div class="col-md-3">
<label class="form-label">Kunde ID</label>
<input type="number" class="form-control" id="ordersCustomerId" placeholder="F.eks. 45">
</div>
<div class="col-md-2">
<label class="form-label">Fra dato</label>
<input type="date" class="form-control" id="ordersDateFrom">
</div>
<div class="col-md-2">
<label class="form-label">Til dato</label>
<input type="date" class="form-control" id="ordersDateTo">
</div>
<div class="col-md-2">
<button class="btn btn-primary w-100" onclick="loadOrders()"><i class="bi bi-search me-1"></i>Filtrér</button>
</div>
<div class="col-md-3 text-end">
<span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="table-wrapper">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<h6 class="mb-0 text-primary"><i class="bi bi-bag-check me-2"></i>Salgslinjer</h6>
<span class="badge bg-light text-dark border" id="salesSubtotal">-</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0" style="vertical-align: middle;">
<thead>
<tr>
<th>Dato</th>
<th>Beskrivelse</th>
<th>Sag</th>
<th>Kunde</th>
<th>Antal</th>
<th>Enhed</th>
<th>Enhedspris</th>
<th>Linjesum</th>
<th>Status</th>
</tr>
</thead>
<tbody id="ordersSalesBody">
<tr>
<td colspan="9" class="text-center py-4 text-muted">Indlæser...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="table-wrapper">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<h6 class="mb-0 text-primary"><i class="bi bi-cart-x me-2"></i>Indkøbslinjer</h6>
<span class="badge bg-light text-dark border" id="purchaseSubtotal">-</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0" style="vertical-align: middle;">
<thead>
<tr>
<th>Dato</th>
<th>Beskrivelse</th>
<th>Sag</th>
<th>Kunde</th>
<th>Antal</th>
<th>Enhed</th>
<th>Enhedspris</th>
<th>Linjesum</th>
<th>Status</th>
</tr>
</thead>
<tbody id="ordersPurchaseBody">
<tr>
<td colspan="9" class="text-center py-4 text-muted">Indlæser...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function formatCurrency(value) {
const num = Number(value || 0);
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(num);
}
function formatNumber(value) {
const num = Number(value || 0);
return new Intl.NumberFormat('da-DK', { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(num);
}
function renderOrderRows(items, tbodyId) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted">Ingen linjer</td></tr>';
return;
}
tbody.innerHTML = items.map(item => {
const statusLabel = item.status || 'draft';
const caseLink = item.sag_id ? `<a href="/sag/${item.sag_id}" class="text-decoration-none">${item.sag_titel || 'Sag ' + item.sag_id}</a>` : '-';
return `
<tr>
<td>${item.line_date || '-'}</td>
<td>${item.description || '-'}</td>
<td>${caseLink}</td>
<td>${item.customer_name || '-'}</td>
<td>${item.quantity ?? '-'}</td>
<td>${item.unit || '-'}</td>
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
<td class="fw-bold">${formatCurrency(item.amount)}</td>
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
</tr>
`;
}).join('');
}
async function loadOrders() {
const search = document.getElementById('ordersSearch').value.trim();
const status = document.getElementById('ordersStatus').value;
const caseId = document.getElementById('ordersCaseId').value;
const customerId = document.getElementById('ordersCustomerId').value;
const dateFrom = document.getElementById('ordersDateFrom').value;
const dateTo = document.getElementById('ordersDateTo').value;
const params = new URLSearchParams();
if (search) params.append('q', search);
if (status) params.append('status', status);
if (caseId) params.append('sag_id', caseId);
if (customerId) params.append('customer_id', customerId);
if (dateFrom) params.append('date_from', dateFrom);
if (dateTo) params.append('date_to', dateTo);
const res = await fetch(`/api/v1/sag/sale-items/all?${params.toString()}`);
if (!res.ok) {
document.getElementById('ordersSalesBody').innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
document.getElementById('ordersPurchaseBody').innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
return;
}
const data = await res.json();
const sales = data.filter(item => (item.type || '').toLowerCase() !== 'purchase');
const purchases = data.filter(item => (item.type || '').toLowerCase() === 'purchase');
renderOrderRows(sales, 'ordersSalesBody');
renderOrderRows(purchases, 'ordersPurchaseBody');
const salesSum = sales.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const purchaseSum = purchases.reduce((sum, item) => sum + Number(item.amount || 0), 0);
document.getElementById('summarySalesTotal').textContent = formatCurrency(salesSum);
document.getElementById('summaryPurchaseTotal').textContent = formatCurrency(purchaseSum);
document.getElementById('summaryNetTotal').textContent = formatCurrency(salesSum - purchaseSum);
document.getElementById('summaryLinesTotal').textContent = formatNumber(data.length);
document.getElementById('salesSubtotal').textContent = formatCurrency(salesSum);
document.getElementById('purchaseSubtotal').textContent = formatCurrency(purchaseSum);
}
document.addEventListener('DOMContentLoaded', () => {
loadOrders();
});
</script>
{% endblock %}

View File

View File

@ -0,0 +1,84 @@
from fastapi import APIRouter, Query
from app.core.database import execute_query
router = APIRouter()
@router.get("/search/customers")
async def search_customers(q: str = Query(..., min_length=2)):
"""
Autocomplete search for customers.
Returns list of {id, name, cvr_nummer, email}
"""
sql = """
SELECT id, name, cvr_number as cvr_nummer, email
FROM customers
WHERE (name ILIKE %s OR cvr_number ILIKE %s) AND deleted_at IS NULL
ORDER BY name ASC
LIMIT 20
"""
term = f"%{q}%"
results = execute_query(sql, (term, term))
return results
@router.get("/search/contacts")
async def search_contacts(q: str = Query(..., min_length=2)):
"""
Autocomplete search for contacts.
Returns list of {id, first_name, last_name, email}
"""
sql = """
SELECT id, first_name, last_name, email
FROM contacts
WHERE
(first_name ILIKE %s OR
last_name ILIKE %s OR
email ILIKE %s)
ORDER BY first_name ASC, last_name ASC
LIMIT 20
"""
term = f"%{q}%"
results = execute_query(sql, (term, term, term))
return results
@router.get("/search/hardware")
async def search_hardware(q: str = Query(..., min_length=2)):
"""
Autocomplete search for hardware.
Returns list of {id, brand, model, serial_number, internal_asset_id}
"""
sql = """
SELECT id, brand, model, serial_number, internal_asset_id
FROM hardware_assets
WHERE
(brand ILIKE %s OR
model ILIKE %s OR
serial_number ILIKE %s OR
internal_asset_id ILIKE %s)
AND deleted_at IS NULL
ORDER BY brand ASC, model ASC
LIMIT 20
"""
term = f"%{q}%"
results = execute_query(sql, (term, term, term, term))
return results
@router.get("/search/locations")
async def search_locations(q: str = Query(..., min_length=2)):
"""
Autocomplete search for locations.
Returns list of {id, name, address_street, address_city}
"""
sql = """
SELECT id, name, address_street, address_city
FROM locations_locations
WHERE
(name ILIKE %s OR
address_street ILIKE %s OR
address_city ILIKE %s)
AND deleted_at IS NULL
ORDER BY name ASC
LIMIT 20
"""
term = f"%{q}%"
results = execute_query(sql, (term, term, term))
return results

View File

@ -0,0 +1,225 @@
"""
Email Templates API Router
Management of system and customer-specific email templates
"""
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Json
from app.core.database import execute_query
import json
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/email-templates", tags=["Email Templates"])
# --- Models ---
class EmailTemplateBase(BaseModel):
name: str
slug: str
subject: str
body: str
category: str = "general"
description: Optional[str] = None
variables: Dict[str, str] = {}
customer_id: Optional[int] = None
class EmailTemplateCreate(EmailTemplateBase):
pass
class EmailTemplateUpdate(BaseModel):
name: Optional[str] = None
subject: Optional[str] = None
body: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
variables: Optional[Dict[str, str]] = None
customer_id: Optional[int] = None
class EmailTemplate(BaseModel):
id: int
name: str
slug: str
subject: str
body: str
category: str
description: Optional[str] = None
variables: Optional[Dict[str, str]] = {}
is_system: bool
customer_id: Optional[int] = None
created_at: datetime
updated_at: datetime
# --- Endpoints ---
@router.get("/", response_model=List[EmailTemplate])
async def get_email_templates(
category: Optional[str] = None,
customer_id: Optional[int] = None
):
"""
Get all email templates.
Optionally filter by category or specific customer.
"""
sql = """
SELECT id, name, slug, subject, body, category, description, variables,
is_system, customer_id, created_at, updated_at
FROM email_templates
WHERE 1=1
"""
params = []
if category:
sql += " AND category = %s"
params.append(category)
if customer_id is not None:
# Fetch global templates (customer_id IS NULL) AND this specific customer's templates
sql += " AND (customer_id IS NULL OR customer_id = %s)"
params.append(customer_id)
sql += " ORDER BY category, name"
rows = execute_query(sql, tuple(params))
return rows
@router.get("/{template_id}", response_model=EmailTemplate)
async def get_email_template(template_id: int):
"""Get a single template by ID"""
sql = """
SELECT id, name, slug, subject, body, category, description, variables,
is_system, customer_id, created_at, updated_at
FROM email_templates
WHERE id = %s
"""
rows = execute_query(sql, (template_id,))
if not rows:
raise HTTPException(status_code=404, detail="Template not found")
return rows[0]
@router.post("/", response_model=EmailTemplate)
async def create_email_template(template: EmailTemplateCreate):
"""Create a new email template"""
# Check for slug uniqueness
check_sql = "SELECT id FROM email_templates WHERE slug = %s AND (customer_id IS NULL OR customer_id = %s)"
check_val = (template.customer_id,) if template.customer_id else (None,)
# If customer_id is None, we check where customer_id IS NULL.
# Actually, SQL `slug = %s AND customer_id = %s` works if we handle NULL correctly in python -> SQL
# Simpler uniqueness check in python logic tailored to the constraint
if template.customer_id:
existing = execute_query(
"SELECT id FROM email_templates WHERE slug = %s AND customer_id = %s",
(template.slug, template.customer_id)
)
else:
existing = execute_query(
"SELECT id FROM email_templates WHERE slug = %s AND customer_id IS NULL",
(template.slug,)
)
if existing:
raise HTTPException(status_code=400, detail=f"A template with slug '{template.slug}' already exists for this context.")
sql = """
INSERT INTO email_templates
(name, slug, subject, body, category, description, variables, customer_id, is_system)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, FALSE)
RETURNING id, name, slug, subject, body, category, description, variables,
is_system, customer_id, created_at, updated_at
"""
params = (
template.name,
template.slug,
template.subject,
template.body,
template.category,
template.description,
json.dumps(template.variables),
template.customer_id
)
try:
rows = execute_query(sql, params)
return rows[0]
except Exception as e:
logger.error(f"Error creating template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{template_id}", response_model=EmailTemplate)
async def update_email_template(template_id: int, update: EmailTemplateUpdate):
"""Update an existing template"""
# Verify existence
current = execute_query("SELECT is_system FROM email_templates WHERE id = %s", (template_id,))
if not current:
raise HTTPException(status_code=404, detail="Template not found")
is_system = current[0]['is_system']
# Prevent changing critical fields on system templates?
# Usually we allow editing subject/body, but maybe not slug.
# For now, we allow everything except slug changes on system templates if defined so,
# but the logic below updates whatever is provided.
fields = []
params = []
if update.name is not None:
fields.append("name = %s")
params.append(update.name)
if update.subject is not None:
fields.append("subject = %s")
params.append(update.subject)
if update.body is not None:
fields.append("body = %s")
params.append(update.body)
if update.category is not None:
fields.append("category = %s")
params.append(update.category)
if update.description is not None:
fields.append("description = %s")
params.append(update.description)
if update.variables is not None:
fields.append("variables = %s")
params.append(json.dumps(update.variables))
if update.customer_id is not None:
fields.append("customer_id = %s")
params.append(update.customer_id)
# Don't change slug if it's a system template?
# Usually slugs are fixed for system templates so the code can find them.
# But for now we don't expose slug update in the provided model if we want to be strict.
# Wait, the frontend might need to update other things.
# Let's assume we don't update slug for now via this endpoint as per my minimalist implementation.
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
fields.append("updated_at = NOW()")
sql = f"UPDATE email_templates SET {', '.join(fields)} WHERE id = %s RETURNING *"
params.append(template_id)
rows = execute_query(sql, tuple(params))
return rows[0]
@router.delete("/{template_id}")
async def delete_email_template(template_id: int):
"""Delete a template (Non-system only)"""
current = execute_query("SELECT is_system FROM email_templates WHERE id = %s", (template_id,))
if not current:
raise HTTPException(status_code=404, detail="Template not found")
if current[0]['is_system']:
raise HTTPException(status_code=403, detail="Cannot delete system templates")
execute_query("DELETE FROM email_templates WHERE id = %s", (template_id,))
return {"message": "Template deleted successfully"}

View File

@ -101,6 +101,9 @@
<a class="nav-link" href="#ai-prompts" data-tab="ai-prompts"> <a class="nav-link" href="#ai-prompts" data-tab="ai-prompts">
<i class="bi bi-robot me-2"></i>AI Prompts <i class="bi bi-robot me-2"></i>AI Prompts
</a> </a>
<a class="nav-link" href="#email-templates" data-tab="email-templates">
<i class="bi bi-envelope-paper me-2"></i>Email skabeloner
</a>
<a class="nav-link" href="/admin/bmc-office-upload"> <a class="nav-link" href="/admin/bmc-office-upload">
<i class="bi bi-cloud-upload me-2"></i>BMC Office Import <i class="bi bi-cloud-upload me-2"></i>BMC Office Import
</a> </a>
@ -151,6 +154,50 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="mb-1 fw-bold">Nextcloud</h5>
<p class="text-muted mb-0">Administrer kundeinstanser, credentials og auditlog</p>
</div>
<button class="btn btn-primary" onclick="openNextcloudInstanceModal()">
<i class="bi bi-plus-lg me-2"></i>Opret instans
</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Kunde</th>
<th>Base URL</th>
<th>Bruger</th>
<th>Status</th>
<th>Sidst opdateret</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="nextcloudInstancesTable">
<tr>
<td colspan="6" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-4">
<div>
<h6 class="fw-bold mb-1">Auditlog retention</h6>
<small class="text-muted">Manuel sletning pr. kunde (tidsbaseret)</small>
</div>
<button class="btn btn-outline-danger" onclick="openNextcloudPurgeModal()">
<i class="bi bi-trash me-2"></i>Slet ældre events
</button>
</div>
</div>
</div> </div>
<!-- Notifications --> <!-- Notifications -->
@ -165,6 +212,67 @@
</div> </div>
</div> </div>
<!-- Email Templates -->
<div class="tab-pane fade" id="email-templates">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-1">Email Skabeloner</h5>
<p class="text-muted mb-0">Administrer system- og kundespecifikke email skabeloner</p>
</div>
<button class="btn btn-primary" onclick="openEmailTemplateModal()">
<i class="bi bi-plus-lg me-2"></i>Ny Skabelon
</button>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label small text-muted">Kategori</label>
<select class="form-select" id="emailTemplateCategoryFilter" onchange="loadEmailTemplates()">
<option value="">Alle kategorier</option>
<option value="general">Generelt</option>
<option value="internal">Internt</option>
<option value="nextcloud">Nextcloud</option>
<option value="billing">Fakturering</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">Kunde</label>
<select class="form-select" id="emailTemplateCustomerFilter" onchange="loadEmailTemplates()">
<option value="">Alle kunder (Globale)</option>
<!-- Populated via JS -->
</select>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th>Navn</th>
<th>Emne</th>
<th>Kategori</th>
<th>Type</th>
<th>Sidst opdateret</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="emailTemplatesTableBody">
<tr>
<td colspan="6" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Users --> <!-- Users -->
<div class="tab-pane fade" id="users"> <div class="tab-pane fade" id="users">
<div class="card p-4"> <div class="card p-4">
@ -743,6 +851,21 @@ async def scan_document(file_path: str):
</div> </div>
</div> </div>
</div> </div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1 fw-bold">Sags-typer</h5>
<p class="text-muted mb-0">Administrer tilladte typer for sager</p>
</div>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="caseTypeInput" placeholder="F.eks. ticket" style="max-width: 220px;">
<button class="btn btn-primary" onclick="addCaseType()"><i class="bi bi-plus-lg me-1"></i>Tilføj</button>
</div>
</div>
<div id="caseTypesList" class="d-flex flex-wrap gap-2">
<div class="text-muted">Indlæser...</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -838,18 +961,120 @@ async def scan_document(file_path: str):
</div> </div>
</div> </div>
<!-- Nextcloud Instance Modal -->
<div class="modal fade" id="nextcloudInstanceModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title"><i class="bi bi-cloud me-2"></i>Opret Nextcloud instans</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="nextcloudInstanceForm" class="row g-3">
<div class="col-12">
<label class="form-label">Kunde</label>
<select class="form-select" id="nextcloudCustomerSelect"></select>
</div>
<div class="col-md-6">
<label class="form-label">Base URL</label>
<input type="url" class="form-control" id="nextcloudBaseUrl" placeholder="https://cloud.example.com" required>
</div>
<div class="col-md-6">
<label class="form-label">Auth type</label>
<select class="form-select" id="nextcloudAuthType">
<option value="basic">Basic / App Password</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Brugernavn</label>
<input type="text" class="form-control" id="nextcloudUsername" required>
</div>
<div class="col-md-6">
<label class="form-label">Password</label>
<input type="password" class="form-control" id="nextcloudPassword" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="createNextcloudInstance()">Gem</button>
</div>
</div>
</div>
</div>
<!-- Nextcloud Rotate Credentials Modal -->
<div class="modal fade" id="nextcloudRotateModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title"><i class="bi bi-arrow-repeat me-2"></i>Rotér credentials</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="nextcloudRotateForm">
<input type="hidden" id="nextcloudRotateInstanceId">
<label class="form-label">Nyt password</label>
<input type="password" class="form-control" id="nextcloudRotatePassword" required>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="rotateNextcloudCredentials()">Opdater</button>
</div>
</div>
</div>
</div>
<!-- Nextcloud Audit Purge Modal -->
<div class="modal fade" id="nextcloudPurgeModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title"><i class="bi bi-trash me-2"></i>Slet auditlog</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="nextcloudPurgeForm" class="row g-3">
<div class="col-12">
<label class="form-label">Kunde</label>
<select class="form-select" id="nextcloudPurgeCustomerSelect"></select>
</div>
<div class="col-12">
<label class="form-label">Slet events før dato</label>
<input type="date" class="form-control" id="nextcloudPurgeBefore" required>
</div>
<div class="col-12">
<div class="alert alert-warning mb-0">
Dette kan ikke fortrydes.
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-danger" onclick="purgeNextcloudAudit()">Slet</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
let allSettings = []; let allSettings = [];
let pipelineStagesCache = []; let pipelineStagesCache = [];
let nextcloudInstancesCache = [];
let customersCache = [];
async function loadSettings() { async function loadSettings() {
try { try {
const response = await fetch('/api/v1/settings'); const response = await fetch('/api/v1/settings');
allSettings = await response.json(); allSettings = await response.json();
displaySettingsByCategory(); displaySettingsByCategory();
await loadCaseTypesSetting();
await loadNextcloudInstances();
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
} }
@ -875,10 +1100,236 @@ function displaySettingsByCategory() {
// Notification settings // Notification settings
displaySettings('notificationSettings', categories.notifications); displaySettings('notificationSettings', categories.notifications);
// Email templates
displaySettings('emailTemplatesInternal', [
'email_template_internal_subject',
'email_template_internal_body'
]);
displaySettings('emailTemplatesExternal', [
'email_template_external_subject',
'email_template_external_body',
'nextcloud_user_welcome_subject',
'nextcloud_user_welcome_body'
]);
// System settings // System settings
displaySettings('systemSettings', categories.system); displaySettings('systemSettings', categories.system);
} }
async function loadNextcloudInstances() {
try {
const response = await fetch('/api/v1/nextcloud/instances');
if (!response.ok) {
throw new Error('Failed to fetch instances');
}
nextcloudInstancesCache = await response.json();
if (!customersCache.length) {
const customersResponse = await fetch('/api/v1/customers?limit=1000&offset=0');
if (customersResponse.ok) {
const payload = await customersResponse.json();
customersCache = Array.isArray(payload) ? payload : (payload.customers || []);
} else {
customersCache = [];
}
}
renderNextcloudInstances();
} catch (error) {
console.error('Error loading Nextcloud instances:', error);
const table = document.getElementById('nextcloudInstancesTable');
if (table) {
table.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Kunne ikke hente instanser</td></tr>';
}
}
}
function renderNextcloudInstances() {
const table = document.getElementById('nextcloudInstancesTable');
if (!table) return;
if (!nextcloudInstancesCache.length) {
table.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Ingen instanser oprettet</td></tr>';
return;
}
const customerMap = new Map(customersCache.map(c => [c.id, c]));
table.innerHTML = nextcloudInstancesCache.map(instance => {
const customer = customerMap.get(instance.customer_id);
return `
<tr>
<td>${customer ? escapeHtml(customer.name) : 'Ukendt'}</td>
<td>${escapeHtml(instance.base_url || '-')}</td>
<td>${escapeHtml(instance.username || '-')}</td>
<td>
<span class="badge ${instance.is_enabled ? 'bg-success' : 'bg-secondary'}">
${instance.is_enabled ? 'Aktiv' : 'Deaktiveret'}
</span>
</td>
<td>${instance.updated_at ? formatDate(instance.updated_at) : '-'}</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-light" onclick="toggleNextcloudInstance(${instance.id}, ${!instance.is_enabled})" title="${instance.is_enabled ? 'Deaktiver' : 'Aktiver'}">
<i class="bi bi-${instance.is_enabled ? 'pause' : 'play'}-circle"></i>
</button>
<button class="btn btn-light" onclick="openNextcloudRotateModal(${instance.id})" title="Rotér credentials">
<i class="bi bi-arrow-repeat"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
function openNextcloudInstanceModal() {
const modal = new bootstrap.Modal(document.getElementById('nextcloudInstanceModal'));
populateNextcloudCustomerSelect('nextcloudCustomerSelect');
modal.show();
}
async function populateNextcloudCustomerSelect(selectId) {
if (!customersCache.length) {
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
if (response.ok) {
const payload = await response.json();
customersCache = Array.isArray(payload) ? payload : (payload.customers || []);
} else {
customersCache = [];
}
}
const select = document.getElementById(selectId);
if (!select) return;
if (!customersCache.length) {
select.innerHTML = '<option value="">Ingen kunder fundet</option>';
return;
}
select.innerHTML = customersCache.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
}
async function createNextcloudInstance() {
const payload = {
customer_id: parseInt(document.getElementById('nextcloudCustomerSelect').value || '0', 10),
base_url: document.getElementById('nextcloudBaseUrl').value.trim(),
auth_type: document.getElementById('nextcloudAuthType').value,
username: document.getElementById('nextcloudUsername').value.trim(),
password: document.getElementById('nextcloudPassword').value
};
if (!payload.customer_id || !payload.base_url || !payload.username || !payload.password) {
alert('Udfyld alle felter');
return;
}
try {
const response = await fetch('/api/v1/nextcloud/instances', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json();
alert(error.detail || 'Kunne ikke oprette instans');
return;
}
bootstrap.Modal.getInstance(document.getElementById('nextcloudInstanceModal')).hide();
document.getElementById('nextcloudInstanceForm').reset();
await loadNextcloudInstances();
} catch (error) {
console.error('Error creating Nextcloud instance:', error);
alert('Kunne ikke oprette instans');
}
}
async function toggleNextcloudInstance(instanceId, enable) {
const endpoint = enable ? 'enable' : 'disable';
try {
const response = await fetch(`/api/v1/nextcloud/instances/${instanceId}/${endpoint}`, { method: 'POST' });
if (!response.ok) {
alert('Kunne ikke opdatere instans');
return;
}
await loadNextcloudInstances();
} catch (error) {
console.error('Error toggling Nextcloud instance:', error);
}
}
function openNextcloudRotateModal(instanceId) {
document.getElementById('nextcloudRotateInstanceId').value = instanceId;
const modal = new bootstrap.Modal(document.getElementById('nextcloudRotateModal'));
modal.show();
}
async function rotateNextcloudCredentials() {
const instanceId = document.getElementById('nextcloudRotateInstanceId').value;
const password = document.getElementById('nextcloudRotatePassword').value;
if (!password) {
alert('Angiv nyt password');
return;
}
try {
const response = await fetch(`/api/v1/nextcloud/instances/${instanceId}/rotate-credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (!response.ok) {
alert('Kunne ikke rotere credentials');
return;
}
bootstrap.Modal.getInstance(document.getElementById('nextcloudRotateModal')).hide();
document.getElementById('nextcloudRotateForm').reset();
await loadNextcloudInstances();
} catch (error) {
console.error('Error rotating credentials:', error);
}
}
function openNextcloudPurgeModal() {
const modal = new bootstrap.Modal(document.getElementById('nextcloudPurgeModal'));
populateNextcloudCustomerSelect('nextcloudPurgeCustomerSelect');
modal.show();
}
async function purgeNextcloudAudit() {
const customerId = parseInt(document.getElementById('nextcloudPurgeCustomerSelect').value || '0', 10);
const beforeDate = document.getElementById('nextcloudPurgeBefore').value;
if (!customerId || !beforeDate) {
alert('Vælg kunde og dato');
return;
}
try {
const response = await fetch('/api/v1/nextcloud/audit/purge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customerId, before_date: beforeDate })
});
if (!response.ok) {
const error = await response.json();
alert(error.detail || 'Kunne ikke slette auditlog');
return;
}
const result = await response.json();
alert(`Slettet ${result.deleted || 0} events`);
bootstrap.Modal.getInstance(document.getElementById('nextcloudPurgeModal')).hide();
document.getElementById('nextcloudPurgeForm').reset();
} catch (error) {
console.error('Error purging audit log:', error);
}
}
function displaySettings(containerId, keys) { function displaySettings(containerId, keys) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
const settings = allSettings.filter(s => keys.includes(s.key)); const settings = allSettings.filter(s => keys.includes(s.key));
@ -900,6 +1351,12 @@ function displaySettings(containerId, keys) {
onchange="updateSetting('${setting.key}', this.checked ? 'true' : 'false')"> onchange="updateSetting('${setting.key}', this.checked ? 'true' : 'false')">
</div> </div>
`; `;
} else if (setting.value_type === 'text' || setting.key.includes('template')) {
inputHtml = `
<textarea class="form-control" id="${inputId}" rows="6"
onblur="updateSetting('${setting.key}', this.value)"
style="max-width: 100%;"></textarea>
`;
} else if (setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('token')) { } else if (setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('token')) {
inputHtml = ` inputHtml = `
<input type="password" class="form-control" id="${inputId}" <input type="password" class="form-control" id="${inputId}"
@ -926,6 +1383,13 @@ function displaySettings(containerId, keys) {
</div> </div>
`; `;
}).join(''); }).join('');
settings.forEach(setting => {
if (setting.value_type === 'text' || setting.key.includes('template')) {
const textarea = document.getElementById(`setting_${setting.key}`);
if (textarea) textarea.value = setting.value || '';
}
});
} }
async function updateSetting(key, value) { async function updateSetting(key, value) {
@ -946,6 +1410,79 @@ async function updateSetting(key, value) {
} }
} }
function getCaseTypesSetting() {
return allSettings.find(setting => setting.key === 'case_types');
}
async function loadCaseTypesSetting() {
try {
const response = await fetch('/api/v1/settings/case_types');
if (!response.ok) {
renderCaseTypes([]);
return;
}
const setting = await response.json();
const rawValue = setting.value || '[]';
const parsed = JSON.parse(rawValue);
renderCaseTypes(Array.isArray(parsed) ? parsed : []);
} catch (error) {
console.error('Error loading case types:', error);
renderCaseTypes([]);
}
}
function renderCaseTypes(types) {
const container = document.getElementById('caseTypesList');
if (!container) return;
if (!types.length) {
container.innerHTML = '<span class="text-muted">Ingen typer defineret</span>';
return;
}
container.innerHTML = types.map(type => `
<span class="badge bg-light text-dark border d-inline-flex align-items-center gap-2">
${type}
<button type="button" class="btn btn-sm p-0" onclick="removeCaseType('${type.replace(/'/g, "&#39;")}')">
<i class="bi bi-x"></i>
</button>
</span>
`).join('');
}
async function saveCaseTypes(types) {
await updateSetting('case_types', JSON.stringify(types));
renderCaseTypes(types);
}
async function addCaseType() {
const input = document.getElementById('caseTypeInput');
if (!input) return;
const value = input.value.trim();
if (!value) return;
const response = await fetch('/api/v1/settings/case_types');
if (!response.ok) return;
const setting = await response.json();
const current = JSON.parse(setting.value || '[]');
const types = Array.isArray(current) ? current : [];
if (!types.includes(value)) {
types.push(value);
await saveCaseTypes(types);
}
input.value = '';
}
async function removeCaseType(type) {
const response = await fetch('/api/v1/settings/case_types');
if (!response.ok) return;
const setting = await response.json();
const current = JSON.parse(setting.value || '[]');
const types = Array.isArray(current) ? current : [];
const filtered = types.filter(t => t !== type);
await saveCaseTypes(filtered);
}
async function loadUsers() { async function loadUsers() {
try { try {
const response = await fetch('/api/v1/users'); const response = await fetch('/api/v1/users');
@ -2081,4 +2618,326 @@ document.addEventListener('DOMContentLoaded', () => {
</div> </div>
</div> </div>
<!-- Email Template Modal -->
<div class="modal fade" id="emailTemplateModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="emailTemplateModalTitle">Opret Email Skabelon</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<!-- Editor Column -->
<div class="col-md-8">
<form id="emailTemplateForm">
<input type="hidden" id="emailTemplateId">
<div class="mb-3">
<label class="form-label">Skabelon Navn *</label>
<input type="text" class="form-control" id="emailTemplateName" required>
<small class="text-muted">Internt navn til identifikation</small>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Slug (Unik ID) *</label>
<input type="text" class="form-control font-monospace" id="emailTemplateSlug" required>
<small class="text-muted">Bruges i koden (f.eks. 'nextcloud_welcome')</small>
</div>
<div class="col-md-6">
<label class="form-label">Kategori</label>
<select class="form-select" id="emailTemplateCategory">
<option value="general">Generelt</option>
<option value="internal">Internt</option>
<option value="nextcloud">Nextcloud</option>
<option value="billing">Fakturering</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Kunde (Valgfri)</label>
<select class="form-select" id="emailTemplateCustomer">
<option value="">Alle kunder (Global skabelon)</option>
<!-- Populated via JS -->
</select>
<small class="text-muted">Vælg en kunde for at lave en specifik override</small>
</div>
<hr>
<div class="mb-3">
<label class="form-label">Emne *</label>
<input type="text" class="form-control" id="emailTemplateSubject" required>
</div>
<div class="mb-3">
<label class="form-label">Indhold *</label>
<textarea class="form-control font-monospace" id="emailTemplateBody" rows="12" required></textarea>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<input type="text" class="form-control" id="emailTemplateDescription">
</div>
</form>
</div>
<!-- Variables Column -->
<div class="col-md-4 bg-light p-3 rounded">
<h6 class="fw-bold mb-3">Tilgængelige Variabler</h6>
<p class="small text-muted mb-3">Klik på en variabel for at kopiere den til udklipsholderen.</p>
<div id="emailTemplateVariablesList" class="d-grid gap-2">
<!-- Populated via JS -->
</div>
<div class="mt-4">
<h6 class="fw-bold mb-2">Tilføj Variabel info</h6>
<p class="small text-muted mb-2">Definer hvilke variabler systemet understøtter for denne slug.</p>
<textarea class="form-control font-monospace small" id="emailTemplateVariablesJson" rows="5" placeholder='{"name": "Navn", "url": "Login URL"}'></textarea>
<small class="text-muted">JSON format</small>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveEmailTemplate()">Gem Skabelon</button>
</div>
</div>
</div>
</div>
<script>
// --- Email Template Management ---
async function loadEmailTemplates() {
const category = document.getElementById('emailTemplateCategoryFilter').value;
const customerId = document.getElementById('emailTemplateCustomerFilter').value;
let url = '/api/v1/email-templates/';
const params = new URLSearchParams();
if (category) params.append('category', category);
if (customerId) params.append('customer_id', customerId);
if (params.toString()) url += '?' + params.toString();
const tbody = document.getElementById('emailTemplatesTableBody');
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
try {
const response = await fetch(url);
const templates = await response.json();
tbody.innerHTML = '';
if (templates.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">Ingen skabeloner fundet</td></tr>';
return;
}
templates.forEach(tpl => {
const customerLabel = tpl.customer_id ? '<span class="badge bg-info text-dark">Kunde-specifik</span>' : '<span class="badge bg-secondary">Global</span>';
const updatedAt = new Date(tpl.updated_at).toLocaleDateString('da-DK');
tbody.innerHTML += `
<tr>
<td>
<div class="fw-bold">${tpl.name}</div>
<small class="text-muted font-monospace">${tpl.slug}</small>
</td>
<td>${tpl.subject}</td>
<td><span class="badge bg-light text-dark border">${tpl.category}</span></td>
<td>${customerLabel}</td>
<td>${updatedAt}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary me-1" onclick="editEmailTemplate(${tpl.id})">
<i class="bi bi-pencil"></i>
</button>
${!tpl.is_system ? `
<button class="btn btn-sm btn-outline-danger" onclick="deleteEmailTemplate(${tpl.id})">
<i class="bi bi-trash"></i>
</button>` : ''}
</td>
</tr>
`;
});
} catch (e) {
console.error("Error loading templates:", e);
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-danger">Fejl ved indlæsning af skabeloner</td></tr>';
}
}
async function loadEmailTemplateCustomers() {
// Populate customer dropdowns (Filter and Modal)
try {
const response = await fetch('/api/v1/customers');
const customers = await response.json();
const filterSelect = document.getElementById('emailTemplateCustomerFilter');
const modalSelect = document.getElementById('emailTemplateCustomer');
// Reset (keep first option)
filterSelect.length = 1;
modalSelect.length = 1;
customers.forEach(c => {
const opt1 = new Option(`${c.name} (#${c.id})`, c.id);
const opt2 = new Option(`${c.name} (#${c.id})`, c.id);
filterSelect.add(opt1);
modalSelect.add(opt2);
});
} catch (e) {
console.error("Error loading customers:", e);
}
}
async function openEmailTemplateModal() {
// Reset form
document.getElementById('emailTemplateForm').reset();
document.getElementById('emailTemplateId').value = '';
document.getElementById('emailTemplateModalTitle').textContent = 'Opret Email Skabelon';
document.getElementById('emailTemplateSlug').readOnly = false;
document.getElementById('emailTemplateVariablesList').innerHTML = '<p class="text-muted small">Ingen variabler defineret</p>';
const modal = new bootstrap.Modal(document.getElementById('emailTemplateModal'));
modal.show();
}
async function editEmailTemplate(id) {
try {
const response = await fetch(`/api/v1/email-templates/${id}`);
if(!response.ok) throw new Error("Load failed");
const tpl = await response.json();
document.getElementById('emailTemplateId').value = tpl.id;
document.getElementById('emailTemplateName').value = tpl.name;
document.getElementById('emailTemplateSlug').value = tpl.slug;
document.getElementById('emailTemplateSlug').readOnly = tpl.is_system; // Cannot change slug of system template
document.getElementById('emailTemplateCategory').value = tpl.category;
document.getElementById('emailTemplateCustomer').value = tpl.customer_id || "";
document.getElementById('emailTemplateSubject').value = tpl.subject;
document.getElementById('emailTemplateBody').value = tpl.body;
document.getElementById('emailTemplateDescription').value = tpl.description || "";
document.getElementById('emailTemplateVariablesJson').value = JSON.stringify(tpl.variables || {}, null, 2);
document.getElementById('emailTemplateModalTitle').textContent = 'Rediger Email Skabelon';
updateVariablesList(tpl.variables);
const modal = new bootstrap.Modal(document.getElementById('emailTemplateModal'));
modal.show();
} catch (e) {
alert("Kunne ikke hente skabelon data");
}
}
function updateVariablesList(variables) {
const list = document.getElementById('emailTemplateVariablesList');
list.innerHTML = '';
if (!variables || Object.keys(variables).length === 0) {
list.innerHTML = '<p class="text-muted small">Ingen variabler defineret</p>';
return;
}
for (const [key, desc] of Object.entries(variables)) {
const btn = document.createElement('button');
btn.className = 'btn btn-outline-secondary btn-sm text-start';
// Escape Jinja2 curly braces to prevent conflict with JS template literals
btn.innerHTML = `<span class="fw-bold">{{ '{{' }}${key}{{ '}}' }}</span> <br><small>${desc}</small>`;
btn.onclick = () => {
navigator.clipboard.writeText(`{{ '{{' }}${key}{{ '}}' }}`);
// Flash button
const originalClass = btn.className;
btn.className = 'btn btn-success btn-sm text-start';
setTimeout(() => btn.className = originalClass, 500);
};
list.appendChild(btn);
}
}
// Update variables preview when JSON changes
document.getElementById('emailTemplateVariablesJson').addEventListener('input', function(e) {
try {
const vars = JSON.parse(e.target.value);
updateVariablesList(vars);
e.target.classList.remove('is-invalid');
} catch {
// e.target.classList.add('is-invalid');
}
});
async function saveEmailTemplate() {
const id = document.getElementById('emailTemplateId').value;
const isNew = !id;
// Parse JSON
let variables = {};
try {
const jsonStr = document.getElementById('emailTemplateVariablesJson').value;
if (jsonStr.trim()) variables = JSON.parse(jsonStr);
} catch (e) {
alert("Ugyldigt JSON format i variabler");
return;
}
const payload = {
name: document.getElementById('emailTemplateName').value,
slug: document.getElementById('emailTemplateSlug').value,
category: document.getElementById('emailTemplateCategory').value,
subject: document.getElementById('emailTemplateSubject').value,
body: document.getElementById('emailTemplateBody').value,
description: document.getElementById('emailTemplateDescription').value,
customer_id: document.getElementById('emailTemplateCustomer').value || null,
variables: variables
};
if (!payload.name || !payload.slug || !payload.subject || !payload.body) {
alert("Udfyld venligst alle obligatoriske felter (*)");
return;
}
const url = isNew ? '/api/v1/email-templates/' : `/api/v1/email-templates/${id}`;
const method = isNew ? 'POST' : 'PUT';
try {
const response = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "Fejl ved gemning");
}
bootstrap.Modal.getInstance(document.getElementById('emailTemplateModal')).hide();
loadEmailTemplates();
} catch (e) {
alert(e.message);
}
}
async function deleteEmailTemplate(id) {
if(!confirm("Er du sikker på at du vil slette denne skabelon?")) return;
try {
const response = await fetch(`/api/v1/email-templates/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error("Fejl ved sletning");
loadEmailTemplates();
} catch (e) {
alert(e.message);
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
// Other loaders are called at bottom of file in existing script
loadEmailTemplates();
loadEmailTemplateCustomers();
});
</script>
{% endblock %} {% endblock %}

View File

@ -149,6 +149,21 @@ async def remove_tag_from_entity(
raise HTTPException(status_code=404, detail="Tag association not found") raise HTTPException(status_code=404, detail="Tag association not found")
return {"message": "Tag removed from entity"} return {"message": "Tag removed from entity"}
@router.delete("/entity/{entity_type}/{entity_id}/{tag_id}")
async def remove_tag_from_entity_path(
entity_type: str,
entity_id: int,
tag_id: int
):
"""Remove tag from entity (Path param version)"""
result = execute_update(
"DELETE FROM entity_tags WHERE entity_type = %s AND entity_id = %s AND tag_id = %s",
(entity_type, entity_id, tag_id)
)
if result == 0:
raise HTTPException(status_code=404, detail="Tag association not found")
return {"message": "Tag removed from entity"}
@router.get("/entity/{entity_type}/{entity_id}", response_model=List[Tag]) @router.get("/entity/{entity_type}/{entity_id}", response_model=List[Tag])
async def get_entity_tags(entity_type: str, entity_id: int): async def get_entity_tags(entity_type: str, entity_id: int):
"""Get all tags for a specific entity""" """Get all tags for a specific entity"""

View File

@ -52,7 +52,8 @@ async def worklog_review_page(
status: Filter by status (default: draft) status: Filter by status (default: draft)
""" """
try: try:
# Build query with filters # Build query with filters (UNION of Ticket Worklogs and Sag/Module Times)
# Ticket Worklogs (Positive IDs)
query = """ query = """
SELECT SELECT
w.id, w.id,
@ -73,7 +74,8 @@ async def worklog_review_page(
c.name AS customer_name, c.name AS customer_name,
u.username AS user_name, u.username AS user_name,
pc.card_number, pc.card_number,
pc.remaining_hours AS card_remaining_hours pc.remaining_hours AS card_remaining_hours,
false as is_sag_module
FROM tticket_worklog w FROM tticket_worklog w
INNER JOIN tticket_tickets t ON t.id = w.ticket_id INNER JOIN tticket_tickets t ON t.id = w.ticket_id
LEFT JOIN customers c ON c.id = t.customer_id LEFT JOIN customers c ON c.id = t.customer_id
@ -88,7 +90,52 @@ async def worklog_review_page(
query += " AND t.customer_id = %s" query += " AND t.customer_id = %s"
params.append(customer_id) params.append(customer_id)
query += " ORDER BY w.work_date DESC, w.created_at DESC" # UNION ALL with Sag Module Times (Negative IDs)
# Map status: pending -> draft, approved -> billable
mapped_status = 'pending' if status == 'draft' else ('approved' if status == 'billable' else status)
query += """
UNION ALL
SELECT
(tm.id * -1) as id,
tm.sag_id as ticket_id,
NULL as user_id,
tm.worked_date as work_date,
tm.original_hours as hours,
'sag' as work_type,
tm.description,
COALESCE(tm.billing_method, 'invoice') as billing_method,
CASE
WHEN tm.status = 'pending' THEN 'draft'
WHEN tm.status = 'approved' THEN 'billable'
ELSE tm.status
END as status,
tm.prepaid_card_id,
tm.created_at,
CONCAT('SAG-', tm.sag_id) as ticket_number,
COALESCE(sol.title, 'Sagssarbejde') as ticket_subject,
cust.id as customer_id, -- Access core customer ID via Sag join
'Open' as ticket_status,
COALESCE(cust.name, 'Ukendt Kunde') as customer_name,
tm.user_name,
pc.card_number,
pc.remaining_hours as card_remaining_hours,
true as is_sag_module
FROM tmodule_times tm
LEFT JOIN sag_sager s ON tm.sag_id = s.id
LEFT JOIN sag_solutions sol ON tm.solution_id = sol.id
LEFT JOIN customers cust ON s.customer_id = cust.id
LEFT JOIN tticket_prepaid_cards pc ON tm.prepaid_card_id = pc.id
WHERE tm.status = %s
"""
params.append(mapped_status)
if customer_id:
query += " AND s.customer_id = %s"
params.append(customer_id)
query += " ORDER BY work_date DESC, created_at DESC"
worklogs = execute_query(query, tuple(params)) worklogs = execute_query(query, tuple(params))
@ -142,6 +189,14 @@ async def approve_worklog_entry(
redirect_to: URL to redirect after approval redirect_to: URL to redirect after approval
""" """
try: try:
# Handle Sag Module Entries (Negative IDs)
if worklog_id < 0:
tm_id = abs(worklog_id)
update_query = "UPDATE tmodule_times SET status = 'approved' WHERE id = %s AND status = 'pending'"
execute_update(update_query, (tm_id,))
logger.info(f"✅ Approved Sag time entry {tm_id}")
return RedirectResponse(url=redirect_to, status_code=303)
# Check entry exists and is draft # Check entry exists and is draft
check_query = """ check_query = """
SELECT id, status, billing_method SELECT id, status, billing_method

View File

@ -86,13 +86,15 @@ class TModuleCase(TModuleCaseBase):
class TModuleTimeBase(BaseModel): class TModuleTimeBase(BaseModel):
"""Base model for time entry""" """Base model for time entry"""
vtiger_id: str = Field(..., description="vTiger ModComments ID") vtiger_id: Optional[str] = Field(None, description="vTiger ModComments ID (Optional)")
case_id: int = Field(..., gt=0) case_id: Optional[int] = Field(None, gt=0, description="vTiger Case ID (Optional)")
sag_id: Optional[int] = Field(None, gt=0, description="Hub Sag ID (Optional)")
solution_id: Optional[int] = Field(None, gt=0, description="Hub Solution ID (Optional)")
customer_id: int = Field(..., gt=0) customer_id: int = Field(..., gt=0)
description: Optional[str] = None description: Optional[str] = None
original_hours: Decimal = Field(..., gt=0, description="Original timer fra vTiger") original_hours: Decimal = Field(..., gt=0, description="Original timer")
worked_date: Optional[date] = None worked_date: Optional[date] = None
user_name: Optional[str] = Field(None, max_length=255, description="vTiger bruger") user_name: Optional[str] = Field(None, max_length=255, description="Bruger")
class TModuleTimeCreate(TModuleTimeBase): class TModuleTimeCreate(TModuleTimeBase):
@ -176,6 +178,7 @@ class TModuleOrderLineBase(BaseModel):
unit_price: Decimal = Field(..., ge=0, description="DKK pr. time") unit_price: Decimal = Field(..., ge=0, description="DKK pr. time")
line_total: Decimal = Field(..., ge=0, description="Total for linje") line_total: Decimal = Field(..., ge=0, description="Total for linje")
case_id: Optional[int] = Field(None, gt=0) case_id: Optional[int] = Field(None, gt=0)
sag_id: Optional[int] = Field(None, gt=0)
time_entry_ids: List[int] = Field(default_factory=list) time_entry_ids: List[int] = Field(default_factory=list)
product_number: Optional[str] = Field(None, max_length=50) product_number: Optional[str] = Field(None, max_length=50)
case_contact: Optional[str] = Field(None, max_length=255) case_contact: Optional[str] = Field(None, max_length=255)

View File

@ -89,28 +89,34 @@ class OrderService:
# Debug log # Debug log
logger.info(f"✅ Found customer: {customer.get('name') if isinstance(customer, dict) else type(customer)}") logger.info(f"✅ Found customer: {customer.get('name') if isinstance(customer, dict) else type(customer)}")
# Hent godkendte tider for kunden med case og contact detaljer # Hent godkendte tider for kunden med case og contact detaljer (Supports both vTiger Cases and Hub Sags)
query = """ query = """
SELECT t.*, SELECT t.*,
COALESCE( COALESCE(
NULLIF(TRIM(c.title), ''), NULLIF(TRIM(c.title), ''),
NULLIF(TRIM(c.vtiger_data->>'ticket_title'), ''), NULLIF(TRIM(c.vtiger_data->>'ticket_title'), ''),
NULLIF(TRIM(s.titel), ''),
'Ingen titel' 'Ingen titel'
) as case_title, ) as case_title,
c.vtiger_id as case_vtiger_id, COALESCE(c.vtiger_id, CONCAT('SAG-', s.id)) as case_vtiger_id,
COALESCE(c.vtiger_data->>'case_no', c.vtiger_data->>'ticket_no') as case_number, COALESCE(
c.vtiger_data->>'case_no',
c.vtiger_data->>'ticket_no',
CONCAT('SAG-', s.id)
) as case_number,
c.vtiger_data->>'ticket_title' as vtiger_title, c.vtiger_data->>'ticket_title' as vtiger_title,
c.priority as case_priority, COALESCE(c.priority, 'Normal') as case_priority,
c.status as case_status, COALESCE(c.status, s.status) as case_status,
c.module_type as case_type, COALESCE(c.module_type, 'sag') as case_type,
CONCAT(cont.first_name, ' ', cont.last_name) as contact_name CONCAT(cont.first_name, ' ', cont.last_name) as contact_name
FROM tmodule_times t FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id LEFT JOIN tmodule_cases c ON t.case_id = c.id
LEFT JOIN sag_sager s ON t.sag_id = s.id
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id' LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
WHERE t.customer_id = %s WHERE t.customer_id = %s
AND t.status = 'approved' AND t.status = 'approved'
AND t.billable = true AND t.billable = true
ORDER BY c.id, t.worked_date ORDER BY COALESCE(c.id, s.id), t.worked_date
""" """
approved_times = execute_query(query, (customer_id,)) approved_times = execute_query(query, (customer_id,))
@ -131,11 +137,18 @@ class OrderService:
# Group by case og gem ekstra metadata # Group by case og gem ekstra metadata
case_groups = {} case_groups = {}
for time_entry in approved_times: for time_entry in approved_times:
case_id = time_entry['case_id'] # Use case_number as unique key to prevent collision between Cases and Sags
if case_id not in case_groups: case_key = time_entry.get('case_number')
if not case_key:
# Fallback to ID if no number (should not happen with updated query)
case_key = str(time_entry.get('case_id') or time_entry.get('sag_id'))
if case_key not in case_groups:
# Prioriter case title fra vTiger (c.title), fallback til vtiger_data title # Prioriter case title fra vTiger (c.title), fallback til vtiger_data title
case_title = time_entry.get('case_title') or time_entry.get('vtiger_title') case_title = time_entry.get('case_title') or time_entry.get('vtiger_title')
case_groups[case_id] = { case_groups[case_key] = {
'case_id': time_entry.get('case_id'),
'sag_id': time_entry.get('sag_id'),
'case_vtiger_id': time_entry.get('case_vtiger_id'), 'case_vtiger_id': time_entry.get('case_vtiger_id'),
'case_number': time_entry.get('case_number'), # Fra vtiger_data 'case_number': time_entry.get('case_number'), # Fra vtiger_data
'case_title': case_title, # Case titel fra vTiger 'case_title': case_title, # Case titel fra vTiger
@ -148,17 +161,17 @@ class OrderService:
'entries': [], 'entries': [],
'descriptions': [] # Samle alle beskrivelser 'descriptions': [] # Samle alle beskrivelser
} }
case_groups[case_id]['entries'].append(time_entry) case_groups[case_key]['entries'].append(time_entry)
# Opdater til seneste dato # Opdater til seneste dato
if time_entry.get('worked_date'): if time_entry.get('worked_date'):
if not case_groups[case_id]['worked_date'] or time_entry['worked_date'] > case_groups[case_id]['worked_date']: if not case_groups[case_key]['worked_date'] or time_entry['worked_date'] > case_groups[case_key]['worked_date']:
case_groups[case_id]['worked_date'] = time_entry['worked_date'] case_groups[case_key]['worked_date'] = time_entry['worked_date']
# Marker som rejse hvis nogen entry er rejse # Marker som rejse hvis nogen entry er rejse
if time_entry.get('is_travel'): if time_entry.get('is_travel'):
case_groups[case_id]['is_travel'] = True case_groups[case_key]['is_travel'] = True
# Tilføj beskrivelse hvis den ikke er tom # Tilføj beskrivelse hvis den ikke er tom
if time_entry.get('description') and time_entry['description'].strip(): if time_entry.get('description') and time_entry['description'].strip():
case_groups[case_id]['descriptions'].append(time_entry['description'].strip()) case_groups[case_key]['descriptions'].append(time_entry['description'].strip())
# Build order lines # Build order lines
order_lines = [] order_lines = []
@ -236,7 +249,8 @@ class OrderService:
quantity=case_hours, quantity=case_hours,
unit_price=hourly_rate, unit_price=hourly_rate,
line_total=line_total, line_total=line_total,
case_id=case_id, case_id=group.get('case_id'),
sag_id=group.get('sag_id'),
time_entry_ids=time_entry_ids, time_entry_ids=time_entry_ids,
case_contact=group.get('contact_name'), case_contact=group.get('contact_name'),
time_date=group.get('worked_date'), time_date=group.get('worked_date'),
@ -282,13 +296,14 @@ class OrderService:
for line in order_lines: for line in order_lines:
line_id = execute_insert( line_id = execute_insert(
"""INSERT INTO tmodule_order_lines """INSERT INTO tmodule_order_lines
(order_id, case_id, line_number, description, quantity, unit_price, (order_id, case_id, sag_id, line_number, description, quantity, unit_price,
line_total, time_entry_ids, case_contact, time_date, is_travel) line_total, time_entry_ids, case_contact, time_date, is_travel)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""", RETURNING id""",
( (
order_id, order_id,
line.case_id, line.case_id,
line.sag_id,
line.line_number, line.line_number,
line.description, line.description,
line.quantity, line.quantity,

View File

@ -1742,3 +1742,143 @@ async def uninstall_module(
except Exception as e: except Exception as e:
logger.error(f"❌ Uninstall failed: {e}") logger.error(f"❌ Uninstall failed: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# INTERNAL / HUB INTEGRATION ENDPOINTS
# ============================================================================
@router.get("/entries/sag/{sag_id}", tags=["Internal"])
async def get_time_entries_for_sag(sag_id: int):
"""Get time entries linked to a Hub Sag (Case)."""
try:
query = """
SELECT * FROM tmodule_times
WHERE sag_id = %s
ORDER BY worked_date DESC, created_at DESC
"""
results = execute_query(query, (sag_id,))
return results
except Exception as e:
logger.error(f"❌ Error fetching time entries for sag {sag_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch time entries")
@router.post("/entries/internal", tags=["Internal"])
async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
"""
Create a time entry manually (Internal/Hub).
Requires: sag_id, original_hours
Optional: customer_id (auto-resolved from sag if missing), solution_id
"""
try:
sag_id = entry.get("sag_id")
solution_id = entry.get("solution_id")
customer_id = entry.get("customer_id")
description = entry.get("description")
hours = entry.get("original_hours")
worked_date = entry.get("worked_date") or datetime.now().date()
user_name = entry.get("user_name", "Hub User")
prepaid_card_id = entry.get("prepaid_card_id")
work_type = entry.get("work_type", "support")
is_internal = entry.get("is_internal", False)
if not sag_id or not hours:
raise HTTPException(status_code=400, detail="sag_id and original_hours required")
hours_decimal = float(hours)
# Auto-resolve customer if missing
if not customer_id:
# Get Hub Customer ID from Sag (fallback to linked customers)
sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sag_id,))
if not sag:
raise HTTPException(status_code=404, detail="Sag not found")
hub_customer_id = sag.get("customer_id")
if not hub_customer_id:
linked_customer = execute_query_single(
"SELECT customer_id FROM sag_kunder WHERE sag_id = %s AND deleted_at IS NULL ORDER BY id ASC LIMIT 1",
(sag_id,)
)
hub_customer_id = linked_customer.get("customer_id") if linked_customer else None
if hub_customer_id:
# Find matching tmodule_customer
tm_cust = execute_query_single("SELECT id FROM tmodule_customers WHERE hub_customer_id = %s", (hub_customer_id,))
if tm_cust:
customer_id = tm_cust["id"]
else:
raise HTTPException(
status_code=400,
detail=f"Customer (Hub ID: {hub_customer_id}) not found in Time Module. Please sync customers."
)
else:
raise HTTPException(status_code=400, detail="Sag has no customer linked")
# Handle Prepaid Card
billing_method = entry.get('billing_method', 'invoice')
status = 'pending'
billable = True
if is_internal:
billing_method = 'internal'
billable = False
elif prepaid_card_id:
# Verify card
card = execute_query_single("SELECT * FROM tticket_prepaid_cards WHERE id = %s", (prepaid_card_id,))
if not card:
raise HTTPException(status_code=404, detail="Prepaid card not found")
if float(card['remaining_hours']) < hours_decimal:
# Optional: Allow overdraft? For now, block.
raise HTTPException(status_code=400, detail=f"Insufficient hours on prepaid card (Remaining: {card['remaining_hours']})")
# Deduct hours (remaining_hours is generated; update used_hours instead)
new_used = float(card['used_hours']) + hours_decimal
execute_update(
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
(new_used, prepaid_card_id)
)
updated_card = execute_query_single(
"SELECT remaining_hours FROM tticket_prepaid_cards WHERE id = %s",
(prepaid_card_id,)
)
if updated_card and float(updated_card['remaining_hours']) <= 0:
execute_update(
"UPDATE tticket_prepaid_cards SET status = 'depleted' WHERE id = %s",
(prepaid_card_id,)
)
logger.info(f"💳 Deducted {hours_decimal} hours from prepaid card {prepaid_card_id}")
billing_method = 'prepaid'
status = 'billed' # Mark as processed/billed so it skips invoicing
elif billing_method == 'internal' or billing_method == 'warranty':
billable = False
query = """
INSERT INTO tmodule_times (
sag_id, solution_id, customer_id, description,
original_hours, worked_date, user_name,
status, billable, billing_method, prepaid_card_id, work_type
) VALUES (
%s, %s, %s, %s,
%s, %s, %s,
%s, %s, %s, %s, %s
) RETURNING *
"""
params = (
sag_id, solution_id, customer_id, description,
hours, worked_date, user_name,
status, billable, billing_method, prepaid_card_id, work_type
)
result = execute_query(query, params)
if result:
return result[0]
raise HTTPException(status_code=500, detail="Failed to create entry")
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating internal time entry: {e}")
raise HTTPException(status_code=500, detail=str(e))

36
apply_migration_085.py Normal file
View File

@ -0,0 +1,36 @@
import logging
import os
import sys
# Add project root to path
sys.path.append(os.getcwd())
from app.core.database import execute_query
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def run_migration():
migration_file = "migrations/085_sag_solutions.sql"
logger.info(f"Applying migration: {migration_file}")
try:
with open(migration_file, "r") as f:
sql = f.read()
# Split by command (simple split by semicolon)
# Note: This is a fragile split, but works for simple migrations without function bodies containing semicolons
commands = [cmd.strip() for cmd in sql.split(";") if cmd.strip()]
for cmd in commands:
logger.info(f"Executing: {cmd[:50]}...")
execute_query(cmd, ())
logger.info("✅ Migration applied successfully")
except Exception as e:
logger.error(f"❌ Migration failed: {e}")
sys.exit(1)
if __name__ == "__main__":
run_migration()

76
main.py
View File

@ -5,7 +5,7 @@ Main application entry point
import logging import logging
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@ -13,6 +13,8 @@ from contextlib import asynccontextmanager
from app.core.config import settings from app.core.config import settings
from app.core.database import init_db from app.core.database import init_db
from app.core.auth_service import AuthService
from app.core.database import execute_query_single
def get_version(): def get_version():
"""Read version from VERSION file""" """Read version from VERSION file"""
@ -48,6 +50,7 @@ from app.tags.backend import views as tags_views
from app.emails.backend import router as emails_api from app.emails.backend import router as emails_api
from app.emails.frontend import views as emails_views from app.emails.frontend import views as emails_views
from app.settings.backend import router as settings_api from app.settings.backend import router as settings_api
from app.settings.backend import email_templates as email_templates_api
from app.settings.backend import views as settings_views from app.settings.backend import views as settings_views
from app.backups.backend.router import router as backups_api from app.backups.backend.router import router as backups_api
from app.backups.frontend import views as backups_views from app.backups.frontend import views as backups_views
@ -56,6 +59,9 @@ from app.conversations.backend import router as conversations_api
from app.conversations.frontend import views as conversations_views from app.conversations.frontend import views as conversations_views
from app.opportunities.backend import router as opportunities_api from app.opportunities.backend import router as opportunities_api
from app.opportunities.frontend import views as opportunities_views from app.opportunities.frontend import views as opportunities_views
from app.auth.backend import router as auth_api
from app.auth.backend import views as auth_views
from app.auth.backend import admin as auth_admin_api
# Modules # Modules
from app.modules.webshop.backend import router as webshop_api from app.modules.webshop.backend import router as webshop_api
@ -66,6 +72,8 @@ from app.modules.hardware.backend import router as hardware_module_api
from app.modules.hardware.frontend import views as hardware_module_views from app.modules.hardware.frontend import views as hardware_module_views
from app.modules.locations.backend import router as locations_api from app.modules.locations.backend import router as locations_api
from app.modules.locations.frontend import views as locations_views from app.modules.locations.frontend import views as locations_views
from app.modules.nextcloud.backend import router as nextcloud_api
from app.modules.search.backend import router as search_api
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -126,6 +134,66 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Global auth middleware
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
public_paths = {
"/health",
"/login",
"/api/v1/auth/login"
}
if path in public_paths or path.startswith("/static") or path.startswith("/docs"):
return await call_next(request)
token = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1]
else:
token = request.cookies.get("access_token")
payload = AuthService.verify_token(token) if token else None
if not payload:
if path.startswith("/api"):
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=401,
content={"detail": "Not authenticated"}
)
return RedirectResponse(url="/login")
if path.startswith("/api") and not payload.get("shadow_admin"):
if not payload.get("sub"):
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=401,
content={"detail": "Invalid token"}
)
user_id = int(payload.get("sub"))
user = execute_query_single(
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,)
)
is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
if not is_2fa_enabled:
allowed_2fa_paths = (
"/api/v1/auth/2fa",
"/api/v1/auth/me",
"/api/v1/auth/logout"
)
if not path.startswith(allowed_2fa_paths):
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=403,
content={"detail": "2FA required"}
)
return await call_next(request)
# Include routers # Include routers
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"]) app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
app.include_router(bmc_office_router.router, prefix="/api/v1", tags=["BMC Office"]) app.include_router(bmc_office_router.router, prefix="/api/v1", tags=["BMC Office"])
@ -142,15 +210,20 @@ app.include_router(timetracking_api, prefix="/api/v1", tags=["Time Tracking"])
app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"]) app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"])
app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"]) app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"])
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"]) app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
app.include_router(email_templates_api.router, prefix="/api/v1", tags=["Email Templates"])
app.include_router(backups_api, prefix="/api/v1", tags=["Backups"]) app.include_router(backups_api, prefix="/api/v1", tags=["Backups"])
app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"]) app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunities"]) app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunities"])
app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Auth"])
app.include_router(auth_admin_api.router, prefix="/api/v1", tags=["Auth Admin"])
# Module Routers # Module Routers
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"]) app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
app.include_router(sag_api.router, prefix="/api/v1", tags=["Cases"]) app.include_router(sag_api.router, prefix="/api/v1", tags=["Cases"])
app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware Module"]) app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware Module"])
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"]) app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
app.include_router(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Nextcloud"])
app.include_router(search_api.router, prefix="/api/v1", tags=["Search"])
# Frontend Routers # Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"]) app.include_router(dashboard_views.router, tags=["Frontend"])
@ -168,6 +241,7 @@ app.include_router(backups_views.router, tags=["Frontend"])
app.include_router(conversations_views.router, tags=["Frontend"]) app.include_router(conversations_views.router, tags=["Frontend"])
app.include_router(webshop_views.router, tags=["Frontend"]) app.include_router(webshop_views.router, tags=["Frontend"])
app.include_router(opportunities_views.router, tags=["Frontend"]) app.include_router(opportunities_views.router, tags=["Frontend"])
app.include_router(auth_views.router, tags=["Frontend"])
app.include_router(sag_views.router, tags=["Frontend"]) app.include_router(sag_views.router, tags=["Frontend"])
app.include_router(hardware_module_views.router, tags=["Frontend"]) app.include_router(hardware_module_views.router, tags=["Frontend"])
app.include_router(locations_views.router, tags=["Frontend"]) app.include_router(locations_views.router, tags=["Frontend"])

View File

@ -0,0 +1,22 @@
-- Migration: 076_nextcloud_instances
-- Created: 2026-02-01
CREATE TABLE IF NOT EXISTS nextcloud_instances (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
base_url TEXT NOT NULL,
auth_type VARCHAR(20) NOT NULL DEFAULT 'basic',
username TEXT NOT NULL,
password_encrypted TEXT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT true,
disabled_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_nextcloud_instances_customer
ON nextcloud_instances(customer_id);
CREATE INDEX IF NOT EXISTS idx_nextcloud_instances_enabled
ON nextcloud_instances(is_enabled);

View File

@ -0,0 +1,12 @@
-- Migration: 077_nextcloud_cache
-- Created: 2026-02-01
CREATE TABLE IF NOT EXISTS nextcloud_cache (
cache_key TEXT PRIMARY KEY,
payload JSONB NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_nextcloud_cache_expires
ON nextcloud_cache(expires_at);

View File

@ -0,0 +1,22 @@
-- Migration: 078_nextcloud_audit_log
-- Created: 2026-02-01
CREATE TABLE IF NOT EXISTS nextcloud_audit_log (
id BIGSERIAL NOT NULL,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
instance_id INTEGER REFERENCES nextcloud_instances(id) ON DELETE SET NULL,
event_type VARCHAR(50) NOT NULL,
request_meta JSONB,
response_meta JSONB,
actor_user_id INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- Create current month partition
CREATE TABLE IF NOT EXISTS nextcloud_audit_log_2026_02
PARTITION OF nextcloud_audit_log
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE INDEX IF NOT EXISTS idx_nextcloud_audit_customer_created
ON nextcloud_audit_log (customer_id, created_at DESC);

View File

@ -0,0 +1,38 @@
-- Migration: 079_email_templates
-- Created: 2026-02-01
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
(
'email_template_internal_subject',
'Intern besked fra BMC Hub',
'email_templates',
'Intern email emne',
'string',
false
),
(
'email_template_internal_body',
'Hej team,\n\n[Indsæt besked]\n\n--\nBMC Hub',
'email_templates',
'Intern email skabelon',
'text',
false
),
(
'email_template_external_subject',
'Besked fra BMC Networks',
'email_templates',
'Ekstern email emne',
'string',
false
),
(
'email_template_external_body',
'Hej {{customer_name}},\n\n[Indsæt besked]\n\nVenlig hilsen\nBMC Networks',
'email_templates',
'Ekstern email skabelon',
'text',
false
)
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,22 @@
-- Migration: 080_nextcloud_user_email_template
-- Created: 2026-02-01
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
(
'nextcloud_user_welcome_subject',
'Velkommen til Nextcloud hos BMC Networks',
'email_templates',
'Nextcloud velkomstmail emne',
'string',
false
),
(
'nextcloud_user_welcome_body',
'Hej {{name}},\n\nDin Nextcloud-konto er oprettet.\n\nLogin URL: {{url}}\nBrugernavn: {{username}}\nPassword: {{password}}\n\nGuide sådan logger du ind:\n1) Åbn linket\n2) Indtast brugernavn og password\n3) Skift gerne password ved første login\n\nVenlig hilsen\nBMC Networks',
'email_templates',
'Nextcloud velkomstmail skabelon',
'text',
false
)
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,96 @@
-- Migration: 081_better_email_templates
-- Created: 2026-02-01
-- Create email_templates table
CREATE TABLE IF NOT EXISTS email_templates (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
category VARCHAR(50) DEFAULT 'general',
description TEXT,
variables JSONB DEFAULT '{}',
is_system BOOLEAN DEFAULT FALSE,
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(slug, customer_id) -- A slug must be unique per customer (or global if customer_id is null)
);
-- Index for faster lookups
CREATE INDEX idx_email_templates_slug ON email_templates(slug);
CREATE INDEX idx_email_templates_customer ON email_templates(customer_id);
-- Migrate existing settings if they exist
DO $$
DECLARE
internal_subj TEXT;
internal_body TEXT;
external_subj TEXT;
external_body TEXT;
nc_subj TEXT;
nc_body TEXT;
BEGIN
-- Intern besked
SELECT value INTO internal_subj FROM settings WHERE key = 'email_template_internal_subject';
SELECT value INTO internal_body FROM settings WHERE key = 'email_template_internal_body';
IF internal_subj IS NOT NULL AND internal_body IS NOT NULL THEN
INSERT INTO email_templates (name, slug, subject, body, category, description, variables, is_system)
VALUES (
'Intern besked',
'internal_message',
internal_subj,
internal_body,
'internal',
'Standard skabelon til interne beskeder',
'{"message": "Selve beskeden"}'::jsonb,
TRUE
) ON CONFLICT DO NOTHING;
END IF;
-- Ekstern besked
SELECT value INTO external_subj FROM settings WHERE key = 'email_template_external_subject';
SELECT value INTO external_body FROM settings WHERE key = 'email_template_external_body';
IF external_subj IS NOT NULL AND external_body IS NOT NULL THEN
INSERT INTO email_templates (name, slug, subject, body, category, description, variables, is_system)
VALUES (
'Generel kundebesked',
'external_message',
external_subj,
external_body,
'general',
'Standard skabelon til emails sendt til kunder',
'{"customer_name": "Navnet på kunden", "message": "Selve beskeden"}'::jsonb,
TRUE
) ON CONFLICT DO NOTHING;
END IF;
-- Nextcloud velkomst
SELECT value INTO nc_subj FROM settings WHERE key = 'nextcloud_user_welcome_subject';
SELECT value INTO nc_body FROM settings WHERE key = 'nextcloud_user_welcome_body';
IF nc_subj IS NOT NULL AND nc_body IS NOT NULL THEN
INSERT INTO email_templates (name, slug, subject, body, category, description, variables, is_system)
VALUES (
'Nextcloud Velkomst',
'nextcloud_welcome',
nc_subj,
nc_body,
'nextcloud',
'Sendes til nye brugere når deres konto oprettes',
'{"name": "Modtagerens navn", "url": "URL til Nextcloud instans", "username": "Brugernavn", "password": "Det autogenererede password"}'::jsonb,
TRUE
) ON CONFLICT DO NOTHING;
END IF;
END $$;
-- Cleanup old settings (optional, keeping them for backward compatibility if needed, but usually safe to remove if code is updated simultaneously)
-- DELETE FROM settings WHERE key IN (
-- 'email_template_internal_subject', 'email_template_internal_body',
-- 'email_template_external_subject', 'email_template_external_body',
-- 'nextcloud_user_welcome_subject', 'nextcloud_user_welcome_body'
-- );

View File

@ -0,0 +1,14 @@
-- Migration: 082_sag_comments
-- Created: 2026-02-01
CREATE TABLE IF NOT EXISTS sag_kommentarer (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
forfatter VARCHAR(255) NOT NULL,
indhold TEXT NOT NULL,
er_system_besked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL
);
CREATE INDEX idx_sag_kommentarer_sag_id ON sag_comments(sag_id);

View File

@ -0,0 +1,27 @@
-- Migration: 083_sag_hardware_locations
-- Created: 2026-02-01
-- Description: Enable linking Hardware and Locations to Cases
BEGIN;
CREATE TABLE IF NOT EXISTS sag_hardware (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE,
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP,
UNIQUE(sag_id, hardware_id)
);
CREATE TABLE IF NOT EXISTS sag_lokationer (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE,
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP,
UNIQUE(sag_id, location_id)
);
COMMIT;

View File

@ -0,0 +1,27 @@
-- Migration 084: Sag (Case) Files and Linked Emails
-- Adds support for uploading files and linking emails to cases (Sager)
-- Files linked to a Case
CREATE TABLE IF NOT EXISTS sag_files (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
size_bytes INTEGER,
stored_name TEXT NOT NULL,
uploaded_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sag_files_sag_id ON sag_files(sag_id);
COMMENT ON TABLE sag_files IS 'Files uploaded directly to the Case.';
-- Emails linked to a Case (Many-to-Many)
CREATE TABLE IF NOT EXISTS sag_emails (
sag_id INTEGER REFERENCES sag_sager(id) ON DELETE CASCADE,
email_id INTEGER REFERENCES email_messages(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (sag_id, email_id)
);
COMMENT ON TABLE sag_emails IS 'Emails linked to the Case.';

View File

@ -0,0 +1,28 @@
-- Migration 085: Solution Module & Timetracking Integration
-- 1. Create Solutions Table
CREATE TABLE IF NOT EXISTS sag_solutions (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
solution_type VARCHAR(50), -- Support, Drift, Konsulent, etc.
result VARCHAR(50), -- Løst, Delvist, Workaround, Ej løst
created_by_user_id INTEGER, -- Reference to auth user (no FK to abstract 'users' table if not std)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_sag_solutions_sag_id UNIQUE (sag_id) -- 1:1 relation
);
-- 2. Update Timetracking Table to support Internal Cases & Solutions
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS solution_id INTEGER REFERENCES sag_solutions(id) ON DELETE SET NULL;
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL;
-- 3. Relax vTiger constraints to allow manual creation in Hub
ALTER TABLE tmodule_times ALTER COLUMN vtiger_id DROP NOT NULL;
ALTER TABLE tmodule_times ALTER COLUMN case_id DROP NOT NULL;
-- 4. Indexes for performance
CREATE INDEX IF NOT EXISTS idx_sag_solutions_sag_id ON sag_solutions(sag_id);
CREATE INDEX IF NOT EXISTS idx_tmodule_times_solution_id ON tmodule_times(solution_id);
CREATE INDEX IF NOT EXISTS idx_tmodule_times_sag_id ON tmodule_times(sag_id);

View File

@ -0,0 +1,11 @@
-- Add case types setting
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (
'case_types',
'["ticket", "opgave", "ordre", "projekt", "service"]',
'system',
'Sags-typer',
'json',
true
)
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,8 @@
-- Migration: Add prepaid_card_id to tmodule_times for integration with Ticket Prepaid Cards
-- Date: 2025-02-02
-- Description: Allows Solutions/Time Module entries to be linked to prepaid cards (klippekort)
ALTER TABLE tmodule_times
ADD COLUMN IF NOT EXISTS prepaid_card_id INTEGER REFERENCES tticket_prepaid_cards(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_tmodule_times_prepaid_card ON tmodule_times(prepaid_card_id);

View File

@ -0,0 +1,10 @@
-- Migration: Add billing_method to tmodule_times
-- Date: 2025-02-02
-- Description: Adds detailed billing method to replace simple boolean, aligning with tticket_worklog
ALTER TABLE tmodule_times
ADD COLUMN IF NOT EXISTS billing_method VARCHAR(50) DEFAULT 'invoice';
-- Migrate existing data
UPDATE tmodule_times SET billing_method = 'internal' WHERE billable = false;
UPDATE tmodule_times SET billing_method = 'invoice' WHERE billable = true;

View File

@ -0,0 +1,8 @@
-- Migration: Add sag_id to tmodule_order_lines
-- Date: 2025-02-02
-- Description: Allows order lines to reference Sag module cases
ALTER TABLE tmodule_order_lines
ADD COLUMN IF NOT EXISTS sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_tmodule_order_lines_sag ON tmodule_order_lines(sag_id);

View File

@ -0,0 +1,8 @@
-- Migration: Add work_type to tmodule_times
-- Date: 2026-02-02
-- Description: Adds work_type to align with tticket_worklog
ALTER TABLE tmodule_times
ADD COLUMN IF NOT EXISTS work_type VARCHAR(50) DEFAULT 'support';
CREATE INDEX IF NOT EXISTS idx_tmodule_times_work_type ON tmodule_times(work_type);

View File

@ -0,0 +1,18 @@
-- Migration 090: Add 2FA support to users table
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'totp_secret'
) THEN
ALTER TABLE users ADD COLUMN totp_secret VARCHAR(255);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'is_2fa_enabled'
) THEN
ALTER TABLE users ADD COLUMN is_2fa_enabled BOOLEAN DEFAULT FALSE;
END IF;
END $$;

View File

@ -8,6 +8,7 @@ python-multipart==0.0.17
python-dateutil==2.8.2 python-dateutil==2.8.2
jinja2==3.1.4 jinja2==3.1.4
aiohttp==3.10.10 aiohttp==3.10.10
cryptography==42.0.8
msal==1.31.1 msal==1.31.1
paramiko==3.4.1 paramiko==3.4.1
apscheduler==3.10.4 apscheduler==3.10.4
@ -15,3 +16,5 @@ pandas==2.2.3
openpyxl==3.1.2 openpyxl==3.1.2
extract-msg==0.55.0 extract-msg==0.55.0
pdfplumber==0.11.4 pdfplumber==0.11.4
passlib[bcrypt]==1.7.4
pyotp==2.9.0

View File

@ -396,16 +396,22 @@ window.removeEntityTag = async function(entityType, entityId, tagId, containerId
if (!confirm('Fjern dette tag?')) return; if (!confirm('Fjern dette tag?')) return;
try { try {
const response = await fetch(`/api/v1/tags/entity?entity_type=${entityType}&entity_id=${entityId}&tag_id=${tagId}`, { console.log(`🏷️ Removing tag ${tagId} from ${entityType} #${entityId}`);
// Use path-based endpoint for better compatibility
const response = await fetch(`/api/v1/tags/entity/${entityType}/${entityId}/${tagId}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (!response.ok) throw new Error('Failed to remove tag'); if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to remove tag: ${response.status} ${errorText}`);
}
console.log('🏷️ Tag removed successfully');
// Refresh tags display // Refresh tags display
await window.renderEntityTags(entityType, entityId, containerId); await window.renderEntityTags(entityType, entityId, containerId);
} catch (error) { } catch (error) {
console.error('Error removing tag:', error); console.error('Error removing tag:', error);
alert('Fejl ved fjernelse af tag'); alert('Fejl ved fjernelse af tag: ' + error.message);
} }
}; };