############################################################################## # ScoDoc # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """ ScoDoc 9 API : partitions CATEGORY -------- Groupes et Partitions """ from operator import attrgetter from flask import g, request from flask_json import as_json from flask_login import login_required import sqlalchemy as sa from sqlalchemy.exc import IntegrityError import app from app import db, log from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR from app.api import api_permission_required as permission_required from app.decorators import scodoc from app.scodoc.sco_utils import json_error from app.models import FormSemestre, FormSemestreInscription, Identite from app.models import GroupDescr, Partition, Scolog from app.models.groups import group_membership from app.scodoc import sco_cache from app.scodoc import sco_groups from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_permissions import Permission from app.scodoc import sco_utils as scu @bp.route("/partition/") @api_web_bp.route("/partition/") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def partition_info(partition_id: int): """Info sur une partition. Exemple de résultat : ```json { 'bul_show_rank': False, 'formsemestre_id': 39, 'groups': [ {'id': 268, 'name': 'A', 'partition_id': 100}, {'id': 269, 'name': 'B', 'partition_id': 100} ], 'groups_editable': True, 'id': 100, 'numero': 100, 'partition_name': 'TD', 'show_in_lists': True } ``` """ query = Partition.query.filter_by(id=partition_id) if g.scodoc_dept: query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) partition = query.first_or_404() return partition.to_dict(with_groups=True) @bp.route("/formsemestre//partitions") @api_web_bp.route("/formsemestre//partitions") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def formsemestre_partitions(formsemestre_id: int): """Liste de toutes les partitions d'un formsemestre. Exemple de résultat : ```json { partition_id : { "bul_show_rank": False, "formsemestre_id": 1063, "groups" : group_id : { "id" : 12, "name" : "A", "partition_id" : partition_id, } }, ... } ``` """ query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(formsemestre_id) partitions = sorted(formsemestre.partitions, key=attrgetter("numero")) return { str(partition.id): partition.to_dict(with_groups=True, str_keys=True) for partition in partitions if partition.partition_name is not None } @bp.route("/group//etudiants") @api_web_bp.route("/group//etudiants") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def group_etudiants(group_id: int): """ Retourne la liste des étudiants dans un groupe (inscrits au groupe et inscrits au semestre). PARAMS ------ group_id : l'id d'un groupe Exemple de résultat : ```json [ { 'civilite': 'M', 'id': 123456, 'ine': None, 'nip': '987654321', 'nom': 'MARTIN', 'nom_usuel': null, 'prenom': 'JEAN'} }, ... ] ``` """ query = GroupDescr.query.filter_by(id=group_id) if g.scodoc_dept: query = ( query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group = query.first_or_404() query = ( Identite.query.join(group_membership) .filter_by(group_id=group_id) .join(FormSemestreInscription) .filter_by(formsemestre_id=group.partition.formsemestre_id) ) return [etud.to_dict_short() for etud in query] @bp.route("/group//etudiants/query") @api_web_bp.route("/group//etudiants/query") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def group_etudiants_query(group_id: int): """Étudiants du groupe, filtrés par état (aucun, `I`, `D`, `DEF`) QUERY ----- etat : string """ etat = request.args.get("etat") if etat not in {None, scu.INSCRIT, scu.DEMISSION, scu.DEF}: return json_error(API_CLIENT_ERROR, "etat: valeur invalide") query = GroupDescr.query.filter_by(id=group_id) if g.scodoc_dept: query = ( query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group = query.first_or_404() # just to ckeck that group exists in accessible dept query = Identite.query.join(FormSemestreInscription).filter_by( formsemestre_id=group.partition.formsemestre_id ) if etat is not None: query = query.filter_by(etat=etat) query = query.join(group_membership).filter_by(group_id=group_id) return [etud.to_dict_short() for etud in query] @bp.route("/group//set_etudiant/", methods=["POST"]) @api_web_bp.route("/group//set_etudiant/", methods=["POST"]) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def group_set_etudiant(group_id: int, etudid: int): """Affecte l'étudiant au groupe indiqué.""" etud = Identite.query.get_or_404(etudid) query = GroupDescr.query.filter_by(id=group_id) if g.scodoc_dept: query = ( query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group = query.first_or_404() if not group.partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not group.partition.formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") if etud.id not in {e.id for e in group.partition.formsemestre.etuds}: return json_error(404, "etud non inscrit au formsemestre du groupe") try: sco_groups.change_etud_group_in_partition(etudid, group) except ScoValueError as exc: return json_error(404, exc.args[0]) except IntegrityError: return json_error(404, "échec de l'enregistrement") return {"group_id": group_id, "etudid": etudid} @bp.route("/group//remove_etudiant/", methods=["POST"]) @api_web_bp.route( "/group//remove_etudiant/", methods=["POST"] ) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def group_remove_etud(group_id: int, etudid: int): """Retire l'étudiant de ce groupe. S'il n'y est pas, ne fait rien.""" etud = Identite.query.get_or_404(etudid) query = GroupDescr.query.filter_by(id=group_id) if g.scodoc_dept: query = ( query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group = query.first_or_404() if not group.partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not group.partition.formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") group.remove_etud(etud) return {"group_id": group_id, "etudid": etudid} @bp.route( "/partition//remove_etudiant/", methods=["POST"] ) @api_web_bp.route( "/partition//remove_etudiant/", methods=["POST"] ) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def partition_remove_etud(partition_id: int, etudid: int): """Enlève l'étudiant de tous les groupes de cette partition. (NB: en principe, un étudiant ne doit être que dans 0 ou 1 groupe d'une partition) """ etud = Identite.query.get_or_404(etudid) query = Partition.query.filter_by(id=partition_id) if g.scodoc_dept: query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) partition = query.first_or_404() if not partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not partition.formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") db.session.execute( sa.text( """DELETE FROM group_membership WHERE etudid=:etudid and group_id IN ( SELECT id FROM group_descr WHERE partition_id = :partition_id ); """ ), {"etudid": etudid, "partition_id": partition_id}, ) Scolog.logdb( method="partition_remove_etud", etudid=etud.id, msg=f"Retrait de la partition {partition.partition_name}", commit=False, ) db.session.commit() # Update parcours partition.formsemestre.update_inscriptions_parcours_from_groups(etudid=etudid) app.set_sco_dept(partition.formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(partition.formsemestre_id) return {"partition_id": partition_id, "etudid": etudid} @bp.route("/partition//group/create", methods=["POST"]) @api_web_bp.route("/partition//group/create", methods=["POST"]) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def group_create(partition_id: int): # partition-group-create """Création d'un groupe dans une partition. DATA ---- ```json { "group_name" : nom_du_groupe, } ``` """ query = Partition.query.filter_by(id=partition_id) if g.scodoc_dept: query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) partition: Partition = query.first_or_404() if not partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not partition.groups_editable: return json_error(403, "partition non editable") if not partition.formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") args = request.get_json(force=True) # may raise 400 Bad Request group_name = args.get("group_name") if not isinstance(group_name, str): return json_error(API_CLIENT_ERROR, "missing group name or invalid data format") args["group_name"] = args["group_name"].strip() if not GroupDescr.check_name(partition, args["group_name"]): return json_error(API_CLIENT_ERROR, "invalid group_name") # le numero est optionnel numero = args.get("numero") if numero is None: numeros = [gr.numero or 0 for gr in partition.groups] numero = (max(numeros) + 1) if numeros else 0 args["numero"] = numero args["partition_id"] = partition_id try: group = GroupDescr(**args) except TypeError: return json_error(API_CLIENT_ERROR, "invalid arguments") db.session.add(group) db.session.commit() log(f"created group {group}") app.set_sco_dept(partition.formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(partition.formsemestre_id) return group.to_dict(with_partition=True) @bp.route("/group//delete", methods=["POST"]) @api_web_bp.route("/group//delete", methods=["POST"]) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def group_delete(group_id: int): """Suppression d'un groupe.""" query = GroupDescr.query.filter_by(id=group_id) if g.scodoc_dept: query = ( query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group: GroupDescr = query.first_or_404() if not group.partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not group.partition.groups_editable: return json_error(403, "partition non editable") if not group.partition.formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") formsemestre_id = group.partition.formsemestre_id log(f"deleting {group}") db.session.delete(group) db.session.commit() app.set_sco_dept(group.partition.formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(formsemestre_id) return {"OK": True} @bp.route("/group//edit", methods=["POST"]) @api_web_bp.route("/group//edit", methods=["POST"]) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def group_edit(group_id: int): """Édition d'un groupe.""" query = GroupDescr.query.filter_by(id=group_id) if g.scodoc_dept: query = ( query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group: GroupDescr = query.first_or_404() if not group.partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not group.partition.groups_editable: return json_error(403, "partition non editable") if not group.partition.formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") args = request.get_json(force=True) # may raise 400 Bad Request if "group_name" in args: if not isinstance(args["group_name"], str): return json_error(API_CLIENT_ERROR, "invalid data format for group_name") args["group_name"] = args["group_name"].strip() if args["group_name"] else "" if not GroupDescr.check_name( group.partition, args["group_name"], existing=True ): return json_error(API_CLIENT_ERROR, "invalid group_name") group.from_dict(args) db.session.add(group) db.session.commit() log(f"modified {group}") app.set_sco_dept(group.partition.formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(group.partition.formsemestre_id) return group.to_dict(with_partition=True) @bp.route("/group//set_edt_id/", methods=["POST"]) @api_web_bp.route("/group//set_edt_id/", methods=["POST"]) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def group_set_edt_id(group_id: int, edt_id: str): """Set edt_id du groupe. Contrairement à `/edit`, peut-être changé pour toute partition d'un formsemestre non verrouillé. """ query = GroupDescr.query.filter_by(id=group_id) if g.scodoc_dept: query = ( query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group: GroupDescr = query.first_or_404() if not group.partition.formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") log(f"group_set_edt_id( {group_id}, '{edt_id}' )") group.edt_id = edt_id db.session.add(group) db.session.commit() return group.to_dict(with_partition=True) @bp.route("/formsemestre//partition/create", methods=["POST"]) @api_web_bp.route( "/formsemestre//partition/create", methods=["POST"] ) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def partition_create(formsemestre_id: int): """Création d'une partition dans un semestre. DATA ---- ```json { "partition_name": str, "numero": int, "bul_show_rank": bool, "show_in_lists": bool, "groups_editable": bool } ``` """ query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(formsemestre_id) if not formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") data = request.get_json(force=True) # may raise 400 Bad Request partition_name = data.get("partition_name") if partition_name is None: return json_error( API_CLIENT_ERROR, "missing partition_name or invalid data format" ) if partition_name == scu.PARTITION_PARCOURS: return json_error( API_CLIENT_ERROR, f"invalid partition_name {scu.PARTITION_PARCOURS}" ) if not Partition.check_name(formsemestre, partition_name): return json_error(API_CLIENT_ERROR, "invalid partition_name") numero = data.get("numero", 0) if not isinstance(numero, int): return json_error(API_CLIENT_ERROR, "invalid type for numero") args = { "formsemestre_id": formsemestre_id, "partition_name": partition_name.strip(), "numero": numero, } for boolean_field in ("bul_show_rank", "show_in_lists", "groups_editable"): value = data.get( boolean_field, False if boolean_field != "groups_editable" else True ) if not isinstance(value, bool): return json_error(API_CLIENT_ERROR, f"invalid type for {boolean_field}") args[boolean_field] = value partition = Partition(**args) db.session.add(partition) db.session.commit() log(f"created partition {partition}") app.set_sco_dept(formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(formsemestre_id) return partition.to_dict(with_groups=True) @bp.route("/formsemestre//partitions/order", methods=["POST"]) @api_web_bp.route( "/formsemestre//partitions/order", methods=["POST"] ) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def formsemestre_set_partitions_order(formsemestre_id: int): """Modifie l'ordre des partitions du formsemestre. DATA ---- ```json [ partition_id1, partition_id2, ... ] ``` """ query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(formsemestre_id) if not formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") partition_ids = request.get_json(force=True) # may raise 400 Bad Request if not isinstance(partition_ids, list) and not all( isinstance(x, int) for x in partition_ids ): return json_error( API_CLIENT_ERROR, message="paramètre liste des partitions invalide", ) for p_id, numero in zip(partition_ids, range(len(partition_ids))): partition = Partition.query.get_or_404(p_id) partition.numero = numero db.session.add(partition) db.session.commit() app.set_sco_dept(formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(formsemestre_id) log(f"formsemestre_set_partitions_order({partition_ids})") return [ partition.to_dict() for partition in formsemestre.partitions.order_by(Partition.numero) if partition.partition_name is not None ] @bp.route("/partition//groups/order", methods=["POST"]) @api_web_bp.route("/partition//groups/order", methods=["POST"]) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def partition_order_groups(partition_id: int): """Modifie l'ordre des groupes de la partition. DATA ---- ```json [ group_id1, group_id2, ... ] ``` """ query = Partition.query.filter_by(id=partition_id) if g.scodoc_dept: query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) partition: Partition = query.first_or_404() if not partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not partition.formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") group_ids = request.get_json(force=True) # may raise 400 Bad Request if not isinstance(group_ids, list) and not all( isinstance(x, int) for x in group_ids ): return json_error( API_CLIENT_ERROR, message="paramètre liste de groupe invalide", ) for group_id, numero in zip(group_ids, range(len(group_ids))): group = GroupDescr.query.get_or_404(group_id) group.numero = numero db.session.add(group) db.session.commit() app.set_sco_dept(partition.formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(partition.formsemestre_id) log(f"partition_order_groups: {partition} : {group_ids}") return partition.to_dict(with_groups=True) @bp.route("/partition//edit", methods=["POST"]) @api_web_bp.route("/partition//edit", methods=["POST"]) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def partition_edit(partition_id: int): """Modification d'une partition dans un semestre. Tous les champs sont optionnels. DATA ---- ```json { "partition_name": str, "numero":int, "bul_show_rank":bool, "show_in_lists":bool, "groups_editable":bool } ``` """ query = Partition.query.filter_by(id=partition_id) if g.scodoc_dept: query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) partition: Partition = query.first_or_404() if not partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not partition.formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") data = request.get_json(force=True) # may raise 400 Bad Request modified = False partition_name = data.get("partition_name") # if partition_name is not None and partition_name != partition.partition_name: if partition.is_parcours(): return json_error( API_CLIENT_ERROR, f"can't rename {scu.PARTITION_PARCOURS}" ) if not Partition.check_name( partition.formsemestre, partition_name, existing=True ): return json_error(API_CLIENT_ERROR, "invalid partition_name") partition.partition_name = partition_name.strip() modified = True numero = data.get("numero") if numero is not None and numero != partition.numero: if not isinstance(numero, int): return json_error(API_CLIENT_ERROR, "invalid type for numero") partition.numero = numero modified = True for boolean_field in ("bul_show_rank", "show_in_lists", "groups_editable"): value = data.get(boolean_field) if value is not None and value != getattr(partition, boolean_field): if not isinstance(value, bool): return json_error(API_CLIENT_ERROR, f"invalid type for {boolean_field}") if boolean_field == "groups_editable" and partition.is_parcours(): return json_error( API_CLIENT_ERROR, f"can't change {scu.PARTITION_PARCOURS}" ) setattr(partition, boolean_field, value) modified = True if modified: db.session.add(partition) db.session.commit() log(f"modified partition {partition}") app.set_sco_dept(partition.formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(partition.formsemestre_id) return partition.to_dict(with_groups=True) @bp.route("/partition//delete", methods=["POST"]) @api_web_bp.route("/partition//delete", methods=["POST"]) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def partition_delete(partition_id: int): """Suppression d'une partition (et de tous ses groupes). * Note 1: La partition par défaut (tous les étudiants du sem.) ne peut pas être supprimée. * Note 2: Si la partition de parcours est supprimée, les étudiants sont désinscrits des parcours. """ query = Partition.query.filter_by(id=partition_id) if g.scodoc_dept: query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) partition: Partition = query.first_or_404() if not partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not partition.formsemestre.can_change_groups(): return json_error(401, "opération non autorisée") if not partition.partition_name: return json_error( API_CLIENT_ERROR, "ne peut pas supprimer la partition par défaut" ) is_parcours = partition.is_parcours() formsemestre: FormSemestre = partition.formsemestre log(f"deleting partition {partition}") db.session.delete(partition) db.session.commit() app.set_sco_dept(partition.formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(formsemestre.id) if is_parcours: formsemestre.update_inscriptions_parcours_from_groups() return {"OK": True}