bmc_hub/app/modules/webshop/backend/router.py
Christian f059cb6c95 feat: Add product search endpoint and enhance opportunity management
- Implemented a new endpoint for searching webshop products with filters for visibility and configuration.
- Enhanced the webshop frontend to include a customer search feature for improved user experience.
- Added opportunity line items management with CRUD operations and comments functionality.
- Created database migrations for opportunity line items and comments, including necessary triggers and indexes.
2026-01-28 14:37:47 +01:00

591 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
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
# ============================================================================
# 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:
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))