Migrate assessments to class-based model
All checks were successful
Build & Push / Build & Push image (push) Successful in 42s

- Dropped the existing assessments table to remove user_id association.
- Created a new assessments table linked to classes (class_id) allowing multiple teachers to share assessments.
- Added necessary indexes for performance optimization.
- Downgrade functionality to revert back to user-based assessments if needed.
This commit is contained in:
2026-03-05 22:36:36 +01:00
parent d55b700502
commit 1acaf26a38
5 changed files with 1074 additions and 1826 deletions

View File

@@ -0,0 +1,60 @@
"""assessments: herstructureer naar klasgebonden model
Revision ID: 0004
Revises: 0003
Create Date: 2026-03-05
Wijziging: assessments zijn niet langer gekoppeld aan een individuele
leerkracht (user_id) maar aan een klas (class_id). Meerdere leerkrachten
van dezelfde klas delen één set beoordelingen.
OPGELET: dit dropt de bestaande assessments tabel — testdata gaat verloren.
"""
from alembic import op
import sqlalchemy as sa
revision = '0004'
down_revision = '0003'
branch_labels = None
depends_on = None
def upgrade():
# Drop oude tabel volledig (testomgeving — geen productiedata)
op.execute("DROP TABLE IF EXISTS assessments CASCADE")
# Nieuwe tabel: klasgebonden, geen user_id
op.execute("""
CREATE TABLE assessments (
id SERIAL PRIMARY KEY,
class_id INTEGER NOT NULL REFERENCES classes(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,
opmerking VARCHAR(500),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(class_id, school_year_id, vak_id, goal_id)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_assessments_class_year ON assessments(class_id, school_year_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_assessments_vak ON assessments(vak_id)")
def downgrade():
op.execute("DROP TABLE IF EXISTS assessments CASCADE")
# Zet terug naar user-gebaseerde tabel (zonder data)
op.execute("""
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,
goal_id VARCHAR(50) NOT NULL,
status VARCHAR(10) NOT NULL,
opmerking VARCHAR(500),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, school_year_id, vak_id, goal_id)
)
""")

View File

@@ -151,8 +151,8 @@ 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)
# Klasgebonden — geen koppeling aan individuele leerkracht
class_id = db.Column(db.Integer, db.ForeignKey('classes.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)
@@ -160,17 +160,18 @@ class Assessment(db.Model):
opmerking = db.Column(db.String(500), nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = db.relationship('User')
school = db.relationship('School')
klas = db.relationship('Class')
school_year = db.relationship('SchoolYear', back_populates='assessments')
__table_args__ = (
db.UniqueConstraint('user_id', 'school_year_id', 'vak_id', 'goal_id'),
db.UniqueConstraint('class_id', 'school_year_id', 'vak_id', 'goal_id',
name='uq_assessment_class_year_vak_goal'),
)
def to_dict(self):
return {
'id': self.id,
'class_id': self.class_id,
'vak_id': self.vak_id,
'goal_id': self.goal_id,
'status': self.status,

View File

@@ -19,18 +19,36 @@ def director_required(f):
return decorated
def get_active_year(school_id=None):
"""Geeft het globaal actief schooljaar terug (school_id wordt genegeerd)."""
def get_active_year():
"""Geeft het globaal actief schooljaar terug."""
return SchoolYear.query.filter_by(school_id=None, is_active=True).first()
def check_class_access(class_id):
"""
Geeft de klas terug als de huidige gebruiker er toegang toe heeft.
- Leerkrachten: enkel hun eigen klassen (via teacher_classes).
- Directeur en hoger: alle klassen van hun school.
- Geeft False terug als de klas niet bestaat.
- Geeft None terug als de gebruiker geen toegang heeft.
"""
klas = Class.query.filter_by(id=class_id).first()
if not klas:
return False
if klas.school_id != current_user.school_id:
return None
if current_user.is_teacher:
if not any(c.id == class_id for c in current_user.classes):
return None
return klas
# ── Doelen (statische JSON bestanden) ─────────────────────────────────────────
@api_bp.route('/doelen/index')
@login_required
def doelen_index():
data = load_index()
# Altijd een geldig object teruggeven — lege vakkenlijst is geen fout
return jsonify(data)
@@ -50,16 +68,30 @@ def doelen_vak(vak_id):
@api_bp.route('/assessments', methods=['GET'])
@login_required
def get_assessments():
if not current_user.school_id:
"""Haal beoordelingen op voor een klas (en optioneel een vak)."""
class_id_str = request.args.get('class_id')
if not class_id_str:
return jsonify({'assessments': []})
school_year = get_active_year(current_user.school_id)
try:
class_id = int(class_id_str)
except ValueError:
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
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)
query = Assessment.query.filter_by(class_id=class_id, school_year_id=year_id)
if vak_id:
query = query.filter_by(vak_id=vak_id)
@@ -68,30 +100,39 @@ def get_assessments():
@api_bp.route('/assessments', methods=['POST'])
@login_required
@limiter.limit('120 per minute') # max 2 per seconde per gebruiker
@limiter.limit('120 per minute')
def save_assessment():
data = request.get_json() or {}
class_id = data.get('class_id')
vak_id = (data.get('vak_id') or '').strip()
goal_id = (data.get('goal_id') or '').strip()
status = (data.get('status') or '').strip()
opmerking = (data.get('opmerking') or '').strip()[:500]
if not vak_id or not goal_id:
return jsonify({'error': 'vak_id en goal_id zijn verplicht'}), 400
if not class_id or not vak_id or not goal_id:
return jsonify({'error': 'class_id, 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
# Sanitiseer input — voorkomt oversized data in DB
return jsonify({'error': 'Ongeldige status'}), 400
if len(vak_id) > 100 or len(goal_id) > 50:
return jsonify({'error': 'Ongeldige invoer'}), 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)
try:
class_id = int(class_id)
except (ValueError, TypeError):
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
if not school_year:
return jsonify({'error': 'Geen actief schooljaar gevonden'}), 400
assessment = Assessment.query.filter_by(
user_id=current_user.id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -109,8 +150,7 @@ def save_assessment():
assessment.updated_at = datetime.utcnow()
else:
assessment = Assessment(
user_id=current_user.id,
school_id=current_user.school_id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -120,38 +160,44 @@ def save_assessment():
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})
target_type='class', target_id=str(class_id),
detail={'status': status, 'vak': vak_id, 'goal': goal_id})
return jsonify({'assessment': assessment.to_dict()})
@api_bp.route('/assessments/opmerking', methods=['POST'])
@login_required
@limiter.limit('120 per minute')
def save_opmerking():
"""Sla enkel een opmerking op bij een bestaand of nieuw assessment record."""
data = request.get_json() or {}
class_id = data.get('class_id')
vak_id = (data.get('vak_id') or '').strip()
goal_id = (data.get('goal_id') or '').strip()
opmerking = (data.get('opmerking') or '').strip()[:500]
if not vak_id or not goal_id:
return jsonify({'error': 'vak_id en goal_id zijn verplicht'}), 400
if not class_id or not vak_id or not goal_id:
return jsonify({'error': 'class_id, vak_id en goal_id zijn verplicht'}), 400
if len(vak_id) > 100 or len(goal_id) > 50:
return jsonify({'error': 'Ongeldige invoer'}), 400
if not current_user.school_id:
return jsonify({'error': 'Account niet gekoppeld aan een school'}), 400
school_year = get_active_year(current_user.school_id)
try:
class_id = int(class_id)
except (ValueError, TypeError):
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
if not school_year:
return jsonify({'error': 'Geen actief schooljaar'}), 400
assessment = Assessment.query.filter_by(
user_id=current_user.id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -161,10 +207,8 @@ def save_opmerking():
assessment.opmerking = opmerking or None
assessment.updated_at = datetime.utcnow()
else:
# Maak een record aan zonder status voor de opmerking
assessment = Assessment(
user_id=current_user.id,
school_id=current_user.school_id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -176,32 +220,43 @@ def save_opmerking():
db.session.commit()
return jsonify({'ok': True})
@api_bp.route('/assessments/bulk-import', methods=['POST'])
@login_required
@limiter.limit('5 per minute')
def bulk_import_assessments():
"""
Importeer beoordelingen vanuit de legacy standalone JSON export.
Body: { "vakken": { "vak_id": { "goal_id": "status", ... }, ... } }
of v4 formaat: { "vakken": { "vak_id": { "statussen": { "goal_id": "status" } } } }
Importeer beoordelingen vanuit legacy standalone JSON export.
Body: { "class_id": 1, "vakken": { "vak_id": { "goal_id": "status" } } }
"""
if not current_user.school_id:
return jsonify({'error': 'Account 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'}), 400
data = request.get_json() or {}
class_id = data.get('class_id')
vakken = data.get('vakken', {})
if not class_id:
return jsonify({'error': 'class_id is verplicht'}), 400
if not vakken:
return jsonify({'error': 'Geen vakken gevonden in payload'}), 400
try:
class_id = int(class_id)
except (ValueError, TypeError):
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
if not school_year:
return jsonify({'error': 'Geen actief schooljaar'}), 400
totaal = 0
fouten = 0
for vak_id, vak_data in vakken.items():
# Sanitiseer vak_id
if not isinstance(vak_id, str) or len(vak_id) > 100:
fouten += 1
continue
@@ -224,7 +279,7 @@ def bulk_import_assessments():
try:
assessment = Assessment.query.filter_by(
user_id=current_user.id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -235,8 +290,7 @@ def bulk_import_assessments():
assessment.updated_at = datetime.utcnow()
else:
db.session.add(Assessment(
user_id=current_user.id,
school_id=current_user.school_id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -249,6 +303,7 @@ def bulk_import_assessments():
db.session.commit()
audit_log('assessment.bulk_import', 'assessment',
target_type='class', target_id=str(class_id),
detail={'totaal': totaal, 'fouten': fouten})
return jsonify({'totaal': totaal, 'fouten': fouten})
@@ -261,47 +316,44 @@ def bulk_import_assessments():
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)
school_year = get_active_year()
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
if year_id_param:
selected_year = SchoolYear.query.get(int(year_id_param)) 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()
# Alle klassen van deze school
klassen = Class.query.filter_by(school_id=current_user.school_id)\
.order_by(Class.name).all()
class_ids = [k.id for k in klassen]
query = Assessment.query.filter_by(
school_id=current_user.school_id, school_year_id=year_id
query = Assessment.query.filter(
Assessment.class_id.in_(class_ids),
Assessment.school_year_id == selected_year.id,
)
if vak_id:
query = query.filter_by(vak_id=vak_id)
by_teacher = {t.id: {} for t in teachers}
# Groepeer per klas → vak → goal
by_class = {k.id: {} for k in klassen}
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
by_class[a.class_id].setdefault(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,
'classes': [k.to_dict() for k in klassen],
'assessments_by_class': by_class,
})
# ── Gebruikersbeheer (school_ict / directeur) ──────────────────────────────────
# ── Gebruikersbeheer (director / school_ict) ──────────────────────────────────
@api_bp.route('/users', methods=['GET'])
@login_required
@@ -347,39 +399,39 @@ def delete_user(user_id):
return jsonify({'deleted': True})
# ── Schooljaren (directeur/admin leesbaar) ────────────────────────────────────
# ── Schooljaren ────────────────────────────────────────────────────────────────
@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()
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 ────────────────────────────────────────────────
# ── 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
school_year = get_active_year() 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) ──────────────────────────────────
# ── Klassen voor leerkracht ────────────────────────────────────────────────────
@api_bp.route('/my/classes', methods=['GET'])
@login_required
def my_classes():
"""Geeft alle beschikbare klassen en eigen klassen terug."""
"""Geeft alle klassen van de school en de eigen klassen van de leerkracht."""
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()
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],
@@ -389,12 +441,12 @@ def my_classes():
@api_bp.route('/my/classes', methods=['PUT'])
@login_required
def set_my_classes():
"""Leerkracht stelt eigen klassen in."""
"""Leerkracht stelt zijn 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
Class.school_id == current_user.school_id,
).all()
current_user.classes = classes
audit_log('class.user_assignment', 'class', target_type='user',
@@ -404,8 +456,7 @@ def set_my_classes():
return jsonify({'my_classes': [{'id': c.id, 'name': c.name} for c in current_user.classes]})
# ── Auditlog ──────────────────────────────────────────────────────────────────
# ── Auditlog ───────────────────────────────────────────────────────────────────
@api_bp.route('/audit-log')
@login_required
@@ -414,16 +465,13 @@ def get_audit_log():
return jsonify({'error': 'Geen toegang'}), 403
page = max(1, int(request.args.get('page', 1)))
per_page = min(100, max(1, int(request.args.get('per_page', 50)))) # max 100 per pagina
per_page = min(100, max(1, 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:
@@ -435,7 +483,8 @@ def get_audit_log():
)
total = query.count()
entries = query.order_by(AuditLog.timestamp.desc()) .offset((page - 1) * per_page).limit(per_page).all()
entries = query.order_by(AuditLog.timestamp.desc())\
.offset((page - 1) * per_page).limit(per_page).all()
return jsonify({
'total': total,
@@ -445,17 +494,11 @@ def get_audit_log():
})
# ── SSO-lookup: welke loginmethodes heeft dit e-maildomein? ──────────────────
# ── SSO-lookup ─────────────────────────────────────────────────────────────────
@api_bp.route('/sso-lookup')
def sso_lookup():
"""
Publieke endpoint — geen auth vereist.
Geeft aan welke SSO-methodes beschikbaar zijn voor een e-maildomein.
Legt NOOIT credentials bloot — enkel of Google geconfigureerd is.
"""
from flask import current_app
from app import limiter
email = request.args.get('email', '').lower().strip()
if not email or '@' not in email:
@@ -474,11 +517,7 @@ def sso_lookup():
)
if not school:
return jsonify({
'found': False,
'microsoft': microsoft_available,
'google': False,
})
return jsonify({'found': False, 'microsoft': microsoft_available, 'google': False})
return jsonify({
'found': True,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff