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>
This commit is contained in:
2026-06-09 16:05:26 +02:00
co-authored by Claude Sonnet 4.6
parent adf303ad53
commit 1ec63844ce
3 changed files with 438 additions and 23 deletions
+9 -1
View File
@@ -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"
+205 -4
View File
@@ -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)
+215 -9
View File
@@ -33,6 +33,90 @@
{% endif %}
</div>
<!-- Mapping export / import card -->
<div class="bg-white border border-gray-200 rounded-lg p-5 shadow-sm">
<div class="flex items-center justify-between gap-6 flex-wrap">
<div>
<p class="text-sm font-medium text-gray-700">Mapping</p>
<p class="text-xs text-gray-400 mt-0.5">Export or import the saved QWE Client / Restaurant assignments.</p>
</div>
<div class="flex items-center gap-3 flex-wrap">
<a href="/m365/mapping/export"
class="px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-200 transition-colors whitespace-nowrap">
Export CSV
</a>
<form method="post" action="/m365/mapping/import" enctype="multipart/form-data"
class="flex items-center gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="file" name="file" accept=".csv" required
class="text-sm text-gray-500
file:mr-2 file:py-1.5 file:px-3 file:rounded-md file:border-0
file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700
hover:file:bg-gray-200 cursor-pointer border border-gray-300 rounded-md">
<button type="submit"
class="px-4 py-2 bg-gray-700 text-white text-sm font-medium rounded-md hover:bg-gray-800 transition-colors whitespace-nowrap">
Import
</button>
</form>
</div>
</div>
</div>
<!-- License prices card -->
<div class="bg-white border border-gray-200 rounded-lg p-5 shadow-sm">
<div class="flex items-start justify-between gap-4 flex-wrap mb-3">
<div>
<p class="text-sm font-medium text-gray-700">License Prices <span class="text-xs font-normal text-gray-400">(€ / user / month)</span></p>
<p class="text-xs text-gray-400 mt-0.5">Used to calculate monthly totals in the summary. Saved automatically.</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<a href="/m365/prices/export"
class="px-3 py-1.5 bg-gray-100 text-gray-700 text-xs font-medium rounded-md hover:bg-gray-200 transition-colors whitespace-nowrap">
Export CSV
</a>
<form method="post" action="/m365/prices/import" enctype="multipart/form-data" class="flex items-center gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="file" name="file" accept=".csv" required
class="text-xs text-gray-500
file:mr-2 file:py-1 file:px-2.5 file:rounded file:border-0
file:text-xs file:font-medium file:bg-gray-100 file:text-gray-700
hover:file:bg-gray-200 cursor-pointer border border-gray-300 rounded-md">
<button type="submit"
class="px-3 py-1.5 bg-gray-700 text-white text-xs font-medium rounded-md hover:bg-gray-800 transition-colors whitespace-nowrap">
Import
</button>
</form>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{% for key, label in license_keys %}
<div>
<label class="block text-xs text-gray-500 mb-1">{{ label }}</label>
<div class="relative">
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-gray-400 pointer-events-none"></span>
<input type="number" min="0" step="0.01"
class="price-input w-full pl-6 pr-2 py-1.5 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"
data-key="{{ key }}"
value="{{ prices[key] if prices[key] else '' }}"
placeholder="0.00">
</div>
</div>
{% endfor %}
</div>
</div>
{% if import_success is not none %}
<div class="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-700">
Imported {{ import_success }} mapping {{ 'entry' if import_success == 1 else 'entries' }} successfully.
</div>
{% endif %}
{% if prices_import_success is not none %}
<div class="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-700">
Imported {{ prices_import_success }} price {{ 'entry' if prices_import_success == 1 else 'entries' }} successfully.
</div>
{% endif %}
{% if error %}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{{ error }}</div>
{% endif %}
@@ -58,9 +142,15 @@
<!-- 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">
<div class="px-5 py-3 border-b border-gray-200 flex items-center justify-between gap-3">
<h2 class="text-sm font-semibold text-gray-900">License Counts per QWE Client</h2>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-400">Updates live as you assign clients below</span>
<button onclick="exportPDF()"
class="px-3 py-1.5 bg-gray-100 text-gray-700 text-xs font-medium rounded-md hover:bg-gray-200 transition-colors whitespace-nowrap">
Export PDF
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
@@ -73,6 +163,7 @@
<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>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly Cost</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100" id="clientTableBody">
@@ -178,11 +269,37 @@
{% 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 }};
let PRICES = {{ prices | tojson }};
// ── Price inputs (always active) ───────────────────────────────────────────
async function savePrices(payload) {
try {
await fetch('/m365/prices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (_) {}
}
let priceTimer = null;
document.querySelectorAll('.price-input').forEach(inp => {
inp.addEventListener('input', () => {
PRICES[inp.dataset.key] = parseFloat(inp.value) || 0;
if (typeof renderClientTable === 'function') renderClientTable();
clearTimeout(priceTimer);
const snap = Object.assign({ csrf_token: CSRF_TOKEN }, PRICES);
priceTimer = setTimeout(() => savePrices(snap), 800);
});
});
{% if users %}
// ── User data ─────────────────────────────────────────────────────────────
const USERS_DATA = {{ users_json | safe }};
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -194,6 +311,13 @@
.replace(/"/g, '&quot;');
}
function fmt(n) { return n > 0 ? String(n) : '<span class="text-gray-300">—</span>'; }
function fmtEur(n) {
if (n === 0) return '<span class="text-gray-300">—</span>';
return '€ ' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// ── Live QWE-Client summary ────────────────────────────────────────────────
function calcClientBreakdown() {
@@ -221,17 +345,29 @@
}));
}
function fmt(n) { return n > 0 ? String(n) : '<span class="text-gray-300">—</span>'; }
function rowCost(r) {
return r.m365_standard * PRICES.m365_standard
+ r.m365_basic * PRICES.m365_basic
+ r.defender_p1 * PRICES.defender_p1
+ r.pbi_pro * PRICES.pbi_pro
+ r.pbi_premium * PRICES.pbi_premium;
}
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>';
tbody.innerHTML = '<tr><td colspan="8" class="px-4 py-4 text-center text-sm text-gray-400">No licensed users</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => `
let grandCost = 0, grandUsers = 0;
const rowHtml = rows.map(r => {
const cost = rowCost(r);
grandCost += cost;
grandUsers += r.count;
return `
<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>
@@ -240,8 +376,19 @@
<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('');
<td class="px-4 py-2 text-right font-medium text-gray-700 whitespace-nowrap">${fmtEur(cost)}</td>
</tr>`;
}).join('');
const totalRow = `
<tr class="bg-gray-50 border-t-2 border-gray-200 font-semibold text-sm">
<td class="px-4 py-2 text-gray-900">Total</td>
<td class="px-4 py-2 text-center text-gray-700">${grandUsers}</td>
<td colspan="5"></td>
<td class="px-4 py-2 text-right text-gray-900 whitespace-nowrap">${fmtEur(grandCost)}</td>
</tr>`;
tbody.innerHTML = rowHtml + totalRow;
}
// ── Save override to server ────────────────────────────────────────────────
@@ -312,8 +459,67 @@
});
});
// ── PDF export ────────────────────────────────────────────────────────────
function exportPDF() {
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
const rows = calcClientBreakdown();
const hasCost = Object.values(PRICES).some(p => p > 0);
const head = [['QWE Client', 'Licensed Users', 'M365 Standard', 'M365 Basic', 'Defender P1', 'PBI Pro', 'PBI Premium']];
if (hasCost) head[0].push('Monthly Cost (€)');
let grandUsers = 0, grandCost = 0;
const body = rows.map(r => {
const cost = rowCost(r);
grandUsers += r.count;
grandCost += cost;
const row = [r.name, r.count, r.m365_standard || '—', r.m365_basic || '—', r.defender_p1 || '—', r.pbi_pro || '—', r.pbi_premium || '—'];
if (hasCost) row.push(cost > 0 ? cost.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—');
return row;
});
const totalRow = ['Total', grandUsers, '', '', '', '', ''];
if (hasCost) totalRow.push(grandCost > 0 ? grandCost.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—');
body.push(totalRow);
const filename = {{ filename | tojson }};
const dateStr = new Date().toLocaleDateString('en-GB');
doc.setFontSize(13);
doc.setFont('helvetica', 'bold');
doc.text('License Counts per QWE Client', 14, 16);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(120);
doc.text(`Source: ${filename || 'manual'} | Generated: ${dateStr}`, 14, 22);
doc.setTextColor(0);
doc.autoTable({
head,
body,
startY: 27,
styles: { fontSize: 9, cellPadding: 3 },
headStyles: { fillColor: [55, 65, 81], textColor: 255, fontStyle: 'bold' },
columnStyles: { 0: { halign: 'left' } },
didParseCell(data) {
if (data.row.index === body.length - 1) {
data.cell.styles.fontStyle = 'bold';
data.cell.styles.fillColor = [243, 244, 246];
}
},
});
doc.save('m365_license_summary.pdf');
}
// ── Initial render ────────────────────────────────────────────────────────
renderClientTable();
{% endif %}
</script>
{% if users %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
{% endif %}
{% endblock %}