feat: add FastAPI admin dashboard with sync orchestration and test suite
Replace Flask admin with FastAPI app (api/app/) featuring: - Dashboard with stat cards, sync control, and history - Mappings CRUD for ARTICOLE_TERTI with CSV import/export - Article autocomplete from NOM_ARTICOLE - SKU pre-validation before import - Sync orchestration: read JSONs -> validate -> import -> log to SQLite - APScheduler for periodic sync from UI - File logging to logs/sync_comenzi_YYYYMMDD_HHMMSS.log - Oracle pool None guard (503 vs 500 on unavailable) Test suite: - test_app_basic.py: 30 tests (imports + routes) without Oracle - test_integration.py: 9 integration tests with Oracle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
api/app/routers/__init__.py
Normal file
0
api/app/routers/__init__.py
Normal file
10
api/app/routers/articles.py
Normal file
10
api/app/routers/articles.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from ..services import article_service
|
||||
|
||||
router = APIRouter(prefix="/api/articles", tags=["articles"])
|
||||
|
||||
@router.get("/search")
|
||||
def search_articles(q: str = Query("", min_length=2)):
|
||||
results = article_service.search_articles(q)
|
||||
return {"results": results}
|
||||
17
api/app/routers/dashboard.py
Normal file
17
api/app/routers/dashboard.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pathlib import Path
|
||||
|
||||
from ..services import sqlite_service
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||
|
||||
@router.get("/missing-skus", response_class=HTMLResponse)
|
||||
async def missing_skus_page(request: Request):
|
||||
return templates.TemplateResponse("missing_skus.html", {"request": request})
|
||||
30
api/app/routers/health.py
Normal file
30
api/app/routers/health.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter
|
||||
from .. import database
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
result = {"oracle": "error", "sqlite": "error"}
|
||||
|
||||
# Check Oracle
|
||||
try:
|
||||
if database.pool:
|
||||
with database.pool.acquire() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT SYSDATE FROM DUAL")
|
||||
cur.fetchone()
|
||||
result["oracle"] = "ok"
|
||||
except Exception as e:
|
||||
result["oracle"] = str(e)
|
||||
|
||||
# Check SQLite
|
||||
try:
|
||||
db = await database.get_sqlite()
|
||||
await db.execute("SELECT 1")
|
||||
await db.close()
|
||||
result["sqlite"] = "ok"
|
||||
except Exception as e:
|
||||
result["sqlite"] = str(e)
|
||||
|
||||
return result
|
||||
84
api/app/routers/mappings.py
Normal file
84
api/app/routers/mappings.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from fastapi import APIRouter, Query, Request, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse, HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import io
|
||||
|
||||
from ..services import mapping_service, sqlite_service
|
||||
|
||||
router = APIRouter(tags=["mappings"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
class MappingCreate(BaseModel):
|
||||
sku: str
|
||||
codmat: str
|
||||
cantitate_roa: float = 1
|
||||
procent_pret: float = 100
|
||||
|
||||
class MappingUpdate(BaseModel):
|
||||
cantitate_roa: Optional[float] = None
|
||||
procent_pret: Optional[float] = None
|
||||
activ: Optional[int] = None
|
||||
|
||||
# HTML page
|
||||
@router.get("/mappings", response_class=HTMLResponse)
|
||||
async def mappings_page(request: Request):
|
||||
return templates.TemplateResponse("mappings.html", {"request": request})
|
||||
|
||||
# API endpoints
|
||||
@router.get("/api/mappings")
|
||||
def list_mappings(search: str = "", page: int = 1, per_page: int = 50):
|
||||
return mapping_service.get_mappings(search=search, page=page, per_page=per_page)
|
||||
|
||||
@router.post("/api/mappings")
|
||||
async def create_mapping(data: MappingCreate):
|
||||
try:
|
||||
result = mapping_service.create_mapping(data.sku, data.codmat, data.cantitate_roa, data.procent_pret)
|
||||
# Mark SKU as resolved in missing_skus tracking
|
||||
await sqlite_service.resolve_missing_sku(data.sku)
|
||||
return {"success": True, **result}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@router.put("/api/mappings/{sku}/{codmat}")
|
||||
def update_mapping(sku: str, codmat: str, data: MappingUpdate):
|
||||
try:
|
||||
updated = mapping_service.update_mapping(sku, codmat, data.cantitate_roa, data.procent_pret, data.activ)
|
||||
return {"success": updated}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@router.delete("/api/mappings/{sku}/{codmat}")
|
||||
def delete_mapping(sku: str, codmat: str):
|
||||
try:
|
||||
deleted = mapping_service.delete_mapping(sku, codmat)
|
||||
return {"success": deleted}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@router.post("/api/mappings/import-csv")
|
||||
async def import_csv(file: UploadFile = File(...)):
|
||||
content = await file.read()
|
||||
text = content.decode("utf-8-sig")
|
||||
result = mapping_service.import_csv(text)
|
||||
return result
|
||||
|
||||
@router.get("/api/mappings/export-csv")
|
||||
def export_csv():
|
||||
csv_content = mapping_service.export_csv()
|
||||
return StreamingResponse(
|
||||
io.BytesIO(csv_content.encode("utf-8-sig")),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=mappings.csv"}
|
||||
)
|
||||
|
||||
@router.get("/api/mappings/csv-template")
|
||||
def csv_template():
|
||||
content = mapping_service.get_csv_template()
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content.encode("utf-8-sig")),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=mappings_template.csv"}
|
||||
)
|
||||
90
api/app/routers/sync.py
Normal file
90
api/app/routers/sync.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from fastapi import APIRouter, Request, BackgroundTasks
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..services import sync_service, scheduler_service, sqlite_service
|
||||
|
||||
router = APIRouter(tags=["sync"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
|
||||
class ScheduleConfig(BaseModel):
|
||||
enabled: bool
|
||||
interval_minutes: int = 5
|
||||
|
||||
|
||||
# HTML pages
|
||||
@router.get("/sync", response_class=HTMLResponse)
|
||||
async def sync_page(request: Request):
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/sync/run/{run_id}", response_class=HTMLResponse)
|
||||
async def sync_detail_page(request: Request, run_id: str):
|
||||
return templates.TemplateResponse("sync_detail.html", {"request": request, "run_id": run_id})
|
||||
|
||||
|
||||
# API endpoints
|
||||
@router.post("/api/sync/start")
|
||||
async def start_sync(background_tasks: BackgroundTasks):
|
||||
"""Trigger a sync run in the background."""
|
||||
status = await sync_service.get_sync_status()
|
||||
if status.get("status") == "running":
|
||||
return {"error": "Sync already running", "run_id": status.get("run_id")}
|
||||
|
||||
background_tasks.add_task(sync_service.run_sync)
|
||||
return {"message": "Sync started"}
|
||||
|
||||
|
||||
@router.post("/api/sync/stop")
|
||||
async def stop_sync():
|
||||
"""Stop a running sync."""
|
||||
sync_service.stop_sync()
|
||||
return {"message": "Stop signal sent"}
|
||||
|
||||
|
||||
@router.get("/api/sync/status")
|
||||
async def sync_status():
|
||||
"""Get current sync status."""
|
||||
status = await sync_service.get_sync_status()
|
||||
stats = await sqlite_service.get_dashboard_stats()
|
||||
return {**status, "stats": stats}
|
||||
|
||||
|
||||
@router.get("/api/sync/history")
|
||||
async def sync_history(page: int = 1, per_page: int = 20):
|
||||
"""Get sync run history."""
|
||||
return await sqlite_service.get_sync_runs(page, per_page)
|
||||
|
||||
|
||||
@router.get("/api/sync/run/{run_id}")
|
||||
async def sync_run_detail(run_id: str):
|
||||
"""Get details for a specific sync run."""
|
||||
detail = await sqlite_service.get_sync_run_detail(run_id)
|
||||
if not detail:
|
||||
return {"error": "Run not found"}
|
||||
return detail
|
||||
|
||||
|
||||
@router.put("/api/sync/schedule")
|
||||
async def update_schedule(config: ScheduleConfig):
|
||||
"""Update scheduler configuration."""
|
||||
if config.enabled:
|
||||
scheduler_service.start_scheduler(config.interval_minutes)
|
||||
else:
|
||||
scheduler_service.stop_scheduler()
|
||||
|
||||
# Persist config
|
||||
await sqlite_service.set_scheduler_config("enabled", str(config.enabled))
|
||||
await sqlite_service.set_scheduler_config("interval_minutes", str(config.interval_minutes))
|
||||
|
||||
return scheduler_service.get_scheduler_status()
|
||||
|
||||
|
||||
@router.get("/api/sync/schedule")
|
||||
async def get_schedule():
|
||||
"""Get current scheduler status."""
|
||||
return scheduler_service.get_scheduler_status()
|
||||
111
api/app/routers/validation.py
Normal file
111
api/app/routers/validation.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import csv
|
||||
import io
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ..services import order_reader, validation_service
|
||||
from ..database import get_sqlite
|
||||
|
||||
router = APIRouter(prefix="/api/validate", tags=["validation"])
|
||||
|
||||
@router.post("/scan")
|
||||
async def scan_and_validate():
|
||||
"""Scan JSON files and validate all SKUs."""
|
||||
orders, json_count = order_reader.read_json_orders()
|
||||
|
||||
if not orders:
|
||||
return {"orders": 0, "json_files": json_count, "skus": {}, "message": "No orders found"}
|
||||
|
||||
all_skus = order_reader.get_all_skus(orders)
|
||||
result = validation_service.validate_skus(all_skus)
|
||||
importable, skipped = validation_service.classify_orders(orders, result)
|
||||
|
||||
# Track missing SKUs in SQLite
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
for sku in result["missing"]:
|
||||
# Find product name from orders
|
||||
product_name = ""
|
||||
for order in orders:
|
||||
for item in order.items:
|
||||
if item.sku == sku:
|
||||
product_name = item.name
|
||||
break
|
||||
if product_name:
|
||||
break
|
||||
|
||||
await db.execute("""
|
||||
INSERT OR IGNORE INTO missing_skus (sku, product_name)
|
||||
VALUES (?, ?)
|
||||
""", (sku, product_name))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
return {
|
||||
"json_files": json_count,
|
||||
"total_orders": len(orders),
|
||||
"total_skus": len(all_skus),
|
||||
"importable": len(importable),
|
||||
"skipped": len(skipped),
|
||||
"skus": {
|
||||
"mapped": len(result["mapped"]),
|
||||
"direct": len(result["direct"]),
|
||||
"missing": len(result["missing"]),
|
||||
"missing_list": sorted(result["missing"])
|
||||
},
|
||||
"skipped_orders": [
|
||||
{
|
||||
"number": order.number,
|
||||
"customer": order.billing.company_name or f"{order.billing.firstname} {order.billing.lastname}",
|
||||
"items_count": len(order.items),
|
||||
"missing_skus": missing
|
||||
}
|
||||
for order, missing in skipped[:50] # limit to 50
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/missing-skus")
|
||||
async def get_missing_skus():
|
||||
"""Get all tracked missing SKUs."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
cursor = await db.execute("""
|
||||
SELECT sku, product_name, first_seen, resolved, resolved_at
|
||||
FROM missing_skus
|
||||
ORDER BY resolved ASC, first_seen DESC
|
||||
""")
|
||||
rows = await cursor.fetchall()
|
||||
return {
|
||||
"missing_skus": [dict(row) for row in rows],
|
||||
"total": len(rows),
|
||||
"unresolved": sum(1 for r in rows if not r["resolved"])
|
||||
}
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
@router.get("/missing-skus-csv")
|
||||
async def export_missing_skus_csv():
|
||||
"""Export missing SKUs as CSV."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
cursor = await db.execute("""
|
||||
SELECT sku, product_name, first_seen, resolved
|
||||
FROM missing_skus WHERE resolved = 0
|
||||
ORDER BY first_seen DESC
|
||||
""")
|
||||
rows = await cursor.fetchall()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["sku", "product_name", "first_seen"])
|
||||
for row in rows:
|
||||
writer.writerow([row["sku"], row["product_name"], row["first_seen"]])
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(output.getvalue().encode("utf-8-sig")),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=missing_skus.csv"}
|
||||
)
|
||||
Reference in New Issue
Block a user