diff --git a/app/database.py b/app/database.py index 5041063..b207afb 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, Boolean +from sqlalchemy import create_engine, Column, Integer, String, DateTime, Boolean, Float from sqlalchemy.orm import declarative_base, sessionmaker DATABASE_URL = f"sqlite:///{os.getenv('DB_PATH', '/data/audit.db')}" @@ -19,6 +19,14 @@ class M365UserOverride(Base): updated_at = Column(DateTime, default=datetime.datetime.utcnow) +class M365LicensePrice(Base): + __tablename__ = "m365_license_prices" + + license_key = Column(String, primary_key=True) + price = Column(Float, default=0.0, 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 index 3edb407..9e76197 100644 --- a/app/m365.py +++ b/app/m365.py @@ -8,12 +8,12 @@ import secrets from collections import defaultdict from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile -from fastapi.responses import HTMLResponse, RedirectResponse +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 M365UserOverride, get_db +from app.database import M365LicensePrice, M365UserOverride, get_db logger = logging.getLogger(__name__) @@ -25,6 +25,14 @@ 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: @@ -32,6 +40,11 @@ def _csrf_token(request: Request) -> str: 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): @@ -158,6 +171,10 @@ def _render(request: Request, user, csrf: str, **kwargs): "users_json": "[]", "filename": None, "error": None, + "import_success": None, + "prices": {}, + "license_keys": LICENSE_KEYS, + "prices_import_success": None, **kwargs, }) @@ -175,12 +192,28 @@ async def m365_index(request: Request): @router.get("/m365/license-calculator", response_class=HTMLResponse) -async def license_calculator_page(request: Request): +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) - return _render(request, user, csrf) + 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) @@ -209,6 +242,7 @@ async def upload_csv( 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) @@ -242,3 +276,170 @@ async def save_override(request: Request, db: Session = Depends(get_db)): 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) diff --git a/app/templates/m365_license.html b/app/templates/m365_license.html index f103b94..486cb4c 100644 --- a/app/templates/m365_license.html +++ b/app/templates/m365_license.html @@ -33,6 +33,90 @@ {% endif %} + +
+
+
+

Mapping

+

Export or import the saved QWE Client / Restaurant assignments.

+
+
+ + Export CSV + +
+ + + +
+
+
+
+ + +
+
+
+

License Prices (€ / user / month)

+

Used to calculate monthly totals in the summary. Saved automatically.

+
+
+ + Export CSV + +
+ + + +
+
+
+
+ {% for key, label in license_keys %} +
+ +
+ + +
+
+ {% endfor %} +
+
+ + {% if import_success is not none %} +
+ Imported {{ import_success }} mapping {{ 'entry' if import_success == 1 else 'entries' }} successfully. +
+ {% endif %} + + {% if prices_import_success is not none %} +
+ Imported {{ prices_import_success }} price {{ 'entry' if prices_import_success == 1 else 'entries' }} successfully. +
+ {% endif %} + {% if error %}
{{ error }}
{% endif %} @@ -58,9 +142,15 @@
-
+

License Counts per QWE Client

- Updates live as you assign clients below +
+ Updates live as you assign clients below + +
@@ -73,6 +163,7 @@ + @@ -178,11 +269,37 @@ {% endblock %} {% block scripts %} -{% if users %} +{% if users %} + + {% endif %} {% endblock %}
Defender P1 PBI Pro PBI PremiumMonthly Cost