commit 6295c58d336f0bb73c325e323c54f9aabf4406ae Author: Sam Date: Sat Feb 28 00:02:02 2026 +0100 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..75e80c8 --- /dev/null +++ b/README.md @@ -0,0 +1,212 @@ +# Leerdoelen Tracker + +Leerdoelen-opvolgsysteem voor GO! scholengroepen. + +## Rollenstructuur + +| Rol | Wat kan die? | +|---|---| +| `superadmin` | Platformbeheer, scholengroep ICT aanwijzen | +| `scholengroep_ict` | Scholen aanmaken, school ICT en directeurs toewijzen | +| `school_ict` | Leerkrachten en klassen van eigen school beheren | +| `director` | Overzicht van eigen school raadplegen | +| `teacher` | Leerdoelen invullen | + +Alle gebruikers (behalve superadmin) loggen in via **Microsoft Entra ID**. + +--- + +## Snelle start + +```bash +# 1. Configuratie +cp .env.example .env +# Vul POSTGRES_PASSWORD, SECRET_KEY, BASE_URL en Entra gegevens in + +# 2. JSON doelen klaarzetten +# Voer converteer_doelen.py uit en kopieer de doelen/ map hier + +# 3. Opstarten +docker compose up -d + +# 4. Superadmin wachtwoord instellen +# Ga naar http://localhost/auth/setup +``` + +--- + +## Entra ID configureren + +1. Ga naar https://portal.azure.com → **App registrations** → **New registration** +2. Naam: `Leerdoelen Tracker` +3. **Supported account types**: kies **"Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant)"** + - Dit is cruciaal! Elke school heeft een eigen tenant. +4. **Redirect URI**: `https://jouwdomain.be/auth/callback` +5. Na aanmaken → **Certificates & secrets** → nieuwe client secret aanmaken +6. Kopieer **Application (client) ID** en de secret naar `.env` + +### Benodigde API permissions +- `openid` (ingebouwd) +- `profile` (ingebouwd) +- `email` (ingebouwd) +- `User.Read` (Microsoft Graph) + +--- + +## Opstartflow voor een nieuwe scholengroep + +### Stap 1: Superadmin setup (eenmalig) +``` +http://jouwdomain.be/auth/setup +→ Stel wachtwoord in voor admin@leerdoelen.local +``` + +### Stap 2: Scholengroep ICT toevoegen (superadmin) +``` +Dashboard → Scholengroep ICT → Toevoegen +→ Vul Microsoft e-mailadres in van de ICT-medewerker +→ Zij loggen in via "Inloggen met Microsoft" en krijgen automatisch de juiste rol +``` + +### Stap 3: Scholen aanmaken (scholengroep ICT) +``` +Dashboard → School toevoegen +→ Naam: "Basisschool De Krekel" +→ E-maildomeinen: "dekrekel.be" (optioneel, voor automatische koppeling) +``` + +### Stap 4: Directeur/School ICT toevoegen (scholengroep ICT) +``` +Dashboard → Gebruiker toevoegen +→ Selecteer school, vul Microsoft e-mail in, kies rol +``` + +### Stap 5: Leerkrachten toevoegen (school ICT of directeur) +``` +Directeur dashboard → Leerkracht toevoegen +``` + +--- + +## Automatische schoolkoppeling via e-maildomein + +Als je een e-maildomein koppelt aan een school (bv. `dekrekel.be`), +dan wordt elke nieuwe gebruiker die inlogt met een adres op dat domein +**automatisch** aan die school gekoppeld met de rol `teacher`. + +Handig als leerkrachten zelf de URL krijgen en inloggen zonder dat je +ze eerst handmatig hoeft toe te voegen. + +--- + +## Onderhoud + +### Database backup +```bash +docker compose exec db pg_dump -U leerdoelen leerdoelen > backup_$(date +%Y%m%d).sql +``` + +### Doelen updaten +```bash +# Nieuwe JSON bestanden kopiëren +cp -r doelen/ /pad/naar/leerdoelen/doelen/ +docker compose restart backend +``` + +### Logs +```bash +docker compose logs -f backend +``` + +--- + +## CI/CD via Gitea Actions + +### Hoe het werkt + +Bij elke push op `main`: +1. Runner bouwt de Docker image van `./backend` +2. Image wordt gepusht naar de Gitea Container Registry met twee tags: + - `:latest` — altijd de meest recente versie + - `:sha-` — voor traceerbaarheid en rollback +3. Runner SSH't naar de VPS → `docker compose pull && docker compose up -d` + +### Eenmalige setup in Gitea + +#### 1. Repository variabelen (Settings → Actions → Variables) + +| Naam | Waarde | Uitleg | +|---|---|---| +| `GITEA_REGISTRY` | `gitea.jouwdomein.be` | Hostname van je Gitea instantie | + +#### 2. Repository secrets (Settings → Actions → Secrets) + +| Naam | Waarde | Uitleg | +|---|---|---| +| `REGISTRY_USER` | `jouw-gitea-gebruikersnaam` | Gitea login voor de registry | +| `REGISTRY_TOKEN` | `gitea_xxxx...` | Gitea Access Token (Settings → Applications → Generate token, scope: `package:write`) | +| `DEPLOY_HOST` | `123.456.789.0` | IP of hostnaam van de app-VPS | +| `DEPLOY_USER` | `deploy` | SSH gebruiker op de VPS | +| `DEPLOY_SSH_KEY` | `-----BEGIN OPENSSH...` | Privésleutel (zie stap 3) | +| `DEPLOY_PORT` | `22` | SSH poort (weglaten = standaard 22) | +| `DEPLOY_PATH` | `/opt/leerdoelen` | Pad naar de docker-compose map op de VPS | + +#### 3. SSH deploy key aanmaken + +Voer dit uit op je **lokale machine** (niet op de VPS): + +```bash +ssh-keygen -t ed25519 -C "gitea-deploy" -f ~/.ssh/gitea_deploy -N "" +``` + +Publieke sleutel toevoegen aan de VPS: +```bash +cat ~/.ssh/gitea_deploy.pub | ssh user@jouw-vps "cat >> ~/.ssh/authorized_keys" +``` + +Privésleutel kopiëren naar Gitea secret `DEPLOY_SSH_KEY`: +```bash +cat ~/.ssh/gitea_deploy +``` + +#### 4. `.env` op de VPS aanpassen + +Voeg toe aan `/opt/leerdoelen/.env`: +``` +BACKEND_IMAGE=gitea.jouwdomein.be/jouw-org/leerdoelen-tracker:latest +``` + +#### 5. Eerste push + +```bash +git init +git remote add origin https://gitea.jouwdomein.be/jouw-org/leerdoelen-tracker.git +git add . +git commit -m "Initial commit" +git push -u origin main +``` + +De pipeline start automatisch. Je kan de voortgang volgen via +**Gitea → jouw repo → Actions**. + +### Handmatig deployen + +Via de Gitea UI: **Actions → Build, Push & Deploy → Run workflow** + +Of via de VPS zelf (zonder pipeline): +```bash +cd /opt/leerdoelen +docker compose pull backend +docker compose up -d --no-deps backend +``` + +### Rollback naar vorige versie + +```bash +# Bekijk beschikbare tags in de registry +# Pas de image tag aan in .env: +BACKEND_IMAGE=gitea.jouwdomein.be/jouw-org/leerdoelen-tracker:sha-a1b2c3d4 + +docker compose pull backend +docker compose up -d --no-deps backend +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..230e767 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Systeem dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# App code + entrypoint (chmod als root, vóór USER switch) +COPY . . +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Non-root user voor security +RUN useradd -m appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE 5000 + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "app:app"] diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..3138d58 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,76 @@ +import os +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from flask_migrate import Migrate +from werkzeug.middleware.proxy_fix import ProxyFix + +db = SQLAlchemy() +login_manager = LoginManager() +migrate = Migrate() + + +def create_app(): + app = Flask(__name__, template_folder='templates', static_folder='static') + + # Config + app.config['SECRET_KEY'] = os.environ['SECRET_KEY'] + app.config['SQLALCHEMY_DATABASE_URI'] = os.environ['DATABASE_URL'] + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config['BASE_URL'] = os.environ.get('BASE_URL', 'http://localhost') + app.config['ORG_NAME'] = os.environ.get('ORG_NAME', 'GO! Scholengroep') + + # OAuth2 config (voor later) + app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID') + app.config['MICROSOFT_CLIENT_SECRET'] = os.environ.get('MICROSOFT_CLIENT_SECRET') + app.config['MICROSOFT_TENANT_ID'] = os.environ.get('MICROSOFT_TENANT_ID', 'common') + app.config['GOOGLE_CLIENT_ID'] = os.environ.get('GOOGLE_CLIENT_ID') + app.config['GOOGLE_CLIENT_SECRET'] = os.environ.get('GOOGLE_CLIENT_SECRET') + + # ProxyFix: Flask zit achter nginx als reverse proxy. + # x_for=1, x_proto=1 zorgt dat Flask de echte client IP en https ziet. + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) + + # Extensions + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + login_manager.login_view = 'auth.login' + login_manager.login_message = 'Gelieve in te loggen.' + + # Import models (zodat Flask-Migrate ze kent) + from models import User, School, SchoolYear, Class, TeacherClass, Assessment + + @login_manager.user_loader + def load_user(user_id): + return User.query.get(int(user_id)) + + # Blueprints registreren + from routes.auth import auth_bp + from routes.api import api_bp + from routes.admin import admin_bp + from routes.pages import pages_bp + + app.register_blueprint(auth_bp, url_prefix='/auth') + app.register_blueprint(api_bp, url_prefix='/api') + app.register_blueprint(admin_bp, url_prefix='/admin') + app.register_blueprint(pages_bp) + + # ── Auditlog cleanup (1 jaar bewaren) ───────────────────────────────────── + @app.cli.command('cleanup-audit') + def cleanup_audit(): + """Verwijder auditlog entries ouder dan 1 jaar. Voer uit via cron of handmatig.""" + from models import AuditLog + from datetime import datetime, timedelta + cutoff = datetime.utcnow() - timedelta(days=365) + deleted = AuditLog.query.filter(AuditLog.timestamp < cutoff).delete() + db.session.commit() + print(f"Verwijderd: {deleted} audit entries ouder dan {cutoff.date()}") + + return app + + +app = create_app() + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=5000) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100755 index 0000000..974e4c8 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +echo "→ Flask-Migrate: database upgraden naar laatste versie..." +flask db upgrade + +echo "→ App starten..." +exec "$@" diff --git a/backend/migrations/alembic.ini b/backend/migrations/alembic.ini new file mode 100644 index 0000000..fc4ec07 --- /dev/null +++ b/backend/migrations/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = migrations +prepend_sys_path = . + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..0bd01de --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,64 @@ +import logging +from logging.config import fileConfig +from flask import current_app +from alembic import context + +config = context.config +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace('%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=get_metadata(), literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=get_metadata(), **conf_args) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/0001_initial_schema.py b/backend/migrations/versions/0001_initial_schema.py new file mode 100644 index 0000000..5dd5534 --- /dev/null +++ b/backend/migrations/versions/0001_initial_schema.py @@ -0,0 +1,122 @@ +"""Initial schema + +Revision ID: 0001 +Revises: +Create Date: 2025-01-01 00:00:00 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = '0001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # Alle tabellen aanmaken als ze nog niet bestaan + # (idempotent via checkfirst=True) + op.execute(""" + CREATE TABLE IF NOT EXISTS schools ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + email_domains TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMP DEFAULT NOW() + ) + """) + op.execute(""" + CREATE TABLE IF NOT EXISTS school_years ( + id SERIAL PRIMARY KEY, + school_id INTEGER REFERENCES schools(id) ON DELETE CASCADE, + label VARCHAR(20) NOT NULL UNIQUE, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() + ) + """) + op.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255), + first_name VARCHAR(100), + last_name VARCHAR(100), + role VARCHAR(20) NOT NULL DEFAULT 'teacher', + school_id INTEGER REFERENCES schools(id) ON DELETE SET NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + last_login TIMESTAMP, + oauth_provider VARCHAR(20), + oauth_id VARCHAR(255), + entra_tenant_id VARCHAR(255) + ) + """) + op.execute(""" + CREATE TABLE IF NOT EXISTS classes ( + id SERIAL PRIMARY KEY, + school_id INTEGER NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(school_id, name) + ) + """) + op.execute(""" + CREATE TABLE IF NOT EXISTS teacher_classes ( + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + class_id INTEGER REFERENCES classes(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, class_id) + ) + """) + op.execute(""" + CREATE TABLE IF NOT EXISTS assessments ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + school_id INTEGER NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + school_year_id INTEGER NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, + vak_id VARCHAR(50) NOT NULL, + goal_id VARCHAR(50) NOT NULL, + status VARCHAR(10) NOT NULL, + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(user_id, school_year_id, vak_id, goal_id) + ) + """) + op.execute(""" + CREATE TABLE IF NOT EXISTS audit_logs ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMP NOT NULL DEFAULT NOW(), + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + school_id INTEGER REFERENCES schools(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + category VARCHAR(20) NOT NULL, + target_type VARCHAR(50), + target_id VARCHAR(100), + detail TEXT, + ip_address VARCHAR(45) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_audit_logs_timestamp ON audit_logs(timestamp)") + op.execute("CREATE INDEX IF NOT EXISTS ix_audit_logs_action ON audit_logs(action)") + op.execute("CREATE INDEX IF NOT EXISTS ix_audit_logs_category ON audit_logs(category)") + + # Verwijder school_year_id van classes als die nog bestaat (oude structuur) + op.execute(""" + DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name='classes' AND column_name='school_year_id' + ) THEN + ALTER TABLE classes DROP COLUMN school_year_id; + END IF; + END $$ + """) + + +def downgrade(): + op.execute("DROP TABLE IF EXISTS audit_logs CASCADE") + op.execute("DROP TABLE IF EXISTS assessments CASCADE") + op.execute("DROP TABLE IF EXISTS teacher_classes CASCADE") + op.execute("DROP TABLE IF EXISTS classes CASCADE") + op.execute("DROP TABLE IF EXISTS users CASCADE") + op.execute("DROP TABLE IF EXISTS school_years CASCADE") + op.execute("DROP TABLE IF EXISTS schools CASCADE") diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..29bfdd4 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,206 @@ +from datetime import datetime +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +from app import db + + +class School(db.Model): + __tablename__ = 'schools' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), nullable=False) + slug = db.Column(db.String(100), nullable=False, unique=True) + email_domains = db.Column(db.ARRAY(db.Text), nullable=False, default=list) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + users = db.relationship('User', back_populates='school', lazy='dynamic') + school_years = db.relationship('SchoolYear', back_populates='school', lazy='dynamic') + classes = db.relationship('Class', back_populates='school', lazy='dynamic') + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'slug': self.slug, + 'email_domains': self.email_domains or [], + } + + +class SchoolYear(db.Model): + __tablename__ = 'school_years' + + id = db.Column(db.Integer, primary_key=True) + # school_id=None = globaal schooljaar voor alle scholen + school_id = db.Column(db.Integer, db.ForeignKey('schools.id', ondelete='CASCADE'), nullable=True) + label = db.Column(db.String(20), nullable=False, unique=True) + is_active = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + school = db.relationship('School', back_populates='school_years') + assessments = db.relationship('Assessment', back_populates='school_year', lazy='dynamic') + + def to_dict(self): + return {'id': self.id, 'label': self.label, 'is_active': self.is_active} + + +class Class(db.Model): + __tablename__ = 'classes' + + id = db.Column(db.Integer, primary_key=True) + school_id = db.Column(db.Integer, db.ForeignKey('schools.id', ondelete='CASCADE'), nullable=False) + name = db.Column(db.String(50), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + school = db.relationship('School', back_populates='classes') + teachers = db.relationship('User', secondary='teacher_classes', back_populates='classes') + + __table_args__ = ( + db.UniqueConstraint('school_id', 'name', name='uq_class_school_name'), + ) + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'school_id': self.school_id, + 'teachers': [{'id': t.id, 'full_name': t.full_name} for t in self.teachers], + } + + +class TeacherClass(db.Model): + __tablename__ = 'teacher_classes' + + user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True) + class_id = db.Column(db.Integer, db.ForeignKey('classes.id', ondelete='CASCADE'), primary_key=True) + + +class User(UserMixin, db.Model): + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), nullable=False, unique=True) + password_hash = db.Column(db.String(255)) + first_name = db.Column(db.String(100)) + last_name = db.Column(db.String(100)) + role = db.Column(db.String(20), nullable=False, default='teacher') + school_id = db.Column(db.Integer, db.ForeignKey('schools.id', ondelete='SET NULL')) + is_active = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + last_login = db.Column(db.DateTime) + oauth_provider = db.Column(db.String(20)) + oauth_id = db.Column(db.String(255)) + entra_tenant_id = db.Column(db.String(255)) + + school = db.relationship('School', back_populates='users') + classes = db.relationship('Class', secondary='teacher_classes', back_populates='teachers') + + @property + def is_superadmin(self): return self.role == 'superadmin' + @property + def is_scholengroep_ict(self): return self.role in ('superadmin', 'scholengroep_ict') + @property + def is_school_ict(self): return self.role in ('superadmin', 'scholengroep_ict', 'school_ict') + @property + def is_director(self): return self.role in ('superadmin', 'scholengroep_ict', 'school_ict', 'director') + @property + def is_teacher(self): return self.role == 'teacher' + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + if not self.password_hash: + return False + return check_password_hash(self.password_hash, password) + + @property + def full_name(self): + if self.first_name and self.last_name: + return f"{self.first_name} {self.last_name}" + return self.email + + @property + def class_names(self): + return [c.name for c in self.classes] + + def to_dict(self): + return { + 'id': self.id, + 'email': self.email, + 'first_name': self.first_name, + 'last_name': self.last_name, + 'full_name': self.full_name, + 'role': self.role, + 'school_id': self.school_id, + 'school_name': self.school.name if self.school else None, + 'school': self.school.to_dict() if self.school else None, + 'last_login': self.last_login.isoformat() if self.last_login else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'classes': [{'id': c.id, 'name': c.name} for c in self.classes], + } + + +class Assessment(db.Model): + __tablename__ = 'assessments' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + school_id = db.Column(db.Integer, db.ForeignKey('schools.id', ondelete='CASCADE'), nullable=False) + school_year_id = db.Column(db.Integer, db.ForeignKey('school_years.id', ondelete='CASCADE'), nullable=False) + vak_id = db.Column(db.String(50), nullable=False) + goal_id = db.Column(db.String(50), nullable=False) + status = db.Column(db.String(10), nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + user = db.relationship('User') + school = db.relationship('School') + school_year = db.relationship('SchoolYear', back_populates='assessments') + + __table_args__ = ( + db.UniqueConstraint('user_id', 'school_year_id', 'vak_id', 'goal_id'), + ) + + def to_dict(self): + return { + 'id': self.id, + 'vak_id': self.vak_id, + 'goal_id': self.goal_id, + 'status': self.status, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + + +class AuditLog(db.Model): + __tablename__ = 'audit_logs' + + id = db.Column(db.Integer, primary_key=True) + timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True) + school_id = db.Column(db.Integer, db.ForeignKey('schools.id', ondelete='SET NULL'), nullable=True) + action = db.Column(db.String(50), nullable=False, index=True) + # categorie: auth | user | school | class | assessment | doelen | system + category = db.Column(db.String(20), nullable=False, index=True) + target_type = db.Column(db.String(50)) # bv. 'user', 'school', 'class' + target_id = db.Column(db.String(100)) # id of naam van het object + detail = db.Column(db.Text) # extra context in JSON string + ip_address = db.Column(db.String(45)) # IPv4 of IPv6 + + user = db.relationship('User', foreign_keys=[user_id]) + school = db.relationship('School', foreign_keys=[school_id]) + + def to_dict(self): + return { + 'id': self.id, + 'timestamp': self.timestamp.isoformat(), + 'user_id': self.user_id, + 'user_name': self.user.full_name if self.user else 'Systeem', + 'user_email': self.user.email if self.user else None, + 'school_id': self.school_id, + 'school_name': self.school.name if self.school else None, + 'action': self.action, + 'category': self.category, + 'target_type': self.target_type, + 'target_id': self.target_id, + 'detail': self.detail, + 'ip_address': self.ip_address, + } diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f22efd4 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,12 @@ +flask==3.0.3 +flask-login==0.6.3 +flask-sqlalchemy==3.1.1 +flask-migrate==4.0.7 +flask-cors==4.0.1 +psycopg2-binary==2.9.9 +gunicorn==22.0.0 +python-dotenv==1.0.1 +werkzeug==3.0.3 +sqlalchemy==2.0.31 +authlib==1.3.1 # OAuth2 (Microsoft + Google, later) +requests==2.32.3 diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routes/admin.py b/backend/routes/admin.py new file mode 100644 index 0000000..ce3985c --- /dev/null +++ b/backend/routes/admin.py @@ -0,0 +1,544 @@ +""" +Admin routes + +Toegang per rol: + superadmin → alles + scholengroep_ict → scholen + gebruikers beheren, doelen uploaden + school_ict → leerkrachten en klassen van eigen school beheren +""" + +import re +import json as jsonlib +from flask import Blueprint, jsonify, request +from services.audit import audit_log +from flask_login import login_required, current_user +from models import User, School, SchoolYear, Class, TeacherClass +from app import db +from functools import wraps + +admin_bp = Blueprint('admin', __name__) + +VALID_ROLES = ('superadmin', 'scholengroep_ict', 'school_ict', 'director', 'teacher') + + +# ── Toegangsdecorators ──────────────────────────────────────────────────────── + +def superadmin_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not current_user.is_superadmin: + return jsonify({'error': 'Geen toegang — superadmin vereist'}), 403 + return f(*args, **kwargs) + return decorated + +def scholengroep_ict_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not current_user.is_scholengroep_ict: + return jsonify({'error': 'Geen toegang — scholengroep ICT vereist'}), 403 + return f(*args, **kwargs) + return decorated + +def school_ict_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not current_user.is_school_ict: + return jsonify({'error': 'Geen toegang — school ICT vereist'}), 403 + return f(*args, **kwargs) + return decorated + + +# ── Scholen (scholengroep_ict) ──────────────────────────────────────────────── + +@admin_bp.route('/schools', methods=['GET']) +@login_required +@scholengroep_ict_required +def list_schools(): + schools = School.query.order_by(School.name).all() + result = [] + for s in schools: + d = s.to_dict() + d['user_count'] = User.query.filter_by(school_id=s.id, is_active=True).count() + result.append(d) + return jsonify({'schools': result}) + + +@admin_bp.route('/schools', methods=['POST']) +@login_required +@scholengroep_ict_required +def create_school(): + data = request.get_json() or {} + name = data.get('name', '').strip() + slug = data.get('slug', '').strip().lower() + domains = [d.strip().lower() for d in data.get('email_domains', []) if d.strip()] + + if not name: + return jsonify({'error': 'Naam is verplicht'}), 400 + if not slug: + slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-') + if School.query.filter_by(slug=slug).first(): + return jsonify({'error': f'Slug "{slug}" is al in gebruik'}), 409 + + school = School(name=name, slug=slug, email_domains=domains) + db.session.add(school) + db.session.flush() + audit_log('school.create', 'school', target_type='school', target_id=str(school.id), + detail={'name': name, 'slug': slug}, school_id=school.id) + db.session.commit() + return jsonify({'school': school.to_dict()}), 201 + + +@admin_bp.route('/schools/', methods=['PUT']) +@login_required +@scholengroep_ict_required +def update_school(school_id): + school = School.query.get_or_404(school_id) + data = request.get_json() or {} + if 'name' in data: + school.name = data['name'].strip() + if 'email_domains' in data: + school.email_domains = [d.strip().lower() for d in data['email_domains'] if d.strip()] + db.session.commit() + return jsonify({'school': school.to_dict()}) + + +@admin_bp.route('/schools/', methods=['DELETE']) +@login_required +@scholengroep_ict_required +def delete_school(school_id): + school = School.query.get_or_404(school_id) + school_name = school.name + try: + audit_log('school.delete', 'school', target_type='school', target_id=str(school_id), + detail={'name': school_name}, school_id=school_id) + db.session.flush() + db.session.execute( + db.text("DELETE FROM schools WHERE id = :id"), + {"id": school_id} + ) + db.session.commit() + except Exception as e: + db.session.rollback() + return jsonify({'error': f'Verwijderen mislukt: {str(e)}'}), 500 + return jsonify({'deleted': True}) + + + +# ── Schooljaren (globaal — niet per school) ────────────────────────────────── + +@admin_bp.route('/years', methods=['GET']) +@login_required +@school_ict_required +def list_years(): + """Alle globale schooljaren, nieuwste eerst.""" + years = SchoolYear.query.filter_by(school_id=None) .order_by(SchoolYear.label.desc()).all() + return jsonify({'years': [y.to_dict() for y in years]}) + + +@admin_bp.route('/years', methods=['POST']) +@login_required +@scholengroep_ict_required +def create_year(): + """Maak een nieuw globaal schooljaar aan.""" + data = request.get_json() or {} + label = data.get('label', '').strip() + + if not label: + return jsonify({'error': 'Label is verplicht (bv. 2025-2026)'}), 400 + if SchoolYear.query.filter_by(label=label).first(): + return jsonify({'error': f'Schooljaar {label} bestaat al'}), 409 + + if data.get('set_active', True): + SchoolYear.query.filter_by(school_id=None, is_active=True) .update({'is_active': False}) + + year = SchoolYear(school_id=None, label=label, + is_active=data.get('set_active', True)) + db.session.add(year) + db.session.flush() + audit_log('year.create', 'system', target_type='school_year', target_id=str(year.id), + detail={'label': label, 'active': year.is_active}) + db.session.commit() + return jsonify({'year': year.to_dict()}), 201 + + +@admin_bp.route('/years//activate', methods=['PUT']) +@login_required +@scholengroep_ict_required +def activate_year(year_id): + """Zet een schooljaar als actief (deactiveert de rest).""" + year = SchoolYear.query.filter_by(id=year_id, school_id=None).first_or_404() + SchoolYear.query.filter_by(school_id=None, is_active=True) .update({'is_active': False}) + year.is_active = True + audit_log('year.activate', 'system', target_type='school_year', target_id=str(year_id), + detail={'label': year.label}) + db.session.commit() + return jsonify({'year': year.to_dict()}) + + +# ── Gebruikers per school ───────────────────────────────────────────────────── + +@admin_bp.route('/schools//users', methods=['GET']) +@login_required +@school_ict_required +def list_school_users(school_id): + if not current_user.is_scholengroep_ict and current_user.school_id != school_id: + return jsonify({'error': 'Geen toegang tot deze school'}), 403 + users = User.query.filter_by(school_id=school_id, is_active=True)\ + .order_by(User.last_name, User.first_name).all() + return jsonify({'users': [u.to_dict() for u in users]}) + + +@admin_bp.route('/schools//users', methods=['POST']) +@login_required +@school_ict_required +def add_user_to_school(school_id): + if not current_user.is_scholengroep_ict and current_user.school_id != school_id: + return jsonify({'error': 'Geen toegang tot deze school'}), 403 + + School.query.get_or_404(school_id) + data = request.get_json() or {} + email = data.get('email', '').strip().lower() + role = data.get('role', 'teacher') + + if not email: + return jsonify({'error': 'E-mailadres is verplicht'}), 400 + + allowed_roles = ('teacher', 'director', 'school_ict') + if current_user.role == 'school_ict' and role not in allowed_roles: + return jsonify({'error': f'Rol "{role}" mag niet worden toegewezen door school ICT'}), 403 + if role not in VALID_ROLES: + return jsonify({'error': f'Ongeldige rol: {role}'}), 400 + + existing = User.query.filter_by(email=email).first() + if existing: + # Account bestaat al (ook als uitgeschakeld) — activeer en update rol/school + existing.school_id = school_id + existing.role = role + existing.is_active = True + db.session.commit() + return jsonify({'user': existing.to_dict(), 'linked': True}) + + user = User( + email=email, + first_name=data.get('first_name', '').strip(), + last_name=data.get('last_name', '').strip(), + role=role, + school_id=school_id, + is_active=True, + ) + db.session.add(user) + db.session.commit() + return jsonify({'user': user.to_dict(), 'linked': False}), 201 + + +@admin_bp.route('/schools//users//role', methods=['PUT']) +@login_required +@school_ict_required +def update_user_role(school_id, user_id): + if not current_user.is_scholengroep_ict and current_user.school_id != school_id: + return jsonify({'error': 'Geen toegang tot deze school'}), 403 + user = User.query.filter_by(id=user_id, school_id=school_id).first_or_404() + data = request.get_json() or {} + role = data.get('role', '') + allowed = ('teacher', 'director', 'school_ict') + if current_user.role == 'school_ict' and role not in allowed: + return jsonify({'error': f'Rol "{role}" mag niet worden toegewezen'}), 403 + if role not in VALID_ROLES: + return jsonify({'error': f'Ongeldige rol: {role}'}), 400 + user.role = role + db.session.commit() + return jsonify({'user': user.to_dict()}) + + +@admin_bp.route('/schools//users/', methods=['DELETE']) +@login_required +@school_ict_required +def remove_user_from_school(school_id, user_id): + if not current_user.is_scholengroep_ict and current_user.school_id != school_id: + return jsonify({'error': 'Geen toegang tot deze school'}), 403 + user = User.query.filter_by(id=user_id, school_id=school_id).first_or_404() + user.is_active = False + audit_log('user.deactivate', 'user', target_type='user', target_id=str(user_id), + detail={'email': user.email, 'role': user.role}, + school_id=current_user.school_id) + db.session.commit() + return jsonify({'deleted': True}) + + +# ── Scholengroep ICT beheer (superadmin) ────────────────────────────────────── + +@admin_bp.route('/scholengroep-ict', methods=['GET']) +@login_required +@superadmin_required +def list_scholengroep_ict(): + users = User.query.filter_by(role='scholengroep_ict', is_active=True)\ + .order_by(User.last_name).all() + return jsonify({'users': [u.to_dict() for u in users]}) + + +@admin_bp.route('/scholengroep-ict', methods=['POST']) +@login_required +@superadmin_required +def add_scholengroep_ict(): + data = request.get_json() or {} + email = data.get('email', '').strip().lower() + if not email: + return jsonify({'error': 'E-mailadres is verplicht'}), 400 + + user = User.query.filter_by(email=email).first() + if user: + user.role = 'scholengroep_ict' + user.school_id = None + user.is_active = True + db.session.commit() + return jsonify({'user': user.to_dict()}) + + user = User( + email=email, + first_name=data.get('first_name', '').strip(), + last_name=data.get('last_name', '').strip(), + role='scholengroep_ict', + is_active=True, + ) + db.session.add(user) + db.session.flush() + audit_log('user.create', 'user', target_type='user', target_id=str(user.id), + detail={'email': email, 'role': user.role, 'school_id': school_id}, + school_id=school_id) + db.session.commit() + return jsonify({'user': user.to_dict()}), 201 + + +@admin_bp.route('/scholengroep-ict/', methods=['DELETE']) +@login_required +@superadmin_required +def remove_scholengroep_ict(user_id): + user = User.query.get_or_404(user_id) + if user.role != 'scholengroep_ict': + return jsonify({'error': 'Gebruiker is geen scholengroep ICT'}), 400 + user.is_active = False + db.session.commit() + return jsonify({'ok': True}) + + +# ── Doelen upload (scholengroep_ict) ────────────────────────────────────────── + +@admin_bp.route('/doelen', methods=['GET']) +@login_required +@scholengroep_ict_required +def list_doelen(): + from services.doelen import load_index, list_installed_vakken + index = load_index() + installed = set(list_installed_vakken()) + return jsonify({ + 'vakken': index.get('vakken', []), + 'versie': index.get('versie'), + 'installed': list(installed), + }) + + +@admin_bp.route('/doelen/upload', methods=['POST']) +@login_required +@scholengroep_ict_required +def upload_doelen(): + """ + Upload één of meerdere vak JSON bestanden via multipart/form-data (veld: 'files'). + Bij maandelijkse update gewoon opnieuw uploaden — overschrijft bestaande bestanden. + """ + from services.doelen import validate_vak_json, save_vak, is_valid_vak_id + + if 'files' not in request.files: + return jsonify({'error': 'Geen bestanden ontvangen (verwacht veld "files")'}), 400 + + results = [] + + for file in request.files.getlist('files'): + if not file.filename: + continue + + result = {'filename': file.filename, 'ok': False} + + if not file.filename.lower().endswith('.json'): + result['error'] = 'Alleen .json bestanden zijn toegestaan' + results.append(result) + continue + + try: + data = jsonlib.loads(file.read().decode('utf-8')) + except Exception: + result['error'] = 'Ongeldig JSON — kon bestand niet lezen' + results.append(result) + continue + + # Vak ID: uit het bestand zelf, anders van de bestandsnaam + vak_id = (data.get('vak') or file.filename[:-5]).lower().strip() + + if not is_valid_vak_id(vak_id): + result['error'] = f'Ongeldig vak ID: "{vak_id}"' + results.append(result) + continue + + errors = validate_vak_json(data) + if errors: + result['error'] = '; '.join(errors) + results.append(result) + continue + + doelzinnen = [r for r in data['rijen'] if r.get('type') == 'doelzin'] + save_vak(vak_id, data) + + result.update({ + 'ok': True, + 'vak_id': vak_id, + 'vak_naam': data.get('vakNaam') or vak_id, + 'aantalDoelzinnen': len(doelzinnen), + 'versie': data.get('versie', '?'), + }) + results.append(result) + + ok_count = sum(1 for r in results if r['ok']) + err_count = len(results) - ok_count + + return jsonify({ + 'ok': ok_count, + 'errors': err_count, + 'results': results, + }), (200 if ok_count > 0 else 400) + + +@admin_bp.route('/doelen/', methods=['DELETE']) +@login_required +@scholengroep_ict_required +def delete_doelen(vak_id): + from services.doelen import delete_vak, is_valid_vak_id + if not is_valid_vak_id(vak_id): + return jsonify({'error': 'Ongeldig vak ID'}), 400 + if not delete_vak(vak_id): + return jsonify({'error': 'Bestand niet gevonden'}), 404 + return jsonify({'deleted': True, 'vak_id': vak_id}) + + +# ── Globale statistieken (superadmin) ───────────────────────────────────────── + +@admin_bp.route('/stats') +@login_required +@scholengroep_ict_required +def global_stats(): + return jsonify({ + 'schools': School.query.count(), + 'users': User.query.filter_by(is_active=True).count(), + 'teachers': User.query.filter_by(role='teacher', is_active=True).count(), + 'directors': User.query.filter_by(role='director', is_active=True).count(), + 'school_ict': User.query.filter_by(role='school_ict', is_active=True).count(), + 'scholengroep_ict': User.query.filter_by(role='scholengroep_ict',is_active=True).count(), + }) + + +# ── Klassen ─────────────────────────────────────────────────────────────────── + +@admin_bp.route('/schools//classes', methods=['GET']) +@login_required +@school_ict_required +def list_classes(school_id): + if not current_user.is_scholengroep_ict and current_user.school_id != school_id: + return jsonify({'error': 'Geen toegang'}), 403 + classes = Class.query.filter_by(school_id=school_id) .order_by(Class.name).all() + return jsonify({'classes': [c.to_dict() for c in classes]}) + + +@admin_bp.route('/schools//classes', methods=['POST']) +@login_required +@school_ict_required +def create_class(school_id): + if not current_user.is_scholengroep_ict and current_user.school_id != school_id: + return jsonify({'error': 'Geen toegang'}), 403 + data = request.get_json() or {} + name = data.get('name', '').strip() + if not name: + return jsonify({'error': 'Naam is verplicht'}), 400 + if Class.query.filter_by(school_id=school_id, name=name).first(): + return jsonify({'error': f'Klas "{name}" bestaat al'}), 409 + + klas = Class(school_id=school_id, name=name) + db.session.add(klas) + db.session.flush() + audit_log('class.create', 'class', target_type='class', target_id=str(klas.id), + detail={'name': name}, school_id=school_id) + db.session.commit() + return jsonify({'class': klas.to_dict()}), 201 + + +@admin_bp.route('/schools//classes/', methods=['DELETE']) +@login_required +@school_ict_required +def delete_class(school_id, class_id): + if not current_user.is_scholengroep_ict and current_user.school_id != school_id: + return jsonify({'error': 'Geen toegang'}), 403 + klas = Class.query.filter_by(id=class_id, school_id=school_id).first_or_404() + audit_log('class.delete', 'class', target_type='class', target_id=str(class_id), + detail={'name': klas.name}, school_id=school_id) + db.session.delete(klas) + db.session.commit() + return jsonify({'deleted': True}) + + +@admin_bp.route('/schools//classes//teachers', methods=['PUT']) +@login_required +@school_ict_required +def set_class_teachers(school_id, class_id): + """Vervang alle leerkrachten van een klas in één keer.""" + if not current_user.is_scholengroep_ict and current_user.school_id != school_id: + return jsonify({'error': 'Geen toegang'}), 403 + klas = Class.query.filter_by(id=class_id, school_id=school_id).first_or_404() + data = request.get_json() or {} + user_ids = data.get('user_ids', []) + + teachers = User.query.filter( + User.id.in_(user_ids), + User.school_id == school_id, + User.is_active == True + ).all() + + klas.teachers = teachers + audit_log('class.teachers_updated', 'class', target_type='class', target_id=str(class_id), + detail={'name': klas.name, 'teacher_ids': user_ids}, school_id=school_id) + db.session.commit() + return jsonify({'class': klas.to_dict()}) + + +@admin_bp.route('/users//classes', methods=['GET']) +@login_required +def get_user_classes(user_id): + """Geeft klassen terug van een specifieke leerkracht (voor leerkracht zelf of beheerder).""" + if current_user.id != user_id and not current_user.is_school_ict: + return jsonify({'error': 'Geen toegang'}), 403 + user = User.query.get_or_404(user_id) + classes = Class.query.filter_by(school_id=user.school_id) .order_by(Class.name).all() + return jsonify({ + 'all_classes': [{'id': c.id, 'name': c.name} for c in classes], + 'my_classes': [{'id': c.id, 'name': c.name} for c in user.classes], + }) + + +@admin_bp.route('/users//classes', methods=['PUT']) +@login_required +def set_user_classes(user_id): + """Leerkracht stelt eigen klassen in, of beheerder doet het.""" + if current_user.id != user_id and not current_user.is_school_ict: + return jsonify({'error': 'Geen toegang'}), 403 + user = User.query.get_or_404(user_id) + data = request.get_json() or {} + class_ids = data.get('class_ids', []) + + classes = Class.query.filter( + Class.id.in_(class_ids), + Class.school_id == user.school_id + ).all() + + user.classes = classes + audit_log('class.user_assignment', 'class', target_type='user', target_id=str(user_id), + detail={'class_ids': class_ids, 'class_names': [c.name for c in classes]}, + school_id=user.school_id) + db.session.commit() + return jsonify({'classes': [{'id': c.id, 'name': c.name} for c in user.classes]}) + diff --git a/backend/routes/api.py b/backend/routes/api.py new file mode 100644 index 0000000..87c6fa8 --- /dev/null +++ b/backend/routes/api.py @@ -0,0 +1,316 @@ +from datetime import datetime +from flask import Blueprint, jsonify, request +from flask_login import login_required, current_user +from models import User, SchoolYear, Assessment, School, Class, AuditLog +from app import db +from services.doelen import load_index, load_vak, is_valid_vak_id +from services.audit import audit_log +from functools import wraps + +api_bp = Blueprint('api', __name__) + + +def director_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not current_user.is_director: + return jsonify({'error': 'Geen toegang'}), 403 + return f(*args, **kwargs) + return decorated + + +def get_active_year(school_id=None): + """Geeft het globaal actief schooljaar terug (school_id wordt genegeerd).""" + return SchoolYear.query.filter_by(school_id=None, is_active=True).first() + + +# ── Doelen (statische JSON bestanden) ───────────────────────────────────────── + +@api_bp.route('/doelen/index') +@login_required +def doelen_index(): + data = load_index() + if not data['vakken']: + return jsonify({ + 'error': 'Geen doelen gevonden. Upload eerst de JSON bestanden via het beheerderspaneel.' + }), 404 + return jsonify(data) + + +@api_bp.route('/doelen/') +@login_required +def doelen_vak(vak_id): + if not is_valid_vak_id(vak_id): + return jsonify({'error': 'Ongeldig vak ID'}), 400 + data = load_vak(vak_id) + if not data: + return jsonify({'error': f'Vak "{vak_id}" niet gevonden'}), 404 + return jsonify(data) + + +# ── Beoordelingen ───────────────────────────────────────────────────────────── + +@api_bp.route('/assessments', methods=['GET']) +@login_required +def get_assessments(): + if not current_user.school_id: + return jsonify({'assessments': []}) + school_year = get_active_year(current_user.school_id) + if not school_year: + return jsonify({'assessments': []}) + + year_id = request.args.get('year_id', school_year.id) + vak_id = request.args.get('vak_id') + + query = Assessment.query.filter_by(user_id=current_user.id, school_year_id=year_id) + if vak_id: + query = query.filter_by(vak_id=vak_id) + + return jsonify({'assessments': [a.to_dict() for a in query.all()]}) + + +@api_bp.route('/assessments', methods=['POST']) +@login_required +def save_assessment(): + data = request.get_json() or {} + vak_id = (data.get('vak_id') or '').strip() + goal_id = (data.get('goal_id') or '').strip() + status = (data.get('status') or '').strip() + + if not vak_id or not goal_id: + return jsonify({'error': 'vak_id en goal_id zijn verplicht'}), 400 + if status not in ('groen', 'oranje', 'roze', ''): + return jsonify({'error': 'Ongeldige status — gebruik groen, oranje, roze of leeg'}), 400 + if not current_user.school_id: + return jsonify({'error': 'Account is nog niet gekoppeld aan een school'}), 400 + + school_year = get_active_year(current_user.school_id) + if not school_year: + return jsonify({'error': 'Geen actief schooljaar gevonden'}), 400 + + assessment = Assessment.query.filter_by( + user_id=current_user.id, + school_year_id=school_year.id, + vak_id=vak_id, + goal_id=goal_id, + ).first() + + if status == '': + if assessment: + db.session.delete(assessment) + db.session.commit() + return jsonify({'deleted': True}) + + if assessment: + assessment.status = status + assessment.updated_at = datetime.utcnow() + else: + assessment = Assessment( + user_id=current_user.id, + school_id=current_user.school_id, + school_year_id=school_year.id, + vak_id=vak_id, + goal_id=goal_id, + status=status, + ) + db.session.add(assessment) + + db.session.commit() + # Auditlog enkel bij statuswijziging (niet bij elke klik) + audit_log('assessment.save', 'assessment', + target_type='goal', target_id=f'{vak_id}:{goal_id}', + detail={'status': status}) + return jsonify({'assessment': assessment.to_dict()}) + + +# ── Directeur schooloverzicht ────────────────────────────────────────────────── + +@api_bp.route('/school/overview') +@login_required +@director_required +def school_overview(): + if not current_user.school_id: + return jsonify({'error': 'Geen school gekoppeld'}), 400 + school_year = get_active_year(current_user.school_id) + if not school_year: + return jsonify({'error': 'Geen actief schooljaar'}), 400 + + # year_id param: directeur/admin kan wisselen, leerkracht zit vast aan actief jaar + year_id_param = request.args.get('year_id') + if year_id_param and current_user.is_director: + year_id = int(year_id_param) + selected_year = SchoolYear.query.filter_by( + id=year_id, school_id=current_user.school_id + ).first() or school_year + else: + selected_year = school_year + year_id = school_year.id + + vak_id = request.args.get('vak_id') + + teachers = User.query.filter_by( + school_id=current_user.school_id, role='teacher', is_active=True + ).all() + + query = Assessment.query.filter_by( + school_id=current_user.school_id, school_year_id=year_id + ) + if vak_id: + query = query.filter_by(vak_id=vak_id) + + by_teacher = {t.id: {} for t in teachers} + for a in query.all(): + by_teacher.setdefault(a.user_id, {}) + by_teacher[a.user_id].setdefault(a.vak_id, {}) + by_teacher[a.user_id][a.vak_id][a.goal_id] = a.status + + return jsonify({ + 'school_year': selected_year.to_dict(), + 'teachers': [t.to_dict() for t in teachers], + 'assessments_by_teacher': by_teacher, + }) + + +# ── Gebruikersbeheer (school_ict / directeur) ────────────────────────────────── + +@api_bp.route('/users', methods=['GET']) +@login_required +@director_required +def list_users(): + users = User.query.filter_by( + school_id=current_user.school_id, is_active=True + ).order_by(User.last_name, User.first_name).all() + return jsonify({'users': [u.to_dict() for u in users]}) + + +@api_bp.route('/users', methods=['POST']) +@login_required +@director_required +def create_user(): + data = request.get_json() or {} + email = data.get('email', '').strip().lower() + if not email: + return jsonify({'error': 'E-mailadres is verplicht'}), 400 + if User.query.filter_by(email=email).first(): + return jsonify({'error': 'E-mailadres is al in gebruik'}), 409 + user = User( + email=email, + first_name=data.get('first_name', '').strip(), + last_name=data.get('last_name', '').strip(), + role='teacher', + school_id=current_user.school_id, + ) + db.session.add(user) + db.session.commit() + return jsonify({'user': user.to_dict()}), 201 + + +@api_bp.route('/users/', methods=['DELETE']) +@login_required +@director_required +def delete_user(user_id): + user = User.query.filter_by( + id=user_id, school_id=current_user.school_id + ).first_or_404() + user.is_active = False + db.session.commit() + return jsonify({'deleted': True}) + + + +# ── Schooljaren (directeur/admin leesbaar) ──────────────────────────────────── + +@api_bp.route('/school/years') +@login_required +@director_required +def get_school_years(): + """Geeft alle globale schooljaren terug (voor jaarselectie in directeur dashboard).""" + years = SchoolYear.query.filter_by(school_id=None) .order_by(SchoolYear.label.desc()).all() + return jsonify({'years': [y.to_dict() for y in years]}) + + +# ── Huidig ingelogde gebruiker ──────────────────────────────────────────────── + +@api_bp.route('/me') +@login_required +def me(): + school_year = get_active_year(current_user.school_id) if current_user.school_id else None + return jsonify({ + 'user': current_user.to_dict(), + 'school_year': school_year.to_dict() if school_year else None, + }) + + +# ── Klassen voor leerkracht (zelf instellen) ────────────────────────────────── + +@api_bp.route('/my/classes', methods=['GET']) +@login_required +def my_classes(): + """Geeft alle beschikbare klassen en eigen klassen terug.""" + if not current_user.school_id: + return jsonify({'all_classes': [], 'my_classes': []}) + all_cls = Class.query.filter_by(school_id=current_user.school_id) .order_by(Class.name).all() + return jsonify({ + 'all_classes': [{'id': c.id, 'name': c.name} for c in all_cls], + 'my_classes': [{'id': c.id, 'name': c.name} for c in current_user.classes], + }) + + +@api_bp.route('/my/classes', methods=['PUT']) +@login_required +def set_my_classes(): + """Leerkracht stelt eigen klassen in.""" + data = request.get_json() or {} + class_ids = data.get('class_ids', []) + classes = Class.query.filter( + Class.id.in_(class_ids), + Class.school_id == current_user.school_id + ).all() + current_user.classes = classes + audit_log('class.user_assignment', 'class', target_type='user', + target_id=str(current_user.id), + detail={'class_ids': class_ids, 'class_names': [c.name for c in classes]}) + db.session.commit() + return jsonify({'my_classes': [{'id': c.id, 'name': c.name} for c in current_user.classes]}) + + + +# ── Auditlog ────────────────────────────────────────────────────────────────── + +@api_bp.route('/audit-log') +@login_required +def get_audit_log(): + if not current_user.is_school_ict: + return jsonify({'error': 'Geen toegang'}), 403 + + page = int(request.args.get('page', 1)) + per_page = int(request.args.get('per_page', 50)) + category = request.args.get('category') + search = request.args.get('search', '').strip() + + query = AuditLog.query + + # School ICT ziet enkel eigen school + if not current_user.is_scholengroep_ict: + query = query.filter(AuditLog.school_id == current_user.school_id) + + if category: + query = query.filter(AuditLog.category == category) + if search: + query = query.filter( + db.or_( + AuditLog.action.ilike(f'%{search}%'), + AuditLog.detail.ilike(f'%{search}%'), + ) + ) + + total = query.count() + entries = query.order_by(AuditLog.timestamp.desc()) .offset((page - 1) * per_page).limit(per_page).all() + + return jsonify({ + 'total': total, + 'page': page, + 'pages': (total + per_page - 1) // per_page, + 'entries': [e.to_dict() for e in entries], + }) + diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 0000000..82e429f --- /dev/null +++ b/backend/routes/auth.py @@ -0,0 +1,291 @@ +""" +Auth routes - Microsoft Entra ID (Azure AD) OAuth2 + superadmin fallback + +Flow voor Entra login: +1. Gebruiker klikt "Login met Microsoft" +2. Redirect naar Microsoft /authorize (common endpoint, werkt voor alle tenants) +3. Microsoft redirect terug naar /auth/callback met een code +4. We wisselen de code in voor tokens +5. We lezen het id_token uit → email, naam, oid, tid +6. We zoeken of maken de gebruiker aan in onze DB +7. We koppelen aan de juiste school via e-maildomein of bestaand account +""" + +import os +import secrets +import logging +from datetime import datetime +from urllib.parse import urlencode + +import requests +from services.audit import audit_log +from flask import (Blueprint, render_template, request, redirect, + url_for, flash, jsonify, session, current_app) +from flask_login import login_user, logout_user, login_required, current_user +from models import User, School +from app import db + +logger = logging.getLogger(__name__) +auth_bp = Blueprint('auth', __name__) + +ENTRA_AUTHORITY = "https://login.microsoftonline.com/common" +ENTRA_AUTH_URL = f"{ENTRA_AUTHORITY}/oauth2/v2.0/authorize" +ENTRA_TOKEN_URL = f"{ENTRA_AUTHORITY}/oauth2/v2.0/token" +ENTRA_USERINFO_URL = "https://graph.microsoft.com/v1.0/me" +ENTRA_SCOPES = "openid profile email User.Read" + + +def _entra_client_id(): + return current_app.config.get('MICROSOFT_CLIENT_ID') + +def _entra_client_secret(): + return current_app.config.get('MICROSOFT_CLIENT_SECRET') + +def _callback_url(): + base = current_app.config.get('BASE_URL', 'http://localhost').rstrip('/') + return f"{base}/auth/callback" + +def _find_school_for_email(email: str): + domain = email.split('@')[-1].lower() + schools = School.query.all() + for school in schools: + if school.email_domains and domain in [d.lower() for d in school.email_domains]: + return school + return None + +def _get_or_create_user(email, first_name, last_name, oid, tid): + user = User.query.filter_by(oauth_provider='microsoft', oauth_id=oid).first() + if user: + user.first_name = first_name or user.first_name + user.last_name = last_name or user.last_name + user.email = email + user.entra_tenant_id = tid + db.session.commit() + return user, False + + user = User.query.filter_by(email=email).first() + if user: + user.oauth_provider = 'microsoft' + user.oauth_id = oid + user.entra_tenant_id = tid + user.first_name = first_name or user.first_name + user.last_name = last_name or user.last_name + db.session.commit() + return user, False + + school = _find_school_for_email(email) + user = User( + email=email, first_name=first_name, last_name=last_name, + role='teacher', school_id=school.id if school else None, + oauth_provider='microsoft', oauth_id=oid, + entra_tenant_id=tid, is_active=True, + ) + db.session.add(user) + db.session.commit() + return user, True + + +@auth_bp.route('/superadmin') +def superadmin_page(): + """Directe loginpagina voor de platformbeheerder.""" + if current_user.is_authenticated: + return redirect(url_for('pages.dashboard')) + return render_template('superadmin_login.html') + + +@auth_bp.route('/login') +def login(): + if current_user.is_authenticated: + return redirect(url_for('pages.dashboard')) + entra_configured = bool(_entra_client_id() and _entra_client_secret()) + org_name = current_app.config.get('ORG_NAME', 'GO! Scholengroep') + return render_template('login.html', entra_configured=entra_configured, org_name=org_name) + + +@auth_bp.route('/logout') +@login_required +def logout(): + audit_log('logout', 'auth') + logout_user() + if _entra_client_id(): + post_logout = current_app.config.get('BASE_URL', 'http://localhost') + '/auth/login' + return redirect( + f"https://login.microsoftonline.com/common/oauth2/v2.0/logout" + f"?post_logout_redirect_uri={post_logout}" + ) + return redirect(url_for('auth.login')) + + +@auth_bp.route('/microsoft') +def microsoft_login(): + if not _entra_client_id(): + flash('Microsoft login is niet geconfigureerd.', 'error') + return redirect(url_for('auth.login')) + + state = secrets.token_urlsafe(32) + session['oauth_state'] = state + + params = { + 'client_id': _entra_client_id(), + 'response_type': 'code', + 'redirect_uri': _callback_url(), + 'scope': ENTRA_SCOPES, + 'state': state, + 'response_mode': 'query', + 'prompt': 'select_account', + } + return redirect(f"{ENTRA_AUTH_URL}?{urlencode(params)}") + + +@auth_bp.route('/callback') +def microsoft_callback(): + error = request.args.get('error') + if error: + logger.warning(f"Entra fout: {error} — {request.args.get('error_description')}") + flash('Inloggen via Microsoft mislukt. Probeer opnieuw.', 'error') + return redirect(url_for('auth.login')) + + state = request.args.get('state', '') + expected_state = session.pop('oauth_state', None) + if not expected_state or state != expected_state: + logger.warning("OAuth2 state mismatch") + flash('Ongeldige sessie. Probeer opnieuw in te loggen.', 'error') + return redirect(url_for('auth.login')) + + code = request.args.get('code') + if not code: + flash('Geen autorisatiecode ontvangen van Microsoft.', 'error') + return redirect(url_for('auth.login')) + + try: + token_resp = requests.post(ENTRA_TOKEN_URL, data={ + 'client_id': _entra_client_id(), + 'client_secret': _entra_client_secret(), + 'code': code, + 'redirect_uri': _callback_url(), + 'grant_type': 'authorization_code', + 'scope': ENTRA_SCOPES, + }, timeout=15) + token_resp.raise_for_status() + tokens = token_resp.json() + except requests.RequestException as e: + logger.error(f"Token uitwisseling mislukt: {e}") + flash('Kon niet communiceren met Microsoft. Probeer opnieuw.', 'error') + return redirect(url_for('auth.login')) + + access_token = tokens.get('access_token') + if not access_token: + flash('Geen access token ontvangen van Microsoft.', 'error') + return redirect(url_for('auth.login')) + + try: + graph_resp = requests.get( + ENTRA_USERINFO_URL, + headers={'Authorization': f'Bearer {access_token}'}, + params={'$select': 'id,displayName,givenName,surname,mail,userPrincipalName'}, + timeout=10 + ) + graph_resp.raise_for_status() + profile = graph_resp.json() + except requests.RequestException as e: + logger.error(f"Graph API mislukt: {e}") + flash('Kon gebruikersgegevens niet ophalen bij Microsoft.', 'error') + return redirect(url_for('auth.login')) + + email = (profile.get('mail') or profile.get('userPrincipalName', '')).lower().strip() + first_name = profile.get('givenName') or '' + last_name = profile.get('surname') or '' + oid = profile.get('id', '') + tid = '' # tenant wordt opgeslagen via de oid + + if not email or not oid: + flash('Onvoldoende profielgegevens ontvangen van Microsoft.', 'error') + return redirect(url_for('auth.login')) + + user, is_new = _get_or_create_user(email, first_name, last_name, oid, tid) + + if not user.is_active: + flash('Uw account is gedeactiveerd. Contacteer uw ICT-beheerder.', 'error') + return redirect(url_for('auth.login')) + + if not user.school_id and not user.is_scholengroep_ict and not user.is_superadmin: + flash( + 'Uw account is aangemaakt maar nog niet gekoppeld aan een school. ' + 'Contacteer uw ICT-beheerder.', 'warning' + ) + + login_user(user, remember=True) + user.last_login = datetime.utcnow() + audit_log('login.success', 'auth', detail={'provider': 'microsoft', 'new_user': is_new}) + db.session.commit() + + logger.info(f"Entra login: {email} (nieuw: {is_new}, school_id: {user.school_id})") + return redirect(request.args.get('next') or url_for('pages.dashboard')) + + +@auth_bp.route('/setup', methods=['GET', 'POST']) +def setup(): + admin = User.query.filter_by(role='superadmin').first() + if admin and admin.password_hash: + flash('Setup is al voltooid.', 'info') + return redirect(url_for('auth.login')) + + if request.method == 'POST': + data = request.get_json() if request.is_json else request.form + password = data.get('password', '') + confirm = data.get('confirm', '') + + if len(password) < 12: + if request.is_json: + return jsonify({'error': 'Wachtwoord moet minstens 12 tekens zijn'}), 400 + flash('Wachtwoord moet minstens 12 tekens zijn', 'error') + return render_template('setup.html') + + if password != confirm: + if request.is_json: + return jsonify({'error': 'Wachtwoorden komen niet overeen'}), 400 + flash('Wachtwoorden komen niet overeen', 'error') + return render_template('setup.html') + + if not admin: + admin = User(email='admin@leerdoelen.local', role='superadmin', + first_name='Super', last_name='Admin') + db.session.add(admin) + + admin.set_password(password) + db.session.commit() + + if request.is_json: + return jsonify({'message': 'Setup voltooid', 'redirect': url_for('auth.login')}) + flash('Setup voltooid! Je kan nu inloggen.', 'success') + return redirect(url_for('auth.login')) + + return render_template('setup.html') + + +@auth_bp.route('/superadmin-login', methods=['POST']) +def superadmin_login(): + """Fallback login ENKEL voor de superadmin — niet voor gewone gebruikers.""" + if current_user.is_authenticated: + return redirect(url_for('pages.dashboard')) + + data = request.get_json() if request.is_json else request.form + email = data.get('email', '').strip().lower() + password = data.get('password', '') + + user = User.query.filter_by(email=email, role='superadmin', is_active=True).first() + + if not user or not user.check_password(password): + if request.is_json: + return jsonify({'error': 'Ongeldig e-mailadres of wachtwoord'}), 401 + flash('Ongeldig e-mailadres of wachtwoord', 'error') + return redirect(url_for('auth.login')) + + login_user(user, remember=False) + user.last_login = datetime.utcnow() + audit_log('login.success', 'auth', detail={'provider': 'superadmin'}, user_id=user.id) + db.session.commit() + + if request.is_json: + return jsonify({'redirect': url_for('pages.dashboard')}) + return redirect(url_for('pages.dashboard')) diff --git a/backend/routes/pages.py b/backend/routes/pages.py new file mode 100644 index 0000000..93f1e2f --- /dev/null +++ b/backend/routes/pages.py @@ -0,0 +1,60 @@ +from flask import Blueprint, render_template, redirect, url_for, current_app +from flask_login import login_required, current_user + +pages_bp = Blueprint('pages', __name__) + + +def _org_name(): + return current_app.config.get('ORG_NAME', 'GO! Scholengroep') + + +def _beheer_required(fn): + """Decorator: alleen superadmin en scholengroep_ict.""" + from functools import wraps + from flask import abort + @wraps(fn) + def wrapper(*args, **kwargs): + if not current_user.is_authenticated: + return redirect(url_for('auth.login')) + if not (current_user.is_superadmin or current_user.role == 'scholengroep_ict'): + abort(403) + return fn(*args, **kwargs) + return wrapper + + +@pages_bp.route('/') +def index(): + if current_user.is_authenticated: + return redirect(url_for('pages.dashboard')) + return redirect(url_for('auth.login')) + + +@pages_bp.route('/dashboard') +@login_required +def dashboard(): + org = _org_name() + if current_user.is_superadmin or current_user.role == 'scholengroep_ict': + return render_template('scholengroep_ict.html', + is_superadmin=current_user.is_superadmin, + org_name=org) + if current_user.role == 'school_ict': + return render_template('school_ict.html', org_name=org) + if current_user.role == 'director': + return render_template('directeur.html', org_name=org) + return render_template('leerkracht.html', org_name=org) + + +@pages_bp.route('/doelen-beheer') +@login_required +@_beheer_required +def doelen_beheer(): + """Aparte pagina voor het beheer van leerdoelen bestanden.""" + return render_template('doelen_beheer.html', + is_superadmin=current_user.is_superadmin, + org_name=_org_name()) + + +@pages_bp.route('/admin') +@login_required +def admin_page(): + return redirect(url_for('pages.dashboard')) diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/audit.py b/backend/services/audit.py new file mode 100644 index 0000000..88cecb2 --- /dev/null +++ b/backend/services/audit.py @@ -0,0 +1,69 @@ +""" +Audit logging service. +Gebruik: from services.audit import audit_log + audit_log('user.create', 'user', target_id=str(user.id), detail={'email': email}) +""" +import json +from datetime import datetime +from flask import request +from flask_login import current_user +from app import db + + +def audit_log(action: str, category: str, *, + target_type: str = None, target_id: str = None, + detail: dict = None, school_id: int = None, + user_id: int = None): + """ + Schrijf een audit entry naar de database. + + action: korte actienaam, bv. 'user.create', 'school.delete', 'login.success' + category: auth | user | school | class | assessment | doelen | system + target_type: wat er veranderd is, bv. 'user', 'school', 'class' + target_id: identifier van het object (als string) + detail: dict met extra context, wordt als JSON opgeslagen + school_id: override school_id (standaard current_user.school_id) + user_id: override user_id (standaard current_user.id) + """ + from models import AuditLog + + try: + uid = user_id + sid = school_id + + if uid is None: + try: + uid = current_user.id if current_user.is_authenticated else None + except Exception: + uid = None + + if sid is None: + try: + sid = current_user.school_id if current_user.is_authenticated else None + except Exception: + sid = None + + ip = None + try: + ip = request.remote_addr + except Exception: + pass + + entry = AuditLog( + timestamp = datetime.utcnow(), + user_id = uid, + school_id = sid, + action = action, + category = category, + target_type = target_type, + target_id = str(target_id) if target_id is not None else None, + detail = json.dumps(detail, ensure_ascii=False) if detail else None, + ip_address = ip, + ) + db.session.add(entry) + # Geen commit hier — de aanroeper commit zelf (of we flushen mee) + db.session.flush() + except Exception as e: + # Audit failures mogen de hoofdflow nooit blokkeren + import logging + logging.getLogger(__name__).warning(f"Audit log failed: {e}") diff --git a/backend/services/doelen.py b/backend/services/doelen.py new file mode 100644 index 0000000..a151169 --- /dev/null +++ b/backend/services/doelen.py @@ -0,0 +1,137 @@ +""" +Doelen service - centrale logica voor vak ID's, namen, validatie en upload. +Eén bron van waarheid. Geen andere plek in de app definieert vaknamen. +""" + +import os +import json +import re + +DOELEN_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'doelen') + +VAK_NAMEN = { + 'doelenset-bao-aardrijkskunde': 'Aardrijkskunde', + 'doelenset-bao-burgerschap': 'Burgerschap', + 'doelenset-bao-frans': 'Frans', + 'doelenset-bao-geschiedenis': 'Geschiedenis', + 'doelenset-bao-ict': 'ICT', + 'doelenset-bao-leren-leren': 'Leren leren', + 'doelenset-bao-lichamelijke-opvoeding': 'Lichamelijke opvoeding', + 'doelenset-bao-muzische-vorming': 'Muzische vorming', + 'doelenset-bao-nederlands': 'Nederlands', + 'doelenset-bao-sociale-vaardigheden': 'Sociale vaardigheden', + 'doelenset-bao-wetenschap-techniek': 'Wetenschap en techniek', + 'doelenset-bao-wiskunde': 'Wiskunde', +} + + +def vak_naam(vak_id): + if vak_id in VAK_NAMEN: + return VAK_NAMEN[vak_id] + cleaned = re.sub(r'^doelenset-bao-', '', vak_id) + return cleaned.replace('-', ' ').title() + + +def is_valid_vak_id(vak_id): + return bool(re.match(r'^[a-z0-9][a-z0-9\-]{2,78}[a-z0-9]$', vak_id)) + + +def get_doelen_path(vak_id): + return os.path.join(DOELEN_DIR, f'{vak_id}.json') + + +def list_installed_vakken(): + if not os.path.exists(DOELEN_DIR): + return [] + return sorted([ + f[:-5] for f in os.listdir(DOELEN_DIR) + if f.endswith('.json') and f != 'index.json' and is_valid_vak_id(f[:-5]) + ]) + + +def load_index(): + path = os.path.join(DOELEN_DIR, 'index.json') + if not os.path.exists(path): + rebuild_index() + with open(path, encoding='utf-8') as f: + data = json.load(f) + for vak in data.get('vakken', []): + vak['naam'] = vak_naam(vak['id']) + data['vakken'].sort(key=lambda v: v['naam']) + return data + + +def load_vak(vak_id): + if not is_valid_vak_id(vak_id): + return None + path = get_doelen_path(vak_id) + if not os.path.exists(path): + return None + with open(path, encoding='utf-8') as f: + data = json.load(f) + data['vakNaam'] = vak_naam(vak_id) + return data + + +def validate_vak_json(data): + errors = [] + for key in ['vak', 'versie', 'rijen']: + if key not in data: + errors.append(f'Verplicht veld ontbreekt: "{key}"') + if errors: + return errors + if not isinstance(data['rijen'], list) or len(data['rijen']) == 0: + errors.append('"rijen" moet een niet-lege lijst zijn') + return errors + doelzinnen = [r for r in data['rijen'] if r.get('type') == 'doelzin'] + if not doelzinnen: + errors.append('Geen doelzinnen gevonden (type="doelzin") — verkeerd bestand?') + else: + zonder_nr = [r for r in doelzinnen if not r.get('goNr')] + if zonder_nr: + errors.append(f'{len(zonder_nr)} doelzin(nen) missen een GO! nummer (goNr)') + vak_id = data.get('vak', '') + if vak_id and not is_valid_vak_id(vak_id): + errors.append(f'Ongeldig vak ID: "{vak_id}"') + return errors + + +def save_vak(vak_id, data): + os.makedirs(DOELEN_DIR, exist_ok=True) + data['vakNaam'] = vak_naam(vak_id) + path = get_doelen_path(vak_id) + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + rebuild_index() + + +def delete_vak(vak_id): + path = get_doelen_path(vak_id) + if not os.path.exists(path): + return False + os.remove(path) + rebuild_index() + return True + + +def rebuild_index(): + os.makedirs(DOELEN_DIR, exist_ok=True) + vakken = [] + for vak_id in list_installed_vakken(): + try: + data = load_vak(vak_id) + if not data: + continue + doelzinnen = [r for r in data.get('rijen', []) if r.get('type') == 'doelzin'] + vakken.append({ + 'id': vak_id, + 'naam': vak_naam(vak_id), + 'aantalDoelzinnen': len(doelzinnen), + 'versie': data.get('versie', '?'), + }) + except Exception: + pass + vakken.sort(key=lambda v: v['naam']) + index = {'versie': '2025-01', 'vakken': vakken} + with open(os.path.join(DOELEN_DIR, 'index.json'), 'w', encoding='utf-8') as f: + json.dump(index, f, ensure_ascii=False, indent=2) diff --git a/backend/templates/admin.html b/backend/templates/admin.html new file mode 100644 index 0000000..ca4294d --- /dev/null +++ b/backend/templates/admin.html @@ -0,0 +1,479 @@ + + + + + + Platform Beheer - Leerdoelen Tracker + + + +
+
+

⚙️ Platform Beheer Superadmin

+ Uitloggen +
+ + +
+
-
Scholen
+
-
Scholengroep ICT
+
-
School ICT
+
-
Directeurs
+
-
Leerkrachten
+
+ + +
+
+

👥 Scholengroep ICT medewerkers

+ +
+ + + +
NaamE-mailLaatste login
Laden...
+
+ + +
+
+

🏫 Scholen

+ +
+ + + +
NaamSlugE-maildomeinenGebruikers
Laden...
+
+
+ + + + + + + + + + +
+ + + + diff --git a/backend/templates/directeur.html b/backend/templates/directeur.html new file mode 100644 index 0000000..74c0add --- /dev/null +++ b/backend/templates/directeur.html @@ -0,0 +1,919 @@ + + + + + + Directeur Dashboard - Leerdoelen Tracker + + + +
+
+
+

🏫 Directeur Dashboard

+
+
+
+
+ + +
+ + Uitloggen +
+
+ + +
+
-
Leerkrachten
+
-
Vakken
+
-
Beoordelingen
+
-
Groen
+
-
Oranje
+
-
Roze
+
+ + +
+
+

👩‍🏫 Leerkrachten

+ +
+
Laden...
+
+ + +
+ + + +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + + + +
DoelSamenvatting
Laden...
+
+
+
+ + + + +
+ + + + diff --git a/backend/templates/doelen_beheer.html b/backend/templates/doelen_beheer.html new file mode 100644 index 0000000..ee5d2b0 --- /dev/null +++ b/backend/templates/doelen_beheer.html @@ -0,0 +1,223 @@ + + + + + + Leerdoelen bestanden – {{ org_name }} + + + +
+ +
+
+

📂 Leerdoelen bestanden

+
{{ org_name }}
+
+ ← Terug naar beheer +
+ + +
+
-
Vakken
+
-
Doelzinnen
+
-
Versie
+
+ + +
+
+

⬆️ Nieuwe bestanden uploaden

+
+

+ Sleep de JSON bestanden van GO! hierheen of klik om te bladeren. + Meerdere bestanden tegelijk zijn mogelijk. Bij een update gewoon opnieuw uploaden — + bestaande bestanden worden automatisch overschreven en de index wordt bijgewerkt. +

+
+
📄
+ Klik of sleep JSON bestanden hier +

Meerdere bestanden tegelijk · Enkel .json · Max. 10 MB per bestand

+
+ + +
+ + +
+
+

📚 Geïnstalleerde vakken

+ +
+ + + + + + + + + + + + + +
VakBestand IDDoelzinnenVersie
Laden...
+
+ +
+
+ + + diff --git a/backend/templates/leerkracht.html b/backend/templates/leerkracht.html new file mode 100644 index 0000000..f01c050 --- /dev/null +++ b/backend/templates/leerkracht.html @@ -0,0 +1,669 @@ + + + + + + Leerdoelen Tracker + + + +
+
+

📚 Leerdoelen Tracker

+ +
+ + Uitloggen +
+
+ +
+ + + +
+ +
+
-
Totaal
+
-
Groen
+
-
Oranje
+
-
Roze
+
-
Beoordeeld
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {% for age in ['3-4','4-5','5-6','6-7','7-8','8-9','9-10','10-11','11-12'] %} + + {% endfor %} +
+
+
+
+ +
+
+ + + + + + + + + + + + + + +
StatusCodeE/B/GLeeftijdenSectieBeschrijving
Selecteer een vak om te beginnen
+
+
+
+ + + + +
+ + + + diff --git a/backend/templates/login.html b/backend/templates/login.html new file mode 100644 index 0000000..1a882ce --- /dev/null +++ b/backend/templates/login.html @@ -0,0 +1,350 @@ + + + + + + Inloggen - Leerdoelen Tracker + + + +
+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + + {% if entra_configured %} + + + + + + + + Inloggen met Microsoft + +

+ Log in met uw school Microsoft account +

+ {% else %} +
+ Microsoft login niet geconfigureerd

+ Stel MICROSOFT_CLIENT_ID en MICROSOFT_CLIENT_SECRET + in de .env in om Entra login te activeren. +
+ {% endif %} + + +
+ +
+ +
+
+ Platformbeheerder toegang +
+
+ + +
+
+ + +
+ +
+
+
+ + + + diff --git a/backend/templates/scholengroep_ict.html b/backend/templates/scholengroep_ict.html new file mode 100644 index 0000000..625d5a1 --- /dev/null +++ b/backend/templates/scholengroep_ict.html @@ -0,0 +1,660 @@ + + + + + + Beheer – {{ org_name }} + + + +
+ +
+
+

+ {{ '⚙️' if is_superadmin else '🔧' }} + {{ 'Platform Beheer' if is_superadmin else 'Scholengroep ICT Beheer' }} + {% if is_superadmin %}Superadmin{% endif %} +

+
{{ org_name }}
+
+ +
+ + +
+
-
Scholen
+
-
Scholengr. ICT
+
-
School ICT
+
-
Directeurs
+
-
Leerkrachten
+
+ + + {% if is_superadmin %} +
+
+

👥 Scholengroep ICT medewerkers

+ +
+

+ Scholengroep ICT medewerkers kunnen alle scholen en gebruikers beheren, + maar kunnen geen scholen aanmaken of andere scholengroep ICT accounts toevoegen. +

+ + + +
NaamE-mailLaatste login
Laden...
+
+ {% endif %} + + + +
+
+

📅 Schooljaren

+ +
+

+ Het actieve schooljaar geldt voor alle scholen tegelijk. + Leerkrachten werken automatisch in het actieve jaar. + Directeurs kunnen alle jaren raadplegen via hun dashboard. +

+ + + +
SchooljaarStatus
Laden...
+
+ + +
+
+

🏫 Scholen

+ +
+ + + + + + + + + + +
NaamE-maildomeinenGebruikers
Laden...
+
+ + +
+
+

👥 Gebruikers per school

+ +
+
Laden...
+
+ +
+ + + + + + + +{% if is_superadmin %} + +{% endif %} + + + + + +
+ + + + diff --git a/backend/templates/school_ict.html b/backend/templates/school_ict.html new file mode 100644 index 0000000..c29e9ac --- /dev/null +++ b/backend/templates/school_ict.html @@ -0,0 +1,540 @@ + + + + + + School ICT - Leerdoelen Tracker + + + +
+
+
+

🏫 School ICT Beheer

+
Laden...
+
+ Uitloggen +
+ + +
+
-
Gebruikers
+
-
School ICT
+
-
Directeurs
+
-
Leerkrachten
+
+ + +
+
+

👥 Gebruikers

+ +
+ + + + + + + + + + + + +
NaamE-mailRol
Laden...
+
+
+ + + + +
+ + + + diff --git a/backend/templates/setup.html b/backend/templates/setup.html new file mode 100644 index 0000000..e412891 --- /dev/null +++ b/backend/templates/setup.html @@ -0,0 +1,282 @@ + + + + + + Eerste Setup - Leerdoelen Tracker + + + +
+ + +
+ 🔧 Eerste installatie
+ Stel een wachtwoord in voor het superadmin account. + Dit scherm verdwijnt na de eerste keer. +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + diff --git a/backend/templates/superadmin_login.html b/backend/templates/superadmin_login.html new file mode 100644 index 0000000..ce24c7c --- /dev/null +++ b/backend/templates/superadmin_login.html @@ -0,0 +1,274 @@ + + + + + + Platformbeheerder - Leerdoelen Tracker + + + +
+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + +
+ +
+ + +
+
+ + +
+ + + ← Terug naar normale loginpagina +
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4a75a33 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +version: '3.9' + +services: + db: + image: postgres:16-alpine + container_name: leerdoelen_db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-leerdoelen} + POSTGRES_USER: ${POSTGRES_USER:-leerdoelen} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-leerdoelen}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + # In productie: image uit de Gitea registry (gezet door CI/CD pipeline) + # Lokaal ontwikkelen: verander naar 'build: ./backend' + image: ${BACKEND_IMAGE:-leerdoelen-backend:local} + build: + context: ./backend + # 'build' wordt genegeerd als 'image' al bestaat in de registry. + # Gebruik 'docker compose build' om lokaal te (her)bouwen. + container_name: leerdoelen_backend + restart: unless-stopped + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-leerdoelen}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-leerdoelen} + SECRET_KEY: ${SECRET_KEY} + FLASK_ENV: ${FLASK_ENV:-production} + FLASK_APP: app.py + # OAuth2 - later in te vullen + MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-} + MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + BASE_URL: ${BASE_URL:-http://localhost} + ORG_NAME: ${ORG_NAME:-GO! Scholengroep} + volumes: + - ./doelen:/app/doelen:ro # JSON doelen bestanden (read-only) + ports: + - "127.0.0.1:${APP_PORT:-5000}:5000" + depends_on: + db: + condition: service_healthy + + # Nginx container verwijderd — SSL offloading gebeurt door de host nginx. + # Flask is bereikbaar op 127.0.0.1:${APP_PORT} van de host. + +volumes: + postgres_data: diff --git a/doelen/.gitkeep b/doelen/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migrate.sh b/migrate.sh new file mode 100755 index 0000000..d155a8c --- /dev/null +++ b/migrate.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# ══════════════════════════════════════════════════════════════════════════════ +# Flask-Migrate helper +# Gebruik: ./migrate.sh [init|migrate|upgrade|history|current] +# +# Draait de migratie commando's BINNEN de backend container. +# Zorg dat de containers draaien: docker compose up -d +# ══════════════════════════════════════════════════════════════════════════════ + +CONTAINER="leerdoelen_backend" +CMD=${1:-upgrade} +MSG=${2:-"auto migration"} + +case "$CMD" in + init) + echo "→ Initialiseer migrations/ map (eenmalig)" + docker exec $CONTAINER flask db init + ;; + migrate) + echo "→ Genereer nieuwe migratie: '$MSG'" + docker exec $CONTAINER flask db migrate -m "$MSG" + echo "" + echo "⚠ Controleer de gegenereerde migratie in migrations/versions/ voor je upgrade!" + ;; + upgrade) + echo "→ Voer alle openstaande migraties uit" + docker exec $CONTAINER flask db upgrade + ;; + downgrade) + echo "→ Zet één stap terug" + docker exec $CONTAINER flask db downgrade + ;; + history) + docker exec $CONTAINER flask db history + ;; + current) + docker exec $CONTAINER flask db current + ;; + *) + echo "Gebruik: ./migrate.sh [init|migrate 'beschrijving'|upgrade|downgrade|history|current]" + exit 1 + ;; +esac diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..17fd5e7 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,63 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Logging + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m; + limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; + + upstream flask { + server backend:5000; + } + + server { + listen 80; + server_name _; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + client_max_body_size 10M; + + # Rate limiting op login + location /auth/login { + limit_req zone=login burst=5 nodelay; + proxy_pass http://flask; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Rate limiting op API + location /api/ { + limit_req zone=api burst=20 nodelay; + proxy_pass http://flask; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Alle andere requests + location / { + proxy_pass http://flask; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 60s; + } + } +} diff --git a/nginx/proxy-params.conf b/nginx/proxy-params.conf new file mode 100644 index 0000000..a8dd1cc --- /dev/null +++ b/nginx/proxy-params.conf @@ -0,0 +1,10 @@ +# /etc/nginx/snippets/proxy-params.conf +# Herbruikbaar snippet voor proxy headers. +# Aanmaken met: sudo nano /etc/nginx/snippets/proxy-params.conf + +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_http_version 1.1; +proxy_set_header Connection ""; diff --git a/nginx/vhost-host.conf b/nginx/vhost-host.conf new file mode 100644 index 0000000..49d9387 --- /dev/null +++ b/nginx/vhost-host.conf @@ -0,0 +1,24 @@ +# /etc/nginx/sites-available/leerdoelen +# +# Vereisten op de host: +# certbot --nginx -d leerdoelen.jouwdomein.be +# Of als je al een wildcard cert hebt, pas ssl_certificate paden aan. + +server { + listen 80; + server_name leerdoelen.jouwdomein.be; + + # Certbot voegt hier automatisch de SSL redirect en het 443 blok aan toe. + # Voer daarna uit: certbot --nginx -d leerdoelen.jouwdomein.be + + access_log /var/log/nginx/leerdoelen.access.log; + error_log /var/log/nginx/leerdoelen.error.log; + + location / { + proxy_pass http://127.0.0.1:5000; + include /etc/nginx/snippets/proxy-params.conf; + + client_max_body_size 10M; + proxy_read_timeout 60s; + } +} diff --git a/postgres/init.sql b/postgres/init.sql new file mode 100644 index 0000000..b482e03 --- /dev/null +++ b/postgres/init.sql @@ -0,0 +1,129 @@ +-- ================================================ +-- LEERDOELEN TRACKER - DATABASE SCHEMA +-- ================================================ + +-- Scholengroep scholen +CREATE TABLE schools ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + -- Eén of meerdere e-maildomeinen gekoppeld aan deze school + -- bv. '{"dekrekel.be", "sintjan.gent.be"}' + email_domains TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMP DEFAULT NOW() +); + +-- Gebruikers +-- Rollen: +-- superadmin → ontwikkelaar/beheerder van het platform +-- scholengroep_ict → maakt scholen aan, wijst directeurs en school_ict toe +-- school_ict → beheert klassen en leerkrachten van één school +-- director → leest overzicht van zijn school, geen beheer +-- teacher → vult leerdoelen in +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255), -- enkel voor superadmin fallback + first_name VARCHAR(100), + last_name VARCHAR(100), + role VARCHAR(20) NOT NULL DEFAULT 'teacher' + CHECK (role IN ('superadmin', 'scholengroep_ict', 'school_ict', 'director', 'teacher')), + school_id INTEGER REFERENCES schools(id) ON DELETE SET NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + last_login TIMESTAMP, + -- Entra / OAuth2 + oauth_provider VARCHAR(20), -- 'microsoft' | NULL + oauth_id VARCHAR(255), -- Entra object ID (oid claim) + entra_tenant_id VARCHAR(255) -- tenant van de gebruiker +); + +CREATE INDEX idx_users_school ON users(school_id); +CREATE INDEX idx_users_email ON users(email); + +-- School jaar (om data per jaar bij te houden) +CREATE TABLE school_years ( + id SERIAL PRIMARY KEY, + school_id INTEGER NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + label VARCHAR(20) NOT NULL, -- bv. "2024-2025" + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(school_id, label) +); + +-- Klassen per school per jaar +CREATE TABLE classes ( + id SERIAL PRIMARY KEY, + school_id INTEGER NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + school_year_id INTEGER NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, -- bv. "3A", "4B" + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(school_id, school_year_id, name) +); + +-- Koppeling leerkracht aan klas (een leerkracht kan meerdere klassen hebben) +CREATE TABLE teacher_classes ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, class_id) +); + +-- Beoordelingen van leerdoelen +CREATE TABLE assessments ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + school_id INTEGER NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + school_year_id INTEGER NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, + vak_id VARCHAR(50) NOT NULL, -- bv. "wiskunde", "nederlands" + goal_id VARCHAR(50) NOT NULL, -- GO! nummer, bv. "WIS.L4.01" + status VARCHAR(10) NOT NULL + CHECK (status IN ('groen', 'oranje', 'roze')), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(user_id, school_year_id, vak_id, goal_id) +); + +CREATE INDEX idx_assessments_school_year ON assessments(school_id, school_year_id); +CREATE INDEX idx_assessments_user ON assessments(user_id); +CREATE INDEX idx_assessments_vak ON assessments(vak_id); + +-- Audit log (wie heeft wat gewijzigd) +CREATE TABLE audit_log ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + details JSONB, + created_at TIMESTAMP DEFAULT NOW() +); + +-- ================================================ +-- SEED DATA - standaard superadmin +-- Wachtwoord wordt gezet via de setup route +-- ================================================ +INSERT INTO schools (name, slug) VALUES ('Demo School', 'demo-school'); + +INSERT INTO users (email, role, first_name, last_name, school_id) +VALUES ('admin@leerdoelen.local', 'superadmin', 'Super', 'Admin', 1); + +INSERT INTO school_years (school_id, label, is_active) +VALUES (1, '2024-2025', TRUE); + + +-- ── Migratie: globale schooljaren (uitvoeren op bestaande installaties) ─────── +-- Dit blok is idempotent (IF NOT EXISTS / DO UPDATE) dus veilig om opnieuw te draaien. + +-- 1. school_id nullable maken (was NOT NULL) +ALTER TABLE school_years ALTER COLUMN school_id DROP NOT NULL; + +-- 2. Unieke constraint op label zodat elk jaar maar één keer bestaat +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'school_years_label_key' + ) THEN + ALTER TABLE school_years ADD CONSTRAINT school_years_label_key UNIQUE (label); + END IF; +END $$; + +-- 3. Bestaande jaren zonder school_id=NULL (indien ze al bestaan) behouden +-- Bestaande per-school jaren omzetten naar globale jaren: +UPDATE school_years SET school_id = NULL WHERE school_id IS NOT NULL; diff --git a/postgres/migrate_v3_global_years.sql b/postgres/migrate_v3_global_years.sql new file mode 100644 index 0000000..8810a99 --- /dev/null +++ b/postgres/migrate_v3_global_years.sql @@ -0,0 +1,34 @@ +-- ══════════════════════════════════════════════════════════════════════════════ +-- MIGRATIE v3: Globale schooljaren +-- Uitvoeren op bestaande installaties die al draaien. +-- Commando: docker exec -i leerdoelen_db psql -U leerdoelen leerdoelen < migrate_v3_global_years.sql +-- ══════════════════════════════════════════════════════════════════════════════ + +BEGIN; + +-- 1. school_id nullable maken (was NOT NULL) +ALTER TABLE school_years ALTER COLUMN school_id DROP NOT NULL; + +-- 2. Unieke constraint op label (elk schooljaar bestaat maar één keer) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'school_years_label_key' + ) THEN + ALTER TABLE school_years ADD CONSTRAINT school_years_label_key UNIQUE (label); + END IF; +END $$; + +-- 3. Bestaande per-school jaren omzetten naar globale jaren +-- (dubbele labels samenvoegen: bewaar het actieve, verwijder de rest) +DELETE FROM school_years sy1 +USING school_years sy2 +WHERE sy1.label = sy2.label + AND sy1.id > sy2.id; + +UPDATE school_years SET school_id = NULL; + +COMMIT; + +-- Controleer resultaat: +SELECT id, label, is_active, school_id FROM school_years ORDER BY label DESC; diff --git a/postgres/migrate_v4_classes_auditlog.sql b/postgres/migrate_v4_classes_auditlog.sql new file mode 100644 index 0000000..2261b64 --- /dev/null +++ b/postgres/migrate_v4_classes_auditlog.sql @@ -0,0 +1,58 @@ +-- ══════════════════════════════════════════════════════════════════════════════ +-- MIGRATIE v4: Klassen zonder school_year_id + Auditlog tabel +-- Uitvoeren op bestaande installaties: +-- docker exec -i leerdoelen_db psql -U leerdoelen leerdoelen < postgres/migrate_v4_classes_auditlog.sql +-- ══════════════════════════════════════════════════════════════════════════════ + +BEGIN; + +-- 1. Verwijder school_year_id van classes (klassen zijn nu schooljaar-onafhankelijk) +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name='classes' AND column_name='school_year_id' + ) THEN + ALTER TABLE classes DROP CONSTRAINT IF EXISTS classes_school_year_id_fkey; + ALTER TABLE classes DROP COLUMN school_year_id; + RAISE NOTICE 'school_year_id verwijderd van classes'; + ELSE + RAISE NOTICE 'school_year_id bestond al niet — niets te doen'; + END IF; +END $$; + +-- 2. Unieke constraint op (school_id, name) voor klassen +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'uq_class_school_name' + ) THEN + ALTER TABLE classes ADD CONSTRAINT uq_class_school_name UNIQUE (school_id, name); + RAISE NOTICE 'Unique constraint uq_class_school_name toegevoegd'; + END IF; +END $$; + +-- 3. Auditlog tabel aanmaken +CREATE TABLE IF NOT EXISTS audit_logs ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMP NOT NULL DEFAULT NOW(), + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + school_id INTEGER REFERENCES schools(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + category VARCHAR(20) NOT NULL, + target_type VARCHAR(50), + target_id VARCHAR(100), + detail TEXT, + ip_address VARCHAR(45) +); + +CREATE INDEX IF NOT EXISTS ix_audit_logs_timestamp ON audit_logs(timestamp); +CREATE INDEX IF NOT EXISTS ix_audit_logs_action ON audit_logs(action); +CREATE INDEX IF NOT EXISTS ix_audit_logs_category ON audit_logs(category); + +COMMIT; + +-- Controleer resultaat +SELECT 'classes kolommen:' AS info; +SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'classes' ORDER BY ordinal_position; + +SELECT 'audit_logs tabel:' AS info; +SELECT COUNT(*) AS entries FROM audit_logs;