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 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"
|
||||
|
||||
|
||||
+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,
|
||||
)
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
</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>
|
||||
{% 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