ScoDoc/app/api/partitions.py

569 lines
21 KiB
Python

##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
ScoDoc 9 API : partitions
"""
from flask import g, jsonify, request
from flask_login import login_required
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
from app.scodoc.sco_utils import json_error
from app.models import FormSemestre, FormSemestreInscription, Identite
from app.models import GroupDescr, Partition
from app.models.groups import group_membership
from app.scodoc import sco_cache
from app.scodoc import sco_groups
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
@bp.route("/partition/<int:partition_id>")
@api_web_bp.route("/partition/<int:partition_id>")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def partition_info(partition_id: int):
"""Info sur une partition.
Exemple de résultat :
```
{
'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 jsonify(partition.to_dict(with_groups=True))
@bp.route("/formsemestre/<int:formsemestre_id>/partitions")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/partitions")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def formsemestre_partitions(formsemestre_id: int):
"""Liste de toutes les partitions d'un formsemestre
formsemestre_id : l'id d'un formsemestre
{
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=lambda p: p.numero or 0)
return jsonify(
{
partition.id: partition.to_dict(with_groups=True)
for partition in partitions
if partition.partition_name is not None
}
)
@bp.route("/group/<int:group_id>/etudiants")
@api_web_bp.route("/group/<int:group_id>/etudiants")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def etud_in_group(group_id: int):
"""
Retourne la liste des étudiants dans un groupe
group_id : l'id d'un groupe
Exemple de résultat :
[
{
'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()
return jsonify([etud.to_dict_short() for etud in group.etuds])
@bp.route("/group/<int:group_id>/etudiants/query")
@api_web_bp.route("/group/<int:group_id>/etudiants/query")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def etud_in_group_query(group_id: int):
"""Étudiants du groupe, filtrés par état"""
etat = request.args.get("etat")
if etat not in {None, scu.INSCRIT, scu.DEMISSION, scu.DEF}:
return json_error(404, "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 jsonify([etud.to_dict_short() for etud in query])
@bp.route("/group/<int:group_id>/set_etudiant/<int:etudid>", methods=["POST"])
@api_web_bp.route("/group/<int:group_id>/set_etudiant/<int:etudid>", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
def set_etud_group(etudid: int, group_id: 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 etud.id not in {e.id for e in group.partition.formsemestre.etuds}:
return json_error(404, "etud non inscrit au formsemestre du groupe")
sco_groups.change_etud_group_in_partition(
etudid, group_id, group.partition.to_dict()
)
return jsonify({"group_id": group_id, "etudid": etudid})
@bp.route("/group/<int:group_id>/remove_etudiant/<int:etudid>", methods=["POST"])
@api_web_bp.route(
"/group/<int:group_id>/remove_etudiant/<int:etudid>", methods=["POST"]
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
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 etud in group.etuds:
group.etuds.remove(etud)
db.session.commit()
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return jsonify({"group_id": group_id, "etudid": etudid})
@bp.route(
"/partition/<int:partition_id>/remove_etudiant/<int:etudid>", methods=["POST"]
)
@api_web_bp.route(
"/partition/<int:partition_id>/remove_etudiant/<int:etudid>", methods=["POST"]
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
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é")
groups = (
GroupDescr.query.filter_by(partition_id=partition_id)
.join(group_membership)
.filter_by(etudid=etudid)
)
for group in groups:
group.etuds.remove(etud)
db.session.commit()
app.set_sco_dept(partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(partition.formsemestre_id)
return jsonify({"partition_id": partition_id, "etudid": etudid})
@bp.route("/partition/<int:partition_id>/group/create", methods=["POST"])
@api_web_bp.route("/partition/<int:partition_id>/group/create", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
def group_create(partition_id: int):
"""Création d'un groupe dans une partition
The request content type should be "application/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")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is None:
return json_error(404, "missing group name or invalid data format")
if not GroupDescr.check_name(partition, group_name):
return json_error(404, "invalid group_name")
group_name = group_name.strip()
group = GroupDescr(group_name=group_name, partition_id=partition_id)
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 jsonify(group.to_dict(with_partition=True))
@bp.route("/group/<int:group_id>/delete", methods=["POST"])
@api_web_bp.route("/group/<int:group_id>/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
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")
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 jsonify({"OK": True})
@bp.route("/group/<int:group_id>/edit", methods=["POST"])
@api_web_bp.route("/group/<int:group_id>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
def group_edit(group_id: int):
"""Edit a group"""
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")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is not None:
group_name = group_name.strip()
if not GroupDescr.check_name(group.partition, group_name, existing=True):
return json_error(404, "invalid group_name")
group.group_name = group_name
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 jsonify(group.to_dict(with_partition=True))
@bp.route("/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"])
@api_web_bp.route(
"/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"]
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
def partition_create(formsemestre_id: int):
"""Création d'une partition dans un semestre
The request content type should be "application/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é")
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(404, "missing partition_name or invalid data format")
if partition_name == scu.PARTITION_PARCOURS:
return json_error(404, f"invalid partition_name {scu.PARTITION_PARCOURS}")
if not Partition.check_name(formsemestre, partition_name):
return json_error(404, "invalid partition_name")
numero = data.get("numero", 0)
if not isinstance(numero, int):
return json_error(404, "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(404, 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 jsonify(partition.to_dict(with_groups=True))
@bp.route("/formsemestre/<int:formsemestre_id>/partitions/order", methods=["POST"])
@api_web_bp.route(
"/formsemestre/<int:formsemestre_id>/partitions/order", methods=["POST"]
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
def formsemestre_order_partitions(formsemestre_id: int):
"""Modifie l'ordre des partitions du formsemestre
JSON args: [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é")
partition_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(partition_ids, int) and not all(
isinstance(x, int) for x in partition_ids
):
return json_error(
404,
message="paramètre liste des partitions invalide",
)
for p_id, numero in zip(partition_ids, range(len(partition_ids))):
p = Partition.query.get_or_404(p_id)
p.numero = numero
db.session.add(p)
db.session.commit()
app.set_sco_dept(formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(formsemestre_id)
return jsonify(
[
partition.to_dict()
for partition in formsemestre.partitions.order_by(Partition.numero)
if partition.partition_name is not None
]
)
@bp.route("/partition/<int:partition_id>/groups/order", methods=["POST"])
@api_web_bp.route("/partition/<int:partition_id>/groups/order", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
def partition_order_groups(partition_id: int):
"""Modifie l'ordre des groupes de la partition
JSON args: [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é")
group_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(group_ids, int) and not all(
isinstance(x, int) for x in group_ids
):
return json_error(
404,
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 jsonify(partition.to_dict(with_groups=True))
@bp.route("/partition/<int:partition_id>/edit", methods=["POST"])
@api_web_bp.route("/partition/<int:partition_id>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
def partition_edit(partition_id: int):
"""Modification d'une partition dans un semestre
The request content type should be "application/json"
All fields are optional:
{
"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é")
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(404, f"can't rename {scu.PARTITION_PARCOURS}")
if not Partition.check_name(
partition.formsemestre, partition_name, existing=True
):
return json_error(404, "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(404, "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(404, f"invalid type for {boolean_field}")
if boolean_field == "groups_editable" and partition.is_parcours():
return json_error(404, 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 jsonify(partition.to_dict(with_groups=True))
@bp.route("/partition/<int:partition_id>/delete", methods=["POST"])
@api_web_bp.route("/partition/<int:partition_id>/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
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.partition_name:
return json_error(404, "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 jsonify({"OK": True})