PIVOT 1.1.0

This commit is contained in:
marijn committed 2026-10-14 13:34:37 +02:00
commit 8a53234eb6
161 files changed
+31109

No files matched your search

+320
View File
@@ -0,0 +1,320 @@
{% extends "base.html" %}
{% block title %}Gedeelde drives{% endblock %}
{% block page_title %}Gedeelde drives{% endblock %}
{% block main_class %}wide{% endblock %}
{% block loader_text %}Gedeelde drives ophalen bij Google...{% endblock %}
{% block extra_css %}
<style>
[hidden] { display: none !important; }
.balk { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 12px 0; }
.balk input[type=search] { flex: 0 1 320px; margin: 0; }
.filters { display: flex; flex-wrap: wrap; gap: 8px; }
.filters button { width: auto; margin: 0; padding: 4px 12px; border-radius: 999px; background: var(--surface-muted);
border: 1px solid var(--border); font-size: .88em; color: inherit; font-weight: 400; cursor: pointer; }
.filters button[aria-pressed="true"] { border-color: var(--primary-color); background: var(--primary-light); color: var(--primary-color); font-weight: 600; }
.filters button.let { background: #fef3c7; border-color: #fcd34d; }
.filters button.let[aria-pressed="true"] { background: #fde68a; color: #92400e; }
th.sorteer { cursor: pointer; user-select: none; white-space: nowrap; }
th.sorteer:hover { color: var(--primary-color); }
th.sorteer .pijl::after { content: "↕"; opacity: .35; margin-left: 2px; }
th.sorteer.op .pijl::after { content: "▲"; opacity: 1; }
th.sorteer.af .pijl::after { content: "▼"; opacity: 1; }
td.getal { text-align: right; white-space: nowrap; }
tr.klik { cursor: pointer; }
tr.klik:hover td { background: var(--primary-light); }
.tabel-voet { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; margin-top: 12px; }
.paginering { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.toevoegen { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(160px, 1fr) auto auto; gap: 10px; align-items: end; }
.toevoegen .field { margin: 0; }
@media (max-width: 800px) { .toevoegen { grid-template-columns: 1fr; } }
td.acties { white-space: nowrap; text-align: right; }
td.acties form { display: inline; margin: 0; }
td.acties select { width: auto; margin: 0; padding: 4px 8px; font-size: 13px; }
.type { font-size: .8em; padding: 1px 8px; border-radius: 999px; background: var(--surface-muted); }
</style>
{% endblock %}
{% block content %}
{% if lid_van is not none %}
{# ======================= Waar is iemand lid van? ======================= #}
<div class="card">
<div class="page-head">
<h2>🔎 {{ lid }}</h2>
<div class="actions"><a class="btn btn-light btn-sm" href="{{ url_for('gedeelde_drives.overzicht') }}">← Alle gedeelde drives</a></div>
</div>
{% if fout %}<div class="alert alert-danger">{{ fout }}</div>
{% elif not lid_van %}
<p class="muted">{{ lid }} is op geen enkele gedeelde drive lid (rechtstreeks; lidmaatschap via een Google-groep telt hier niet mee).</p>
{% else %}
<p class="muted small" style="margin-top:-6px">Lid van {{ lid_van|length }} gedeelde drive(s) (rechtstreeks; lidmaatschap via een Google-groep telt hier niet mee).</p>
<div class="table-wrap"><table>
{% set meerdere = (lid_van|map(attribute='email')|unique|list|length) > 1 or '@' not in lid %}
<thead><tr><th>Drive</th>{% if meerdere %}<th>Lid</th>{% endif %}<th>Rol</th><th></th></tr></thead>
<tbody>{% for d in lid_van %}
<tr><td><a href="{{ url_for('gedeelde_drives.overzicht', drive=d.drive) }}" onclick="showLoader()"><strong>{{ d.naam }}</strong></a></td>
{% if meerdere %}<td class="small">{{ d.wie }}{% if d.wie != d.email %}<br><span class="muted">{{ d.email }}</span>{% endif %}</td>{% endif %}
<td>{{ d.rol_naam }}</td>
<td class="acties"><a class="btn btn-light btn-sm" href="https://drive.google.com/drive/folders/{{ d.drive }}" target="_blank" rel="noopener">Openen in Drive ↗</a></td></tr>
{% endfor %}</tbody>
</table></div>
{% if '@' in lid %}
<form class="balk" method="GET" action="{{ url_for('gedeelde_drives.overzicht') }}" onsubmit="showLoader()" style="margin-top:14px">
<input type="hidden" name="oud" value="{{ lid }}">
<span>🔁 Op al deze drives vervangen door</span>
<input type="email" name="nieuw" required placeholder="nieuwe gebruiker (e-mail)" style="flex:0 1 280px;margin:0">
<button type="submit" class="btn-light btn-sm" style="margin:0">Verder</button>
</form>
{% endif %}
{% endif %}
</div>
{% elif vervang %}
{# ======================= Gebruiker vervangen ======================= #}
<div class="card">
<div class="page-head">
<h2>🔁 {{ vervang.oud }} → {{ vervang.nieuw or '?' }}</h2>
<div class="actions"><a class="btn btn-light btn-sm" href="{{ url_for('gedeelde_drives.overzicht') }}">← Alle gedeelde drives</a></div>
</div>
{% if fout %}<div class="alert alert-danger">{{ fout }}</div>
{% elif resultaat is not none %}
{% set ok = resultaat|selectattr('ok')|list|length %}
<div class="alert {{ 'alert-success' if ok == resultaat|length else 'alert-warning' }}">{{ ok }} van {{ resultaat|length }} drive(s) aangepast.</div>
<div class="table-wrap"><table>
<thead><tr><th>Drive</th><th></th><th>Resultaat</th></tr></thead>
<tbody>{% for r in resultaat %}<tr><td><strong>{{ r.naam }}</strong></td><td>{{ '✅' if r.ok else '⚠️' }}</td><td class="small">{{ r.melding }}</td></tr>{% endfor %}</tbody>
</table></div>
{% elif not vervang.drives %}
<p class="muted">{{ vervang.oud }} is op geen enkele gedeelde drive lid.</p>
{% else %}
<p class="muted small" style="margin-top:-6px">{{ vervang.oud }} is lid van {{ vervang.drives|length }} gedeelde drive(s).
{{ vervang.nieuw }} krijgt op elke aangevinkte drive exact dezelfde rol (ook als die er al lid van was).</p>
<form method="POST" action="{{ url_for('gedeelde_drives.vervangen') }}"
onsubmit="if (!this.querySelector('input[name=drive]:checked')) { alert('Vink minstens één drive aan.'); return false; }
if (!confirm({{ (vervang.oud ~ ' vervangen door ' ~ vervang.nieuw ~ ' op de aangevinkte drives?')|tojson|forceescape }})) return false; showLoader('Vervangen...');">
<input type="hidden" name="oud" value="{{ vervang.oud }}"><input type="hidden" name="nieuw" value="{{ vervang.nieuw }}">
<div class="table-wrap"><table>
<thead><tr><th style="width:28px"><input type="checkbox" checked style="width:auto;margin:0" onchange="this.closest('table').querySelectorAll('input[name=drive]').forEach(x => x.checked = this.checked)"></th>
<th>Drive</th><th>Rol van {{ vervang.oud }}</th></tr></thead>
<tbody>{% for d in vervang.drives %}
<tr><td><input type="checkbox" name="drive" value="{{ d.drive }}" checked style="width:auto;margin:0"></td>
<td><a href="{{ url_for('gedeelde_drives.overzicht', drive=d.drive) }}">{{ d.naam }}</a></td><td>{{ d.rol_naam }}</td></tr>
{% endfor %}</tbody>
</table></div>
<div class="balk">
<label style="display:flex;gap:6px;align-items:center;margin:0;font-weight:400"><input type="checkbox" name="verwijderen" value="1" checked style="width:auto;margin:0"> {{ vervang.oud }} daarna verwijderen</label>
<label style="display:flex;gap:6px;align-items:center;margin:0;font-weight:400"><input type="checkbox" name="melding" value="1" style="width:auto;margin:0"> e-mail sturen naar {{ vervang.nieuw }}</label>
<button type="submit" class="btn-success" style="margin:0 0 0 auto">🔁 Vervangen</button>
</div>
</form>
{% endif %}
</div>
{% elif gekozen %}
{# ======================= Detail van één drive ======================= #}
<div class="card">
<div class="page-head">
<h2>🗂️ {{ gekozen.naam }}</h2>
<div class="actions">
<a class="btn btn-light btn-sm" href="{{ url_for('gedeelde_drives.overzicht') }}">← Alle gedeelde drives</a>
<a class="btn btn-light btn-sm" href="https://drive.google.com/drive/folders/{{ gekozen.id }}" target="_blank" rel="noopener">Openen in Drive ↗</a>
</div>
</div>
<p class="muted small" style="margin-top:-6px">{{ gekozen.leden }} lid/leden · {{ gekozen.beheerders }} beheerder(s)
{% if gekozen.aangemaakt %} · aangemaakt {{ gekozen.aangemaakt }}{% endif %}</p>
{% if not gekozen.beheerders %}<div class="alert alert-warning">⚠️ Deze drive heeft geen beheerder. Voeg hieronder iemand toe als <strong>Beheerder</strong>.</div>{% endif %}
{% if gekozen.extern %}<div class="alert alert-warning">🌍 {{ gekozen.extern }} lid/leden van buiten de school.</div>{% endif %}
<form class="toevoegen" method="POST" action="{{ url_for('gedeelde_drives.lid_toevoegen') }}" onsubmit="showLoader('Toevoegen...')">
<input type="hidden" name="drive" value="{{ gekozen.id }}"><input type="hidden" name="naam" value="{{ gekozen.naam }}">
<div class="field"><label for="email">Persoon of groep (e-mailadres)</label>
<input type="email" id="email" name="email" required placeholder="bv. jan.peeters@school.be of leerkrachten@school.be"></div>
<div class="field"><label for="rol">Rol</label>
<select id="rol" name="rol">{% for r, n in rollen %}<option value="{{ r }}" {{ 'selected' if r == 'writer' }}>{{ n }}</option>{% endfor %}</select></div>
<label style="display:flex;gap:6px;align-items:center;margin:0 0 10px;font-weight:400"><input type="checkbox" name="melding" value="1" style="width:auto;margin:0"> e-mail sturen</label>
<button type="submit" class="btn-success" style="margin:0 0 2px">➕ Toevoegen</button>
</form>
<div class="table-wrap" style="margin-top:16px">
<table>
<thead><tr><th>Naam</th><th>E-mail</th><th>Soort</th><th>Rol</th><th></th></tr></thead>
<tbody>
{% for l in leden %}
<tr>
<td><strong>{{ l.naam or '—' }}</strong>{% if l.extern %} <span class="badge badge-warning" title="Buiten de school">extern</span>{% endif %}
{% if l.verwijderd %} <span class="badge badge-danger">account verwijderd</span>{% endif %}</td>
<td class="small">{{ l.email }}</td>
<td><span class="type">{{ {'user': 'persoon', 'group': 'groep', 'domain': 'domein', 'anyone': 'iedereen met link'}.get(l.type, l.type) }}</span></td>
<td class="acties" style="text-align:left">
<form method="POST" action="{{ url_for('gedeelde_drives.lid_wijzigen') }}">
<input type="hidden" name="drive" value="{{ gekozen.id }}"><input type="hidden" name="naam" value="{{ gekozen.naam }}">
<input type="hidden" name="perm" value="{{ l.id }}"><input type="hidden" name="email" value="{{ l.email or l.naam }}">
<select name="rol" onchange="if (confirm({{ ('Rol van ' ~ (l.email or l.naam) ~ ' wijzigen?')|tojson|forceescape }})) { showLoader(); this.form.submit(); } else { this.value = {{ l.rol|tojson|forceescape }}; }">
{% for r, n in rollen %}<option value="{{ r }}" {{ 'selected' if r == l.rol }}>{{ n }}</option>{% endfor %}
</select>
</form>
</td>
<td class="acties">
<form method="POST" action="{{ url_for('gedeelde_drives.lid_verwijderen') }}"
onsubmit="if (!confirm({{ ((l.email or l.naam) ~ ' uit ' ~ gekozen.naam ~ ' verwijderen?')|tojson|forceescape }})) return false; showLoader();">
<input type="hidden" name="drive" value="{{ gekozen.id }}"><input type="hidden" name="naam" value="{{ gekozen.naam }}">
<input type="hidden" name="perm" value="{{ l.id }}"><input type="hidden" name="email" value="{{ l.email or l.naam }}">
<button type="submit" class="btn-sm btn-ghost" title="Verwijderen">🗑️</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">Deze drive heeft geen leden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="field-hint" style="margin-top:12px"><strong>Beheerder</strong>: alles, ook leden beheren · <strong>Inhoudsbeheerder</strong>:
bestanden toevoegen, bewerken, verplaatsen en verwijderen · <strong>Bijdrager</strong>: toevoegen en bewerken ·
<strong>Reageerder</strong>: bekijken en reageren · <strong>Lezer</strong>: enkel bekijken.</p>
</div>
{% else %}
{# ======================= Overzicht ======================= #}
<div class="card">
<div class="page-head">
<h2>🗂️ Gedeelde drives <span class="muted small">({{ drives|length }})</span></h2>
<div class="actions">
<button type="button" class="btn-success btn-sm" onclick="document.getElementById('nieuwVenster').style.display='block'">➕ Nieuwe gedeelde drive</button>
<a class="btn btn-secondary btn-sm" href="{{ url_for('gedeelde_drives.overzicht', vernieuw=1) }}" onclick="showLoader()">🔄 Opnieuw ophalen</a>
</div>
</div>
<form class="balk" method="GET" action="{{ url_for('gedeelde_drives.overzicht') }}" onsubmit="showLoader('Drives van deze gebruiker zoeken...')"
style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius);margin:0 0 10px">
<strong style="white-space:nowrap">🔎 Waar is iemand lid van?</strong>
<input type="text" name="lid" required placeholder="e-mailadres, of een deel van de naam of het adres" style="flex:1 1 300px;margin:0">
<button type="submit" class="btn-light btn-sm" style="margin:0">Zoeken</button>
</form>
<form class="balk" method="GET" action="{{ url_for('gedeelde_drives.overzicht') }}" onsubmit="showLoader('Drives van deze gebruiker zoeken...')"
style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius);margin-top:0">
<strong style="white-space:nowrap">🔁 Gebruiker vervangen</strong>
<input type="email" name="oud" required placeholder="oude gebruiker (e-mail)" style="flex:1 1 220px;margin:0">
<span>→</span>
<input type="email" name="nieuw" required placeholder="nieuwe gebruiker (e-mail)" style="flex:1 1 220px;margin:0">
<button type="submit" class="btn-light btn-sm" style="margin:0">Drives zoeken</button>
</form>
{% if fout %}
<div class="alert alert-danger">{{ fout }}</div>
{% elif not drives %}
<p class="muted">Er zijn nog geen gedeelde drives in dit domein.</p>
{% else %}
{% set zonder = drives|selectattr('beheerders', 'equalto', 0)|list|length %}
{% set ext = drives|selectattr('extern')|list|length %}
<div class="balk">
<input type="search" id="zoek" placeholder="Zoek een drive..." aria-label="Zoeken">
<div class="filters" id="filters">
<button type="button" data-filter="" aria-pressed="true">Alle <b>{{ drives|length }}</b></button>
{% if zonder %}<button type="button" class="let" data-filter="zonder" aria-pressed="false">Zonder beheerder <b>{{ zonder }}</b></button>{% endif %}
{% if ext %}<button type="button" class="let" data-filter="extern" aria-pressed="false">Met externe leden <b>{{ ext }}</b></button>{% endif %}
</div>
<span id="perPagina"></span>
<span class="muted small">Opgehaald op {{ opgehaald }}</span>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th class="sorteer" data-tekst>Naam <span class="pijl"></span></th>
<th class="sorteer">Leden <span class="pijl"></span></th>
<th class="sorteer">Beheerders <span class="pijl"></span></th>
<th class="sorteer">Groepen <span class="pijl"></span></th>
<th class="sorteer">Extern <span class="pijl"></span></th>
<th class="sorteer">Aangemaakt <span class="pijl"></span></th>
</tr></thead>
<tbody id="body">
{% for d in drives %}
<tr class="klik" data-href="{{ url_for('gedeelde_drives.overzicht', drive=d.id) }}"
data-filters="{{ 'zonder' if not d.beheerders }} {{ 'extern' if d.extern }}">
<td data-sort="{{ d.naam }}"><strong>{{ d.naam }}</strong>{% if d.fout %} <span class="badge badge-danger" title="{{ d.fout }}">leden niet op te halen</span>{% endif %}</td>
<td class="getal" data-sort="{{ d.leden }}">{{ d.leden }}</td>
<td class="getal" data-sort="{{ d.beheerders }}">{% if d.beheerders %}{{ d.beheerders }}{% else %}<span class="badge badge-danger">geen</span>{% endif %}</td>
<td class="getal" data-sort="{{ d.groepen }}">{{ d.groepen or '' }}</td>
<td class="getal" data-sort="{{ d.extern }}">{% if d.extern %}<span class="badge badge-warning">{{ d.extern }}</span>{% endif %}</td>
<td data-sort="{{ d.aangemaakt }}" class="small">{{ d.aangemaakt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tabel-voet">
<div class="muted small" id="teller"></div>
<nav class="paginering" id="paginering" aria-label="Pagina's"></nav>
</div>
{% endif %}
<p class="field-hint" style="margin-top:14px">Vereist in de domeinbrede delegatie: <code>https://www.googleapis.com/auth/drive</code>,
en de <strong>Google Drive API</strong> moet aanstaan in het Cloud-project van het serviceaccount.</p>
</div>
{% endif %}
{% endblock %}
{% block modals %}
<div id="nieuwVenster" class="modal">
<div class="modal-content">
<span class="close" onclick="document.getElementById('nieuwVenster').style.display='none'">&times;</span>
<h3>➕ Nieuwe gedeelde drive</h3>
<form method="POST" action="{{ url_for('gedeelde_drives.nieuw') }}" onsubmit="showLoader('Aanmaken...')">
<div class="field"><label>Naam</label><input type="text" name="naam" required maxlength="100"></div>
<div class="field"><label>Beheerder (optioneel)</label><input type="email" name="beheerder" placeholder="e-mailadres van persoon of groep">
<p class="field-hint">De beheerder van PIVOT wordt automatisch ook beheerder van de nieuwe drive.</p></div>
<button type="submit" class="btn-block">Aanmaken</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
window.addEventListener('click', e => { if (e.target.id === 'nieuwVenster') e.target.style.display = 'none'; });
(function () {
const body = document.getElementById('body');
if (!body) return;
const rijen = Array.from(body.rows), zoek = document.getElementById('zoek');
let PER = perPagina('gedeelde-drives', 25), pagina = 1, filter = '';
body.addEventListener('click', e => { const tr = e.target.closest('tr.klik'); if (tr) { showLoader(); location.href = tr.dataset.href; } });
document.querySelectorAll('#filters button').forEach(b => b.addEventListener('click', () => {
filter = b.dataset.filter;
document.querySelectorAll('#filters button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
pagina = 1; toon();
}));
let kol = null, op = true;
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = th.cellIndex;
op = kol === k ? !op : 'tekst' in th.dataset; kol = k;
document.querySelectorAll('th.sorteer').forEach(x => x.classList.remove('op', 'af'));
th.classList.add(op ? 'op' : 'af');
rijen.sort((a, b) => {
const c = (a.cells[k].dataset.sort || '').localeCompare(b.cells[k].dataset.sort || '', 'nl', { numeric: true, sensitivity: 'base' });
return op ? c : -c;
}).forEach(tr => body.appendChild(tr));
pagina = 1; toon();
}));
function toon() {
const z = zoek.value.trim().toLowerCase();
const lijst = rijen.filter(tr => (!z || tr.cells[0].textContent.toLowerCase().includes(z))
&& (!filter || tr.dataset.filters.split(' ').includes(filter)));
const grootte = PER || Infinity, paginas = Math.max(1, Math.ceil(lijst.length / grootte));
pagina = Math.min(Math.max(1, pagina), paginas);
const van = PER ? (pagina - 1) * PER : 0, tot = van + grootte;
rijen.forEach(tr => tr.style.display = 'none');
lijst.forEach((tr, i) => tr.style.display = i >= van && i < tot ? '' : 'none');
document.getElementById('teller').textContent = lijst.length ? `${van + 1}–${Math.min(tot, lijst.length)} van ${lijst.length}` : 'Niets gevonden';
const nav = document.getElementById('paginering'); nav.innerHTML = '';
if (paginas > 1) {
const knop = (t, p, a) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = t;
b.className = 'btn-sm ' + (a ? 'btn-secondary' : 'btn-light'); b.onclick = () => { pagina = p; toon(); }; nav.appendChild(b); };
if (pagina > 1) { knop('«', 1); knop('‹', pagina - 1); }
for (let p = Math.max(1, pagina - 2); p <= Math.min(paginas, pagina + 2); p++) knop(String(p), p, p === pagina);
if (pagina < paginas) { knop('›', pagina + 1); knop('»', paginas); }
}
}
zoek.addEventListener('input', () => { pagina = 1; toon(); });
perPaginaKiezer(document.getElementById('perPagina'), 'gedeelde-drives', n => { PER = n; pagina = 1; toon(); }, 25);
toon();
})();
</script>
{% endblock %}