bmc_hub/app/modules/webshop/backend/router.py
Christian b43e9f797d feat: Add reminder system for sag cases with user preferences and notification channels
- Implemented user notification preferences table for managing default notification settings.
- Created sag_reminders table to define reminder rules with various trigger types and recipient configurations.
- Developed sag_reminder_queue for processing reminder events triggered by status changes or scheduled times.
- Added sag_reminder_logs to track reminder notifications and user interactions.
- Introduced frontend notification system using Bootstrap 5 Toast for displaying reminders.
- Created email template for sending reminders with case details and action links.
- Implemented rate limiting for user notifications to prevent spamming.
- Added triggers and functions for automatic updates and reminder processing.
2026-02-06 10:47:14 +01:00

605 lines
20 KiB
Python

"""
Webshop Module - API Router
Backend endpoints for webshop administration og konfiguration
"""
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from typing import List, Optional
from pydantic import BaseModel, field_validator
from datetime import datetime
import logging
import os
import shutil
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
logger = logging.getLogger(__name__)
# APIRouter instance (module_loader kigger efter denne)
router = APIRouter()
# Upload directory for logos
LOGO_UPLOAD_DIR = "/app/uploads/webshop_logos"
os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
# ============================================================================
# PYDANTIC MODELS
# ============================================================================
class WebshopConfigCreate(BaseModel):
customer_id: int
name: str
allowed_email_domains: str # Comma-separated
header_text: Optional[str] = None
intro_text: Optional[str] = None
primary_color: str = "#0f4c75"
accent_color: str = "#3282b8"
default_margin_percent: float = 10.0
min_order_amount: float = 0.0
shipping_cost: float = 0.0
enabled: bool = True
class WebshopConfigUpdate(BaseModel):
name: Optional[str] = None
allowed_email_domains: Optional[str] = None
header_text: Optional[str] = None
intro_text: Optional[str] = None
primary_color: Optional[str] = None
accent_color: Optional[str] = None
default_margin_percent: Optional[float] = None
min_order_amount: Optional[float] = None
shipping_cost: Optional[float] = None
enabled: Optional[bool] = None
class WebshopProductCreate(BaseModel):
webshop_config_id: int
product_number: str
ean: Optional[str] = None
name: str
description: Optional[str] = None
unit: str = "stk"
base_price: float
category: Optional[str] = None
custom_margin_percent: Optional[float] = None
visible: bool = True
sort_order: int = 0
@field_validator('base_price')
@classmethod
def validate_base_price(cls, v):
if v <= 0:
raise ValueError('Basispris må ikke være 0 eller negativ')
return v
# ============================================================================
# WEBSHOP CONFIG ENDPOINTS
# ============================================================================
@router.get("/webshop/configs")
async def get_all_webshop_configs():
"""
Hent alle webshop konfigurationer med kunde info
"""
try:
query = """
SELECT
wc.*,
c.name as customer_name,
c.cvr_number as customer_cvr,
(SELECT COUNT(*) FROM webshop_products WHERE webshop_config_id = wc.id) as product_count
FROM webshop_configs wc
LEFT JOIN customers c ON c.id = wc.customer_id
ORDER BY wc.created_at DESC
"""
configs = execute_query(query)
return {
"success": True,
"configs": configs
}
except Exception as e:
logger.error(f"❌ Error fetching webshop configs: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/webshop/configs/{config_id}")
async def get_webshop_config(config_id: int):
"""
Hent enkelt webshop konfiguration
"""
try:
query = """
SELECT
wc.*,
c.name as customer_name,
c.cvr_number as customer_cvr,
c.email as customer_email
FROM webshop_configs wc
LEFT JOIN customers c ON c.id = wc.customer_id
WHERE wc.id = %s
"""
config = execute_query_single(query, (config_id,))
if not config:
raise HTTPException(status_code=404, detail="Webshop config not found")
# Hent produkter
products_query = """
SELECT * FROM webshop_products
WHERE webshop_config_id = %s
ORDER BY sort_order, name
"""
products = execute_query(products_query, (config_id,))
return {
"success": True,
"config": config,
"products": products
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error fetching webshop config {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/webshop/configs")
async def create_webshop_config(config: WebshopConfigCreate):
"""
Opret ny webshop konfiguration
"""
try:
# Check if customer already has a webshop
existing = execute_query_single(
"SELECT id FROM webshop_configs WHERE customer_id = %s",
(config.customer_id,)
)
if existing:
raise HTTPException(
status_code=400,
detail="Customer already has a webshop configuration"
)
query = """
INSERT INTO webshop_configs (
customer_id, name, allowed_email_domains, header_text, intro_text,
primary_color, accent_color, default_margin_percent,
min_order_amount, shipping_cost, enabled
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query_single(
query,
(
config.customer_id, config.name, config.allowed_email_domains,
config.header_text, config.intro_text, config.primary_color,
config.accent_color, config.default_margin_percent,
config.min_order_amount, config.shipping_cost, config.enabled
)
)
logger.info(f"✅ Created webshop config {result['id']} for customer {config.customer_id}")
return {
"success": True,
"config": result,
"message": "Webshop configuration created successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating webshop config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/webshop/configs/{config_id}")
async def update_webshop_config(config_id: int, config: WebshopConfigUpdate):
"""
Opdater webshop konfiguration
"""
try:
# Build dynamic update query
update_fields = []
params = []
if config.name is not None:
update_fields.append("name = %s")
params.append(config.name)
if config.allowed_email_domains is not None:
update_fields.append("allowed_email_domains = %s")
params.append(config.allowed_email_domains)
if config.header_text is not None:
update_fields.append("header_text = %s")
params.append(config.header_text)
if config.intro_text is not None:
update_fields.append("intro_text = %s")
params.append(config.intro_text)
if config.primary_color is not None:
update_fields.append("primary_color = %s")
params.append(config.primary_color)
if config.accent_color is not None:
update_fields.append("accent_color = %s")
params.append(config.accent_color)
if config.default_margin_percent is not None:
update_fields.append("default_margin_percent = %s")
params.append(config.default_margin_percent)
if config.min_order_amount is not None:
update_fields.append("min_order_amount = %s")
params.append(config.min_order_amount)
if config.shipping_cost is not None:
update_fields.append("shipping_cost = %s")
params.append(config.shipping_cost)
if config.enabled is not None:
update_fields.append("enabled = %s")
params.append(config.enabled)
if not update_fields:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(config_id)
query = f"""
UPDATE webshop_configs
SET {', '.join(update_fields)}
WHERE id = %s
RETURNING *
"""
result = execute_query_single(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Webshop config not found")
logger.info(f"✅ Updated webshop config {config_id}")
return {
"success": True,
"config": result,
"message": "Webshop configuration updated successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating webshop config {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/webshop/configs/{config_id}/upload-logo")
async def upload_webshop_logo(config_id: int, logo: UploadFile = File(...)):
"""
Upload logo til webshop
"""
try:
# Validate file type
if not logo.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
# Generate filename
ext = logo.filename.split(".")[-1]
filename = f"webshop_{config_id}.{ext}"
filepath = os.path.join(LOGO_UPLOAD_DIR, filename)
# Save file
with open(filepath, 'wb') as f:
shutil.copyfileobj(logo.file, f)
# Update database
query = """
UPDATE webshop_configs
SET logo_filename = %s
WHERE id = %s
RETURNING *
"""
result = execute_query_single(query, (filename, config_id))
if not result:
raise HTTPException(status_code=404, detail="Webshop config not found")
logger.info(f"✅ Uploaded logo for webshop {config_id}: {filename}")
return {
"success": True,
"filename": filename,
"config": result,
"message": "Logo uploaded successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error uploading logo for webshop {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/webshop/configs/{config_id}")
async def delete_webshop_config(config_id: int):
"""
Slet webshop konfiguration (soft delete - disable)
"""
try:
query = """
UPDATE webshop_configs
SET enabled = FALSE
WHERE id = %s
RETURNING *
"""
result = execute_query_single(query, (config_id,))
if not result:
raise HTTPException(status_code=404, detail="Webshop config not found")
logger.info(f"✅ Disabled webshop config {config_id}")
return {
"success": True,
"message": "Webshop configuration disabled"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error deleting webshop config {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==========================================================================
# PRODUCT SEARCH HELPERS
# ==========================================================================
@router.get("/webshop/products/search")
async def search_webshop_products(
search: Optional[str] = None,
limit: int = 20,
config_id: Optional[int] = None
):
try:
params: List = []
filters = "WHERE visible = TRUE"
if config_id is not None:
filters += " AND webshop_config_id = %s"
params.append(config_id)
if search:
pattern = f"%{search}%"
filters += " AND (name ILIKE %s OR product_number ILIKE %s OR category ILIKE %s)"
params.extend([pattern, pattern, pattern])
query = f"SELECT * FROM webshop_products {filters} ORDER BY updated_at DESC LIMIT %s"
params.append(limit)
return {"products": execute_query(query, tuple(params)) or []}
except Exception as e:
logger.error(f"❌ Error searching webshop products: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# WEBSHOP PRODUCT ENDPOINTS
# ============================================================================
@router.post("/webshop/products")
async def add_webshop_product(product: WebshopProductCreate):
"""
Tilføj produkt til webshop whitelist
"""
try:
# Validate price is not zero
if product.base_price <= 0:
raise HTTPException(
status_code=400,
detail="Basispris må ikke være 0 eller negativ. Angiv en gyldig pris."
)
query = """
INSERT INTO webshop_products (
webshop_config_id, product_number, ean, name, description,
unit, base_price, category, custom_margin_percent,
visible, sort_order
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query_single(
query,
(
product.webshop_config_id, product.product_number, product.ean,
product.name, product.description, product.unit, product.base_price,
product.category, product.custom_margin_percent, product.visible,
product.sort_order
)
)
logger.info(f"✅ Added product {product.product_number} to webshop {product.webshop_config_id}")
return {
"success": True,
"product": result,
"message": "Product added to webshop"
}
except Exception as e:
if "unique" in str(e).lower():
raise HTTPException(status_code=400, detail="Product already exists in this webshop")
logger.error(f"❌ Error adding product to webshop: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/webshop/products/{product_id}")
async def remove_webshop_product(product_id: int):
"""
Fjern produkt fra webshop whitelist
"""
try:
query = "DELETE FROM webshop_products WHERE id = %s RETURNING *"
result = execute_query_single(query, (product_id,))
if not result:
raise HTTPException(status_code=404, detail="Product not found")
logger.info(f"✅ Removed product {product_id} from webshop")
return {
"success": True,
"message": "Product removed from webshop"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error removing product {product_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# PUBLISH TO GATEWAY
# ============================================================================
@router.post("/webshop/configs/{config_id}/publish")
async def publish_webshop_to_gateway(config_id: int):
"""
Push webshop konfiguration til API Gateway
"""
try:
from app.core.config import settings
import aiohttp
# Hent config og produkter
config_data = await get_webshop_config(config_id)
if not config_data["config"]["enabled"]:
raise HTTPException(status_code=400, detail="Cannot publish disabled webshop")
# Byg payload
payload = {
"config_id": config_id,
"customer_id": config_data["config"]["customer_id"],
"company_name": config_data["config"]["customer_name"],
"config_version": config_data["config"]["config_version"].isoformat(),
"branding": {
"logo_url": f"{settings.BASE_URL}/uploads/webshop_logos/{config_data['config']['logo_filename']}" if config_data['config'].get('logo_filename') else None,
"header_text": config_data["config"]["header_text"],
"intro_text": config_data["config"]["intro_text"],
"primary_color": config_data["config"]["primary_color"],
"accent_color": config_data["config"]["accent_color"]
},
"allowed_email_domains": config_data["config"]["allowed_email_domains"].split(","),
"products": [
{
"id": p["id"],
"product_number": p["product_number"],
"ean": p["ean"],
"name": p["name"],
"description": p["description"],
"unit": p["unit"],
"base_price": float(p["base_price"]),
"calculated_price": float(p["base_price"]) * (1 + (float(p.get("custom_margin_percent") or config_data["config"]["default_margin_percent"]) / 100)),
"margin_percent": float(p.get("custom_margin_percent") or config_data["config"]["default_margin_percent"]),
"category": p["category"],
"visible": p["visible"]
}
for p in config_data["products"]
],
"settings": {
"min_order_amount": float(config_data["config"]["min_order_amount"]),
"shipping_cost": float(config_data["config"]["shipping_cost"]),
"default_margin_percent": float(config_data["config"]["default_margin_percent"])
}
}
# Send til Gateway
gateway_url = "https://apigateway.bmcnetworks.dk/webshop/ingest"
# TODO: Uncomment når Gateway er klar
# async with aiohttp.ClientSession() as session:
# async with session.post(gateway_url, json=payload) as response:
# if response.status != 200:
# error_text = await response.text()
# raise HTTPException(status_code=500, detail=f"Gateway error: {error_text}")
#
# gateway_response = await response.json()
# Mock response for now
logger.warning("⚠️ Gateway push disabled - mock response returned")
gateway_response = {"success": True, "message": "Config received (MOCK)"}
# Update last_published timestamps
update_query = """
UPDATE webshop_configs
SET last_published_at = CURRENT_TIMESTAMP,
last_published_version = config_version
WHERE id = %s
RETURNING *
"""
updated_config = execute_query_single(update_query, (config_id,))
logger.info(f"✅ Published webshop {config_id} to Gateway")
return {
"success": True,
"config": updated_config,
"payload": payload,
"gateway_response": gateway_response,
"message": "Webshop configuration published to Gateway"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error publishing webshop {config_id} to Gateway: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# ORDERS FROM GATEWAY
# ============================================================================
@router.get("/webshop/orders")
async def get_webshop_orders(config_id: Optional[int] = None, status: Optional[str] = None):
"""
Hent importerede ordrer fra Gateway
"""
try:
query = """
SELECT
wo.*,
wc.name as webshop_name,
c.name as customer_name
FROM webshop_orders wo
LEFT JOIN webshop_configs wc ON wc.id = wo.webshop_config_id
LEFT JOIN customers c ON c.id = wo.customer_id
WHERE 1=1
"""
params = []
if config_id:
query += " AND wo.webshop_config_id = %s"
params.append(config_id)
if status:
query += " AND wo.status = %s"
params.append(status)
query += " ORDER BY wo.created_at DESC"
orders = execute_query(query, tuple(params) if params else None)
return {
"success": True,
"orders": orders
}
except Exception as e:
logger.error(f"❌ Error fetching webshop orders: {e}")
raise HTTPException(status_code=500, detail=str(e))