From 2548b7acd955e88611d58a981583e76a9a5a1550 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:43:59 +0200 Subject: [PATCH] Pictogram per vak (upload) + media-infra; grote leerling- en ouderweergave met pictogram en vakkleur (migratie core 0009) --- ROADMAP.md | 9 ++ backend/Dockerfile | 2 +- backend/config/settings.py | 4 + backend/config/urls.py | 5 + backend/core/apps.py | 2 + .../migrations/0009_activiteit_pictogram.py | 18 +++ backend/core/models.py | 4 + backend/core/serializers.py | 5 +- backend/requirements.txt | 1 + docker-compose.yml | 4 + frontend/nginx.conf | 7 + frontend/src/App.jsx | 3 + frontend/src/api.js | 21 +++ frontend/src/pages.jsx | 153 +++++++++++++++++- frontend/src/styles.css | 31 ++++ frontend/vite.config.js | 1 + 16 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 backend/core/migrations/0009_activiteit_pictogram.py diff --git a/ROADMAP.md b/ROADMAP.md index ac5fac2..f1c4c9b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,6 +109,15 @@ volgt. - [x] Een **hele dag laten vervallen** kan direct in het weekrooster (knop in de dagkop; maakt een vrije dag aan, met "weer lesdag" om te herstellen). +## Pictogrammen & leerlingweergave (ZML) + +- [x] **Pictogram per vak uploaden** (afbeelding) en tonen in het rooster. +- [x] **Grote leerlingweergave** (één dag, groot pictogram + vakkleur) en + **ouderweergave** (weekoverzicht), per leerling of groep. +- [x] Vakkleur duidelijk gebruikt in de weergaven. +- [ ] Later: pictogrammen ook per losse activiteit/stap binnen een dag; dagdeel- + indeling met picto's. + ## Later - [ ] **Module: automatisch rooster genereren.** De gebruiker ge \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 1b72d03..1d67c46 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -24,7 +24,7 @@ RUN chmod +x /app/entrypoint.sh # Draai niet als root: een eventueel lek in de app geeft dan geen # root-rechten in de container. RUN useradd --create-home --shell /usr/sbin/nologin appuser \ - && mkdir -p /app/staticfiles \ + && mkdir -p /app/staticfiles /app/media \ && chown -R appuser:appuser /app USER appuser diff --git a/backend/config/settings.py b/backend/config/settings.py index a26e417..624702e 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -150,6 +150,10 @@ USE_TZ = True # (o.a. de CSS van de Django-admin). STATIC_URL = "/static/" STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles")) + +# Geüploade bestanden (o.a. pictogrammen bij vakken). +MEDIA_URL = "/media/" +MEDIA_ROOT = os.environ.get("DJANGO_MEDIA_ROOT", str(BASE_DIR / "media")) DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # --- API ------------------------------------------------------------------ diff --git a/backend/config/urls.py b/backend/config/urls.py index e680b99..2f0c244 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -1,5 +1,7 @@ """Hoofd-URL-configuratie.""" from django.contrib import admin +from django.conf import settings +from django.conf.urls.static import static from django.urls import include, path urlpatterns = [ @@ -14,3 +16,6 @@ urlpatterns = [ path("api/", include("modules.uren.urls")), path("api/", include("modules.accounts.urls")), ] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/core/apps.py b/backend/core/apps.py index e1fe18e..1c51211 100644 --- a/backend/core/apps.py +++ b/backend/core/apps.py @@ -30,6 +30,8 @@ class CoreConfig(AppConfig): # Hoofdniveau: Rooster = weergave, Roostermaker = aanpasser. MenuItem("Rooster", "/rooster", icon="calendar", order=10), MenuItem("Roostermaker", "/roostermaker", icon="calendar", order=12), + MenuItem("Leerlingrooster", "/leerlingrooster", icon="book", order=14), + MenuItem("Ouderrooster", "/ouderrooster", icon="badge", order=16), # Submenu Organisatie: wie hoort waar. MenuItem("Personen", "/personen", icon="users", order=20, group="Organisatie"), MenuItem("Groepsindeling", "/groepsindeling", icon="layers", order=24, group="Organisatie"), diff --git a/backend/core/migrations/0009_activiteit_pictogram.py b/backend/core/migrations/0009_activiteit_pictogram.py new file mode 100644 index 0000000..5a5d447 --- /dev/null +++ b/backend/core/migrations/0009_activiteit_pictogram.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-06-16 21:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_roosterblok_geldig_tot_roosterblok_geldig_van'), + ] + + operations = [ + migrations.AddField( + model_name='activiteit', + name='pictogram', + field=models.ImageField(blank=True, help_text='Optioneel pictogram (afbeelding) ter ondersteuning van leerlingen.', null=True, upload_to='pictogrammen/', verbose_name='pictogram'), + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index 29550df..37745d3 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -286,6 +286,10 @@ class Activiteit(models.Model): naam = models.CharField("naam", max_length=80, unique=True) kleur = models.CharField("kleur", max_length=7, default="#2563eb", help_text="hex, bv. #2563eb") icoon = models.CharField("icoon", max_length=8, blank=True, help_text="Optioneel emoji; leeg = automatisch gekozen op naam.") + pictogram = models.ImageField( + "pictogram", upload_to="pictogrammen/", null=True, blank=True, + help_text="Optioneel pictogram (afbeelding) ter ondersteuning van leerlingen.", + ) omschrijving = models.CharField("omschrijving", max_length=255, blank=True) theorievak = models.BooleanField( "theorievak", default=False, diff --git a/backend/core/serializers.py b/backend/core/serializers.py index 90e4772..40cbb6f 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -183,7 +183,7 @@ class ActiviteitSerializer(serializers.ModelSerializer): class Meta: model = Activiteit - fields = ["id", "naam", "kleur", "icoon", "omschrijving", "theorievak", "actief"] + fields = ["id", "naam", "kleur", "icoon", "pictogram", "omschrijving", "theorievak", "actief"] class LocatieSerializer(serializers.ModelSerializer): @@ -208,6 +208,7 @@ class RoosterblokSerializer(serializers.ModelSerializer): activiteit_naam = serializers.CharField(source="activiteit.naam", read_only=True) activiteit_kleur = serializers.CharField(source="activiteit.kleur", read_only=True) activiteit_icoon = serializers.CharField(source="activiteit.icoon", read_only=True, default="") + activiteit_pictogram = serializers.ImageField(source="activiteit.pictogram", read_only=True) tijdslot_label = serializers.CharField(source="tijdslot.__str__", read_only=True) locatie_naam = serializers.CharField(source="locatie.naam", read_only=True, default=None) lokaal_naam = serializers.CharField(source="lokaal.naam", read_only=True, default=None) @@ -219,7 +220,7 @@ class RoosterblokSerializer(serializers.ModelSerializer): model = Roosterblok fields = [ "id", "schooljaar", "tijdslot", "tijdslot_label", - "activiteit", "activiteit_naam", "activiteit_kleur", "activiteit_icoon", + "activiteit", "activiteit_naam", "activiteit_kleur", "activiteit_icoon", "activiteit_pictogram", "locatie", "locatie_naam", "lokaal", "lokaal_naam", "groep", "subgroep", "leerling", "doel_type", "doel_naam", "geldig_van", "geldig_tot", diff --git a/backend/requirements.txt b/backend/requirements.txt index 2108e13..1777fea 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ django-cors-headers>=4.4 # --- Productie / test-server --------------------------------------------- gunicorn>=22.0 # WSGI-server die Django draait psycopg[binary]>=3.2 # PostgreSQL-driver +Pillow>=10.0 # beeldverwerking voor pictogram-upload diff --git a/docker-compose.yml b/docker-compose.yml index 188c86c..702f0c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,8 @@ services: volumes: # Verzamelde statics delen met nginx. - static_volume:/app/staticfiles + # Geüploade media (pictogrammen) delen met nginx. + - media_volume:/app/media nginx: build: ./frontend @@ -52,7 +54,9 @@ services: volumes: # Lees de door Django verzamelde statics (alleen-lezen). - static_volume:/static:ro + - media_volume:/media:ro volumes: pgdata: static_volume: + media_volume: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 070cefe..e6791f7 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -20,6 +20,13 @@ server { expires 30d; } + # Geüploade media (pictogrammen), uit het gedeelde volume. + location /media/ { + alias /media/; + access_log off; + expires 7d; + } + # API-verzoeken naar de Django-backend (gunicorn). location /api/ { proxy_pass http://backend:8000; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index aa2afbd..de741d8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import { ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage, RoosterWeergavePage, RoostermakerPage, AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage, + LeerlingRoosterPage, OuderRoosterPage, } from "./pages.jsx"; const ICONS = { @@ -35,6 +36,8 @@ const PAGES = { "/stage": StagePage, "/gebruikers": GebruikersPage, "/uren": UrenPage, + "/leerlingrooster": LeerlingRoosterPage, + "/ouderrooster": OuderRoosterPage, }; export default function App() { diff --git a/frontend/src/api.js b/frontend/src/api.js index 797f78f..4a25fb5 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -61,3 +61,24 @@ export const deleteResource = (resource, id) => // --- Lokaal-instelling (singleton; dubbele-boekingbeleid) ------------------ export const getLokaalinstelling = () => request("GET", "/lokaalinstelling/"); export const setLokaalinstelling = (data) => request("PUT", "/lokaalinstelling/", data); + +// --- Bestand uploaden (multipart, bv. pictogram bij een vak) --------------- +export async function uploadBestand(resource, id, veld, file) { + const fd = new FormData(); + fd.append(veld, file); + const token = getCookie("csrftoken"); + const res = await fetch(`${BASE}/${resource}/${id}/`, { + method: "PATCH", + headers: token ? { "X-CSRFToken": token } : {}, + credentials: "same-origin", + body: fd, + }); + if (!res.ok) { + let data = null; + try { data = await res.json(); } catch { data = null; } + const err = new Error((data && JSON.stringify(data)) || "Upload mislukt"); + err.status = res.status; + throw err; + } + return res.json(); +} diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 5a953e4..2e7b3f6 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -7,6 +7,7 @@ import { getModules, getLokaalinstelling, setLokaalinstelling, + uploadBestand, } from "./api.js"; const ROLLEN = [ @@ -88,7 +89,7 @@ function Foutmelding({ msg }) { /* ----------------------- Generieke eenvoudige catalogus ------------------- */ // Voor lijsten met alleen naam + omschrijving (Functies, Activiteiten, Locaties). -function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTheorievak = false, metIcoon = false }) { +function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTheorievak = false, metIcoon = false, metPictogram = false }) { const { items, error, loading, reload, setError } = useList(resource); const leeg = { naam: "", omschrijving: "", @@ -98,7 +99,7 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); - const kolommen = 3 + (telKolom ? 1 : 0) + (metTheorievak ? 1 : 0); + const kolommen = 3 + (telKolom ? 1 : 0) + (metTheorievak ? 1 : 0) + (metPictogram ? 1 : 0); async function opslaan(e) { e.preventDefault(); @@ -121,6 +122,14 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh setError(err.message); } } + async function uploadPicto(id, file) { + try { await uploadBestand(resource, id, "pictogram", file); await reload(); } + catch (err) { setError(err.message); } + } + async function verwijderPicto(id) { + try { await updateResource(resource, id, { pictogram: null }); await reload(); } + catch (err) { setError(err.message); } + } function bewerk(it) { setEditId(it.id); setForm({ @@ -165,6 +174,7 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh
Kies hierboven een {scopeType === "klas" ? "groep" : "leerling"} om het rooster te tonen.
} + {scopeId && ouder && ( +Geen lessen op deze dag.
} + {!vrij &&