Files
Salus/app/m365.py
T
cgasserandClaude Sonnet 4.6 1ec63844ce Add M365 mapping/prices export-import, license pricing, and PDF summary export
- Mapping (Username/QWE Client/IsRestaurant): CSV export and import via new routes
- License prices: persistent DB table (M365LicensePrice), editable price card with auto-save, CSV export/import
- Summary table: Monthly Cost column per client + grand-total row, calculated live in JS from current prices
- PDF export: client-side jsPDF/autotable, landscape A4, includes cost column when prices are set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:05:26 +02:00

446 lines
15 KiB
Python

import csv
import datetime
import io
import json
import logging
import os
import secrets
from collections import defaultdict
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.auth import get_current_user
from app.database import M365LicensePrice, M365UserOverride, get_db
logger = logging.getLogger(__name__)
BASE_DIR = os.path.dirname(__file__)
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
router = APIRouter()
AT_DEPTS = {"QWE AT", "KFC AT", "QWE"}
SK_DEPTS = {"QWE SK", "KFC SK"}
LICENSE_KEYS = [
("m365_standard", "M365 Business Standard"),
("m365_basic", "M365 Business Basic"),
("defender_p1", "Defender P1"),
("pbi_pro", "Power BI Pro"),
("pbi_premium", "Power BI Premium"),
]
def _csrf_token(request: Request) -> str:
if "csrf_token" not in request.session:
request.session["csrf_token"] = secrets.token_hex(32)
return request.session["csrf_token"]
def _get_prices(db: Session) -> dict[str, float]:
rows = {r.license_key: r.price for r in db.query(M365LicensePrice).all()}
return {key: rows.get(key, 0.0) for key, _ in LICENSE_KEYS}
def _verify_csrf(request: Request, token: str):
expected = request.session.get("csrf_token")
if not expected or not secrets.compare_digest(expected, token):
raise HTTPException(status_code=403, detail="CSRF token invalid")
def _parse_csv(content: bytes) -> list[dict]:
try:
text = content.decode("utf-8-sig")
except UnicodeDecodeError:
text = content.decode("latin-1")
reader = csv.DictReader(io.StringIO(text))
users = []
for row in reader:
upn = row.get("User principal name", "").strip()
licenses = row.get("Licenses", "").strip()
department = row.get("Department", "").strip()
is_licensed = bool(licenses) and licenses != "Unlicensed"
company = ""
if department in AT_DEPTS:
company = "AT"
elif department in SK_DEPTS:
company = "SK"
users.append({
"display_name": row.get("Display name", "").strip(),
"username": upn,
"first_name": row.get("First name", "").strip(),
"last_name": row.get("Last name", "").strip(),
"department": department,
"job_title": row.get("Title", "").strip(),
"usage_location": row.get("Usage location", "").strip(),
"licenses": licenses,
"is_licensed": is_licensed,
"company": company,
"has_m365_standard": "Microsoft 365 Business Standard" in licenses,
"has_m365_basic": "Microsoft 365 Business Basic" in licenses,
"has_defender_p1": "Microsoft Defender for Office 365 (Plan 1)" in licenses,
"has_pbi_pro": "Power BI Pro" in licenses,
"has_pbi_premium": "Power BI Premium Per User" in licenses,
"qwe_client": "QWE AT" if "AT" in department else ("QWE SK" if "SK" in department else ""),
"is_restaurant": False,
})
return users
def _apply_overrides(users: list[dict], db: Session) -> list[dict]:
upns = [u["username"] for u in users if u["username"]]
overrides = {
o.username: o
for o in db.query(M365UserOverride).filter(M365UserOverride.username.in_(upns)).all()
}
for user in users:
ov = overrides.get(user["username"])
if ov:
if ov.qwe_client: # keep department default when saved value is empty
user["qwe_client"] = ov.qwe_client
user["is_restaurant"] = bool(ov.is_restaurant)
return users
def _calculate_summary(users: list[dict]) -> dict:
licensed = [u for u in users if u["is_licensed"]]
missing = [u for u in licensed if not u["company"]]
# Per-QWE-Client breakdown (primary goal)
client_groups: dict[str, list] = defaultdict(list)
for u in licensed:
key = u["qwe_client"].strip()
client_groups[key].append(u)
def _counts(group: list) -> dict:
return {
"m365_standard": sum(1 for u in group if u["has_m365_standard"]),
"m365_basic": sum(1 for u in group if u["has_m365_basic"]),
"defender_p1": sum(1 for u in group if u["has_defender_p1"]),
"pbi_pro": sum(1 for u in group if u["has_pbi_pro"]),
"pbi_premium": sum(1 for u in group if u["has_pbi_premium"]),
}
# Named clients first (sorted), unnamed last
client_breakdown = []
for name in sorted(client_groups.keys(), key=lambda x: (x == "", x.lower())):
group = client_groups[name]
row = {"name": name or "(no client)", "count": len(group)}
row.update(_counts(group))
client_breakdown.append(row)
return {
"total": len(users),
"licensed": len(licensed),
"client_breakdown": client_breakdown,
"missing": missing,
}
def _users_for_js(users: list[dict]) -> str:
"""Minimal user data serialised as JSON for client-side summary recalculation."""
return json.dumps([
{
"username": u["username"],
"qwe_client": u["qwe_client"],
"is_licensed": u["is_licensed"],
"company": u["company"],
"has_m365_standard": u["has_m365_standard"],
"has_m365_basic": u["has_m365_basic"],
"has_defender_p1": u["has_defender_p1"],
"has_pbi_pro": u["has_pbi_pro"],
"has_pbi_premium": u["has_pbi_premium"],
}
for u in users
])
def _render(request: Request, user, csrf: str, **kwargs):
return templates.TemplateResponse("m365_license.html", {
"request": request,
"user": user,
"csrf_token": csrf,
"users": None,
"summary": None,
"users_json": "[]",
"filename": None,
"error": None,
"import_success": None,
"prices": {},
"license_keys": LICENSE_KEYS,
"prices_import_success": None,
**kwargs,
})
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/m365", response_class=HTMLResponse)
async def m365_index(request: Request):
user = get_current_user(request)
if not user:
return RedirectResponse("/auth/login", status_code=302)
return RedirectResponse("/m365/license-calculator", status_code=302)
@router.get("/m365/license-calculator", response_class=HTMLResponse)
async def license_calculator_page(
request: Request,
imported: int | None = None,
import_error: bool = False,
prices_imported: int | None = None,
prices_error: bool = False,
db: Session = Depends(get_db),
):
user = get_current_user(request)
if not user:
return RedirectResponse("/auth/login", status_code=302)
csrf = _csrf_token(request)
kwargs: dict = {"prices": _get_prices(db)}
if imported is not None:
kwargs["import_success"] = imported
if import_error:
kwargs["error"] = "Failed to import mapping CSV. Check that the file has Username, QWE Client, and IsRestaurant columns."
if prices_imported is not None:
kwargs["prices_import_success"] = prices_imported
if prices_error:
kwargs["error"] = "Failed to import prices CSV. Check that the file has License Key and Price columns."
return _render(request, user, csrf, **kwargs)
@router.post("/m365/license-calculator/upload", response_class=HTMLResponse)
async def upload_csv(
request: Request,
file: UploadFile = File(...),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request)
if not user:
return RedirectResponse("/auth/login", status_code=302)
_verify_csrf(request, csrf_token)
csrf = _csrf_token(request)
try:
content = await file.read()
users = _parse_csv(content)
users = _apply_overrides(users, db)
summary = _calculate_summary(users)
licensed = [u for u in users if u["is_licensed"]]
return _render(
request, user, csrf,
users=licensed,
summary=summary,
users_json=_users_for_js(licensed),
filename=file.filename,
prices=_get_prices(db),
)
except Exception as exc:
logger.error("M365 CSV parse error: %s", exc)
return _render(request, user, csrf, error=f"Failed to process CSV: {exc}")
@router.post("/m365/override")
async def save_override(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
body = await request.json()
_verify_csrf(request, body.get("csrf_token", ""))
username = body.get("username", "").strip()
if not username:
raise HTTPException(status_code=400, detail="username required")
override = db.query(M365UserOverride).filter(M365UserOverride.username == username).first()
if override:
override.qwe_client = body.get("qwe_client") or ""
override.is_restaurant = bool(body.get("is_restaurant", False))
override.updated_at = datetime.datetime.utcnow()
else:
db.add(M365UserOverride(
username=username,
qwe_client=body.get("qwe_client") or "",
is_restaurant=bool(body.get("is_restaurant", False)),
))
db.commit()
return {"status": "ok"}
@router.get("/m365/mapping/export")
async def export_mapping(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse("/auth/login", status_code=302)
overrides = db.query(M365UserOverride).order_by(M365UserOverride.username).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["Username", "QWE Client", "IsRestaurant"])
for o in overrides:
writer.writerow([o.username, o.qwe_client, "1" if o.is_restaurant else "0"])
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=m365_mapping.csv"},
)
@router.post("/m365/mapping/import", response_class=HTMLResponse)
async def import_mapping(
request: Request,
file: UploadFile = File(...),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request)
if not user:
return RedirectResponse("/auth/login", status_code=302)
_verify_csrf(request, csrf_token)
try:
content = await file.read()
try:
text = content.decode("utf-8-sig")
except UnicodeDecodeError:
text = content.decode("latin-1")
reader = csv.DictReader(io.StringIO(text))
count = 0
for row in reader:
username = row.get("Username", "").strip()
if not username:
continue
qwe_client = row.get("QWE Client", "").strip()
is_restaurant = row.get("IsRestaurant", "0").strip().lower() in ("1", "true", "yes")
override = db.query(M365UserOverride).filter(M365UserOverride.username == username).first()
if override:
override.qwe_client = qwe_client
override.is_restaurant = is_restaurant
override.updated_at = datetime.datetime.utcnow()
else:
db.add(M365UserOverride(
username=username,
qwe_client=qwe_client,
is_restaurant=is_restaurant,
))
count += 1
db.commit()
return RedirectResponse(f"/m365/license-calculator?imported={count}", status_code=302)
except Exception as exc:
logger.error("M365 mapping import error: %s", exc)
return RedirectResponse("/m365/license-calculator?import_error=1", status_code=302)
@router.post("/m365/prices")
async def save_prices(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
body = await request.json()
_verify_csrf(request, body.get("csrf_token", ""))
for key, _ in LICENSE_KEYS:
raw = body.get(key)
if raw is None:
continue
try:
price = max(0.0, float(raw))
except (TypeError, ValueError):
continue
row = db.query(M365LicensePrice).filter(M365LicensePrice.license_key == key).first()
if row:
row.price = price
row.updated_at = datetime.datetime.utcnow()
else:
db.add(M365LicensePrice(license_key=key, price=price))
db.commit()
return {"status": "ok"}
@router.get("/m365/prices/export")
async def export_prices(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse("/auth/login", status_code=302)
prices = _get_prices(db)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["License Key", "Label", "Price"])
for key, label in LICENSE_KEYS:
writer.writerow([key, label, prices[key]])
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=m365_prices.csv"},
)
@router.post("/m365/prices/import", response_class=HTMLResponse)
async def import_prices(
request: Request,
file: UploadFile = File(...),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request)
if not user:
return RedirectResponse("/auth/login", status_code=302)
_verify_csrf(request, csrf_token)
valid_keys = {key for key, _ in LICENSE_KEYS}
try:
content = await file.read()
try:
text = content.decode("utf-8-sig")
except UnicodeDecodeError:
text = content.decode("latin-1")
reader = csv.DictReader(io.StringIO(text))
count = 0
for row in reader:
key = row.get("License Key", "").strip()
if key not in valid_keys:
continue
try:
price = max(0.0, float(row.get("Price", "0").strip()))
except (TypeError, ValueError):
continue
existing = db.query(M365LicensePrice).filter(M365LicensePrice.license_key == key).first()
if existing:
existing.price = price
existing.updated_at = datetime.datetime.utcnow()
else:
db.add(M365LicensePrice(license_key=key, price=price))
count += 1
db.commit()
return RedirectResponse(f"/m365/license-calculator?prices_imported={count}", status_code=302)
except Exception as exc:
logger.error("M365 prices import error: %s", exc)
return RedirectResponse("/m365/license-calculator?prices_error=1", status_code=302)