diff --git a/app/database.py b/app/database.py index 480295b..5041063 100644 --- a/app/database.py +++ b/app/database.py @@ -1,6 +1,6 @@ import os import datetime -from sqlalchemy import create_engine, Column, Integer, String, DateTime +from sqlalchemy import create_engine, Column, Integer, String, DateTime, Boolean from sqlalchemy.orm import declarative_base, sessionmaker DATABASE_URL = f"sqlite:///{os.getenv('DB_PATH', '/data/audit.db')}" @@ -10,6 +10,15 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() +class M365UserOverride(Base): + __tablename__ = "m365_user_overrides" + + username = Column(String, primary_key=True) # User principal name + qwe_client = Column(String, default="", nullable=False) + is_restaurant = Column(Boolean, default=False, nullable=False) + updated_at = Column(DateTime, default=datetime.datetime.utcnow) + + class RebootLog(Base): __tablename__ = "reboot_log" diff --git a/app/m365.py b/app/m365.py new file mode 100644 index 0000000..3edb407 --- /dev/null +++ b/app/m365.py @@ -0,0 +1,244 @@ +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 +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from app.auth import get_current_user +from app.database import 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"} + + +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 _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, + **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): + user = get_current_user(request) + if not user: + return RedirectResponse("/auth/login", status_code=302) + csrf = _csrf_token(request) + return _render(request, user, csrf) + + +@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, + ) + 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"} diff --git a/app/main.py b/app/main.py index 8834c14..4d14256 100644 --- a/app/main.py +++ b/app/main.py @@ -25,12 +25,15 @@ from app.auth import ( get_current_user, ) from app.omada import omada_client +from app.m365 import router as m365_router logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logger = logging.getLogger(__name__) app = FastAPI(title="Salus by Stranto", docs_url=None, redoc_url=None) +app.include_router(m365_router) + app.add_middleware( SessionMiddleware, secret_key=SESSION_SECRET_KEY, diff --git a/app/templates/base.html b/app/templates/base.html index 417be55..8813852 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -50,6 +50,11 @@ {% if request.url.path == '/audit' %}bg-gray-100 text-gray-900{% else %}text-gray-600 hover:text-gray-900 hover:bg-gray-100{% endif %}"> Audit Log + + M365 + {% if user %} diff --git a/app/templates/m365_license.html b/app/templates/m365_license.html new file mode 100644 index 0000000..f103b94 --- /dev/null +++ b/app/templates/m365_license.html @@ -0,0 +1,319 @@ +{% extends "base.html" %} +{% block title %}M365 License Calculator – Salus by Stranto{% endblock %} + +{% block content %} +
+ + +
+

M365 License Calculator

+

Upload an Azure AD user export CSV. Assign users to QWE Clients, then see the live license breakdown per client.

+
+ + +
+
+ +
+ + +
+ +
+ {% if filename %} +

Last file: {{ filename }}

+ {% endif %} +
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if summary %} + +
+
+

Total Users

+

{{ summary.total }}

+
+
+

Licensed

+

{{ summary.licensed }}

+
+ {% if summary.missing %} +
+

Missing Assignment

+

{{ summary.missing | length }}

+
+ {% endif %} +
+ + +
+
+

License Counts per QWE Client

+ Updates live as you assign clients below +
+
+ + + + + + + + + + + + + + + +
QWE ClientLicensed UsersM365 StandardM365 BasicDefender P1PBI ProPBI Premium
+
+
+ + {% if summary.missing %} + +
+ + Missing Company Assignments ({{ summary.missing | length }}) — licensed users not in QWE AT / QWE SK / KFC AT / KFC SK / QWE + + +
+ {% endif %} + + +
+
+

All Users ({{ users | length }})

+ +
+ +
+ + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + + {% endfor %} + +
Display NameDepartmentCoLicensesQWE ClientRestaurant
+
{{ u.display_name }}
+
{{ u.username }}
+
{{ u.department or '—' }} + {% if u.company == 'AT' %} + AT + {% elif u.company == 'SK' %} + SK + {% elif u.is_licensed %} + ? + {% else %} + + {% endif %} + +
+ {% if u.has_m365_standard %}Std{% endif %} + {% if u.has_m365_basic %}Basic{% endif %} + {% if u.has_defender_p1 %}Def{% endif %} + {% if u.has_pbi_pro %}PBI Pro{% endif %} + {% if u.has_pbi_premium %}PBI Prem{% endif %} + {% if not u.is_licensed %}Unlicensed{% endif %} +
+
+ + + + + +
+
+
+ {% endif %} + +
+{% endblock %} + +{% block scripts %} +{% if users %} + +{% endif %} +{% endblock %}