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.
529 lines
18 KiB
Python
529 lines
18 KiB
Python
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 services.doelen import load_index, load_vak, is_valid_vak_id
|
|
from services.audit import audit_log
|
|
from functools import wraps
|
|
from app import db, limiter
|
|
|
|
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():
|
|
"""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()
|
|
return jsonify(data)
|
|
|
|
|
|
@api_bp.route('/doelen/<vak_id>')
|
|
@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():
|
|
"""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': []})
|
|
|
|
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(class_id=class_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
|
|
@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 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'}), 400
|
|
if len(vak_id) > 100 or len(goal_id) > 50:
|
|
return jsonify({'error': 'Ongeldige invoer'}), 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 gevonden'}), 400
|
|
|
|
assessment = Assessment.query.filter_by(
|
|
class_id=class_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.opmerking = opmerking or None
|
|
assessment.updated_at = datetime.utcnow()
|
|
else:
|
|
assessment = Assessment(
|
|
class_id=class_id,
|
|
school_year_id=school_year.id,
|
|
vak_id=vak_id,
|
|
goal_id=goal_id,
|
|
status=status,
|
|
opmerking=opmerking or None,
|
|
)
|
|
db.session.add(assessment)
|
|
|
|
db.session.commit()
|
|
audit_log('assessment.save', 'assessment',
|
|
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():
|
|
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 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
|
|
|
|
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(
|
|
class_id=class_id,
|
|
school_year_id=school_year.id,
|
|
vak_id=vak_id,
|
|
goal_id=goal_id,
|
|
).first()
|
|
|
|
if assessment:
|
|
assessment.opmerking = opmerking or None
|
|
assessment.updated_at = datetime.utcnow()
|
|
else:
|
|
assessment = Assessment(
|
|
class_id=class_id,
|
|
school_year_id=school_year.id,
|
|
vak_id=vak_id,
|
|
goal_id=goal_id,
|
|
status='',
|
|
opmerking=opmerking or None,
|
|
)
|
|
db.session.add(assessment)
|
|
|
|
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 legacy standalone JSON export.
|
|
Body: { "class_id": 1, "vakken": { "vak_id": { "goal_id": "status" } } }
|
|
"""
|
|
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():
|
|
if not isinstance(vak_id, str) or len(vak_id) > 100:
|
|
fouten += 1
|
|
continue
|
|
|
|
# Ondersteun zowel { statussen: {...} } als { goal_id: status }
|
|
if isinstance(vak_data, dict) and 'statussen' in vak_data:
|
|
statussen = vak_data['statussen']
|
|
else:
|
|
statussen = vak_data
|
|
|
|
if not isinstance(statussen, dict):
|
|
continue
|
|
|
|
for goal_id, status in statussen.items():
|
|
if not isinstance(goal_id, str) or len(goal_id) > 50:
|
|
fouten += 1
|
|
continue
|
|
if status not in ('groen', 'oranje', 'roze'):
|
|
continue
|
|
|
|
try:
|
|
assessment = Assessment.query.filter_by(
|
|
class_id=class_id,
|
|
school_year_id=school_year.id,
|
|
vak_id=vak_id,
|
|
goal_id=goal_id,
|
|
).first()
|
|
|
|
if assessment:
|
|
assessment.status = status
|
|
assessment.updated_at = datetime.utcnow()
|
|
else:
|
|
db.session.add(Assessment(
|
|
class_id=class_id,
|
|
school_year_id=school_year.id,
|
|
vak_id=vak_id,
|
|
goal_id=goal_id,
|
|
status=status,
|
|
))
|
|
totaal += 1
|
|
except Exception:
|
|
db.session.rollback()
|
|
fouten += 1
|
|
|
|
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})
|
|
|
|
|
|
# ── 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()
|
|
if not school_year:
|
|
return jsonify({'error': 'Geen actief schooljaar'}), 400
|
|
|
|
year_id_param = request.args.get('year_id')
|
|
if year_id_param:
|
|
selected_year = SchoolYear.query.get(int(year_id_param)) or school_year
|
|
else:
|
|
selected_year = school_year
|
|
|
|
vak_id = request.args.get('vak_id')
|
|
|
|
# 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(
|
|
Assessment.class_id.in_(class_ids),
|
|
Assessment.school_year_id == selected_year.id,
|
|
)
|
|
if vak_id:
|
|
query = query.filter_by(vak_id=vak_id)
|
|
|
|
# Groepeer per klas → vak → goal
|
|
by_class = {k.id: {} for k in klassen}
|
|
for a in query.all():
|
|
by_class[a.class_id].setdefault(a.vak_id, {})[a.goal_id] = a.status
|
|
|
|
return jsonify({
|
|
'school_year': selected_year.to_dict(),
|
|
'classes': [k.to_dict() for k in klassen],
|
|
'assessments_by_class': by_class,
|
|
})
|
|
|
|
|
|
# ── Gebruikersbeheer (director / school_ict) ───────────────────────────────────
|
|
|
|
@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/<int:user_id>', 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 ────────────────────────────────────────────────────────────────
|
|
|
|
@api_bp.route('/school/years')
|
|
@login_required
|
|
@director_required
|
|
def get_school_years():
|
|
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() 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 ────────────────────────────────────────────────────
|
|
|
|
@api_bp.route('/my/classes', methods=['GET'])
|
|
@login_required
|
|
def my_classes():
|
|
"""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()
|
|
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 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,
|
|
).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 = max(1, int(request.args.get('page', 1)))
|
|
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
|
|
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],
|
|
})
|
|
|
|
|
|
# ── SSO-lookup ─────────────────────────────────────────────────────────────────
|
|
|
|
@api_bp.route('/sso-lookup')
|
|
def sso_lookup():
|
|
from flask import current_app
|
|
|
|
email = request.args.get('email', '').lower().strip()
|
|
if not email or '@' not in email:
|
|
return jsonify({'error': 'Ongeldig e-mailadres'}), 400
|
|
|
|
domain = email.split('@')[-1]
|
|
schools = School.query.all()
|
|
school = next(
|
|
(s for s in schools if s.email_domains and domain in [d.lower() for d in s.email_domains]),
|
|
None
|
|
)
|
|
|
|
microsoft_available = bool(
|
|
current_app.config.get('MICROSOFT_CLIENT_ID') and
|
|
current_app.config.get('MICROSOFT_CLIENT_SECRET')
|
|
)
|
|
|
|
if not school:
|
|
return jsonify({'found': False, 'microsoft': microsoft_available, 'google': False})
|
|
|
|
return jsonify({
|
|
'found': True,
|
|
'school_id': school.id,
|
|
'school_name': school.name,
|
|
'microsoft': microsoft_available,
|
|
'google': bool(school.google_client_id and school.google_client_secret),
|
|
})
|