Add M365 License Calculator feature
New top-nav section with a CSV-based license calculator. Upload an Azure AD user export to see license counts (M365 Standard/Basic, Defender P1, PBI Pro, PBI Premium) grouped by QWE Client. QWE Client and IsRestaurant are editable per user, saved to SQLite, and restored on the next upload. Department-based defaults pre-populate QWE Client (AT→QWE AT, SK→QWE SK) when no saved value exists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import datetime
|
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
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
|
||||||
DATABASE_URL = f"sqlite:///{os.getenv('DB_PATH', '/data/audit.db')}"
|
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()
|
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):
|
class RebootLog(Base):
|
||||||
__tablename__ = "reboot_log"
|
__tablename__ = "reboot_log"
|
||||||
|
|
||||||
|
|||||||
+244
@@ -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"}
|
||||||
@@ -25,12 +25,15 @@ from app.auth import (
|
|||||||
get_current_user,
|
get_current_user,
|
||||||
)
|
)
|
||||||
from app.omada import omada_client
|
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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI(title="Salus by Stranto", docs_url=None, redoc_url=None)
|
app = FastAPI(title="Salus by Stranto", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
app.include_router(m365_router)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
SessionMiddleware,
|
SessionMiddleware,
|
||||||
secret_key=SESSION_SECRET_KEY,
|
secret_key=SESSION_SECRET_KEY,
|
||||||
|
|||||||
@@ -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 %}">
|
{% 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
|
Audit Log
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/m365/license-calculator"
|
||||||
|
class="px-3 py-2 rounded-md text-sm font-medium transition-colors
|
||||||
|
{% if request.url.path.startswith('/m365') %}bg-gray-100 text-gray-900{% else %}text-gray-600 hover:text-gray-900 hover:bg-gray-100{% endif %}">
|
||||||
|
M365
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}M365 License Calculator – Salus by Stranto{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Page header -->
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900">M365 License Calculator</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Upload an Azure AD user export CSV. Assign users to QWE Clients, then see the live license breakdown per client.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload card -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-5 shadow-sm">
|
||||||
|
<form method="post" action="/m365/license-calculator/upload" enctype="multipart/form-data"
|
||||||
|
class="flex items-end gap-4 flex-wrap">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="flex-1 min-w-64">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">M365 User Export CSV</label>
|
||||||
|
<input type="file" name="file" accept=".csv" required id="csvFileInput"
|
||||||
|
class="block w-full text-sm text-gray-500
|
||||||
|
file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0
|
||||||
|
file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700
|
||||||
|
hover:file:bg-blue-100 cursor-pointer border border-gray-300 rounded-md">
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors whitespace-nowrap">
|
||||||
|
Calculate
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% if filename %}
|
||||||
|
<p class="mt-2 text-xs text-gray-400">Last file: <span class="font-medium text-gray-600">{{ filename }}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if summary %}
|
||||||
|
<!-- Stat cards -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-gray-500 uppercase tracking-wide font-medium">Total Users</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold text-gray-900">{{ summary.total }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-gray-500 uppercase tracking-wide font-medium">Licensed</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold text-blue-600">{{ summary.licensed }}</p>
|
||||||
|
</div>
|
||||||
|
{% if summary.missing %}
|
||||||
|
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-amber-700 uppercase tracking-wide font-medium">Missing Assignment</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold text-amber-600">{{ summary.missing | length }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-QWE-Client license breakdown (primary goal, updates live) -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<div class="px-5 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-900">License Counts per QWE Client</h2>
|
||||||
|
<span class="text-xs text-gray-400">Updates live as you assign clients below</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">QWE Client</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Licensed Users</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-medium text-blue-600 uppercase tracking-wider">M365 Standard</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-medium text-sky-600 uppercase tracking-wider">M365 Basic</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-medium text-orange-600 uppercase tracking-wider">Defender P1</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-medium text-purple-600 uppercase tracking-wider">PBI Pro</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-medium text-red-600 uppercase tracking-wider">PBI Premium</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-100" id="clientTableBody">
|
||||||
|
<!-- rendered by JS from USERS_DATA -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if summary.missing %}
|
||||||
|
<!-- Missing assignments -->
|
||||||
|
<details class="bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
|
<summary class="px-5 py-3 text-sm font-medium text-amber-800 cursor-pointer select-none">
|
||||||
|
Missing Company Assignments ({{ summary.missing | length }}) — licensed users not in QWE AT / QWE SK / KFC AT / KFC SK / QWE
|
||||||
|
</summary>
|
||||||
|
<ul class="px-5 pb-4 pt-2 text-xs text-amber-700 space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{% for u in summary.missing %}
|
||||||
|
<li>{{ u.display_name }} — {{ u.department or '(no department)' }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- User table -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<div class="px-5 py-3 border-b border-gray-200 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-900">All Users ({{ users | length }})</h2>
|
||||||
|
<input type="text" id="userFilter" placeholder="Filter by name, UPN, or department…"
|
||||||
|
class="text-sm border border-gray-300 rounded-md px-3 py-1.5 w-72 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm" id="userTable">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider min-w-48">Display Name</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Department</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Co</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Licenses</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-900 uppercase tracking-wider min-w-40">QWE Client</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-medium text-gray-900 uppercase tracking-wider">Restaurant</th>
|
||||||
|
<th class="w-6 px-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-100">
|
||||||
|
{% for u in users %}
|
||||||
|
<tr class="hover:bg-gray-50 user-row"
|
||||||
|
data-name="{{ u.display_name | lower }}"
|
||||||
|
data-upn="{{ u.username | lower }}"
|
||||||
|
data-dept="{{ u.department | lower }}">
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<div class="font-medium text-gray-900 truncate max-w-48" title="{{ u.display_name }}">{{ u.display_name }}</div>
|
||||||
|
<div class="text-xs text-gray-400 truncate max-w-48" title="{{ u.username }}">{{ u.username }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-gray-600 whitespace-nowrap">{{ u.department or '—' }}</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{% if u.company == 'AT' %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">AT</span>
|
||||||
|
{% elif u.company == 'SK' %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">SK</span>
|
||||||
|
{% elif u.is_licensed %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700" title="No company assignment">?</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-300">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% if u.has_m365_standard %}<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700">Std</span>{% endif %}
|
||||||
|
{% if u.has_m365_basic %}<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-sky-50 text-sky-700">Basic</span>{% endif %}
|
||||||
|
{% if u.has_defender_p1 %}<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-orange-50 text-orange-700">Def</span>{% endif %}
|
||||||
|
{% if u.has_pbi_pro %}<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-purple-50 text-purple-700">PBI Pro</span>{% endif %}
|
||||||
|
{% if u.has_pbi_premium %}<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-red-50 text-red-700">PBI Prem</span>{% endif %}
|
||||||
|
{% if not u.is_licensed %}<span class="text-xs text-gray-400">Unlicensed</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<select class="qwe-client-input w-full min-w-32 px-2 py-1 text-sm border border-gray-200 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none bg-gray-50 hover:bg-white focus:bg-white transition-colors cursor-pointer"
|
||||||
|
data-username="{{ u.username }}">
|
||||||
|
<option value="" {% if not u.qwe_client %}selected{% endif %}>—</option>
|
||||||
|
<option value="QWE AT" {% if u.qwe_client == 'QWE AT' %}selected{% endif %}>QWE AT</option>
|
||||||
|
<option value="QWE SK" {% if u.qwe_client == 'QWE SK' %}selected{% endif %}>QWE SK</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="is-restaurant-checkbox h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||||
|
{% if u.is_restaurant %}checked{% endif %}
|
||||||
|
data-username="{{ u.username }}">
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
<span class="save-indicator text-xs" data-username="{{ u.username }}"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if users %}
|
||||||
|
<script>
|
||||||
|
// User data for live client-summary recalculation
|
||||||
|
const USERS_DATA = {{ users_json | safe }};
|
||||||
|
const CSRF_TOKEN = {{ csrf_token | tojson }};
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live QWE-Client summary ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function calcClientBreakdown() {
|
||||||
|
const groups = {};
|
||||||
|
USERS_DATA.filter(u => u.is_licensed).forEach(u => {
|
||||||
|
const key = (u.qwe_client || '').trim();
|
||||||
|
if (!groups[key]) groups[key] = [];
|
||||||
|
groups[key].push(u);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(groups)
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
if (a === '' && b !== '') return 1;
|
||||||
|
if (b === '' && a !== '') return -1;
|
||||||
|
return a.toLowerCase().localeCompare(b.toLowerCase());
|
||||||
|
})
|
||||||
|
.map(([name, grp]) => ({
|
||||||
|
name: name || '(no client)',
|
||||||
|
count: grp.length,
|
||||||
|
m365_standard: grp.filter(u => u.has_m365_standard).length,
|
||||||
|
m365_basic: grp.filter(u => u.has_m365_basic).length,
|
||||||
|
defender_p1: grp.filter(u => u.has_defender_p1).length,
|
||||||
|
pbi_pro: grp.filter(u => u.has_pbi_pro).length,
|
||||||
|
pbi_premium: grp.filter(u => u.has_pbi_premium).length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n) { return n > 0 ? String(n) : '<span class="text-gray-300">—</span>'; }
|
||||||
|
|
||||||
|
function renderClientTable() {
|
||||||
|
const tbody = document.getElementById('clientTableBody');
|
||||||
|
if (!tbody) return;
|
||||||
|
const rows = calcClientBreakdown();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-4 text-center text-sm text-gray-400">No licensed users</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows.map(r => `
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-900 whitespace-nowrap">${escHtml(r.name)}</td>
|
||||||
|
<td class="px-4 py-2 text-center text-gray-600">${r.count}</td>
|
||||||
|
<td class="px-4 py-2 text-center font-medium text-blue-700">${fmt(r.m365_standard)}</td>
|
||||||
|
<td class="px-4 py-2 text-center font-medium text-sky-700">${fmt(r.m365_basic)}</td>
|
||||||
|
<td class="px-4 py-2 text-center font-medium text-orange-700">${fmt(r.defender_p1)}</td>
|
||||||
|
<td class="px-4 py-2 text-center font-medium text-purple-700">${fmt(r.pbi_pro)}</td>
|
||||||
|
<td class="px-4 py-2 text-center font-medium text-red-700">${fmt(r.pbi_premium)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save override to server ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function saveOverride(username, qweClient, isRestaurant) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/m365/override', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
csrf_token: CSRF_TOKEN,
|
||||||
|
username,
|
||||||
|
qwe_client: qweClient,
|
||||||
|
is_restaurant: isRestaurant,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const ind = document.querySelector(`.save-indicator[data-username="${CSS.escape(username)}"]`);
|
||||||
|
if (ind) {
|
||||||
|
ind.textContent = '✓';
|
||||||
|
ind.className = 'save-indicator text-xs text-green-500';
|
||||||
|
setTimeout(() => { ind.textContent = ''; ind.className = 'save-indicator text-xs'; }, 2000);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Save failed: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowData(username) {
|
||||||
|
const sel = CSS.escape(username);
|
||||||
|
const ci = document.querySelector(`.qwe-client-input[data-username="${sel}"]`);
|
||||||
|
const cb = document.querySelector(`.is-restaurant-checkbox[data-username="${sel}"]`);
|
||||||
|
return { qweClient: ci ? ci.value : '', isRestaurant: cb ? cb.checked : false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire up QWE Client inputs ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.querySelectorAll('.qwe-client-input').forEach(select => {
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
const username = select.dataset.username;
|
||||||
|
const obj = USERS_DATA.find(u => u.username === username);
|
||||||
|
if (obj) obj.qwe_client = select.value;
|
||||||
|
renderClientTable();
|
||||||
|
const { qweClient, isRestaurant } = getRowData(username);
|
||||||
|
saveOverride(username, qweClient, isRestaurant);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Wire up IsRestaurant checkboxes ───────────────────────────────────────
|
||||||
|
|
||||||
|
document.querySelectorAll('.is-restaurant-checkbox').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const { qweClient, isRestaurant } = getRowData(cb.dataset.username);
|
||||||
|
saveOverride(cb.dataset.username, qweClient, isRestaurant);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── User table filter ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.getElementById('userFilter').addEventListener('input', function () {
|
||||||
|
const q = this.value.toLowerCase();
|
||||||
|
document.querySelectorAll('.user-row').forEach(row => {
|
||||||
|
const match = !q
|
||||||
|
|| row.dataset.name.includes(q)
|
||||||
|
|| row.dataset.upn.includes(q)
|
||||||
|
|| row.dataset.dept.includes(q);
|
||||||
|
row.style.display = match ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Initial render ────────────────────────────────────────────────────────
|
||||||
|
renderClientTable();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user