1
0
forked from ScoDoc/ScoDoc

Compare commits

..

24 Commits

Author SHA1 Message Date
ScoDoc service
580293207d Merge branch 'master' of https://scodoc.org/git/ScoDoc/ScoDoc into bac_a_sable_prod 2023-05-17 22:12:26 +02:00
ScoDoc service
0c6a21425a Merge branch 'table' of https://scodoc.org/git/ScoDoc/ScoDoc into bac_a_sable_prod 2023-05-14 15:01:20 +02:00
ScoDoc service
ed05c1f7fe Merge branch 'table' of https://scodoc.org/git/ScoDoc/ScoDoc into bac_a_sable_prod 2023-05-13 18:38:49 +02:00
ScoDoc service
793dfc4353 Merge branch 'table' of https://scodoc.org/git/ScoDoc/ScoDoc into bac_a_sable_prod 2023-05-12 21:30:22 +02:00
ScoDoc service
0c215565e8 Merge branch 'table' of https://scodoc.org/git/ScoDoc/ScoDoc into bac_a_sable_prod 2023-05-12 10:01:08 +02:00
ScoDoc service
7bf00f1ad9 Merge branch 'table' of https://scodoc.org/git/ScoDoc/ScoDoc into bac_a_sable_prod 2023-05-11 18:08:08 +02:00
ScoDoc service
48453aab86 Merge branch 'table' of https://scodoc.org/git/ScoDoc/ScoDoc into bac_a_sable_prod 2023-05-11 18:05:35 +02:00
ScoDoc service
e9d5e14f16 Merge branch 'table' of https://scodoc.org/git/ScoDoc/ScoDoc into bac_a_sable_prod 2023-05-11 17:45:19 +02:00
ScoDoc service
395dca1f32 Merge branch 'table' of https://scodoc.org/git/ScoDoc/ScoDoc into bac_a_sable_prod 2023-05-11 16:51:11 +02:00
iziram
b7b91fa415 bugfix : duplication rouge get_photo_image 2023-04-21 07:22:15 +02:00
iziram
e00fc8dd09 Assiduités : Page Liste Assiduites / Justifs (WIP) 2023-04-21 07:08:57 +02:00
iziram
f26ecb1f8a Assiduites : Gestion des justificatifs (rapide) WIP
Assiduites : ajout style justifié (minitimeline)
2023-04-21 07:08:57 +02:00
iziram
f3b540b4c1 Assiduites : modification automatique du moduleimpl_id 2023-04-21 07:08:57 +02:00
iziram
5a997dddf4 Assiduites : modification styles (proposition Sébastien Lehmann) 2023-04-21 07:08:57 +02:00
iziram
dae7486d3f Assiduites : Fonctionnement BackEnd + API 2023-04-21 07:07:22 +02:00
iziram
c7300ccf0d bugfix : placement modaux + affichage conflit 2023-04-18 14:08:46 +02:00
iziram
71aa49b1b1 bugfix : disparition liste dept page accueil 2023-04-18 14:02:07 +02:00
iziram
70a52e7ce1 bac à sable : table + assiduites 2023-04-17 16:11:25 +02:00
iziram
5a9d65788f Assiduites : Front End 2023-04-17 15:53:30 +02:00
iziram
650deff2c6 Assiduites : script de migration et de suppression 2023-04-17 15:52:05 +02:00
iziram
d57a3ba1db Assiduités : Ajout des tests (Unit/API) 2023-04-17 15:52:05 +02:00
iziram
68a35864d1 Assiduites : Fonctionnement BackEnd + API 2023-04-17 15:52:05 +02:00
iziram
33855cd38d Assiduités : Ajout des migrations 2023-04-17 15:52:05 +02:00
iziram
4691ed8f36 Assiduites :Création des models 2023-04-17 15:52:05 +02:00
242 changed files with 18123 additions and 19605 deletions

4
app/__init__.py Normal file → Executable file
View File

@ -322,6 +322,7 @@ def create_app(config_class=DevConfig):
from app.views import notes_bp from app.views import notes_bp
from app.views import users_bp from app.views import users_bp
from app.views import absences_bp from app.views import absences_bp
from app.views import assiduites_bp
from app.api import api_bp from app.api import api_bp
from app.api import api_web_bp from app.api import api_web_bp
@ -340,6 +341,9 @@ def create_app(config_class=DevConfig):
app.register_blueprint( app.register_blueprint(
absences_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Absences" absences_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Absences"
) )
app.register_blueprint(
assiduites_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Assiduites"
)
app.register_blueprint(api_bp, url_prefix="/ScoDoc/api") app.register_blueprint(api_bp, url_prefix="/ScoDoc/api")
app.register_blueprint(api_web_bp, url_prefix="/ScoDoc/<scodoc_dept>/api") app.register_blueprint(api_web_bp, url_prefix="/ScoDoc/<scodoc_dept>/api")

View File

@ -2,7 +2,8 @@
""" """
from flask import Blueprint from flask import Blueprint
from flask import request from flask import request, g, jsonify
from app import db
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException from app.scodoc.sco_exceptions import ScoException
@ -34,9 +35,26 @@ def requested_format(default_format="json", allowed_formats=None):
return None return None
def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None):
"""
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]"
Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemstre) -> join_cls
exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py
"""
query = model_cls.query.filter_by(id=model_id)
if g.scodoc_dept and join_cls is not None:
query = query.join(join_cls).filter_by(dept_id=g.scodoc_dept_id)
unique: model_cls = query.first_or_404()
return jsonify(unique.to_dict(format_api=True))
from app.api import tokens from app.api import tokens
from app.api import ( from app.api import (
absences, absences,
assiduites,
billets_absences, billets_absences,
departements, departements,
etudiants, etudiants,
@ -44,6 +62,7 @@ from app.api import (
formations, formations,
formsemestres, formsemestres,
jury, jury,
justificatifs,
logos, logos,
partitions, partitions,
semset, semset,

View File

@ -8,7 +8,6 @@
from flask_json import as_json from flask_json import as_json
from app import db
from app.api import api_bp as bp, API_CLIENT_ERROR from app.api import api_bp as bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required from app.decorators import scodoc, permission_required
@ -52,7 +51,7 @@ def absences(etudid: int = None):
} }
] ]
""" """
etud = db.session.get(Identite, etudid) etud = Identite.query.get(etudid)
if etud is None: if etud is None:
return json_error(404, message="etudiant inexistant") return json_error(404, message="etudiant inexistant")
# Absences de l'étudiant # Absences de l'étudiant
@ -97,7 +96,7 @@ def absences_just(etudid: int = None):
} }
] ]
""" """
etud = db.session.get(Identite, etudid) etud = Identite.query.get(etudid)
if etud is None: if etud is None:
return json_error(404, message="etudiant inexistant") return json_error(404, message="etudiant inexistant")

868
app/api/assiduites.py Normal file
View File

@ -0,0 +1,868 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask import g, jsonify, request
from flask_login import login_required, current_user
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object
from app.decorators import permission_required, scodoc
from app.models import Assiduite, FormSemestre, Identite, ModuleImpl
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@bp.route("/assiduite/<int:assiduite_id>")
@api_web_bp.route("/assiduite/<int:assiduite_id>")
@scodoc
@permission_required(Permission.ScoView)
def assiduite(assiduite_id: int = None):
"""Retourne un objet assiduité à partir de son id
Exemple de résultat:
{
"assiduite_id": 1,
"etudid": 2,
"moduleimpl_id": 3,
"date_debut": "2022-10-31T08:00+01:00",
"date_fin": "2022-10-31T10:00+01:00",
"etat": "retard",
"desc": "une description",
"user_id: 1 or null,
"est_just": False or True,
}
"""
return get_model_api_object(Assiduite, assiduite_id, Identite)
@bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def count_assiduites(etudid: int = None, with_query: bool = False):
"""
Retourne le nombre d'assiduités d'un étudiant
chemin : /assiduites/<int:etudid>/count
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/count/query?
Les différents filtres :
Type (type de comptage -> journee, demi, heure, nombre d'assiduite):
query?type=(journee, demi, heure) -> une seule valeur parmis les trois
ex: .../query?type=heure
Comportement par défaut : compte le nombre d'assiduité enregistrée
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemestre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
filtered: dict[str, object] = {}
metric: str = "all"
if with_query:
metric, filtered = _count_manager(request)
return jsonify(
scass.get_assiduites_stats(
assiduites=etud.assiduites, metric=metric, filtered=filtered
)
)
@bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def assiduites(etudid: int = None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /assiduites/<int:etudid>
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/query?
Les différents filtres :
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemstre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
assiduites_query = etud.assiduites
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
data_set: list[dict] = []
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data_set.append(data)
return jsonify(data_set)
@bp.route("/assiduites/group/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/group/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def assiduites_group(with_query: bool = False):
"""
Retourne toutes les assiduités d'un groupe d'étudiants
chemin : /assiduites/group/query?etudids=1,2,3
Un filtrage peut être donné avec une query
chemin : /assiduites/group/query?etudids=1,2,3
Les différents filtres :
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemstre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
"""
etuds = request.args.get("etudids", "")
etuds = etuds.split(",")
try:
etuds = [int(etu) for etu in etuds]
except ValueError:
return json_error(404, "Le champs etudids n'est pas correctement formé")
query = Identite.query.filter(Identite.id.in_(etuds))
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
if len(etuds) != query.count() or len(etuds) == 0:
return json_error(
404,
"Tous les étudiants ne sont pas dans le même département et/ou n'existe pas.",
)
assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds))
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
data_set: dict[list[dict]] = {key: [] for key in etuds}
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data_set.get(data["etudid"]).append(data)
return jsonify(data_set)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/query",
defaults={"with_query": True},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/query",
defaults={"with_query": True},
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne toutes les assiduités du formsemestre"""
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
assiduites_query = scass.filter_by_formsemestre(Assiduite.query, formsemestre)
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
data_set: list[dict] = []
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data_set.append(data)
return jsonify(data_set)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count",
defaults={"with_query": False},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count",
defaults={"with_query": False},
)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count/query",
defaults={"with_query": True},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count/query",
defaults={"with_query": True},
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
def count_assiduites_formsemestre(
formsemestre_id: int = None, with_query: bool = False
):
"""Comptage des assiduités du formsemestre"""
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
etuds = formsemestre.etuds.all()
etuds_id = [etud.id for etud in etuds]
assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds_id))
assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre)
metric: str = "all"
filtered: dict = {}
if with_query:
metric, filtered = _count_manager(request)
return jsonify(scass.get_assiduites_stats(assiduites_query, metric, filtered))
@bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_create(etudid: int = None):
"""
Création d'une assiduité pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
"date_fin": str,
"etat": str,
},
{
"date_debut": str,
"date_fin": str,
"etat": str,
"moduleimpl_id": int,
"desc":str,
}
...
]
"""
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(create_list):
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
else:
success[i] = obj
db.session.commit()
return jsonify({"errors": errors, "success": success})
@bp.route("/assiduites/create", methods=["POST"])
@api_web_bp.route("/assiduites/create", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduites_create():
"""
Création d'une assiduité ou plusieurs assiduites
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
"date_fin": str,
"etat": str,
"etudid":int,
},
{
"date_debut": str,
"date_fin": str,
"etat": str,
"etudid":int,
"moduleimpl_id": int,
"desc":str,
}
...
]
"""
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(create_list):
etud: Identite = Identite.query.filter_by(id=data["etudid"]).first()
if etud is None:
errors[i] = "Cet étudiant n'existe pas."
continue
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
else:
success[i] = obj
return jsonify({"errors": errors, "success": success})
def _create_singular(
data: dict,
etud: Identite,
) -> tuple[int, object]:
errors: list[str] = []
# -- vérifications de l'objet json --
# cas 1 : ETAT
etat = data.get("etat", None)
if etat is None:
errors.append("param 'etat': manquant")
elif not scu.EtatAssiduite.contains(etat):
errors.append("param 'etat': invalide")
etat = scu.EtatAssiduite.get(etat)
# cas 2 : date_debut
date_debut = data.get("date_debut", None)
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut, convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 3 : date_fin
date_fin = data.get("date_fin", None)
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin, convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
# cas 4 : moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
# cas 5 : desc
desc: str = data.get("desc", None)
if errors:
err: str = ", ".join(errors)
return (404, err)
# TOUT EST OK
try:
nouv_assiduite: Assiduite = Assiduite.create_assiduite(
date_debut=deb,
date_fin=fin,
etat=etat,
etud=etud,
moduleimpl=moduleimpl,
description=desc,
user_id=current_user.id,
)
db.session.add(nouv_assiduite)
db.session.commit()
return (200, {"assiduite_id": nouv_assiduite.id})
except ScoValueError as excp:
return (
404,
excp.args[0],
)
@bp.route("/assiduite/delete", methods=["POST"])
@api_web_bp.route("/assiduite/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_delete():
"""
Suppression d'une assiduité à partir de son id
Forme des données envoyées :
[
<assiduite_id:int>,
...
]
"""
assiduites_list: list[int] = request.get_json(force=True)
if not isinstance(assiduites_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
output = {"errors": {}, "success": {}}
for i, ass in enumerate(assiduites_list):
code, msg = _delete_singular(ass, db)
if code == 404:
output["errors"][f"{i}"] = msg
else:
output["success"][f"{i}"] = {"OK": True}
db.session.commit()
return jsonify(output)
def _delete_singular(assiduite_id: int, database):
assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first()
if assiduite_unique is None:
return (404, "Assiduite non existante")
database.session.delete(assiduite_unique)
return (200, "OK")
@bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
@api_web_bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_edit(assiduite_id: int):
"""
Edition d'une assiduité à partir de son id
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"moduleimpl_id"?: int
"desc"?: str
"est_just"?: bool
}
"""
assiduite_unique: Assiduite = Assiduite.query.filter_by(
id=assiduite_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatAssiduite.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
assiduite_unique.etat = etat
# Cas 2 : Moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
if moduleimpl_id is not None:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
else:
if not moduleimpl.est_inscrit(
Identite.query.filter_by(id=assiduite_unique.etudid).first()
):
errors.append("param 'moduleimpl_id': etud non inscrit")
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
# Cas 3 : desc
desc = data.get("desc", False)
if desc is not False:
assiduite_unique.desc = desc
# Cas 4 : est_just
est_just = data.get("est_just")
if est_just is not None:
if not isinstance(est_just, bool):
errors.append("param 'est_just' : booléen non reconnu")
else:
assiduite_unique.est_just = est_just
if errors:
err: str = ", ".join(errors)
return json_error(404, err)
db.session.add(assiduite_unique)
db.session.commit()
return jsonify({"OK": True})
@bp.route("/assiduites/edit", methods=["POST"])
@api_web_bp.route("/assiduites/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduites_edit():
"""
Edition d'une assiduité à partir de son id
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"moduleimpl_id"?: int
"desc"?: str
"est_just"?: bool
}
"""
edit_list: list[object] = request.get_json(force=True)
if not isinstance(edit_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(edit_list):
assi: Identite = Assiduite.query.filter_by(id=data["assiduite_id"]).first()
if assi is None:
errors[i] = "Cet assiduité n'existe pas."
continue
code, obj = _edit_singular(assi, data)
if code == 404:
errors[i] = obj
else:
success[i] = obj
db.session.commit()
return jsonify({"errors": errors, "success": success})
def _edit_singular(assiduite_unique, data):
errors: list[str] = []
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatAssiduite.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
assiduite_unique.etat = etat
# Cas 2 : Moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
if moduleimpl_id is not None:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
else:
if not moduleimpl.est_inscrit(
Identite.query.filter_by(id=assiduite_unique.etudid).first()
):
errors.append("param 'moduleimpl_id': etud non inscrit")
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
# Cas 3 : desc
desc = data.get("desc", False)
if desc is not False:
assiduite_unique.desc = desc
# Cas 4 : est_just
est_just = data.get("est_just")
if est_just is not None:
if not isinstance(est_just, bool):
errors.append("param 'est_just' : booléen non reconnu")
else:
assiduite_unique.est_just = est_just
if errors:
err: str = ", ".join(errors)
return (404, err)
db.session.add(assiduite_unique)
return (200, "OK")
# -- Utils --
def _count_manager(requested) -> tuple[str, dict]:
"""
Retourne la/les métriques à utiliser ainsi que le filtre donnés en query de la requête
"""
filtered: dict = {}
# cas 1 : etat assiduite
etat = requested.args.get("etat")
if etat is not None:
filtered["etat"] = etat
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
if deb is not None:
filtered["date_debut"] = deb
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if fin is not None:
filtered["date_fin"] = fin
# cas 4 : moduleimpl_id
module = requested.args.get("moduleimpl_id", False)
try:
if module is False:
raise ValueError
if module != "":
module = int(module)
else:
module = None
except ValueError:
module = False
if module is not False:
filtered["moduleimpl_id"] = module
# cas 5 : formsemestre_id
formsemestre_id = requested.args.get("formsemestre_id")
if formsemestre_id is not None:
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
filtered["formsemestre"] = formsemestre
# cas 6 : type
metric = requested.args.get("metric", "all")
# cas 7 : est_just
est_just: str = requested.args.get("est_just")
if est_just is not None:
trues: tuple[str] = ("v", "t", "vrai", "true")
falses: tuple[str] = ("f", "faux", "false")
if est_just.lower() in trues:
filtered["est_just"] = True
elif est_just.lower() in falses:
filtered["est_just"] = False
# cas 8 : user_id
user_id = requested.args.get("user_id", False)
if user_id is not False:
filtered["user_id"] = user_id
return (metric, filtered)
def _filter_manager(requested, assiduites_query: Assiduite):
"""
Retourne les assiduites entrées filtrées en fonction de la request
"""
# cas 1 : etat assiduite
etat = requested.args.get("etat")
if etat is not None:
assiduites_query = scass.filter_assiduites_by_etat(assiduites_query, etat)
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if (deb, fin) != (None, None):
assiduites_query: Assiduite = scass.filter_by_date(
assiduites_query, Assiduite, deb, fin
)
# cas 4 : moduleimpl_id
module = requested.args.get("moduleimpl_id", False)
try:
if module is False:
raise ValueError
if module != "":
module = int(module)
else:
module = None
except ValueError:
module = False
if module is not False:
assiduites_query = scass.filter_by_module_impl(assiduites_query, module)
# cas 5 : formsemestre_id
formsemestre_id = requested.args.get("formsemestre_id")
if formsemestre_id is not None:
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre)
# cas 6 : est_just
est_just: str = requested.args.get("est_just")
if est_just is not None:
trues: tuple[str] = ("v", "t", "vrai", "true")
falses: tuple[str] = ("f", "faux", "false")
if est_just.lower() in trues:
assiduites_query: Assiduite = scass.filter_assiduites_by_est_just(
assiduites_query, True
)
elif est_just.lower() in falses:
assiduites_query: Assiduite = scass.filter_assiduites_by_est_just(
assiduites_query, False
)
# cas 8 : user_id
user_id = requested.args.get("user_id", False)
if user_id is not False:
assiduites_query: Assiduite = scass.filter_by_user_id(assiduites_query, user_id)
return assiduites_query

73
app/api/etudiants.py Normal file → Executable file
View File

@ -8,17 +8,16 @@
API : accès aux étudiants API : accès aux étudiants
""" """
from datetime import datetime from datetime import datetime
from operator import attrgetter
from flask import g, request from flask import g, request
from flask_json import as_json from flask_json import as_json
from flask_login import current_user from flask_login import current_user
from flask_login import login_required from flask_login import login_required
from sqlalchemy import desc, func, or_ from sqlalchemy import desc, or_
from sqlalchemy.dialects.postgresql import VARCHAR
import app import app
from app.api import api_bp as bp, api_web_bp from app.api import api_bp as bp, api_web_bp
from app.scodoc.sco_utils import json_error
from app.api import tools from app.api import tools
from app.decorators import scodoc, permission_required from app.decorators import scodoc, permission_required
from app.models import ( from app.models import (
@ -32,8 +31,7 @@ from app.scodoc import sco_bulletins
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error, suppress_accents import app.scodoc.sco_photos as sco_photos
# Un exemple: # Un exemple:
# @bp.route("/api_function/<int:arg>") # @bp.route("/api_function/<int:arg>")
@ -136,6 +134,42 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
return etud.to_dict_api() return etud.to_dict_api()
@api_web_bp.route("/etudiant/etudid/<int:etudid>/photo")
@api_web_bp.route("/etudiant/nip/<string:nip>/photo")
@api_web_bp.route("/etudiant/ine/<string:ine>/photo")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
"""
Retourne la photo de l'étudiant
correspondant ou un placeholder si non existant.
etudid : l'etudid de l'étudiant
nip : le code nip de l'étudiant
ine : le code ine de l'étudiant
Attention : Ne peut être qu'utilisée en tant que route de département
"""
etud = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
if not etudid:
filename = sco_photos.UNKNOWN_IMAGE_PATH
size = request.args.get("size", "orig")
filename = sco_photos.photo_pathname(etud.photo_filename, size=size)
if not filename:
filename = sco_photos.UNKNOWN_IMAGE_PATH
res = sco_photos.build_image_response(filename)
return res
@bp.route("/etudiants/etudid/<int:etudid>", methods=["GET"]) @bp.route("/etudiants/etudid/<int:etudid>", methods=["GET"])
@bp.route("/etudiants/nip/<string:nip>", methods=["GET"]) @bp.route("/etudiants/nip/<string:nip>", methods=["GET"])
@bp.route("/etudiants/ine/<string:ine>", methods=["GET"]) @bp.route("/etudiants/ine/<string:ine>", methods=["GET"])
@ -167,39 +201,12 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
) )
if not None in allowed_depts: if not None in allowed_depts:
# restreint aux départements autorisés: # restreint aux départements autorisés:
query = query.join(Departement).filter( etuds = etuds.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts) or_(Departement.acronym == acronym for acronym in allowed_depts)
) )
return [etud.to_dict_api() for etud in query] return [etud.to_dict_api() for etud in query]
@bp.route("/etudiants/name/<string:start>")
@api_web_bp.route("/etudiants/name/<string:start>")
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants_by_name(start: str = "", min_len=3, limit=32):
"""Liste des étudiants dont le nom débute par start.
Si start fait moins de min_len=3 caractères, liste vide.
La casse et les accents sont ignorés.
"""
if len(start) < min_len:
return []
start = suppress_accents(start).lower()
query = Identite.query.filter(
func.lower(func.unaccent(Identite.nom, type_=VARCHAR)).ilike(start + "%")
)
allowed_depts = current_user.get_depts_with_permission(Permission.ScoView)
if not None in allowed_depts:
# restreint aux départements autorisés:
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit)
# Note: on raffine le tri pour les caractères spéciaux et nom usuel ici:
return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))]
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres") @bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@bp.route("/etudiant/nip/<string:nip>/formsemestres") @bp.route("/etudiant/nip/<string:nip>/formsemestres")
@bp.route("/etudiant/ine/<string:ine>/formsemestres") @bp.route("/etudiant/ine/<string:ine>/formsemestres")

View File

@ -8,7 +8,7 @@
ScoDoc 9 API : accès aux évaluations ScoDoc 9 API : accès aux évaluations
""" """
from flask import g, request from flask import g
from flask_json import as_json from flask_json import as_json
from flask_login import login_required from flask_login import login_required
@ -17,7 +17,7 @@ import app
from app.api import api_bp as bp, api_web_bp from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required from app.decorators import scodoc, permission_required
from app.models import Evaluation, ModuleImpl, FormSemestre from app.models import Evaluation, ModuleImpl, FormSemestre
from app.scodoc import sco_evaluation_db, sco_saisie_notes from app.scodoc import sco_evaluation_db
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -28,7 +28,7 @@ import app.scodoc.sco_utils as scu
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def evaluation(evaluation_id: int): def the_eval(evaluation_id: int):
"""Description d'une évaluation. """Description d'une évaluation.
{ {
@ -93,22 +93,24 @@ def evaluations(moduleimpl_id: int):
@as_json @as_json
def evaluation_notes(evaluation_id: int): def evaluation_notes(evaluation_id: int):
""" """
Retourne la liste des notes de l'évaluation Retourne la liste des notes à partir de l'id d'une évaluation donnée
evaluation_id : l'id de l'évaluation evaluation_id : l'id d'une évaluation
Exemple de résultat : Exemple de résultat :
{ {
"11": { "1": {
"etudid": 11, "id": 1,
"etudid": 10,
"evaluation_id": 1, "evaluation_id": 1,
"value": 15.0, "value": 15.0,
"comment": "", "comment": "",
"date": "Wed, 20 Apr 2022 06:49:05 GMT", "date": "Wed, 20 Apr 2022 06:49:05 GMT",
"uid": 2 "uid": 2
}, },
"12": { "2": {
"etudid": 12, "id": 2,
"etudid": 1,
"evaluation_id": 1, "evaluation_id": 1,
"value": 12.0, "value": 12.0,
"comment": "", "comment": "",
@ -126,8 +128,8 @@ def evaluation_notes(evaluation_id: int):
.filter_by(dept_id=g.scodoc_dept_id) .filter_by(dept_id=g.scodoc_dept_id)
) )
evaluation = query.first_or_404() the_eval = query.first_or_404()
dept = evaluation.moduleimpl.formsemestre.departement dept = the_eval.moduleimpl.formsemestre.departement
app.set_sco_dept(dept.acronym) app.set_sco_dept(dept.acronym)
notes = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) notes = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
@ -135,49 +137,7 @@ def evaluation_notes(evaluation_id: int):
# "ABS", "EXC", etc mais laisse les notes sur le barème de l'éval. # "ABS", "EXC", etc mais laisse les notes sur le barème de l'éval.
note = notes[etudid] note = notes[etudid]
note["value"] = scu.fmt_note(note["value"], keep_numeric=True) note["value"] = scu.fmt_note(note["value"], keep_numeric=True)
note["note_max"] = evaluation.note_max note["note_max"] = the_eval.note_max
del note["id"] del note["id"]
# in JS, keys must be string, not integers return notes
return {str(etudid): note for etudid, note in notes.items()}
@bp.route("/evaluation/<int:evaluation_id>/notes/set", methods=["POST"])
@api_web_bp.route("/evaluation/<int:evaluation_id>/notes/set", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEnsView)
@as_json
def evaluation_set_notes(evaluation_id: int):
"""Écriture de notes dans une évaluation.
The request content type should be "application/json",
and contains:
{
'notes' : [ [etudid, value], ... ],
'comment' : optional string
}
Result:
- nb_changed: nombre de notes changées
- nb_suppress: nombre de notes effacées
- etudids_with_decision: liste des etudiants dont la note a changé
alors qu'ils ont une décision de jury enregistrée.
"""
query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept:
query = (
query.join(ModuleImpl)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
)
evaluation = query.first_or_404()
dept = evaluation.moduleimpl.formsemestre.departement
app.set_sco_dept(dept.acronym)
data = request.get_json(force=True) # may raise 400 Bad Request
notes = data.get("notes")
if notes is None:
return scu.json_error(404, "no notes")
if not isinstance(notes, list):
return scu.json_error(404, "invalid notes argument (must be a list)")
return sco_saisie_notes.save_notes(
evaluation, notes, comment=data.get("comment", "")
)

View File

@ -5,38 +5,19 @@
############################################################################## ##############################################################################
""" """
ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions ScoDoc 9 API : jury WIP
""" """
import datetime
from flask import flash, g, request, url_for
from flask_json import as_json from flask_json import as_json
from flask_login import current_user, login_required from flask_login import login_required
import app import app
from app import db, log from app.api import api_bp as bp, api_web_bp
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR, tools
from app.decorators import scodoc, permission_required from app.decorators import scodoc, permission_required
from app.scodoc.sco_exceptions import ScoException from app.scodoc.sco_exceptions import ScoException
from app.but import jury_but_results from app.but import jury_but_results
from app.models import ( from app.models import FormSemestre
ApcParcours,
ApcValidationAnnee,
ApcValidationRCUE,
Formation,
FormSemestre,
Identite,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
ScolarNews,
Scolog,
UniteEns,
)
from app.scodoc import codes_cursus
from app.scodoc import sco_cache
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@bp.route("/formsemestre/<int:formsemestre_id>/decisions_jury") @bp.route("/formsemestre/<int:formsemestre_id>/decisions_jury")
@ -48,304 +29,10 @@ from app.scodoc.sco_utils import json_error
def decisions_jury(formsemestre_id: int): def decisions_jury(formsemestre_id: int):
"""Décisions du jury des étudiants du formsemestre.""" """Décisions du jury des étudiants du formsemestre."""
# APC, pair: # APC, pair:
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
if formsemestre.formation.is_apc(): if formsemestre.formation.is_apc():
app.set_sco_dept(formsemestre.departement.acronym) app.set_sco_dept(formsemestre.departement.acronym)
rows = jury_but_results.get_jury_but_results(formsemestre) rows = jury_but_results.get_jury_but_results(formsemestre)
return rows return rows
else: else:
raise ScoException("non implemente") raise ScoException("non implemente")
def _news_delete_jury_etud(etud: Identite):
"génère news sur effacement décision"
# n'utilise pas g.scodoc_dept, pas toujours dispo en mode API
url = url_for(
"scolar.ficheEtud", scodoc_dept=etud.departement.acronym, etudid=etud.id
)
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=etud.id,
text=f"""Suppression décision jury pour <a href="{url}">{etud.nomprenom}</a>""",
url=url,
)
@bp.route(
"/etudiant/<int:etudid>/jury/validation_ue/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_ue/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def validation_ue_delete(etudid: int, validation_id: int):
"Efface cette validation"
return _validation_ue_delete(etudid, validation_id)
@bp.route(
"/etudiant/<int:etudid>/jury/validation_formsemestre/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_formsemestre/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def validation_formsemestre_delete(etudid: int, validation_id: int):
"Efface cette validation"
# c'est la même chose (formations classiques)
return _validation_ue_delete(etudid, validation_id)
def _validation_ue_delete(etudid: int, validation_id: int):
"Efface cette validation (semestres classiques ou UEs)"
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ScolarFormSemestreValidation.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
# Vérification de la permission:
# A le droit de supprimer cette validation: le chef de dept ou quelqu'un ayant
# le droit de saisir des décisions de jury dans le formsemestre concerné s'il y en a un
# (c'est le cas pour les validations de jury, mais pas pour les "antérieures" non
# rattachées à un formsemestre)
if not g.scodoc_dept: # accès API
if not current_user.has_permission(Permission.ScoEtudInscrit):
return json_error(403, "opération non autorisée (117)")
else:
if validation.formsemestre:
if (
validation.formsemestre.dept_id != g.scodoc_dept_id
) or not validation.formsemestre.can_edit_jury():
return json_error(403, "opération non autorisée (123)")
elif not current_user.has_permission(Permission.ScoEtudInscrit):
# Validation non rattachée à un semestre: on doit être chef
return json_error(403, "opération non autorisée (126)")
log(f"validation_ue_delete: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud)
return "ok"
@bp.route(
"/etudiant/<int:etudid>/jury/autorisation_inscription/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/autorisation_inscription/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudInscrit)
@as_json
def autorisation_inscription_delete(etudid: int, validation_id: int):
"Efface cette validation"
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ScolarAutorisationInscription.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
log(f"autorisation_inscription_delete: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud)
return "ok"
@bp.route(
"/etudiant/<int:etudid>/jury/validation_rcue/record",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_rcue/record",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudInscrit)
@as_json
def validation_rcue_record(etudid: int):
"""Enregistre une validation de RCUE.
Si une validation existe déjà pour ce RCUE, la remplace.
The request content type should be "application/json":
{
"code" : str,
"ue1_id" : int,
"ue2_id" : int,
// Optionnel:
"formsemestre_id" : int,
"date" : date_iso, // si non spécifié, now()
"parcours_id" :int,
}
"""
etud = tools.get_etud(etudid)
if etud is None:
return json_error(404, "étudiant inconnu")
data = request.get_json(force=True) # may raise 400 Bad Request
code = data.get("code")
if code is None:
return json_error(API_CLIENT_ERROR, "missing argument: code")
if code not in codes_cursus.CODES_JURY_RCUE:
return json_error(API_CLIENT_ERROR, "invalid code value")
ue1_id = data.get("ue1_id")
if ue1_id is None:
return json_error(API_CLIENT_ERROR, "missing argument: ue1_id")
try:
ue1_id = int(ue1_id)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid value for ue1_id")
ue2_id = data.get("ue2_id")
if ue2_id is None:
return json_error(API_CLIENT_ERROR, "missing argument: ue2_id")
try:
ue2_id = int(ue2_id)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid value for ue2_id")
formsemestre_id = data.get("formsemestre_id")
date_validation_str = data.get("date", datetime.datetime.now().isoformat())
parcours_id = data.get("parcours_id")
#
query = UniteEns.query.filter_by(id=ue1_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue1: UniteEns = query.first_or_404()
query = UniteEns.query.filter_by(id=ue2_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue2: UniteEns = query.first_or_404()
if ue1.niveau_competence_id != ue2.niveau_competence_id:
return json_error(
API_CLIENT_ERROR, "UEs non associees au meme niveau de competence"
)
if formsemestre_id is not None:
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()
if (formsemestre.formation_id != ue1.formation_id) or (
formsemestre.formation_id != ue2.formation_id
):
return json_error(
API_CLIENT_ERROR, "ues et semestre ne sont pas de la meme formation"
)
else:
formsemestre = None
try:
date_validation = datetime.datetime.fromisoformat(date_validation_str)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid date string")
if parcours_id is not None:
parcours: ApcParcours = ApcParcours.query.get_or_404(parcours_id)
if parcours.referentiel_id != ue1.niveau_competence.competence.referentiel_id:
return json_error(API_CLIENT_ERROR, "niveau et parcours incompatibles")
# Une validation pour ce niveau de compétence existe-elle ?
validation = (
ApcValidationRCUE.query.filter_by(etudid=etudid)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
.filter_by(niveau_competence_id=ue2.niveau_competence_id)
.first()
)
if validation:
validation.code = code
validation.date = date_validation
validation.formsemestre_id = formsemestre_id
validation.parcours_id = parcours_id
validation.ue1_id = ue1_id
validation.ue2_id = ue2_id
operation = "update"
else:
validation = ApcValidationRCUE(
code=code,
date=date_validation,
etudid=etudid,
formsemestre_id=formsemestre_id,
parcours_id=parcours_id,
ue1_id=ue1_id,
ue2_id=ue2_id,
)
operation = "record"
db.session.add(validation)
# invalider bulletins (les autres résultats ne dépendent pas des RCUEs):
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
Scolog.logdb(
method="validation_rcue_record",
etudid=etudid,
msg=f"Enregistrement {validation}",
commit=True,
)
log(f"{operation} {validation}")
return validation.to_dict()
@bp.route(
"/etudiant/<int:etudid>/jury/validation_rcue/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_rcue/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudInscrit)
@as_json
def validation_rcue_delete(etudid: int, validation_id: int):
"Efface cette validation"
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ApcValidationRCUE.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
log(f"validation_ue_delete: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud)
return "ok"
@bp.route(
"/etudiant/<int:etudid>/jury/validation_annee_but/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_annee_but/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudInscrit)
@as_json
def validation_annee_but_delete(etudid: int, validation_id: int):
"Efface cette validation"
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ApcValidationAnnee.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
log(f"validation_annee_but: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud)
return "ok"

596
app/api/justificatifs.py Normal file
View File

@ -0,0 +1,596 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask import g, jsonify, request
from flask_login import login_required, current_user
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object
from app.decorators import permission_required, scodoc
from app.models import Identite, Justificatif
from app.models.assiduites import compute_assiduites_justified
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
# Partie Modèle
@bp.route("/justificatif/<int:justif_id>")
@api_web_bp.route("/justificatif/<int:justif_id>")
@scodoc
@permission_required(Permission.ScoView)
def justificatif(justif_id: int = None):
"""Retourne un objet justificatif à partir de son id
Exemple de résultat:
{
"justif_id": 1,
"etudid": 2,
"date_debut": "2022-10-31T08:00+01:00",
"date_fin": "2022-10-31T10:00+01:00",
"etat": "valide",
"fichier": "archive_id",
"raison": "une raison",
"entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null,
}
"""
return get_model_api_object(Justificatif, justif_id, Identite)
@bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def justificatifs(etudid: int = None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /justificatifs/<int:etudid>
Un filtrage peut être donné avec une query
chemin : /justificatifs/<int:etudid>/query?
Les différents filtres :
Etat (etat du justificatif -> validé, non validé, modifé, en attente):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=validé,modifié
Date debut
(date de début du justificatif, sont affichés les justificatifs
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin du justificatif, sont affichés les justificatifs
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
user_id (l'id de l'auteur du justificatif)
query?user_id=[int]
ex query?user_id=3
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
justificatifs_query = etud.justificatifs
if with_query:
justificatifs_query = _filter_manager(request, justificatifs_query)
data_set: list[dict] = []
for just in justificatifs_query.all():
data = just.to_dict(format_api=True)
data_set.append(data)
return jsonify(data_set)
@bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_create(etudid: int = None):
"""
Création d'un justificatif pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
"date_fin": str,
"etat": str,
},
{
"date_debut": str,
"date_fin": str,
"etat": str,
"raison":str,
}
...
]
"""
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(create_list):
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
else:
success[i] = obj
compute_assiduites_justified(Justificatif.query.filter_by(etudid=etudid), True)
return jsonify({"errors": errors, "success": success})
def _create_singular(
data: dict,
etud: Identite,
) -> tuple[int, object]:
errors: list[str] = []
# -- vérifications de l'objet json --
# cas 1 : ETAT
etat = data.get("etat", None)
if etat is None:
errors.append("param 'etat': manquant")
elif not scu.EtatJustificatif.contains(etat):
errors.append("param 'etat': invalide")
etat = scu.EtatJustificatif.get(etat)
# cas 2 : date_debut
date_debut = data.get("date_debut", None)
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut, convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 3 : date_fin
date_fin = data.get("date_fin", None)
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin, convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
# cas 4 : raison
raison: str = data.get("raison", None)
if errors:
err: str = ", ".join(errors)
return (404, err)
# TOUT EST OK
try:
nouv_justificatif: Justificatif = Justificatif.create_justificatif(
date_debut=deb,
date_fin=fin,
etat=etat,
etud=etud,
raison=raison,
user_id=current_user.id,
)
db.session.add(nouv_justificatif)
db.session.commit()
return (
200,
{
"justif_id": nouv_justificatif.id,
"couverture": scass.justifies(nouv_justificatif),
},
)
except ScoValueError as excp:
return (
404,
excp.args[0],
)
@bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_edit(justif_id: int):
"""
Edition d'un justificatif à partir de son id
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"raison"?: str
"date_debut"?: str
"date_fin"?: str
}
"""
justificatif_unique: Justificatif = Justificatif.query.filter_by(
id=justif_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
avant_ids: list[int] = scass.justifies(justificatif_unique)
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatJustificatif.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
justificatif_unique.etat = etat
# Cas 2 : raison
raison = data.get("raison", False)
if raison is not False:
justificatif_unique.raison = raison
deb, fin = None, None
# cas 3 : date_debut
date_debut = data.get("date_debut", False)
if date_debut is not False:
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut.replace(" ", "+"), convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
if justificatif_unique.date_fin >= deb:
errors.append("param 'date_debut': date de début située après date de fin ")
# cas 4 : date_fin
date_fin = data.get("date_fin", False)
if date_fin is not False:
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin.replace(" ", "+"), convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
if justificatif_unique.date_debut <= fin:
errors.append("param 'date_fin': date de fin située avant date de début ")
# Mise à jour des dates
deb = deb if deb is not None else justificatif_unique.date_debut
fin = fin if fin is not None else justificatif_unique.date_fin
justificatif_unique.date_debut = deb
justificatif_unique.date_fin = fin
if errors:
err: str = ", ".join(errors)
return json_error(404, err)
db.session.add(justificatif_unique)
db.session.commit()
return jsonify(
{
"couverture": {
"avant": avant_ids,
"après": compute_assiduites_justified(
Justificatif.query.filter_by(etudid=justificatif_unique.etudid),
True,
),
}
}
)
@bp.route("/justificatif/delete", methods=["POST"])
@api_web_bp.route("/justificatif/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_delete():
"""
Suppression d'un justificatif à partir de son id
Forme des données envoyées :
[
<justif_id:int>,
...
]
"""
justificatifs_list: list[int] = request.get_json(force=True)
if not isinstance(justificatifs_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
output = {"errors": {}, "success": {}}
for i, ass in enumerate(justificatifs_list):
code, msg = _delete_singular(ass, db)
if code == 404:
output["errors"][f"{i}"] = msg
else:
output["success"][f"{i}"] = {"OK": True}
db.session.commit()
return jsonify(output)
def _delete_singular(justif_id: int, database):
justificatif_unique: Justificatif = Justificatif.query.filter_by(
id=justif_id
).first()
if justificatif_unique is None:
return (404, "Justificatif non existant")
archive_name: str = justificatif_unique.fichier
if archive_name is not None:
archiver: JustificatifArchiver = JustificatifArchiver()
archiver.delete_justificatif(justificatif_unique.etudid, archive_name)
database.session.delete(justificatif_unique)
compute_assiduites_justified(
Justificatif.query.filter_by(etudid=justificatif_unique.etudid), True
)
return (200, "OK")
# Partie archivage
@bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_import(justif_id: int = None):
"""
Importation d'un fichier (création d'archive)
"""
if len(request.files) == 0:
return json_error(404, "Il n'y a pas de fichier joint")
file = list(request.files.values())[0]
if file.filename == "":
return json_error(404, "Il n'y a pas de fichier joint")
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
archiver: JustificatifArchiver = JustificatifArchiver()
try:
fname: str
archive_name, fname = archiver.save_justificatif(
etudid=justificatif_unique.etudid,
filename=file.filename,
data=file.stream.read(),
archive_name=archive_name,
)
justificatif_unique.fichier = archive_name
db.session.add(justificatif_unique)
db.session.commit()
return jsonify({"filename": fname})
except ScoValueError as err:
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_export(justif_id: int = None, filename: str = None):
"""
Retourne un fichier d'une archive d'un justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
if archive_name is None:
return json_error(404, "le justificatif ne possède pas de fichier")
archiver: JustificatifArchiver = JustificatifArchiver()
try:
return archiver.get_justificatif_file(
archive_name, justificatif_unique.etudid, filename
)
except ScoValueError as err:
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_remove(justif_id: int = None):
"""
Supression d'un fichier ou d'une archive
# TOTALK: Doc, expliquer les noms coté server
{
"remove": <"all"/"list">
"filenames"?: [
<filename:str>,
...
]
}
"""
data: dict = request.get_json(force=True)
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
if archive_name is None:
return json_error(404, "le justificatif ne possède pas de fichier")
remove: str = data.get("remove")
if remove is None or remove not in ("all", "list"):
return json_error(404, "param 'remove': Valeur invalide")
archiver: JustificatifArchiver = JustificatifArchiver()
etudid: int = justificatif_unique.etudid
try:
if remove == "all":
archiver.delete_justificatif(etudid=etudid, archive_name=archive_name)
justificatif_unique.fichier = None
db.session.add(justificatif_unique)
db.session.commit()
else:
for fname in data.get("filenames", []):
archiver.delete_justificatif(
etudid=etudid,
archive_name=archive_name,
filename=fname,
)
if len(archiver.list_justificatifs(archive_name, etudid)) == 0:
archiver.delete_justificatif(etudid, archive_name)
justificatif_unique.fichier = None
db.session.add(justificatif_unique)
db.session.commit()
except ScoValueError as err:
return json_error(404, err.args[0])
return jsonify({"response": "removed"})
@bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_list(justif_id: int = None):
"""
Liste les fichiers du justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
filenames: list[str] = []
archiver: JustificatifArchiver = JustificatifArchiver()
if archive_name is not None:
filenames = archiver.list_justificatifs(
archive_name, justificatif_unique.etudid
)
return jsonify(filenames)
# Partie justification
@bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_justifies(justif_id: int = None):
"""
Liste assiduite_id justifiées par le justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
assiduites_list: list[int] = scass.justifies(justificatif_unique)
return jsonify(assiduites_list)
# -- Utils --
def _filter_manager(requested, justificatifs_query):
"""
Retourne les justificatifs entrés filtrés en fonction de la request
"""
# cas 1 : etat justificatif
etat = requested.args.get("etat")
if etat is not None:
justificatifs_query = scass.filter_justificatifs_by_etat(
justificatifs_query, etat
)
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if (deb, fin) != (None, None):
justificatifs_query: Justificatif = scass.filter_by_date(
justificatifs_query, Justificatif, deb, fin
)
user_id = requested.args.get("user_id", False)
if user_id is not False:
justificatif_query: Justificatif = scass.filter_by_user_id(
justificatif_query, user_id
)
return justificatifs_query

View File

@ -12,8 +12,6 @@ from operator import attrgetter
from flask import g, request from flask import g, request
from flask_json import as_json from flask_json import as_json
from flask_login import login_required from flask_login import login_required
import sqlalchemy as sa
from sqlalchemy.exc import IntegrityError
import app import app
from app import db, log from app import db, log
@ -25,7 +23,6 @@ from app.models import GroupDescr, Partition, Scolog
from app.models.groups import group_membership from app.models.groups import group_membership
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -185,12 +182,10 @@ def set_etud_group(etudid: int, group_id: int):
if etud.id not in {e.id for e in group.partition.formsemestre.etuds}: 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") return json_error(404, "etud non inscrit au formsemestre du groupe")
try: sco_groups.change_etud_group_in_partition(
sco_groups.change_etud_group_in_partition(etudid, group) etudid, group_id, group.partition.to_dict()
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} return {"group_id": group_id, "etudid": etudid}
@ -249,25 +244,19 @@ def partition_remove_etud(partition_id: int, etudid: int):
partition = query.first_or_404() partition = query.first_or_404()
if not partition.formsemestre.etat: if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé") return json_error(403, "formsemestre verrouillé")
groups = (
db.session.execute( GroupDescr.query.filter_by(partition_id=partition_id)
sa.text( .join(group_membership)
"""DELETE FROM group_membership .filter_by(etudid=etudid)
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,
) )
for group in groups:
group.etuds.remove(etud)
Scolog.logdb(
method="partition_remove_etud",
etudid=etud.id,
msg=f"Retrait du groupe {group.group_name} de {group.partition.partition_name}",
commit=True,
)
db.session.commit() db.session.commit()
# Update parcours # Update parcours
partition.formsemestre.update_inscriptions_parcours_from_groups() partition.formsemestre.update_inscriptions_parcours_from_groups()
@ -282,7 +271,7 @@ def partition_remove_etud(partition_id: int, etudid: int):
@scodoc @scodoc
@permission_required(Permission.ScoEtudChangeGroups) @permission_required(Permission.ScoEtudChangeGroups)
@as_json @as_json
def group_create(partition_id: int): # partition-group-create def group_create(partition_id: int):
"""Création d'un groupe dans une partition """Création d'un groupe dans une partition
The request content type should be "application/json": The request content type should be "application/json":

View File

@ -35,7 +35,7 @@ def user_info(uid: int):
""" """
Info sur un compte utilisateur scodoc Info sur un compte utilisateur scodoc
""" """
user: User = db.session.get(User, uid) user: User = User.query.get(uid)
if user is None: if user is None:
return json_error(404, "user not found") return json_error(404, "user not found")
if g.scodoc_dept: if g.scodoc_dept:

View File

@ -9,7 +9,7 @@ from flask import current_app, g, redirect, request, url_for
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
import flask_login import flask_login
from app import db, login from app import login
from app.auth.models import User from app.auth.models import User
from app.models.config import ScoDocSiteConfig from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import json_error
@ -39,7 +39,7 @@ def basic_auth_error(status):
@login.user_loader @login.user_loader
def load_user(uid: str) -> User: def load_user(uid: str) -> User:
"flask-login: accès à un utilisateur" "flask-login: accès à un utilisateur"
return db.session.get(User, int(uid)) return User.query.get(int(uid))
@token_auth.verify_token @token_auth.verify_token

View File

@ -225,7 +225,7 @@ class User(UserMixin, db.Model):
return None return None
except (TypeError, KeyError): except (TypeError, KeyError):
return None return None
return db.session.get(User, user_id) return User.query.get(user_id)
def to_dict(self, include_email=True): def to_dict(self, include_email=True):
"""l'utilisateur comme un dict, avec des champs supplémentaires""" """l'utilisateur comme un dict, avec des champs supplémentaires"""
@ -376,9 +376,7 @@ class User(UserMixin, db.Model):
""" """
if not isinstance(role, Role): if not isinstance(role, Role):
raise ScoValueError("add_role: rôle invalide") raise ScoValueError("add_role: rôle invalide")
user_role = UserRole(user=self, role=role, dept=dept) self.user_roles.append(UserRole(user=self, role=role, dept=dept))
db.session.add(user_role)
self.user_roles.append(user_role)
def add_roles(self, roles: "list[Role]", dept: str): def add_roles(self, roles: "list[Role]", dept: str):
"""Add roles to this user. """Add roles to this user.

View File

@ -12,7 +12,6 @@ import datetime
import numpy as np import numpy as np
from flask import g, has_request_context, url_for from flask import g, has_request_context, url_for
from app import db
from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_but import ResultatsSemestreBUT
from app.models import Evaluation, FormSemestre, Identite from app.models import Evaluation, FormSemestre, Identite
from app.models.groups import GroupDescr from app.models.groups import GroupDescr
@ -159,7 +158,7 @@ class BulletinBUT:
[etud.id] [etud.id]
].iterrows(): ].iterrows():
if codes_cursus.code_ue_validant(ue_capitalisee.code): if codes_cursus.code_ue_validant(ue_capitalisee.code):
ue = db.session.get(UniteEns, ue_capitalisee.ue_id) # XXX cacher ? ue = UniteEns.query.get(ue_capitalisee.ue_id) # XXX cacher ?
# déjà capitalisé ? montre la meilleure # déjà capitalisé ? montre la meilleure
if ue.acronyme in d: if ue.acronyme in d:
moy_cap = d[ue.acronyme]["moyenne_num"] or 0.0 moy_cap = d[ue.acronyme]["moyenne_num"] or 0.0

View File

@ -189,9 +189,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
moy_ue = moy_ue.get("value", "-") if moy_ue is not None else "-" moy_ue = moy_ue.get("value", "-") if moy_ue is not None else "-"
t = { t = {
"titre": f"{ue_acronym} - {ue['titre']}", "titre": f"{ue_acronym} - {ue['titre']}",
"moyenne": Paragraph( "moyenne": Paragraph(f"""<para align=right><b>{moy_ue}</b></para>"""),
f"""<para align=right><b>{moy_ue or "-"}</b></para>"""
),
"_css_row_class": "note_bold", "_css_row_class": "note_bold",
"_pdf_row_markup": ["b"], "_pdf_row_markup": ["b"],
"_pdf_style": [ "_pdf_style": [

View File

@ -14,14 +14,17 @@ Classe raccordant avec ScoDoc 7:
""" """
import collections import collections
from operator import attrgetter from typing import Union
from flask import g, url_for from flask import g, url_for
from app import db, log from app import db
from app import log
from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.comp import res_sem
from app.models.but_refcomp import ( from app.models.but_refcomp import (
ApcAnneeParcours, ApcAnneeParcours,
ApcCompetence, ApcCompetence,
@ -34,6 +37,7 @@ from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import ( from app.models.but_validations import (
ApcValidationAnnee, ApcValidationAnnee,
ApcValidationRCUE, ApcValidationRCUE,
RegroupementCoherentUE,
) )
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.formations import Formation from app.models.formations import Formation
@ -41,8 +45,7 @@ from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus as sco_codes from app.scodoc import codes_cursus as sco_codes
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD from app.scodoc.codes_cursus import RED, UE_STANDARD
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
@ -69,7 +72,6 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
class EtudCursusBUT: class EtudCursusBUT:
"""L'état de l'étudiant dans son cursus BUT """L'état de l'étudiant dans son cursus BUT
Liste des niveaux validés/à valider Liste des niveaux validés/à valider
(utilisé pour le résumé sur la fiche étudiant)
""" """
def __init__(self, etud: Identite, formation: Formation): def __init__(self, etud: Identite, formation: Formation):
@ -101,8 +103,8 @@ class EtudCursusBUT:
"Liste des inscriptions aux sem. de la formation, triées par indice et chronologie" "Liste des inscriptions aux sem. de la formation, triées par indice et chronologie"
self.parcour: ApcParcours = self.inscriptions[-1].parcour self.parcour: ApcParcours = self.inscriptions[-1].parcour
"Le parcours à valider: celui du DERNIER semestre suivi (peut être None)" "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
self.niveaux_by_annee: dict[int, list[ApcNiveau]] = {} self.niveaux_by_annee = {}
"{ annee:int : liste des niveaux à valider }" "{ annee : liste des niveaux à valider }"
self.niveaux: dict[int, ApcNiveau] = {} self.niveaux: dict[int, ApcNiveau] = {}
"cache les niveaux" "cache les niveaux"
for annee in (1, 2, 3): for annee in (1, 2, 3):
@ -116,6 +118,21 @@ class EtudCursusBUT:
self.niveaux.update( self.niveaux.update(
{niveau.id: niveau for niveau in self.niveaux_by_annee[annee]} {niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
) )
# Probablement inutile:
# # Cherche les validations de jury enregistrées pour chaque niveau
# self.validations_by_niveau = collections.defaultdict(lambda: [])
# " { niveau_id : [ ApcValidationRCUE ] }"
# for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# self.validations_by_niveau[validation_rcue.niveau().id].append(
# validation_rcue
# )
# self.validation_by_niveau = {
# niveau_id: sorted(
# validations, key=lambda v: sco_codes.BUT_CODES_ORDERED[v.code]
# )[0]
# for niveau_id, validations in self.validations_by_niveau.items()
# }
# "{ niveau_id : meilleure validation pour ce niveau }"
self.validation_par_competence_et_annee = {} self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }""" """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
@ -128,8 +145,8 @@ class EtudCursusBUT:
).get(validation_rcue.annee()) ).get(validation_rcue.annee())
# prend la "meilleure" validation # prend la "meilleure" validation
if (not previous_validation) or ( if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code] sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation.code] > sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
): ):
self.validation_par_competence_et_annee[niveau.competence.id][ self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee niveau.annee
@ -189,28 +206,6 @@ class EtudCursusBUT:
) )
return d return d
def competence_annee_has_niveau(self, competence_id: int, annee: str) -> bool:
"vrai si la compétence à un niveau dans cette annee ('BUT1') pour le parcour de cet etud"
# slow, utile pour affichage fiche
return annee in [n.annee for n in self.competences[competence_id].niveaux]
def load_validation_by_niveau(self) -> dict[int, list[ApcValidationRCUE]]:
"""Cherche les validations de jury enregistrées pour chaque niveau
Résultat: { niveau_id : [ ApcValidationRCUE ] }
meilleure validation pour ce niveau
"""
validations_by_niveau = collections.defaultdict(lambda: [])
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=self.etud):
validations_by_niveau[validation_rcue.niveau().id].append(validation_rcue)
validation_by_niveau = {
niveau_id: sorted(
validations, key=lambda v: sco_codes.BUT_CODES_ORDER[v.code]
)[0]
for niveau_id, validations in validations_by_niveau.items()
if validations
}
return validation_by_niveau
class FormSemestreCursusBUT: class FormSemestreCursusBUT:
"""L'état des étudiants d'un formsemestre dans leur cursus BUT """L'état des étudiants d'un formsemestre dans leur cursus BUT
@ -251,9 +246,7 @@ class FormSemestreCursusBUT:
parcour = None parcour = None
else: else:
if parcour_id not in self.parcours_by_id: if parcour_id not in self.parcours_by_id:
self.parcours_by_id[parcour_id] = db.session.get( self.parcours_by_id[parcour_id] = ApcParcours.query.get(parcour_id)
ApcParcours, parcour_id
)
parcour = self.parcours_by_id[parcour_id] parcour = self.parcours_by_id[parcour_id]
return self.get_niveaux_parcours_by_annee(parcour) return self.get_niveaux_parcours_by_annee(parcour)
@ -310,8 +303,8 @@ class FormSemestreCursusBUT:
).get(validation_rcue.annee()) ).get(validation_rcue.annee())
# prend la "meilleure" validation # prend la "meilleure" validation
if (not previous_validation) or ( if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code] sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation["code"]] > sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
): ):
self.validation_par_competence_et_annee[niveau.competence.id][ self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee niveau.annee
@ -347,8 +340,8 @@ class FormSemestreCursusBUT:
).get(validation_rcue.annee()) ).get(validation_rcue.annee())
# prend la "meilleure" validation # prend la "meilleure" validation
if (not previous_validation) or ( if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code] sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation["code"]] > sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
): ):
self.validation_par_competence_et_annee[niveau.competence.id][ self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee niveau.annee
@ -365,66 +358,6 @@ class FormSemestreCursusBUT:
"cache { competence_id : competence }" "cache { competence_id : competence }"
def but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float:
"""Nombre d'ECTS validés par etud dans le BUT de référentiel indiqué.
Ne prend que les UE associées à des niveaux de compétences,
et ne les compte qu'une fois même en cas de redoublement avec re-validation.
"""
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.filter(ScolarFormSemestreValidation.ue_id != None)
.join(UniteEns)
.join(ApcNiveau)
.join(ApcCompetence)
.filter_by(referentiel_id=referentiel_competence_id)
)
ects_dict = {}
for v in validations:
key = (v.ue.semestre_idx, v.ue.niveau_competence.id)
if v.code in CODES_UE_VALIDES:
ects_dict[key] = v.ue.ects
return sum(ects_dict.values()) if ects_dict else 0.0
def etud_ues_de_but1_non_validees(
etud: Identite, formation: Formation, parcour: ApcParcours
) -> list[UniteEns]:
"""Liste des UEs de S1 et S2 non validées, dans son parcours"""
# Les UEs avec décisions, dans les S1 ou S2 d'une formation de même code:
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.filter(ScolarFormSemestreValidation.ue_id != None)
.join(UniteEns)
.filter(db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2))
.join(Formation)
.filter_by(formation_code=formation.formation_code)
)
codes_validations_by_ue_code = collections.defaultdict(list)
for v in validations:
codes_validations_by_ue_code[v.ue.ue_code].append(v.code)
# Les UEs du parcours en S1 et S2:
ues = formation.query_ues_parcour(parcour).filter(
db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2)
)
# Liste triée des ues non validées
return sorted(
[
ue
for ue in ues
if not any(
(
code_ue_validant(code)
for code in codes_validations_by_ue_code[ue.ue_code]
)
)
],
key=attrgetter("numero", "acronyme"),
)
def formsemestre_warning_apc_setup( def formsemestre_warning_apc_setup(
formsemestre: FormSemestre, res: ResultatsSemestreBUT formsemestre: FormSemestre, res: ResultatsSemestreBUT
) -> str: ) -> str:
@ -480,122 +413,3 @@ def formsemestre_warning_apc_setup(
</p> </p>
</div> </div>
""" """
def ue_associee_au_niveau_du_parcours(
ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
) -> UniteEns:
"L'UE associée à ce niveau, ou None"
ues = [ue for ue in ues_possibles if ue.niveau_competence_id == niveau.id]
if len(ues) > 1:
# plusieurs UEs associées à ce niveau: élimine celles sans parcours
ues_pair_avec_parcours = [ue for ue in ues if ue.parcours]
if ues_pair_avec_parcours:
ues = ues_pair_avec_parcours
if len(ues) > 1:
log(f"_niveau_ues: {len(ues)} associées au niveau {niveau} / {sem_name}")
return ues[0] if ues else None
def parcour_formation_competences(parcour: ApcParcours, formation: Formation) -> list:
"""
[
{
'competence' : ApcCompetence,
'niveaux' : {
1 : { ... },
2 : { ... },
3 : {
'niveau' : ApcNiveau,
'ue_impair' : UniteEns, # actuellement associée
'ues_impair' : list[UniteEns], # choix possibles
'ue_pair' : UniteEns,
'ues_pair' : list[UniteEns],
}
}
}
]
"""
refcomp: ApcReferentielCompetences = formation.referentiel_competence
def _niveau_ues(competence: ApcCompetence, annee: int) -> dict:
"""niveau et ues pour cette compétence de cette année du parcours.
Si parcour est None, les niveaux du tronc commun
"""
if parcour is not None:
# L'étudiant est inscrit à un parcours: cherche les niveaux
niveaux = ApcNiveau.niveaux_annee_de_parcours(
parcour, annee, competence=competence
)
else:
# sans parcours, on cherche les niveaux du Tronc Commun de cette année
niveaux = [
niveau
for niveau in refcomp.get_niveaux_by_parcours(annee)[1]["TC"]
if niveau.competence_id == competence.id
]
if len(niveaux) > 0:
if len(niveaux) > 1:
log(
f"""_niveau_ues: plus d'un niveau pour {competence}
annee {annee} {("parcours " + parcour.code) if parcour else ""}"""
)
niveau = niveaux[0]
elif len(niveaux) == 0:
return {
"niveau": None,
"ue_pair": None,
"ue_impair": None,
"ues_pair": [],
"ues_impair": [],
}
# Toutes les UEs de la formation dans ce parcours ou tronc commun
ues = [
ue
for ue in formation.ues
if (
(not ue.parcours)
or (parcour is not None and (parcour.id in (p.id for p in ue.parcours)))
)
and ue.type == UE_STANDARD
]
ues_pair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee)]
ues_impair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee - 1)]
# UE associée au niveau dans ce parcours
ue_pair = ue_associee_au_niveau_du_parcours(
ues_pair_possibles, niveau, f"S{2*annee}"
)
ue_impair = ue_associee_au_niveau_du_parcours(
ues_impair_possibles, niveau, f"S{2*annee-1}"
)
return {
"niveau": niveau,
"ue_pair": ue_pair,
"ues_pair": [
ue
for ue in ues_pair_possibles
if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
],
"ue_impair": ue_impair,
"ues_impair": [
ue
for ue in ues_impair_possibles
if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
],
}
competences = [
{
"competence": competence,
"niveaux": {annee: _niveau_ues(competence, annee) for annee in (1, 2, 3)},
}
for competence in (
parcour.query_competences()
if parcour
else refcomp.competences.order_by(ApcCompetence.numero)
)
]
return competences

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,6 @@ from openpyxl.styles import Font, Border, Side, Alignment, PatternFill
from app import log from app import log
from app.but import jury_but from app.but import jury_but
from app.but.cursus_but import but_ects_valides
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
@ -110,11 +109,6 @@ def pvjury_table_but(
""" """
# remplace pour le BUT la fonction sco_pv_forms.pvjury_table # remplace pour le BUT la fonction sco_pv_forms.pvjury_table
annee_but = (formsemestre.semestre_id + 1) // 2 annee_but = (formsemestre.semestre_id + 1) // 2
referentiel_competence_id = formsemestre.formation.referentiel_competence_id
if referentiel_competence_id is None:
raise ScoValueError(
"pas de référentiel de compétences associé à la formation de ce semestre !"
)
titles = { titles = {
"nom": "Code" if anonymous else "Nom", "nom": "Code" if anonymous else "Nom",
"cursus": "Cursus", "cursus": "Cursus",
@ -159,7 +153,7 @@ def pvjury_table_but(
etudid=etud.id, etudid=etud.id,
), ),
"cursus": _descr_cursus_but(etud), "cursus": _descr_cursus_but(etud),
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {but_ects_valides(etud, referentiel_competence_id ):g}""", "ects": f"{deca.formsemestre_ects():g}",
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-", "ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": deca.descr_niveaux_validation(line_sep=line_sep) "niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
if deca if deca

View File

@ -48,9 +48,9 @@ def _get_jury_but_etud_result(
# --- Les RCUEs # --- Les RCUEs
rcue_list = [] rcue_list = []
if deca: if deca:
for dec_rcue in deca.get_decisions_rcues_annee(): for rcue in deca.rcues_annee:
rcue = dec_rcue.rcue dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
if rcue.complete: # n'exporte que les RCUEs complets if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
dec_ue1 = deca.decisions_ues[rcue.ue_1.id] dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
dec_ue2 = deca.decisions_ues[rcue.ue_2.id] dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
rcue_dict = { rcue_dict = {

View File

@ -6,22 +6,24 @@
"""Jury BUT: calcul des décisions de jury annuelles "automatiques" """Jury BUT: calcul des décisions de jury annuelles "automatiques"
""" """
from flask import g, url_for
from app import db from app import db
from app.but import jury_but from app.but import jury_but
from app.models import Identite, FormSemestre, ScolarNews from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but( def formsemestre_validation_auto_but(
formsemestre: FormSemestre, only_adm: bool = True formsemestre: FormSemestre, only_adm: bool = True, no_overwrite: bool = True
) -> int: ) -> int:
"""Calcul automatique des décisions de jury sur une "année" BUT. """Calcul automatique des décisions de jury sur une "année" BUT.
- N'enregistre jamais de décisions de l'année scolaire précédente, même - N'enregistre jamais de décisions de l'année scolaire précédente, même
si on a des RCUE "à cheval". si on a des RCUE "à cheval".
- Ne modifie jamais de décisions déjà enregistrées (sauf si no_overwrite est faux,
ce qui est utilisé pour certains tests unitaires).
- Normalement, only_adm est True et on n'enregistre que les décisions validantes - Normalement, only_adm est True et on n'enregistre que les décisions validantes
de droit: ADM ou CMP. de droit: ADM ou CMP.
En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc
@ -36,17 +38,9 @@ def formsemestre_validation_auto_but(
for etudid in formsemestre.etuds_inscriptions: for etudid in formsemestre.etuds_inscriptions:
etud = Identite.get_etud(etudid) etud = Identite.get_etud(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
nb_etud_modif += deca.record_all(only_validantes=only_adm) nb_etud_modif += deca.record_all(
no_overwrite=no_overwrite, only_validantes=only_adm
)
db.session.commit() db.session.commit()
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=formsemestre.id,
text=f"""Calcul jury automatique du semestre {formsemestre.html_link_status()}""",
url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
)
return nb_etud_modif return nb_etud_modif

View File

@ -31,11 +31,9 @@ from app.models import (
UniteEns, UniteEns,
ScolarAutorisationInscription, ScolarAutorisationInscription,
ScolarFormSemestreValidation, ScolarFormSemestreValidation,
ScolarNews,
) )
from app.models.config import ScoDocSiteConfig from app.models.config import ScoDocSiteConfig
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -93,25 +91,35 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
<div class="titre">RCUE</div> <div class="titre">RCUE</div>
""" """
) )
for dec_rcue in deca.get_decisions_rcues_annee(): for niveau in deca.niveaux_competences:
rcue = dec_rcue.rcue
niveau = rcue.niveau
H.append( H.append(
f"""<div class="but_niveau_titre"> f"""<div class="but_niveau_titre">
<div title="{niveau.competence.titre_long}">{niveau.competence.titre}</div> <div title="{niveau.competence.titre_long}">{niveau.competence.titre}</div>
</div>""" </div>"""
) )
ue_impair, ue_pair = rcue.ue_1, rcue.ue_2 dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) # peut être None
ues = [
ue
for ue in deca.ues_impair
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
]
ue_impair = ues[0] if ues else None
ues = [
ue
for ue in deca.ues_pair
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
]
ue_pair = ues[0] if ues else None
# Les UEs à afficher, # Les UEs à afficher,
# qui # qui seront toujours en readonly sur le formsemestre de l'année précédente du redoublant
ues_ro = [ ues_ro = [
( (
ue_impair, ue_impair,
rcue.ue_cur_impair is None, (deca.a_cheval and deca.formsemestre_id != deca.formsemestre_impair.id),
), ),
( (
ue_pair, ue_pair,
rcue.ue_cur_pair is None, deca.a_cheval and deca.formsemestre_id != deca.formsemestre_pair.id,
), ),
] ]
# Ordonne selon les dates des 2 semestres considérés: # Ordonne selon les dates des 2 semestres considérés:
@ -145,22 +153,17 @@ def _gen_but_select(
code_valide: str, code_valide: str,
disabled: bool = False, disabled: bool = False,
klass: str = "", klass: str = "",
data: dict = None, data: dict = {},
code_valide_label: str = "",
) -> str: ) -> str:
"Le menu html select avec les codes" "Le menu html select avec les codes"
# if disabled: # mauvaise idée car le disabled est traité en JS # if disabled: # mauvaise idée car le disabled est traité en JS
# return f"""<div class="but_code {klass}">{code_valide}</div>""" # return f"""<div class="but_code {klass}">{code_valide}</div>"""
data = data or {}
options_htm = "\n".join( options_htm = "\n".join(
[ [
f"""<option value="{code}" f"""<option value="{code}"
{'selected' if code == code_valide else ''} {'selected' if code == code_valide else ''}
class="{'recorded' if code == code_valide else ''}" class="{'recorded' if code == code_valide else ''}"
>{code >{code}</option>"""
if ((code != code_valide) or not code_valide_label)
else code_valide_label
}</option>"""
for code in codes for code in codes
] ]
) )
@ -199,54 +202,20 @@ def _gen_but_niveau_ue(
</div> </div>
</div> </div>
""" """
elif dec_ue.formsemestre is None:
# Validation d'UE antérieure (semestre hors année scolaire courante)
if dec_ue.validation:
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.validation.moy_ue)}</span>"""
scoplement = f"""<div class="scoplement">
<div>
<b>UE {ue.acronyme} antérieure </b>
<span>validée {dec_ue.validation.code}
le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
</span>
</div>
<div>Non reprise dans l'année en cours</div>
</div>
"""
else:
moy_ue_str = """<span>-</span>"""
scoplement = """<div class="scoplement">
<div>
<b>Pas d'UE en cours ou validée dans cette compétence de ce côté.</b>
</div>
</div>
"""
else: else:
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>""" moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
if dec_ue.code_valide: if dec_ue.code_valide:
date_str = (
f"""enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
à {dec_ue.validation.event_date.strftime("%Hh%M")}
"""
if dec_ue.validation and dec_ue.validation.event_date
else ""
)
scoplement = f"""<div class="scoplement"> scoplement = f"""<div class="scoplement">
<div>Code {dec_ue.code_valide} {date_str} <div>Code {dec_ue.code_valide} enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
à {dec_ue.validation.event_date.strftime("%Hh%M")}
</div> </div>
</div> </div>
""" """
else: else:
scoplement = "" scoplement = ""
ue_class = "" # 'recorded' if dec_ue.code_valide is not None else '' return f"""<div class="but_niveau_ue {
if dec_ue.code_valide is not None and dec_ue.codes: 'recorded' if dec_ue.code_valide is not None else ''}
if dec_ue.code_valide == dec_ue.codes[0]:
ue_class = "recorded"
else:
ue_class = "recorded_different"
return f"""<div class="but_niveau_ue {ue_class}
{'annee_prec' if annee_prec else ''} {'annee_prec' if annee_prec else ''}
"> ">
<div title="{ue.titre}">{ue.acronyme}</div> <div title="{ue.titre}">{ue.acronyme}</div>
@ -267,7 +236,7 @@ def _gen_but_niveau_ue(
def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str: def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
if dec_rcue is None or not dec_rcue.rcue.complete: if dec_rcue is None:
return """ return """
<div class="but_niveau_rcue niveau_vide with_scoplement"> <div class="but_niveau_rcue niveau_vide with_scoplement">
<div></div> <div></div>
@ -275,25 +244,13 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
</div> </div>
""" """
code_propose_menu = dec_rcue.code_valide # le code enregistré scoplement = (
code_valide_label = code_propose_menu f"""<div class="scoplement">{
if dec_rcue.validation: dec_rcue.validation.to_html()
if dec_rcue.code_valide == dec_rcue.codes[0]: }</div>"""
descr_validation = dec_rcue.validation.html() if dec_rcue.validation
else: # on une validation enregistrée différence de celle proposée else ""
descr_validation = f"""Décision recommandée: <b>{dec_rcue.codes[0]}.</b> )
Il y avait {dec_rcue.validation.html()}"""
if (
sco_codes.BUT_CODES_ORDER[dec_rcue.codes[0]]
> sco_codes.BUT_CODES_ORDER[dec_rcue.code_valide]
):
code_propose_menu = dec_rcue.codes[0]
code_valide_label = (
f"{dec_rcue.codes[0]} (actuel {dec_rcue.code_valide})"
)
scoplement = f"""<div class="scoplement">{descr_validation}</div>"""
else:
scoplement = "" # "pas de validation"
# Déjà enregistré ? # Déjà enregistré ?
niveau_rcue_class = "" niveau_rcue_class = ""
@ -313,11 +270,10 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
<div class="but_code"> <div class="but_code">
{_gen_but_select("code_rcue_"+str(niveau.id), {_gen_but_select("code_rcue_"+str(niveau.id),
dec_rcue.codes, dec_rcue.codes,
code_propose_menu, dec_rcue.code_valide,
disabled=True, disabled=True,
klass="manual code_rcue", klass="manual code_rcue",
data = { "niveau_id" : str(niveau.id)}, data = { "niveau_id" : str(niveau.id)}
code_valide_label = code_valide_label,
)} )}
</div> </div>
</div> </div>
@ -395,16 +351,6 @@ def jury_but_semestriel(
flash( flash(
f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée" f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée"
) )
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=formsemestre.id,
text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""",
url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
)
return flask.redirect( return flask.redirect(
url_for( url_for(
"notes.formsemestre_validation_but", "notes.formsemestre_validation_but",
@ -448,7 +394,7 @@ def jury_but_semestriel(
{warning} {warning}
</div> </div>
<form method="post" class="jury_but_box" id="jury_but"> <form method="post" id="jury_but">
""", """,
] ]

View File

@ -1,67 +0,0 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury édition manuelle des décisions (correction d'erreurs, parcours hors normes)
Non spécifique au BUT.
"""
from flask import render_template
import sqlalchemy as sa
from app.models import (
ApcValidationAnnee,
ApcValidationRCUE,
Identite,
UniteEns,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
)
from app.views import ScoData
def jury_delete_manual(etud: Identite):
"""Vue (réservée au chef de dept.)
présentant *toutes* les décisions de jury concernant cet étudiant
et permettant de les supprimer une à une.
"""
sem_vals = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, ue_id=None
).order_by(ScolarFormSemestreValidation.event_date)
ue_vals = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.join(UniteEns)
.order_by(
sa.extract("year", ScolarFormSemestreValidation.event_date),
UniteEns.semestre_idx,
UniteEns.numero,
UniteEns.acronyme,
)
)
autorisations = ScolarAutorisationInscription.query.filter_by(
etudid=etud.id
).order_by(
ScolarAutorisationInscription.semestre_id, ScolarAutorisationInscription.date
)
rcue_vals = (
ApcValidationRCUE.query.filter_by(etudid=etud.id)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
.order_by(UniteEns.semestre_idx, UniteEns.numero, ApcValidationRCUE.date)
)
annee_but_vals = ApcValidationAnnee.query.filter_by(etudid=etud.id).order_by(
ApcValidationAnnee.ordre, ApcValidationAnnee.date
)
return render_template(
"jury/jury_delete_manual.j2",
etud=etud,
sem_vals=sem_vals,
ue_vals=ue_vals,
autorisations=autorisations,
rcue_vals=rcue_vals,
annee_but_vals=annee_but_vals,
sco=ScoData(),
title=f"Toutes les décisions de jury enregistrées pour {etud.html_link_fiche()}",
)

View File

@ -1,253 +0,0 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury BUT: un RCUE, ou Regroupe Cohérent d'UEs
"""
from typing import Union
from flask_sqlalchemy.query import Query
from app.comp.res_but import ResultatsSemestreBUT
from app.models import (
ApcNiveau,
ApcValidationRCUE,
Identite,
ScolarFormSemestreValidation,
UniteEns,
)
from app.scodoc import codes_cursus
from app.scodoc.codes_cursus import BUT_CODES_ORDER
class RegroupementCoherentUE:
"""Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs
de la même année (BUT1,2,3) liées au *même niveau de compétence*.
La moyenne (10/20) au RCUE déclenche la compensation des UEs.
"""
def __init__(
self,
etud: Identite,
niveau: ApcNiveau,
res_pair: ResultatsSemestreBUT,
res_impair: ResultatsSemestreBUT,
semestre_id_impair: int,
cur_ues_pair: list[UniteEns],
cur_ues_impair: list[UniteEns],
):
"""
res_pair, res_impair: résultats des formsemestre de l'année en cours, ou None
cur_ues_pair, cur_ues_impair: ues auxquelles l'étudiant est inscrit cette année
"""
self.semestre_id_impair = semestre_id_impair
self.semestre_id_pair = semestre_id_impair + 1
self.etud: Identite = etud
self.niveau: ApcNiveau = niveau
"Le niveau de compétences de ce RCUE"
# Chercher l'UE en cours pour pair, impair
# une UE à laquelle l'étudiant est inscrit (non dispensé)
# dans l'un des formsemestre en cours
ues = [ue for ue in cur_ues_pair if ue.niveau_competence_id == niveau.id]
self.ue_cur_pair = ues[0] if ues else None
"UE paire en cours"
ues = [ue for ue in cur_ues_impair if ue.niveau_competence_id == niveau.id]
self.ue_cur_impair = ues[0] if ues else None
"UE impaire en cours"
self.validation_ue_cur_pair = (
ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id,
formsemestre_id=res_pair.formsemestre.id,
ue_id=self.ue_cur_pair.id,
).first()
if self.ue_cur_pair
else None
)
self.validation_ue_cur_impair = (
ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id,
formsemestre_id=res_impair.formsemestre.id,
ue_id=self.ue_cur_impair.id,
).first()
if self.ue_cur_impair
else None
)
# Autres validations pour l'UE paire
self.validation_ue_best_pair = best_autre_ue_validation(
etud.id,
niveau.id,
semestre_id_impair + 1,
res_pair.formsemestre.id if (res_pair and self.ue_cur_pair) else None,
)
self.validation_ue_best_impair = best_autre_ue_validation(
etud.id,
niveau.id,
semestre_id_impair,
res_impair.formsemestre.id if (res_impair and self.ue_cur_impair) else None,
)
# Suis-je complet ? (= en cours ou validé sur les deux moitiés)
self.complete = (self.ue_cur_pair or self.validation_ue_best_pair) and (
self.ue_cur_impair or self.validation_ue_best_impair
)
if not self.complete:
self.moy_rcue = None
# Stocke les moyennes d'UE
self.res_impair = None
"résultats formsemestre de l'UE si elle est courante, None sinon"
self.ue_status_impair = None
if self.ue_cur_impair:
ue_status = res_impair.get_etud_ue_status(etud.id, self.ue_cur_impair.id)
self.moy_ue_1 = ue_status["moy"] if ue_status else None # avec capitalisée
self.ue_1 = self.ue_cur_impair
self.res_impair = res_impair
self.ue_status_impair = ue_status
elif self.validation_ue_best_impair:
self.moy_ue_1 = self.validation_ue_best_impair.moy_ue
self.ue_1 = self.validation_ue_best_impair.ue
else:
self.moy_ue_1, self.ue_1 = None, None
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
self.res_pair = None
"résultats formsemestre de l'UE si elle est courante, None sinon"
self.ue_status_pair = None
if self.ue_cur_pair:
ue_status = res_pair.get_etud_ue_status(etud.id, self.ue_cur_pair.id)
self.moy_ue_2 = ue_status["moy"] if ue_status else None # avec capitalisée
self.ue_2 = self.ue_cur_pair
self.res_pair = res_pair
self.ue_status_pair = ue_status
elif self.validation_ue_best_pair:
self.moy_ue_2 = self.validation_ue_best_pair.moy_ue
self.ue_2 = self.validation_ue_best_pair.ue
else:
self.moy_ue_2, self.ue_2 = None, None
self.moy_ue_2_val = self.moy_ue_2 if self.moy_ue_2 is not None else 0.0
# Calcul de la moyenne au RCUE (utilise les moy d'UE capitalisées ou antérieures)
if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
# Moyenne RCUE (les pondérations par défaut sont 1.)
self.moy_rcue = (
self.moy_ue_1 * self.ue_1.coef_rcue
+ self.moy_ue_2 * self.ue_2.coef_rcue
) / (self.ue_1.coef_rcue + self.ue_2.coef_rcue)
else:
self.moy_rcue = None
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} {
self.ue_1.acronyme if self.ue_1 else "?"}({self.moy_ue_1}) {
self.ue_2.acronyme if self.ue_2 else "?"}({self.moy_ue_2})>"""
def __str__(self) -> str:
return f"""RCUE {
self.ue_1.acronyme if self.ue_1 else "?"}({self.moy_ue_1}) + {
self.ue_2.acronyme if self.ue_2 else "?"}({self.moy_ue_2})"""
def query_validations(
self,
) -> Query: # list[ApcValidationRCUE]
"""Les validations de jury enregistrées pour ce RCUE"""
return (
ApcValidationRCUE.query.filter_by(
etudid=self.etud.id,
)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
.join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id)
.filter(ApcNiveau.id == self.niveau.id)
)
def other_ue(self, ue: UniteEns) -> UniteEns:
"""L'autre UE du regroupement. Si ue ne fait pas partie du regroupement, ValueError"""
if ue.id == self.ue_1.id:
return self.ue_2
elif ue.id == self.ue_2.id:
return self.ue_1
raise ValueError(f"ue {ue} hors RCUE {self}")
def est_enregistre(self) -> bool:
"""Vrai si ce RCUE, donc le niveau de compétences correspondant
a une décision jury enregistrée
"""
return self.query_validations().count() > 0
def est_compensable(self):
"""Vrai si ce RCUE est validable (uniquement) par compensation
c'est à dire que sa moyenne est > 10 avec une UE < 10.
Note: si ADM, est_compensable est faux.
"""
return (
(self.moy_rcue is not None)
and (self.moy_rcue > codes_cursus.BUT_BARRE_RCUE)
and (
(self.moy_ue_1_val < codes_cursus.NOTES_BARRE_GEN)
or (self.moy_ue_2_val < codes_cursus.NOTES_BARRE_GEN)
)
)
def est_suffisant(self) -> bool:
"""Vrai si ce RCUE est > 8"""
return (self.moy_rcue is not None) and (
self.moy_rcue > codes_cursus.BUT_RCUE_SUFFISANT
)
def est_validable(self) -> bool:
"""Vrai si ce RCUE satisfait les conditions pour être validé,
c'est à dire que la moyenne des UE qui le constituent soit > 10
"""
return (self.moy_rcue is not None) and (
self.moy_rcue > codes_cursus.BUT_BARRE_RCUE
)
def code_valide(self) -> Union[ApcValidationRCUE, None]:
"Si ce RCUE est ADM, CMP ou ADJ, la validation. Sinon, None"
validation = self.query_validations().first()
if (validation is not None) and (
validation.code in codes_cursus.CODES_RCUE_VALIDES
):
return validation
return None
def best_autre_ue_validation(
etudid: int, niveau_id: int, semestre_id: int, formsemestre_id: int
) -> ScolarFormSemestreValidation:
"""La "meilleure" validation validante d'UE pour ce niveau/semestre"""
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etudid)
.join(UniteEns)
.filter_by(semestre_idx=semestre_id)
.join(ApcNiveau)
.filter(ApcNiveau.id == niveau_id)
)
validations = [v for v in validations if codes_cursus.code_ue_validant(v.code)]
# Elimine l'UE en cours si elle existe
if formsemestre_id is not None:
validations = [v for v in validations if v.formsemestre_id != formsemestre_id]
validations = sorted(validations, key=lambda v: BUT_CODES_ORDER.get(v.code, 0))
return validations[-1] if validations else None
# def compute_ues_by_niveau(
# niveaux: list[ApcNiveau],
# ) -> dict[int, tuple[list[UniteEns], list[UniteEns]]]:
# """UEs à valider cette année pour cet étudiant, selon son parcours.
# Considérer les UEs associées aux niveaux et non celles des formsemestres
# en cours. Notez que même si l'étudiant n'est pas inscrit ("dispensé") à une UE
# dans le formsemestre origine, elle doit apparaitre sur la page jury.
# Return: { niveau_id : ( [ues impair], [ues pair]) }
# """
# # Les UEs associées à ce niveau, toutes formations confondues
# return {
# niveau.id: (
# [ue for ue in niveau.ues if ue.semestre_idx % 2],
# [ue for ue in niveau.ues if not (ue.semestre_idx % 2)],
# )
# for niveau in niveaux
# }

View File

@ -1,117 +0,0 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury édition manuelle des décisions (correction d'erreurs, parcours hors normes)
Non spécifique au BUT.
"""
from flask import render_template
import sqlalchemy as sa
from app import log
from app.but import cursus_but
from app.models import (
ApcCompetence,
ApcNiveau,
ApcReferentielCompetences,
# ApcValidationAnnee, # TODO
ApcValidationRCUE,
Formation,
FormSemestre,
Identite,
UniteEns,
# ScolarAutorisationInscription,
ScolarFormSemestreValidation,
)
from app.scodoc import codes_cursus
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
from app.views import ScoData
def validation_rcues(etud: Identite, formsemestre: FormSemestre, edit: bool = False):
"""Page de saisie des décisions de RCUEs "antérieures"
On peut l'utiliser pour saisir la validation de n'importe quel RCUE
d'une année antérieure et de la formation du formsemestre indiqué.
"""
formation: Formation = formsemestre.formation
refcomp = formation.referentiel_competence
if refcomp is None:
raise ScoNoReferentielCompetences(formation=formation)
parcour = formsemestre.etuds_inscriptions[etud.id].parcour
# Si non inscrit à un parcours, prend toutes les compétences
competences_parcour = cursus_but.parcour_formation_competences(parcour, formation)
ue_validation_by_niveau = get_ue_validation_by_niveau(refcomp, etud)
rcue_validation_by_niveau = get_rcue_validation_by_niveau(refcomp, etud)
ects_total = sum((v.ects() for v in ue_validation_by_niveau.values()))
return render_template(
"but/validation_rcues.j2",
competences_parcour=competences_parcour,
edit=edit,
ects_total=ects_total,
formation=formation,
parcour=parcour,
rcue_validation_by_niveau=rcue_validation_by_niveau,
rcue_codes=sorted(codes_cursus.CODES_JURY_RCUE),
sco=ScoData(formsemestre=formsemestre, etud=etud),
title=f"{formation.acronyme} - Niveaux et UEs",
ue_validation_by_niveau=ue_validation_by_niveau,
)
def get_ue_validation_by_niveau(
refcomp: ApcReferentielCompetences, etud: Identite
) -> dict[tuple[int, str], ScolarFormSemestreValidation]:
"""Les validations d'UEs de cet étudiant liées à ce référentiel de compétences.
Pour chaque niveau / pair ou impair, choisi la "meilleure" validation
"""
validations: list[ScolarFormSemestreValidation] = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.join(UniteEns)
.join(ApcNiveau)
.join(ApcCompetence)
.filter_by(referentiel_id=refcomp.id)
.all()
)
# La meilleure validation pour chaque UE
ue_validation_by_niveau = {} # { (niveau_id, pair|impair) : validation }
for validation in validations:
if validation.ue.niveau_competence is None:
log(
f"""validation_rcues: ignore validation d'UE {
validation.ue.id} pas de niveau de competence"""
)
key = (
validation.ue.niveau_competence.id,
"impair" if validation.ue.semestre_idx % 2 else "pair",
)
existing = ue_validation_by_niveau.get(key, None)
if (not existing) or (
codes_cursus.BUT_CODES_ORDER[existing.code]
< codes_cursus.BUT_CODES_ORDER[validation.code]
):
ue_validation_by_niveau[key] = validation
return ue_validation_by_niveau
def get_rcue_validation_by_niveau(
refcomp: ApcReferentielCompetences, etud: Identite
) -> dict[int, ApcValidationRCUE]:
"""Les validations d'UEs de cet étudiant liées à ce référentiel de compétences.
Pour chaque niveau / pair ou impair, choisi la "meilleure" validation
"""
validations: list[ApcValidationRCUE] = (
ApcValidationRCUE.query.filter_by(etudid=etud.id)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
.join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id)
.join(ApcCompetence)
.filter_by(referentiel_id=refcomp.id)
.all()
)
return {
validation.ue2.niveau_competence.id: validation for validation in validations
}

View File

@ -18,7 +18,7 @@ import pandas as pd
from flask import g from flask import g
from app.scodoc.codes_cursus import UE_SPORT, UE_STANDARD from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.codes_cursus import CursusDUT, CursusDUTMono from app.scodoc.codes_cursus import CursusDUT, CursusDUTMono
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
@ -194,7 +194,7 @@ class BonusSportAdditif(BonusSport):
# les points au dessus du seuil sont comptés (defaut: seuil_moy_gen): # les points au dessus du seuil sont comptés (defaut: seuil_moy_gen):
seuil_comptage = None seuil_comptage = None
proportion_point = 0.05 # multiplie les points au dessus du seuil proportion_point = 0.05 # multiplie les points au dessus du seuil
bonus_max = 20.0 # le bonus ne peut dépasser 20 points bonux_max = 20.0 # le bonus ne peut dépasser 20 points
bonus_min = 0.0 # et ne peut pas être négatif bonus_min = 0.0 # et ne peut pas être négatif
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
@ -435,11 +435,8 @@ class BonusAmiens(BonusSportAdditif):
class BonusBesanconVesoul(BonusSportAdditif): class BonusBesanconVesoul(BonusSportAdditif):
"""Bonus IUT Besançon - Vesoul pour les UE libres """Bonus IUT Besançon - Vesoul pour les UE libres
<p>Le bonus est compris entre 0 et 0,2 points. <p>Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,2 point
et est reporté sur les moyennes d'UE. sur toutes les moyennes d'UE.
</p>
<p>La valeur saisie doit être entre 0 et 0,2: toute valeur
supérieure à 0,2 entraine un bonus de 0,2.
</p> </p>
""" """
@ -447,7 +444,7 @@ class BonusBesanconVesoul(BonusSportAdditif):
displayed_name = "IUT de Besançon - Vesoul" displayed_name = "IUT de Besançon - Vesoul"
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 0.0 # tous les points sont comptés seuil_moy_gen = 0.0 # tous les points sont comptés
proportion_point = 1 proportion_point = 1e10 # infini
bonus_max = 0.2 bonus_max = 0.2
@ -743,7 +740,6 @@ class BonusGrenobleIUT1(BonusSportMultiplicatif):
name = "bonus_iut1grenoble_2017" name = "bonus_iut1grenoble_2017"
displayed_name = "IUT de Grenoble 1" displayed_name = "IUT de Grenoble 1"
# C'est un bonus "multiplicatif": on l'exprime en additif, # C'est un bonus "multiplicatif": on l'exprime en additif,
# sur chaque moyenne d'UE m_0 # sur chaque moyenne d'UE m_0
# Augmenter de 5% correspond à multiplier par a=1.05 # Augmenter de 5% correspond à multiplier par a=1.05
@ -786,7 +782,6 @@ class BonusIUTRennes1(BonusSportAdditif):
seuil_moy_gen = 10.0 seuil_moy_gen = 10.0
proportion_point = 1 / 20.0 proportion_point = 1 / 20.0
classic_use_bonus_ues = False classic_use_bonus_ues = False
# S'applique aussi en classic, sur la moy. gen. # S'applique aussi en classic, sur la moy. gen.
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus""" """calcul du bonus"""
@ -827,32 +822,16 @@ class BonusStMalo(BonusIUTRennes1):
class BonusLaRocheSurYon(BonusSportAdditif): class BonusLaRocheSurYon(BonusSportAdditif):
"""Bonus IUT de La Roche-sur-Yon """Bonus IUT de La Roche-sur-Yon
<p> Si une note de bonus est saisie, l'étudiant est gratifié de 0,2 points
<b>La note saisie s'applique directement</b>: si on saisit 0,2, un bonus de 0,2 points est appliqué sur sa moyenne générale ou, en BUT, sur la moyenne de chaque UE.
aux moyennes.
La valeur maximale du bonus est 1 point. Il est appliqué sur les moyennes d'UEs en BUT,
ou sur la moyenne générale dans les autres formations.
</p>
<p>Pour les <b>semestres antérieurs à janvier 2023</b>: si une note de bonus est saisie,
l'étudiant est gratifié de 0,2 points sur sa moyenne générale ou, en BUT, sur la
moyenne de chaque UE.
</p>
""" """
name = "bonus_larochesuryon" name = "bonus_larochesuryon"
displayed_name = "IUT de La Roche-sur-Yon" displayed_name = "IUT de La Roche-sur-Yon"
seuil_moy_gen = 0.0 seuil_moy_gen = 0.0
seuil_comptage = 0.0 seuil_comptage = 0.0
proportion_point = 1e10 # le moindre point sature le bonus
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): bonus_max = 0.2 # à 0.2
"""calcul du bonus, avec réglage différent suivant la date"""
if self.formsemestre.date_debut > datetime.date(2022, 12, 31):
self.proportion_point = 1.0
self.bonus_max = 1
else: # ancienne règle
self.proportion_point = 1e10 # le moindre point sature le bonus
self.bonus_max = 0.2 # à 0.2
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
class BonusLaRochelle(BonusSportAdditif): class BonusLaRochelle(BonusSportAdditif):
@ -1076,36 +1055,6 @@ class BonusLyon(BonusSportAdditif):
) )
class BonusLyon3(BonusSportAdditif):
"""IUT de Lyon 3 (septembre 2022)
<p>Nous avons deux types de bonifications : sport et/ou culture
</p>
<p>
Pour chaque point au-dessus de 10 obtenu en sport ou en culture nous
ajoutons 0,03 points à toutes les moyennes dUE du semestre. Exemple : 16 en
sport ajoute 6*0,03 = 0,18 points à toutes les moyennes dUE du semestre.
</p>
<p>
Les bonifications sport et culture peuvent se cumuler dans la limite de 0,3
points ajoutés aux moyennes des UE. Exemple : 17 en sport et 16 en culture
conduisent au calcul (7 + 6)*0,03 = 0,39 qui dépasse 0,3. La bonification
dans ce cas ne sera que de 0,3 points ajoutés à toutes les moyennes dUE du
semestre.
</p>
<p>
Dans Scodoc on déclarera une UE Sport&Culture dans laquelle on aura un
module pour le Sport et un autre pour la Culture avec pour chaque module la
note sur 20 obtenue en sport ou en culture par létudiant.
</p>
"""
name = "bonus_lyon3"
displayed_name = "IUT de Lyon 3"
proportion_point = 0.03
bonus_max = 0.3
class BonusMantes(BonusSportAdditif): class BonusMantes(BonusSportAdditif):
"""Calcul bonus modules optionnels (investissement, ...), IUT de Mantes en Yvelines. """Calcul bonus modules optionnels (investissement, ...), IUT de Mantes en Yvelines.
@ -1387,7 +1336,6 @@ class BonusStNazaire(BonusSport):
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
amplitude = 0.01 / 4 # 4pt => 1% amplitude = 0.01 / 4 # 4pt => 1%
factor_max = 0.1 # 10% max factor_max = 0.1 # 10% max
# Modifié 2022-11-29: calculer chaque bonus # Modifié 2022-11-29: calculer chaque bonus
# (de 1 à 3 modules) séparément. # (de 1 à 3 modules) séparément.
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
@ -1585,63 +1533,6 @@ class BonusIUTV(BonusSportAdditif):
# c'est le bonus par défaut: aucune méthode à surcharger # c'est le bonus par défaut: aucune méthode à surcharger
# Finalement inutile: un bonus direct est mieux adapté à leurs besoins.
# # class BonusMastersUSPNIG(BonusSportAdditif):
# """Calcul bonus modules optionnels (sport, culture), règle Masters de l'Institut Galilée (USPN)
# Les étudiants peuvent suivre des enseignements optionnels
# de l'USPN (sports, musique, deuxième langue, culture, etc) dans une
# UE libre. Les points au-dessus de 10 sur 20 obtenus dans cette UE
# libre sont ajoutés au total des points obtenus pour les UE obligatoires
# du semestre concerné.
# """
# name = "bonus_masters__uspn_ig"
# displayed_name = "Masters de l'Institut Galilée (USPN)"
# proportion_point = 1.0
# seuil_moy_gen = 10.0
# def __init__(
# self,
# formsemestre: "FormSemestre",
# sem_modimpl_moys: np.array,
# ues: list,
# modimpl_inscr_df: pd.DataFrame,
# modimpl_coefs: np.array,
# etud_moy_gen,
# etud_moy_ue,
# ):
# # Pour ce bonus, il nous faut la somme des coefs des modules non bonus
# # du formsemestre (et non auxquels les étudiants sont inscrits !)
# self.sum_coefs = sum(
# [
# m.module.coefficient
# for m in formsemestre.modimpls_sorted
# if (m.module.module_type == ModuleType.STANDARD)
# and (m.module.ue.type == UE_STANDARD)
# ]
# )
# super().__init__(
# formsemestre,
# sem_modimpl_moys,
# ues,
# modimpl_inscr_df,
# modimpl_coefs,
# etud_moy_gen,
# etud_moy_ue,
# )
# # Bonus sur la moyenne générale seulement
# # On a dans bonus_moy_arr le bonus additif classique
# # Sa valeur sera appliquée comme moy_gen += bonus_moy_gen
# # or ici on veut
# # moy_gen = (somme des notes + bonus_moy_arr) / somme des coefs
# # moy_gen += bonus_moy_arr / somme des coefs
# self.bonus_moy_gen = (
# None if self.bonus_moy_gen is None else self.bonus_moy_gen / self.sum_coefs
# )
def get_bonus_class_dict(start=BonusSport, d=None): def get_bonus_class_dict(start=BonusSport, d=None):
"""Dictionnaire des classes de bonus """Dictionnaire des classes de bonus
(liste les sous-classes de BonusSport ayant un nom) (liste les sous-classes de BonusSport ayant un nom)

View File

@ -10,17 +10,8 @@ import pandas as pd
import sqlalchemy as sa import sqlalchemy as sa
from app import db from app import db
from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns
from app.comp.res_cache import ResultatsCache from app.comp.res_cache import ResultatsCache
from app.models import (
ApcValidationAnnee,
ApcValidationRCUE,
Formation,
FormSemestre,
Identite,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
UniteEns,
)
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import codes_cursus from app.scodoc import codes_cursus
@ -90,7 +81,7 @@ class ValidationsSemestre(ResultatsCache):
# UEs: { etudid : { ue_id : {"code":, "ects":, "event_date":} }} # UEs: { etudid : { ue_id : {"code":, "ects":, "event_date":} }}
decisions_jury_ues = {} decisions_jury_ues = {}
# Parcoure les décisions d'UE: # Parcours les décisions d'UE:
for decision in ( for decision in (
decisions_jury_q.filter(db.text("ue_id is not NULL")) decisions_jury_q.filter(db.text("ue_id is not NULL"))
.join(UniteEns) .join(UniteEns)
@ -181,79 +172,3 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
with db.engine.begin() as connection: with db.engine.begin() as connection:
df = pd.read_sql_query(query, connection, params=params, index_col="etudid") df = pd.read_sql_query(query, connection, params=params, index_col="etudid")
return df return df
def erase_decisions_annee_formation(
etud: Identite, formation: Formation, annee: int, delete=False
) -> list:
"""Efface toutes les décisions de jury de l'étudiant dans les formations de même code
que celle donnée pour cette année de la formation:
UEs, RCUEs de l'année BUT, année BUT, passage vers l'année suivante.
Ne considère pas l'origine de la décision.
annee: entier, 1, 2, 3, ...
Si delete est faux, renvoie la liste des validations qu'il faudrait effacer, sans y toucher.
"""
sem1, sem2 = annee * 2 - 1, annee * 2
# UEs
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.join(UniteEns)
.filter(db.or_(UniteEns.semestre_idx == sem1, UniteEns.semestre_idx == sem2))
.join(Formation)
.filter_by(formation_code=formation.formation_code)
.order_by(
UniteEns.acronyme, UniteEns.numero
) # acronyme d'abord car 2 semestres
.all()
)
# RCUEs (a priori inutile de matcher sur l'ue2_id)
validations += (
ApcValidationRCUE.query.filter_by(etudid=etud.id)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
.filter_by(semestre_idx=sem1)
.join(Formation)
.filter_by(formation_code=formation.formation_code)
.order_by(UniteEns.acronyme, UniteEns.numero)
.all()
)
# Validation de semestres classiques
validations += (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id, ue_id=None)
.join(
FormSemestre,
FormSemestre.id == ScolarFormSemestreValidation.formsemestre_id,
)
.filter(
db.or_(FormSemestre.semestre_id == sem1, FormSemestre.semestre_id == sem2)
)
.join(Formation)
.filter_by(formation_code=formation.formation_code)
.all()
)
# Année BUT
validations += ApcValidationAnnee.query.filter_by(
etudid=etud.id,
ordre=annee,
referentiel_competence_id=formation.referentiel_competence_id,
).all()
# Autorisations vers les semestres suivants ceux de l'année:
validations += (
ScolarAutorisationInscription.query.filter_by(
etudid=etud.id, formation_code=formation.formation_code
)
.filter(
db.or_(
ScolarAutorisationInscription.semestre_id == sem1 + 1,
ScolarAutorisationInscription.semestre_id == sem2 + 1,
)
)
.all()
)
if delete:
for validation in validations:
db.session.delete(validation)
db.session.commit()
sco_cache.invalidate_formsemestre_etud(etud)
return []
return validations

View File

@ -134,7 +134,7 @@ class ModuleImplResults:
manque des notes) ssi il y a des étudiants inscrits au semestre et au module manque des notes) ssi il y a des étudiants inscrits au semestre et au module
qui ont des notes ATT. qui ont des notes ATT.
""" """
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id) moduleimpl = ModuleImpl.query.get(self.moduleimpl_id)
self.etudids = self._etudids() self.etudids = self._etudids()
# --- Calcul nombre d'inscrits pour déterminer les évaluations "completes": # --- Calcul nombre d'inscrits pour déterminer les évaluations "completes":
@ -225,8 +225,8 @@ class ModuleImplResults:
""" """
return [ return [
inscr.etudid inscr.etudid
for inscr in db.session.get( for inscr in ModuleImpl.query.get(
ModuleImpl, self.moduleimpl_id self.moduleimpl_id
).formsemestre.inscriptions ).formsemestre.inscriptions
] ]
@ -319,16 +319,10 @@ class ModuleImplResultsAPC(ModuleImplResults):
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes) ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
ne donnent pas de coef vers cette UE. ne donnent pas de coef vers cette UE.
""" """
modimpl = db.session.get(ModuleImpl, self.moduleimpl_id) modimpl = ModuleImpl.query.get(self.moduleimpl_id)
nb_etuds, nb_evals = self.evals_notes.shape nb_etuds, nb_evals = self.evals_notes.shape
nb_ues = evals_poids_df.shape[1] nb_ues = evals_poids_df.shape[1]
if evals_poids_df.shape[0] != nb_evals: assert evals_poids_df.shape[0] == nb_evals # compat notes/poids
# compat notes/poids: race condition ?
app.critical_error(
f"""compute_module_moy: evals_poids_df.shape[0] != nb_evals ({
evals_poids_df.shape[0]} != {nb_evals})
"""
)
if nb_etuds == 0: if nb_etuds == 0:
return pd.DataFrame(index=[], columns=evals_poids_df.columns) return pd.DataFrame(index=[], columns=evals_poids_df.columns)
if nb_ues == 0: if nb_ues == 0:
@ -419,7 +413,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
Résultat: (evals_poids, liste de UEs du semestre sauf le sport) Résultat: (evals_poids, liste de UEs du semestre sauf le sport)
""" """
modimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id) modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all()
ues = modimpl.formsemestre.get_ues(with_sport=False) ues = modimpl.formsemestre.get_ues(with_sport=False)
ue_ids = [ue.id for ue in ues] ue_ids = [ue.id for ue in ues]
@ -498,7 +492,7 @@ class ModuleImplResultsClassic(ModuleImplResults):
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes) ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
ne donnent pas de coef. ne donnent pas de coef.
""" """
modimpl = db.session.get(ModuleImpl, self.moduleimpl_id) modimpl = ModuleImpl.query.get(self.moduleimpl_id)
nb_etuds, nb_evals = self.evals_notes.shape nb_etuds, nb_evals = self.evals_notes.shape
if nb_etuds == 0: if nb_etuds == 0:
return pd.Series() return pd.Series()

View File

@ -30,10 +30,7 @@
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from flask import flash, g, url_for from flask import flash, g, Markup, url_for
from markupsafe import Markup
from app import db
from app.models.formations import Formation from app.models.formations import Formation
@ -81,7 +78,7 @@ def compute_sem_moys_apc_using_ects(
moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1) moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1)
except TypeError: except TypeError:
if None in ects: if None in ects:
formation = db.session.get(Formation, formation_id) formation = Formation.query.get(formation_id)
flash( flash(
Markup( Markup(
f"""Calcul moyenne générale impossible: ECTS des UE manquants !<br> f"""Calcul moyenne générale impossible: ECTS des UE manquants !<br>
@ -95,7 +92,7 @@ def compute_sem_moys_apc_using_ects(
return moy_gen return moy_gen
def comp_ranks_series(notes: pd.Series) -> tuple[pd.Series, pd.Series]: def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series):
"""Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur """Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur
numérique) en tenant compte des ex-aequos. numérique) en tenant compte des ex-aequos.

View File

@ -30,7 +30,6 @@
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import app
from app import db from app import db
from app import models from app import models
from app.models import ( from app.models import (
@ -168,14 +167,8 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray:
""" """
assert len(modimpls_notes) assert len(modimpls_notes)
modimpls_notes_arr = [df.values for df in modimpls_notes] modimpls_notes_arr = [df.values for df in modimpls_notes]
try: modimpls_notes = np.stack(modimpls_notes_arr)
modimpls_notes = np.stack(modimpls_notes_arr) # passe de (mod x etud x ue) à (etud x mod x ue)
# passe de (mod x etud x ue) à (etud x mod x ue)
except ValueError:
app.critical_error(
f"""notes_sem_assemble_cube: shapes {
", ".join([x.shape for x in modimpls_notes_arr])}"""
)
return modimpls_notes.swapaxes(0, 1) return modimpls_notes.swapaxes(0, 1)

View File

@ -10,17 +10,17 @@ import time
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from app import db, log from app import log
from app.comp import moy_ue, moy_sem, inscr_mod from app.comp import moy_ue, moy_sem, inscr_mod
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.comp.bonus_spo import BonusSport from app.comp.bonus_spo import BonusSport
from app.models import FormSemestreInscription, ScoDocSiteConfig from app.models import ScoDocSiteConfig
from app.models.moduleimpls import ModuleImpl from app.models.moduleimpls import ModuleImpl
from app.models.but_refcomp import ApcParcours, ApcNiveau from app.models.but_refcomp import ApcParcours, ApcNiveau
from app.models.ues import DispenseUE, UniteEns from app.models.ues import DispenseUE, UniteEns
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc.codes_cursus import BUT_CODES_ORDER, UE_SPORT from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
@ -44,8 +44,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
"""Parcours de chaque étudiant { etudid : parcour_id }""" """Parcours de chaque étudiant { etudid : parcour_id }"""
self.ues_ids_by_parcour: dict[set[int]] = {} self.ues_ids_by_parcour: dict[set[int]] = {}
"""{ parcour_id : set }, ue_id de chaque parcours""" """{ parcour_id : set }, ue_id de chaque parcours"""
self.validations_annee: dict[int, ApcValidationAnnee] = {}
"""chargé par get_validations_annee: jury annuel BUT"""
if not self.load_cached(): if not self.load_cached():
t0 = time.time() t0 = time.time()
self.compute() self.compute()
@ -289,9 +288,9 @@ class ResultatsSemestreBUT(NotesTableCompat):
if ref_comp is None: if ref_comp is None:
return set() return set()
if parcour_id is None: if parcour_id is None:
ues_ids = {ue.id for ue in self.ues if ue.type != UE_SPORT} ues_ids = {ue.id for ue in self.ues}
else: else:
parcour: ApcParcours = db.session.get(ApcParcours, parcour_id) parcour: ApcParcours = ApcParcours.query.get(parcour_id)
annee = (self.formsemestre.semestre_id + 1) // 2 annee = (self.formsemestre.semestre_id + 1) // 2
niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp) niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp)
# Les UEs du formsemestre associées à ces niveaux: # Les UEs du formsemestre associées à ces niveaux:
@ -307,13 +306,12 @@ class ResultatsSemestreBUT(NotesTableCompat):
return ues_ids return ues_ids
def etud_has_decision(self, etudid) -> bool: def etud_has_decision(self, etudid):
"""True s'il y a une décision (quelconque) de jury """True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre.
émanant de ce formsemestre pour cet étudiant.
prend aussi en compte les autorisations de passage. prend aussi en compte les autorisations de passage.
Ici sous-classée (BUT) pour les RCUEs et années. Sous-classée en BUT pour les RCUEs et années.
""" """
return bool( return (
super().etud_has_decision(etudid) super().etud_has_decision(etudid)
or ApcValidationAnnee.query.filter_by( or ApcValidationAnnee.query.filter_by(
formsemestre_id=self.formsemestre.id, etudid=etudid formsemestre_id=self.formsemestre.id, etudid=etudid
@ -322,40 +320,3 @@ class ResultatsSemestreBUT(NotesTableCompat):
formsemestre_id=self.formsemestre.id, etudid=etudid formsemestre_id=self.formsemestre.id, etudid=etudid
).count() ).count()
) )
def get_validations_annee(self) -> dict[int, ApcValidationAnnee]:
"""Les validations des étudiants de ce semestre
pour l'année BUT d'une formation compatible avec celle de ce semestre.
Attention:
1) la validation ne provient pas nécessairement de ce semestre
(redoublants, pair/impair, extérieurs).
2) l'étudiant a pu démissionner ou défaillir.
3) S'il y a plusieurs validations pour le même étudiant, prend la "meilleure".
Mémorise le résultat (dans l'instance, pas en cache: TODO voir au profiler)
"""
if self.validations_annee:
return self.validations_annee
annee_but = (self.formsemestre.semestre_id + 1) // 2
validations = ApcValidationAnnee.query.filter_by(
ordre=annee_but,
referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id,
).join(
FormSemestreInscription,
db.and_(
FormSemestreInscription.etudid == ApcValidationAnnee.etudid,
FormSemestreInscription.formsemestre_id == self.formsemestre.id,
),
)
validation_by_etud = {}
for validation in validations:
if validation.etudid in validation_by_etud:
# keep the "best"
if BUT_CODES_ORDER.get(validation.code, 0) > BUT_CODES_ORDER.get(
validation_by_etud[validation.etudid].code, 0
):
validation_by_etud[validation.etudid] = validation
else:
validation_by_etud[validation.etudid] = validation
self.validations_annee = validation_by_etud
return self.validations_annee

View File

@ -17,7 +17,6 @@ import pandas as pd
from flask import g, url_for from flask import g, url_for
from app import db
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_cache import ResultatsCache from app.comp.res_cache import ResultatsCache
from app.comp.jury import ValidationsSemestre from app.comp.jury import ValidationsSemestre
@ -32,7 +31,6 @@ from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
# Il faut bien distinguer # Il faut bien distinguer
# - ce qui est caché de façon persistente (via redis): # - ce qui est caché de façon persistente (via redis):
# ce sont les attributs listés dans `_cached_attrs` # ce sont les attributs listés dans `_cached_attrs`
@ -139,7 +137,7 @@ class ResultatsSemestre(ResultatsCache):
def etud_ues(self, etudid: int) -> Generator[UniteEns]: def etud_ues(self, etudid: int) -> Generator[UniteEns]:
"""Liste des UE auxquelles l'étudiant est inscrit """Liste des UE auxquelles l'étudiant est inscrit
(sans bonus, en BUT prend en compte le parcours de l'étudiant).""" (sans bonus, en BUT prend en compte le parcours de l'étudiant)."""
return (db.session.get(UniteEns, ue_id) for ue_id in self.etud_ues_ids(etudid)) return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid))
def etud_ects_tot_sem(self, etudid: int) -> float: def etud_ects_tot_sem(self, etudid: int) -> float:
"""Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)""" """Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)"""
@ -353,7 +351,7 @@ class ResultatsSemestre(ResultatsCache):
"""L'état de l'UE pour cet étudiant. """L'état de l'UE pour cet étudiant.
Result: dict, ou None si l'UE n'est pas dans ce semestre. Result: dict, ou None si l'UE n'est pas dans ce semestre.
""" """
ue: UniteEns = db.session.get(UniteEns, ue_id) ue: UniteEns = UniteEns.query.get(ue_id)
ue_dict = ue.to_dict() ue_dict = ue.to_dict()
if ue.type == UE_SPORT: if ue.type == UE_SPORT:
@ -383,11 +381,7 @@ class ResultatsSemestre(ResultatsCache):
was_capitalized = False was_capitalized = False
if etudid in self.validations.ue_capitalisees.index: if etudid in self.validations.ue_capitalisees.index:
ue_cap = self._get_etud_ue_cap(etudid, ue) ue_cap = self._get_etud_ue_cap(etudid, ue)
if ( if ue_cap and not np.isnan(ue_cap["moy_ue"]):
ue_cap
and (ue_cap["moy_ue"] is not None)
and not np.isnan(ue_cap["moy_ue"])
):
was_capitalized = True was_capitalized = True
if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue): if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue):
moy_ue = ue_cap["moy_ue"] moy_ue = ue_cap["moy_ue"]
@ -403,7 +397,7 @@ class ResultatsSemestre(ResultatsCache):
if (not coef_ue) and is_capitalized: # étudiant non inscrit dans l'UE courante if (not coef_ue) and is_capitalized: # étudiant non inscrit dans l'UE courante
if self.is_apc: if self.is_apc:
# Coefs de l'UE capitalisée en formation APC: donné par ses ECTS # Coefs de l'UE capitalisée en formation APC: donné par ses ECTS
ue_capitalized = db.session.get(UniteEns, ue_cap["ue_id"]) ue_capitalized = UniteEns.query.get(ue_cap["ue_id"])
coef_ue = ue_capitalized.ects coef_ue = ue_capitalized.ects
if coef_ue is None: if coef_ue is None:
orig_sem = FormSemestre.get_formsemestre(ue_cap["formsemestre_id"]) orig_sem = FormSemestre.get_formsemestre(ue_cap["formsemestre_id"])

View File

@ -9,10 +9,9 @@
from functools import cached_property from functools import cached_property
import pandas as pd import pandas as pd
from flask import flash, g, url_for from flask import flash, g, Markup, url_for
from markupsafe import Markup
from app import db, log from app import log
from app.comp import moy_sem from app.comp import moy_sem
from app.comp.aux_stats import StatsMoyenne from app.comp.aux_stats import StatsMoyenne
from app.comp.res_common import ResultatsSemestre from app.comp.res_common import ResultatsSemestre
@ -284,12 +283,12 @@ class NotesTableCompat(ResultatsSemestre):
] ]
return etudids return etudids
def etud_has_decision(self, etudid) -> bool: def etud_has_decision(self, etudid):
"""True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre. """True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre.
prend aussi en compte les autorisations de passage. prend aussi en compte les autorisations de passage.
Sous-classée en BUT pour les RCUEs et années. Sous-classée en BUT pour les RCUEs et années.
""" """
return bool( return (
self.get_etud_decisions_ue(etudid) self.get_etud_decisions_ue(etudid)
or self.get_etud_decision_sem(etudid) or self.get_etud_decision_sem(etudid)
or ScolarAutorisationInscription.query.filter_by( or ScolarAutorisationInscription.query.filter_by(
@ -394,7 +393,7 @@ class NotesTableCompat(ResultatsSemestre):
de ce module. de ce module.
Évaluation "complete" ssi toutes notes saisies ou en attente. Évaluation "complete" ssi toutes notes saisies ou en attente.
""" """
modimpl = db.session.get(ModuleImpl, moduleimpl_id) modimpl = ModuleImpl.query.get(moduleimpl_id)
modimpl_results = self.modimpls_results.get(moduleimpl_id) modimpl_results = self.modimpls_results.get(moduleimpl_id)
if not modimpl_results: if not modimpl_results:
return [] # safeguard return [] # safeguard

View File

@ -55,9 +55,6 @@ from wtforms.validators import (
) )
from wtforms.widgets import ListWidget, CheckboxInput from wtforms.widgets import ListWidget, CheckboxInput
from app import db
from app.auth.models import User
from app.entreprises import SIRET_PROVISOIRE_START
from app.entreprises.models import ( from app.entreprises.models import (
Entreprise, Entreprise,
EntrepriseCorrespondant, EntrepriseCorrespondant,
@ -65,6 +62,9 @@ from app.entreprises.models import (
EntrepriseSite, EntrepriseSite,
EntrepriseTaxeApprentissage, EntrepriseTaxeApprentissage,
) )
from app import db
from app.auth.models import User
from app.entreprises import SIRET_PROVISOIRE_START
from app.models import Identite, Departement from app.models import Identite, Departement
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -122,13 +122,13 @@ class EntrepriseCreationForm(FlaskForm):
origine = _build_string_field("Origine du correspondant", required=False) origine = _build_string_field("Origine du correspondant", required=False)
notes = _build_string_field("Notes sur le correspondant", required=False) notes = _build_string_field("Notes sur le correspondant", required=False)
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate(self, extra_validators=None): def validate(self):
validate = True validate = True
if not super().validate(extra_validators): if not FlaskForm.validate(self):
return False validate = False
if EntreprisePreferences.get_check_siret() and self.siret.data != "": if EntreprisePreferences.get_check_siret() and self.siret.data != "":
siret_data = self.siret.data.strip().replace(" ", "") siret_data = self.siret.data.strip().replace(" ", "")
@ -248,13 +248,13 @@ class SiteCreationForm(FlaskForm):
codepostal = _build_string_field("Code postal (*)") codepostal = _build_string_field("Code postal (*)")
ville = _build_string_field("Ville (*)") ville = _build_string_field("Ville (*)")
pays = _build_string_field("Pays", required=False) pays = _build_string_field("Pays", required=False)
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate(self, extra_validators=None): def validate(self):
validate = True validate = True
if not super().validate(extra_validators): if not FlaskForm.validate(self):
return False validate = False
site = EntrepriseSite.query.filter_by( site = EntrepriseSite.query.filter_by(
entreprise_id=self.hidden_entreprise_id.data, nom=self.nom.data entreprise_id=self.hidden_entreprise_id.data, nom=self.nom.data
@ -278,10 +278,10 @@ class SiteModificationForm(FlaskForm):
submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE) submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate(self, extra_validators=None): def validate(self):
validate = True validate = True
if not super().validate(extra_validators): if not FlaskForm.validate(self):
return False validate = False
site = EntrepriseSite.query.filter( site = EntrepriseSite.query.filter(
EntrepriseSite.entreprise_id == self.hidden_entreprise_id.data, EntrepriseSite.entreprise_id == self.hidden_entreprise_id.data,
@ -326,7 +326,7 @@ class OffreCreationForm(FlaskForm):
FileAllowed(["pdf", "docx"], "Fichier .pdf ou .docx uniquement"), FileAllowed(["pdf", "docx"], "Fichier .pdf ou .docx uniquement"),
], ],
) )
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -344,10 +344,10 @@ class OffreCreationForm(FlaskForm):
(dept.id, dept.acronym) for dept in Departement.query.all() (dept.id, dept.acronym) for dept in Departement.query.all()
] ]
def validate(self, extra_validators=None): def validate(self):
validate = True validate = True
if not super().validate(extra_validators): if not FlaskForm.validate(self):
return False validate = False
if len(self.depts.data) < 1: if len(self.depts.data) < 1:
self.depts.errors.append("Choisir au moins un département") self.depts.errors.append("Choisir au moins un département")
@ -392,10 +392,10 @@ class OffreModificationForm(FlaskForm):
(dept.id, dept.acronym) for dept in Departement.query.all() (dept.id, dept.acronym) for dept in Departement.query.all()
] ]
def validate(self, extra_validators=None): def validate(self):
validate = True validate = True
if not super().validate(extra_validators): if not FlaskForm.validate(self):
return False validate = False
if len(self.depts.data) < 1: if len(self.depts.data) < 1:
self.depts.errors.append("Choisir au moins un département") self.depts.errors.append("Choisir au moins un département")
@ -442,10 +442,10 @@ class CorrespondantCreationForm(FlaskForm):
"Notes", required=False, render_kw={"class": "form-control"} "Notes", required=False, render_kw={"class": "form-control"}
) )
def validate(self, extra_validators=None): def validate(self):
validate = True validate = True
if not super().validate(extra_validators): if not FlaskForm.validate(self):
return False validate = False
if not self.telephone.data and not self.mail.data: if not self.telephone.data and not self.mail.data:
msg = "Saisir un moyen de contact (mail ou téléphone)" msg = "Saisir un moyen de contact (mail ou téléphone)"
@ -458,13 +458,13 @@ class CorrespondantCreationForm(FlaskForm):
class CorrespondantsCreationForm(FlaskForm): class CorrespondantsCreationForm(FlaskForm):
hidden_site_id = HiddenField() hidden_site_id = HiddenField()
correspondants = FieldList(FormField(CorrespondantCreationForm), min_entries=1) correspondants = FieldList(FormField(CorrespondantCreationForm), min_entries=1)
submit = SubmitField("Enregistrer") submit = SubmitField("Envoyer")
cancel = SubmitField("Annuler") cancel = SubmitField("Annuler")
def validate(self, extra_validators=None): def validate(self):
validate = True validate = True
if not super().validate(extra_validators): if not FlaskForm.validate(self):
return False validate = False
correspondant_list = [] correspondant_list = []
for entry in self.correspondants.entries: for entry in self.correspondants.entries:
@ -531,10 +531,10 @@ class CorrespondantModificationForm(FlaskForm):
.all() .all()
] ]
def validate(self, extra_validators=None): def validate(self):
validate = True validate = True
if not super().validate(extra_validators): if not FlaskForm.validate(self):
return False validate = False
correspondant = EntrepriseCorrespondant.query.filter( correspondant = EntrepriseCorrespondant.query.filter(
EntrepriseCorrespondant.id != self.hidden_correspondant_id.data, EntrepriseCorrespondant.id != self.hidden_correspondant_id.data,
@ -566,7 +566,7 @@ class ContactCreationForm(FlaskForm):
render_kw={"placeholder": "Tapez le nom de l'utilisateur"}, render_kw={"placeholder": "Tapez le nom de l'utilisateur"},
) )
notes = TextAreaField("Notes (*)", validators=[DataRequired(message=CHAMP_REQUIS)]) notes = TextAreaField("Notes (*)", validators=[DataRequired(message=CHAMP_REQUIS)])
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate_utilisateur(self, utilisateur): def validate_utilisateur(self, utilisateur):
@ -613,9 +613,8 @@ class ContactModificationForm(FlaskForm):
class StageApprentissageCreationForm(FlaskForm): class StageApprentissageCreationForm(FlaskForm):
etudiant = _build_string_field( etudiant = _build_string_field(
"Étudiant (*)", "Étudiant (*)",
render_kw={"placeholder": "Tapez le nom de l'étudiant", "autocomplete": "off"}, render_kw={"placeholder": "Tapez le nom de l'étudiant"},
) )
etudid = HiddenField()
type_offre = SelectField( type_offre = SelectField(
"Type de l'offre (*)", "Type de l'offre (*)",
choices=[("Stage"), ("Alternance")], choices=[("Stage"), ("Alternance")],
@ -628,12 +627,12 @@ class StageApprentissageCreationForm(FlaskForm):
"Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)] "Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
) )
notes = TextAreaField("Notes") notes = TextAreaField("Notes")
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate(self, extra_validators=None): def validate(self):
validate = True validate = True
if not super().validate(extra_validators): if not FlaskForm.validate(self):
validate = False validate = False
if ( if (
@ -647,27 +646,64 @@ class StageApprentissageCreationForm(FlaskForm):
return validate return validate
def validate_etudid(self, field): def validate_etudiant(self, etudiant):
"L'etudid doit avoit été placé par le JS" etudiant_data = etudiant.data.upper().strip()
etudid = int(field.data) if field.data else None stm = text(
etudiant = db.session.get(Identite, etudid) if etudid is not None else None "SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom"
)
etudiant = (
Identite.query.from_statement(stm).params(nom_prenom=etudiant_data).first()
)
if etudiant is None: if etudiant is None:
raise ValidationError("Étudiant introuvable (sélectionnez dans la liste)") raise ValidationError("Champ incorrect (selectionnez dans la liste)")
class FrenchFloatField(StringField): class StageApprentissageModificationForm(FlaskForm):
"A field allowing to enter . or ," etudiant = _build_string_field(
"Étudiant (*)",
render_kw={"placeholder": "Tapez le nom de l'étudiant"},
)
type_offre = SelectField(
"Type de l'offre (*)",
choices=[("Stage"), ("Alternance")],
validators=[DataRequired(message=CHAMP_REQUIS)],
)
date_debut = DateField(
"Date début (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
)
date_fin = DateField(
"Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
)
notes = TextAreaField("Notes")
submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def process_formdata(self, valuelist): def validate(self):
"catch incoming data" validate = True
if not valuelist: if not FlaskForm.validate(self):
return validate = False
try:
value = valuelist[0].replace(",", ".") if (
self.data = float(value) self.date_debut.data
except ValueError as exc: and self.date_fin.data
self.data = None and self.date_debut.data > self.date_fin.data
raise ValueError(self.gettext("Not a valid decimal value.")) from exc ):
self.date_debut.errors.append("Les dates sont incompatibles")
self.date_fin.errors.append("Les dates sont incompatibles")
validate = False
return validate
def validate_etudiant(self, etudiant):
etudiant_data = etudiant.data.upper().strip()
stm = text(
"SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom"
)
etudiant = (
Identite.query.from_statement(stm).params(nom_prenom=etudiant_data).first()
)
if etudiant is None:
raise ValidationError("Champ incorrect (selectionnez dans la liste)")
class TaxeApprentissageForm(FlaskForm): class TaxeApprentissageForm(FlaskForm):
@ -684,26 +720,25 @@ class TaxeApprentissageForm(FlaskForm):
], ],
default=int(datetime.now().strftime("%Y")), default=int(datetime.now().strftime("%Y")),
) )
montant = FrenchFloatField( montant = IntegerField(
"Montant (*)", "Montant (*)",
validators=[ validators=[
DataRequired(message=CHAMP_REQUIS), DataRequired(message=CHAMP_REQUIS),
# NumberRange( NumberRange(
# min=0.1, min=1,
# max=1e8, message="Le montant doit être supérieur à 0",
# message="Le montant doit être supérieur à 0", ),
# ),
], ],
default=1, default=1,
) )
notes = TextAreaField("Notes") notes = TextAreaField("Notes")
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate(self, extra_validators=None): def validate(self):
validate = True validate = True
if not super().validate(extra_validators): if not FlaskForm.validate(self):
return False validate = False
taxe = EntrepriseTaxeApprentissage.query.filter_by( taxe = EntrepriseTaxeApprentissage.query.filter_by(
entreprise_id=self.hidden_entreprise_id.data, annee=self.annee.data entreprise_id=self.hidden_entreprise_id.data, annee=self.annee.data
@ -753,12 +788,12 @@ class EnvoiOffreForm(FlaskForm):
submit = SubmitField("Envoyer") submit = SubmitField("Envoyer")
cancel = SubmitField("Annuler") cancel = SubmitField("Annuler")
def validate(self, extra_validators=None): def validate(self):
validate = True validate = True
list_select = True list_select = True
if not super().validate(extra_validators): if not FlaskForm.validate(self):
return False validate = False
for entry in self.responsables.entries: for entry in self.responsables.entries:
if entry.data: if entry.data:

View File

@ -164,10 +164,7 @@ class EntrepriseStageApprentissage(db.Model):
entreprise_id = db.Column( entreprise_id = db.Column(
db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade")
) )
etudid = db.Column( etudid = db.Column(db.Integer)
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
)
type_offre = db.Column(db.Text) type_offre = db.Column(db.Text)
date_debut = db.Column(db.Date) date_debut = db.Column(db.Date)
date_fin = db.Column(db.Date) date_fin = db.Column(db.Date)
@ -183,7 +180,7 @@ class EntrepriseTaxeApprentissage(db.Model):
db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade")
) )
annee = db.Column(db.Integer) annee = db.Column(db.Integer)
montant = db.Column(db.Float) montant = db.Column(db.Integer)
notes = db.Column(db.Text) notes = db.Column(db.Text)

View File

@ -28,6 +28,7 @@ from app.entreprises.forms import (
ContactCreationForm, ContactCreationForm,
ContactModificationForm, ContactModificationForm,
StageApprentissageCreationForm, StageApprentissageCreationForm,
StageApprentissageModificationForm,
EnvoiOffreForm, EnvoiOffreForm,
AjoutFichierForm, AjoutFichierForm,
TaxeApprentissageForm, TaxeApprentissageForm,
@ -238,7 +239,7 @@ def delete_validation_entreprise(entreprise_id):
text=f"Non validation de la fiche entreprise ({entreprise.nom})", text=f"Non validation de la fiche entreprise ({entreprise.nom})",
) )
db.session.add(log) db.session.add(log)
flash("L'entreprise a été supprimée de la liste des entreprises à valider.") flash("L'entreprise a été supprimé de la liste des entreprise à valider.")
return redirect(url_for("entreprises.validation")) return redirect(url_for("entreprises.validation"))
return render_template( return render_template(
"entreprises/form_confirmation.j2", "entreprises/form_confirmation.j2",
@ -769,7 +770,7 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id):
) )
db.session.add(log) db.session.add(log)
db.session.commit() db.session.commit()
flash("La taxe d'apprentissage a été supprimée de la liste.") flash("La taxe d'apprentissage a été supprimé de la liste.")
return redirect( return redirect(
url_for("entreprises.fiche_entreprise", entreprise_id=taxe.entreprise_id) url_for("entreprises.fiche_entreprise", entreprise_id=taxe.entreprise_id)
) )
@ -965,7 +966,7 @@ def delete_offre(entreprise_id, offre_id):
) )
db.session.add(log) db.session.add(log)
db.session.commit() db.session.commit()
flash("L'offre a été supprimée de la fiche entreprise.") flash("L'offre a été supprimé de la fiche entreprise.")
return redirect( return redirect(
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id) url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
) )
@ -1472,8 +1473,7 @@ def delete_contact(entreprise_id, contact_id):
@permission_required(Permission.RelationsEntreprisesChange) @permission_required(Permission.RelationsEntreprisesChange)
def add_stage_apprentissage(entreprise_id): def add_stage_apprentissage(entreprise_id):
""" """
Permet d'ajouter un étudiant ayant réalisé un stage ou alternance Permet d'ajouter un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise
sur la fiche de l'entreprise
""" """
entreprise = Entreprise.query.filter_by( entreprise = Entreprise.query.filter_by(
id=entreprise_id, visible=True id=entreprise_id, visible=True
@ -1484,8 +1484,15 @@ def add_stage_apprentissage(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id) url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id)
) )
if form.validate_on_submit(): if form.validate_on_submit():
etudid = form.etudid.data etudiant_nomcomplet = form.etudiant.data.upper().strip()
etudiant = Identite.query.get_or_404(etudid) stm = text(
"SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom"
)
etudiant = (
Identite.query.from_statement(stm)
.params(nom_prenom=etudiant_nomcomplet)
.first()
)
formation = etudiant.inscription_courante_date( formation = etudiant.inscription_courante_date(
form.date_debut.data, form.date_fin.data form.date_debut.data, form.date_fin.data
) )
@ -1531,7 +1538,7 @@ def add_stage_apprentissage(entreprise_id):
@permission_required(Permission.RelationsEntreprisesChange) @permission_required(Permission.RelationsEntreprisesChange)
def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id): def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
""" """
Permet de modifier un étudiant ayant réalisé un stage ou alternance sur la fiche de l'entreprise Permet de modifier un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise
""" """
stage_apprentissage = EntrepriseStageApprentissage.query.filter_by( stage_apprentissage = EntrepriseStageApprentissage.query.filter_by(
id=stage_apprentissage_id, entreprise_id=entreprise_id id=stage_apprentissage_id, entreprise_id=entreprise_id
@ -1541,14 +1548,21 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
etudiant = Identite.query.filter_by(id=stage_apprentissage.etudid).first_or_404( etudiant = Identite.query.filter_by(id=stage_apprentissage.etudid).first_or_404(
description=f"etudiant {stage_apprentissage.etudid} inconnue" description=f"etudiant {stage_apprentissage.etudid} inconnue"
) )
form = StageApprentissageCreationForm() form = StageApprentissageModificationForm()
if request.method == "POST" and form.cancel.data: if request.method == "POST" and form.cancel.data:
return redirect( return redirect(
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id) url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id)
) )
if form.validate_on_submit(): if form.validate_on_submit():
etudid = form.etudid.data etudiant_nomcomplet = form.etudiant.data.upper().strip()
etudiant = Identite.query.get_or_404(etudid) stm = text(
"SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom"
)
etudiant = (
Identite.query.from_statement(stm)
.params(nom_prenom=etudiant_nomcomplet)
.first()
)
formation = etudiant.inscription_courante_date( formation = etudiant.inscription_courante_date(
form.date_debut.data, form.date_fin.data form.date_debut.data, form.date_fin.data
) )
@ -1563,7 +1577,6 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
formation.formsemestre.formsemestre_id if formation else None, formation.formsemestre.formsemestre_id if formation else None,
) )
stage_apprentissage.notes = form.notes.data.strip() stage_apprentissage.notes = form.notes.data.strip()
db.session.add(stage_apprentissage)
log = EntrepriseHistorique( log = EntrepriseHistorique(
authenticated_user=current_user.user_name, authenticated_user=current_user.user_name,
entreprise_id=stage_apprentissage.entreprise_id, entreprise_id=stage_apprentissage.entreprise_id,
@ -1580,9 +1593,7 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
) )
) )
elif request.method == "GET": elif request.method == "GET":
form.etudiant.data = f"""{sco_etud.format_nom(etudiant.nom)} { form.etudiant.data = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}"
sco_etud.format_prenom(etudiant.prenom)}"""
form.etudid.data = etudiant.id
form.type_offre.data = stage_apprentissage.type_offre form.type_offre.data = stage_apprentissage.type_offre
form.date_debut.data = stage_apprentissage.date_debut form.date_debut.data = stage_apprentissage.date_debut
form.date_fin.data = stage_apprentissage.date_fin form.date_fin.data = stage_apprentissage.date_fin

View File

@ -65,7 +65,6 @@ class CodesDecisionsForm(FlaskForm):
ADJ = _build_code_field("ADJ") ADJ = _build_code_field("ADJ")
ADJR = _build_code_field("ADJR") ADJR = _build_code_field("ADJR")
ADM = _build_code_field("ADM") ADM = _build_code_field("ADM")
ADSUP = _build_code_field("ADSUP")
AJ = _build_code_field("AJ") AJ = _build_code_field("AJ")
ATB = _build_code_field("ATB") ATB = _build_code_field("ATB")
ATJ = _build_code_field("ATJ") ATJ = _build_code_field("ATJ")
@ -82,8 +81,7 @@ class CodesDecisionsForm(FlaskForm):
NOTES_FMT = StringField( NOTES_FMT = StringField(
label="Format notes exportées", label="Format notes exportées",
description="""Format des notes. Par défaut description="""Format des notes. Par défaut <tt style="font-family: monotype;">%3.2f</tt> (deux chiffres après la virgule)""",
<tt style="font-family: monotype;">%3.2f</tt> (deux chiffres après la virgule)""",
validators=[ validators=[
validators.Length( validators.Length(
max=SHORT_STR_LEN, max=SHORT_STR_LEN,

View File

@ -81,3 +81,5 @@ from app.models.but_refcomp import (
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.config import ScoDocSiteConfig from app.models.config import ScoDocSiteConfig
from app.models.assiduites import Assiduite, Justificatif

341
app/models/assiduites.py Normal file
View File

@ -0,0 +1,341 @@
# -*- coding: UTF-8 -*
"""Gestion de l'assiduité (assiduités + justificatifs)
"""
from datetime import datetime
from app import db
from app.models import ModuleImpl
from app.models.etudiants import Identite
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
localize_datetime,
is_period_overlapping,
)
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
localize_datetime,
)
class Assiduite(db.Model):
"""
Représente une assiduité:
- une plage horaire lié à un état et un étudiant
- un module si spécifiée
- une description si spécifiée
"""
__tablename__ = "assiduites"
id = db.Column(db.Integer, primary_key=True, nullable=False)
assiduite_id = db.synonym("id")
date_debut = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
date_fin = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
moduleimpl_id = db.Column(
db.Integer,
db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"),
)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etat = db.Column(db.Integer, nullable=False)
desc = db.Column(db.Text)
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
)
est_just = db.Column(db.Boolean, server_default="false", nullable=False)
def to_dict(self, format_api=True) -> dict:
"""Retourne la représentation json de l'assiduité"""
etat = self.etat
if format_api:
etat = EtatAssiduite.inverse().get(self.etat).name
data = {
"assiduite_id": self.id,
"etudid": self.etudid,
"moduleimpl_id": self.moduleimpl_id,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"desc": self.desc,
"entry_date": self.entry_date,
"user_id": self.user_id,
"est_just": self.est_just,
}
return data
@classmethod
def create_assiduite(
cls,
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatAssiduite,
moduleimpl: ModuleImpl = None,
description: str = None,
entry_date: datetime = None,
user_id: int = None,
est_just: bool = False,
) -> object or int:
"""Créer une nouvelle assiduité pour l'étudiant"""
# Vérification de non duplication des périodes
assiduites: list[Assiduite] = etud.assiduites
if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite):
raise ScoValueError(
"Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)"
)
if moduleimpl is not None:
# Vérification de l'existence du module pour l'étudiant
if moduleimpl.est_inscrit(etud):
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
moduleimpl_id=moduleimpl.id,
desc=description,
entry_date=entry_date,
user_id=user_id,
est_just=est_just,
)
else:
raise ScoValueError("L'étudiant n'est pas inscrit au moduleimpl")
else:
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
desc=description,
entry_date=entry_date,
user_id=user_id,
est_just=est_just,
)
return nouv_assiduite
@classmethod
def fast_create_assiduite(
cls,
etudid: int,
date_debut: datetime,
date_fin: datetime,
etat: EtatAssiduite,
moduleimpl_id: int = None,
description: str = None,
entry_date: datetime = None,
est_just: bool = False,
) -> object or int:
"""Créer une nouvelle assiduité pour l'étudiant"""
# Vérification de non duplication des périodes
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudid=etudid,
moduleimpl_id=moduleimpl_id,
desc=description,
entry_date=entry_date,
est_just=est_just,
)
return nouv_assiduite
class Justificatif(db.Model):
"""
Représente un justificatif:
- une plage horaire lié à un état et un étudiant
- une raison si spécifiée
- un fichier si spécifié
"""
__tablename__ = "justificatifs"
id = db.Column(db.Integer, primary_key=True)
justif_id = db.synonym("id")
date_debut = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
date_fin = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etat = db.Column(
db.Integer,
nullable=False,
)
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
raison = db.Column(db.Text())
# Archive_id -> sco_archives_justificatifs.py
fichier = db.Column(db.Text())
def to_dict(self, format_api: bool = False) -> dict:
"""transformation de l'objet en dictionnaire sérialisable"""
etat = self.etat
if format_api:
etat = EtatJustificatif.inverse().get(self.etat).name
data = {
"justif_id": self.justif_id,
"etudid": self.etudid,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"raison": self.raison,
"fichier": self.fichier,
"entry_date": self.entry_date,
"user_id": self.user_id,
}
return data
@classmethod
def create_justificatif(
cls,
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
raison: str = None,
entry_date: datetime = None,
user_id: int = None,
) -> object or int:
"""Créer un nouveau justificatif pour l'étudiant"""
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
raison=raison,
entry_date=entry_date,
user_id=user_id,
)
return nouv_justificatif
@classmethod
def fast_create_justificatif(
cls,
etudid: int,
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
raison: str = None,
entry_date: datetime = None,
) -> object or int:
"""Créer un nouveau justificatif pour l'étudiant"""
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudid=etudid,
raison=raison,
entry_date=entry_date,
)
return nouv_justificatif
def is_period_conflicting(
date_debut: datetime,
date_fin: datetime,
collection: list[Assiduite or Justificatif],
collection_cls: Assiduite or Justificatif,
) -> bool:
"""
Vérifie si une date n'entre pas en collision
avec les justificatifs ou assiduites déjà présentes
"""
date_debut = localize_datetime(date_debut)
date_fin = localize_datetime(date_fin)
if (
collection.filter_by(date_debut=date_debut, date_fin=date_fin).first()
is not None
):
return True
count: int = collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut
).count()
return count > 0
def compute_assiduites_justified(
justificatifs: Justificatif = Justificatif, reset: bool = False
) -> list[int]:
"""Calcule et modifie les champs "est_just" de chaque assiduité lié à l'étud
retourne la liste des assiduite_id justifiées
Si reset alors : met à false toutes les assiduités non justifiées par les justificatifs donnés
"""
list_assiduites_id: set[int] = set()
for justi in justificatifs:
assiduites: Assiduite = (
Assiduite.query.join(Justificatif, Justificatif.etudid == Assiduite.etudid)
.filter(justi.etat == EtatJustificatif.VALIDE)
.filter(
Assiduite.date_debut < justi.date_fin,
Assiduite.date_fin > justi.date_debut,
)
)
for assi in assiduites:
assi.est_just = True
list_assiduites_id.add(assi.id)
db.session.add(assi)
if reset:
un_justified: Assiduite = Assiduite.query.filter(
Assiduite.id.not_in(list_assiduites_id)
).join(Justificatif, Justificatif.etudid == Assiduite.etudid)
for assi in un_justified:
assi.est_just = False
db.session.add(assi)
db.session.commit()
return list(list_assiduites_id)

View File

@ -9,7 +9,6 @@ from datetime import datetime
import functools import functools
from operator import attrgetter from operator import attrgetter
from flask import g
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
from sqlalchemy.orm import class_mapper from sqlalchemy.orm import class_mapper
import sqlalchemy import sqlalchemy
@ -94,11 +93,6 @@ class ApcReferentielCompetences(db.Model, XMLModel):
backref="referentiel_competence", backref="referentiel_competence",
order_by="Formation.acronyme, Formation.version", order_by="Formation.acronyme, Formation.version",
) )
validations_annee = db.relationship(
"ApcValidationAnnee",
backref="referentiel_competence",
lazy="dynamic",
)
def __repr__(self): def __repr__(self):
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>" return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
@ -364,9 +358,6 @@ class ApcNiveau(db.Model, XMLModel):
return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={ return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={
self.annee!r} {self.competence!r}>""" self.annee!r} {self.competence!r}>"""
def __str__(self):
return f"""{self.competence.titre} niveau {self.ordre}"""
def to_dict(self, with_app_critiques=True): def to_dict(self, with_app_critiques=True):
"as a dict, recursif (ou non) sur les AC" "as a dict, recursif (ou non) sur les AC"
return { return {
@ -397,9 +388,7 @@ class ApcNiveau(db.Model, XMLModel):
return ( return (
ApcParcours.query.join(ApcAnneeParcours) ApcParcours.query.join(ApcAnneeParcours)
.filter_by(ordre=annee) .filter_by(ordre=annee)
.join(ApcParcoursNiveauCompetence) .join(ApcParcoursNiveauCompetence, ApcCompetence, ApcNiveau)
.join(ApcCompetence)
.join(ApcNiveau)
.filter_by(id=self.id) .filter_by(id=self.id)
.order_by(ApcParcours.numero, ApcParcours.code) .order_by(ApcParcours.numero, ApcParcours.code)
.all() .all()
@ -423,20 +412,6 @@ class ApcNiveau(db.Model, XMLModel):
(dans ce cas, spécifier referentiel_competence) (dans ce cas, spécifier referentiel_competence)
Si competence est indiquée, filtre les niveaux de cette compétence. Si competence est indiquée, filtre les niveaux de cette compétence.
""" """
key = (
parcour.id if parcour else None,
annee,
referentiel_competence.id if referentiel_competence else None,
competence.id if competence else None,
)
_cache = getattr(g, "_niveaux_annee_de_parcours_cache", None)
if _cache:
result = g._niveaux_annee_de_parcours_cache.get(key, False)
if result is not False:
return result
else:
g._niveaux_annee_de_parcours_cache = {}
_cache = g._niveaux_annee_de_parcours_cache
if annee not in {1, 2, 3}: if annee not in {1, 2, 3}:
raise ValueError("annee invalide pour un parcours BUT") raise ValueError("annee invalide pour un parcours BUT")
referentiel_competence = ( referentiel_competence = (
@ -453,13 +428,10 @@ class ApcNiveau(db.Model, XMLModel):
) )
if competence is not None: if competence is not None:
query = query.filter(ApcCompetence.id == competence.id) query = query.filter(ApcCompetence.id == competence.id)
result = query.all() return query.all()
_cache[key] = result
return result
annee_parcour: ApcAnneeParcours = parcour.annees.filter_by(ordre=annee).first() annee_parcour: ApcAnneeParcours = parcour.annees.filter_by(ordre=annee).first()
if not annee_parcour: if not annee_parcour:
_cache[key] = []
return [] return []
if competence is None: if competence is None:
@ -471,17 +443,9 @@ class ApcNiveau(db.Model, XMLModel):
for pn in parcour_niveaux for pn in parcour_niveaux
] ]
else: else:
niveaux: list[ApcNiveau] = ( niveaux: list[ApcNiveau] = competence.niveaux.filter_by(
ApcNiveau.query.filter_by(annee=f"BUT{int(annee)}") annee=f"BUT{int(annee)}"
.join(ApcCompetence) ).all()
.filter_by(id=competence.id)
.join(ApcParcoursNiveauCompetence)
.filter(ApcParcoursNiveauCompetence.niveau == ApcNiveau.ordre)
.join(ApcAnneeParcours)
.filter_by(parcours_id=parcour.id)
.all()
)
_cache[key] = niveaux
return niveaux return niveaux
@ -623,8 +587,7 @@ class ApcParcours(db.Model, XMLModel):
def query_competences(self) -> Query: def query_competences(self) -> Query:
"Les compétences associées à ce parcours" "Les compétences associées à ce parcours"
return ( return (
ApcCompetence.query.join(ApcParcoursNiveauCompetence) ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
.join(ApcAnneeParcours)
.filter_by(parcours_id=self.id) .filter_by(parcours_id=self.id)
.order_by(ApcCompetence.numero) .order_by(ApcCompetence.numero)
) )
@ -633,8 +596,7 @@ class ApcParcours(db.Model, XMLModel):
"La compétence de titre donné dans ce parcours, ou None" "La compétence de titre donné dans ce parcours, ou None"
return ( return (
ApcCompetence.query.filter_by(titre=titre) ApcCompetence.query.filter_by(titre=titre)
.join(ApcParcoursNiveauCompetence) .join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
.join(ApcAnneeParcours)
.filter_by(parcours_id=self.id) .filter_by(parcours_id=self.id)
.order_by(ApcCompetence.numero) .order_by(ApcCompetence.numero)
.first() .first()

View File

@ -2,6 +2,9 @@
"""Décisions de jury (validations) des RCUE et années du BUT """Décisions de jury (validations) des RCUE et années du BUT
""" """
from typing import Union
from flask_sqlalchemy.query import Query
from app import db from app import db
from app.models import CODE_STR_LEN from app.models import CODE_STR_LEN
@ -10,6 +13,8 @@ from app.models.etudiants import Identite
from app.models.formations import Formation from app.models.formations import Formation
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.scodoc import codes_cursus as sco_codes
from app.scodoc import sco_utils as scu
class ApcValidationRCUE(db.Model): class ApcValidationRCUE(db.Model):
@ -17,7 +22,7 @@ class ApcValidationRCUE(db.Model):
aka "regroupements cohérents d'UE" dans le jargon BUT. aka "regroupements cohérents d'UE" dans le jargon BUT.
Le formsemestre est l'origine, utilisé pour effacer Le formsemestre est celui du semestre PAIR du niveau de compétence
""" """
__tablename__ = "apc_validation_rcue" __tablename__ = "apc_validation_rcue"
@ -36,7 +41,7 @@ class ApcValidationRCUE(db.Model):
formsemestre_id = db.Column( formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
) )
"formsemestre origine du RCUE (celui d'où a été émis la validation)" "formsemestre pair du RCUE"
# Les deux UE associées à ce niveau: # Les deux UE associées à ce niveau:
ue1_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False) ue1_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False) ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
@ -61,7 +66,7 @@ class ApcValidationRCUE(db.Model):
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: { return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}""" self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
def html(self) -> str: def to_html(self) -> str:
"description en HTML" "description en HTML"
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
<b>{self.code}</b> <b>{self.code}</b>
@ -82,10 +87,6 @@ class ApcValidationRCUE(db.Model):
"as a dict" "as a dict"
d = dict(self.__dict__) d = dict(self.__dict__)
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
d["etud"] = self.etud.to_dict_short()
d["ue1"] = self.ue1.to_dict()
d["ue2"] = self.ue2.to_dict()
return d return d
def to_dict_bul(self) -> dict: def to_dict_bul(self) -> dict:
@ -108,14 +109,204 @@ class ApcValidationRCUE(db.Model):
} }
# Attention: ce n'est pas un modèle mais une classe ordinaire:
class RegroupementCoherentUE:
"""Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs
de la même année (BUT1,2,3) liées au *même niveau de compétence*.
La moyenne (10/20) au RCUE déclenche la compensation des UE.
"""
def __init__(
self,
etud: Identite,
formsemestre_1: FormSemestre,
dec_ue_1: "DecisionsProposeesUE",
formsemestre_2: FormSemestre,
dec_ue_2: "DecisionsProposeesUE",
inscription_etat: str,
):
ue_1 = dec_ue_1.ue
ue_2 = dec_ue_2.ue
# Ordonne les UE dans le sens croissant (S1,S2) ou (S3,S4)...
if formsemestre_1.semestre_id > formsemestre_2.semestre_id:
(ue_1, formsemestre_1), (ue_2, formsemestre_2) = (
(ue_2, formsemestre_2),
(ue_1, formsemestre_1),
)
assert formsemestre_1.semestre_id % 2 == 1
assert formsemestre_2.semestre_id % 2 == 0
assert abs(formsemestre_1.semestre_id - formsemestre_2.semestre_id) == 1
assert ue_1.niveau_competence_id == ue_2.niveau_competence_id
self.etud = etud
self.formsemestre_1 = formsemestre_1
"semestre impair"
self.ue_1 = ue_1
self.formsemestre_2 = formsemestre_2
"semestre pair"
self.ue_2 = ue_2
# Stocke les moyennes d'UE
if inscription_etat != scu.INSCRIT:
self.moy_rcue = None
self.moy_ue_1 = self.moy_ue_2 = "-"
self.moy_ue_1_val = self.moy_ue_2_val = 0.0
return
self.moy_ue_1 = dec_ue_1.moy_ue_with_cap
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
self.moy_ue_2 = dec_ue_2.moy_ue_with_cap
self.moy_ue_2_val = self.moy_ue_2 if self.moy_ue_2 is not None else 0.0
# Calcul de la moyenne au RCUE (utilise les moy d'UE capitalisées)
if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
# Moyenne RCUE (les pondérations par défaut sont 1.)
self.moy_rcue = (
self.moy_ue_1 * ue_1.coef_rcue + self.moy_ue_2 * ue_2.coef_rcue
) / (ue_1.coef_rcue + ue_2.coef_rcue)
else:
self.moy_rcue = None
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} {
self.ue_1.acronyme}({self.moy_ue_1}) {
self.ue_2.acronyme}({self.moy_ue_2})>"""
def __str__(self) -> str:
return f"""RCUE {
self.ue_1.acronyme}({self.moy_ue_1}) + {
self.ue_2.acronyme}({self.moy_ue_2})"""
def query_validations(
self,
) -> Query: # list[ApcValidationRCUE]
"""Les validations de jury enregistrées pour ce RCUE"""
niveau = self.ue_2.niveau_competence
return (
ApcValidationRCUE.query.filter_by(
etudid=self.etud.id,
)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
.join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id)
.filter(ApcNiveau.id == niveau.id)
)
def other_ue(self, ue: UniteEns) -> UniteEns:
"""L'autre UE du regroupement. Si ue ne fait pas partie du regroupement, ValueError"""
if ue.id == self.ue_1.id:
return self.ue_2
elif ue.id == self.ue_2.id:
return self.ue_1
raise ValueError(f"ue {ue} hors RCUE {self}")
def est_enregistre(self) -> bool:
"""Vrai si ce RCUE, donc le niveau de compétences correspondant
a une décision jury enregistrée
"""
return self.query_validations().count() > 0
def est_compensable(self):
"""Vrai si ce RCUE est validable (uniquement) par compensation
c'est à dire que sa moyenne est > 10 avec une UE < 10.
Note: si ADM, est_compensable est faux.
"""
return (
(self.moy_rcue is not None)
and (self.moy_rcue > sco_codes.BUT_BARRE_RCUE)
and (
(self.moy_ue_1_val < sco_codes.NOTES_BARRE_GEN)
or (self.moy_ue_2_val < sco_codes.NOTES_BARRE_GEN)
)
)
def est_suffisant(self) -> bool:
"""Vrai si ce RCUE est > 8"""
return (self.moy_rcue is not None) and (
self.moy_rcue > sco_codes.BUT_RCUE_SUFFISANT
)
def est_validable(self) -> bool:
"""Vrai si ce RCUE satisfait les conditions pour être validé,
c'est à dire que la moyenne des UE qui le constituent soit > 10
"""
return (self.moy_rcue is not None) and (
self.moy_rcue > sco_codes.BUT_BARRE_RCUE
)
def code_valide(self) -> Union[ApcValidationRCUE, None]:
"Si ce RCUE est ADM, CMP ou ADJ, la validation. Sinon, None"
validation = self.query_validations().first()
if (validation is not None) and (
validation.code in sco_codes.CODES_RCUE_VALIDES
):
return validation
return None
# unused
# def find_rcues(
# formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str
# ) -> list[RegroupementCoherentUE]:
# """Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
# ce semestre pour cette UE.
# Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit.
# En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
# Résultat: la liste peut être vide.
# """
# if (ue.niveau_competence is None) or (ue.semestre_idx is None):
# return []
# if ue.semestre_idx % 2: # S1, S3, S5
# other_semestre_idx = ue.semestre_idx + 1
# else:
# other_semestre_idx = ue.semestre_idx - 1
# cursor = db.session.execute(
# text(
# """SELECT
# ue.id, formsemestre.id
# FROM
# notes_ue ue,
# notes_formsemestre_inscription inscr,
# notes_formsemestre formsemestre
# WHERE
# inscr.etudid = :etudid
# AND inscr.formsemestre_id = formsemestre.id
# AND formsemestre.semestre_id = :other_semestre_idx
# AND ue.formation_id = formsemestre.formation_id
# AND ue.niveau_competence_id = :ue_niveau_competence_id
# AND ue.semestre_idx = :other_semestre_idx
# """
# ),
# {
# "etudid": etud.id,
# "other_semestre_idx": other_semestre_idx,
# "ue_niveau_competence_id": ue.niveau_competence_id,
# },
# )
# rcues = []
# for ue_id, formsemestre_id in cursor:
# other_ue = UniteEns.query.get(ue_id)
# other_formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# rcues.append(
# RegroupementCoherentUE(
# etud, formsemestre, ue, other_formsemestre, other_ue, inscription_etat
# )
# )
# # safety check: 1 seul niveau de comp. concerné:
# assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
# return rcues
class ApcValidationAnnee(db.Model): class ApcValidationAnnee(db.Model):
"""Validation des années du BUT""" """Validation des années du BUT"""
__tablename__ = "apc_validation_annee" __tablename__ = "apc_validation_annee"
# Assure unicité de la décision: # Assure unicité de la décision:
__table_args__ = ( __table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire", "ordre"),)
db.UniqueConstraint("etudid", "ordre", "referentiel_competence_id"),
)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
etudid = db.Column( etudid = db.Column(
db.Integer, db.Integer,
@ -128,11 +319,8 @@ class ApcValidationAnnee(db.Model):
formsemestre_id = db.Column( formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True
) )
"le semestre origine, normalement l'IMPAIR (le 1er) de l'année" "le semestre IMPAIR (le 1er) de l'année"
referentiel_competence_id = db.Column( annee_scolaire = db.Column(db.Integer, nullable=False) # 2021
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
)
annee_scolaire = db.Column(db.Integer, nullable=False) # eg 2021
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True) code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
@ -150,50 +338,25 @@ class ApcValidationAnnee(db.Model):
"dict pour bulletins" "dict pour bulletins"
return { return {
"annee_scolaire": self.annee_scolaire, "annee_scolaire": self.annee_scolaire,
"date": self.date.isoformat() if self.date else "", "date": self.date.isoformat(),
"code": self.code, "code": self.code,
"ordre": self.ordre, "ordre": self.ordre,
} }
def html(self) -> str:
"Affichage html"
date_str = (
f"""le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}"""
if self.date
else "(sans date)"
)
link = (
self.formsemestre.html_link_status(
label=f"{self.formsemestre.titre_formation(with_sem_idx=1)}",
title=self.formsemestre.titre_annee(),
)
if self.formsemestre
else "externe/antérieure"
)
return f"""Validation <b>année BUT{self.ordre}</b> émise par
{link}
: <b>{self.code}</b>
{date_str}
"""
def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict: def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
""" """
Un dict avec les décisions de jury BUT enregistrées: Un dict avec les décisions de jury BUT enregistrées:
- decision_rcue : list[dict] - decision_rcue : list[dict]
- decision_annee : dict (décision issue de ce semestre seulement (à confirmer ?)) - decision_annee : dict
Ne reprend pas les décisions d'UE, non spécifiques au BUT. Ne reprend pas les décisions d'UE, non spécifiques au BUT.
""" """
decisions = {} decisions = {}
# --- RCUEs: seulement sur semestres pairs XXX à améliorer # --- RCUEs: seulement sur semestres pairs XXX à améliorer
if formsemestre.semestre_id % 2 == 0: if formsemestre.semestre_id % 2 == 0:
# validations émises depuis ce formsemestre: # validations émises depuis ce formsemestre:
validations_rcues = ( validations_rcues = ApcValidationRCUE.query.filter_by(
ApcValidationRCUE.query.filter_by( etudid=etud.id, formsemestre_id=formsemestre.id
etudid=etud.id, formsemestre_id=formsemestre.id
)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
.order_by(UniteEns.numero, UniteEns.acronyme)
) )
decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues] decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues]
titres_rcues = [] titres_rcues = []
@ -215,11 +378,16 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
decisions["descr_decisions_rcue"] = "" decisions["descr_decisions_rcue"] = ""
decisions["descr_decisions_niveaux"] = "" decisions["descr_decisions_niveaux"] = ""
# --- Année: prend la validation pour l'année scolaire de ce semestre # --- Année: prend la validation pour l'année scolaire de ce semestre
validation = ApcValidationAnnee.query.filter_by( validation = (
etudid=etud.id, ApcValidationAnnee.query.filter_by(
annee_scolaire=formsemestre.annee_scolaire(), etudid=etud.id,
referentiel_competence_id=formsemestre.formation.referentiel_competence_id, annee_scolaire=formsemestre.annee_scolaire(),
).first() )
.join(ApcValidationAnnee.formsemestre)
.join(FormSemestre.formation)
.filter(Formation.formation_code == formsemestre.formation.formation_code)
.first()
)
if validation: if validation:
decisions["decision_annee"] = validation.to_dict_bul() decisions["decision_annee"] = validation.to_dict_bul()
else: else:

View File

@ -15,7 +15,6 @@ from app.scodoc.codes_cursus import (
ADJ, ADJ,
ADJR, ADJR,
ADM, ADM,
ADSUP,
AJ, AJ,
ATB, ATB,
ATJ, ATJ,
@ -38,7 +37,6 @@ CODES_SCODOC_TO_APO = {
ADJ: "ADM", ADJ: "ADM",
ADJR: "ADM", ADJR: "ADM",
ADM: "ADM", ADM: "ADM",
ADSUP: "ADM",
AJ: "AJ", AJ: "AJ",
ATB: "AJAC", ATB: "AJAC",
ATJ: "AJAC", ATJ: "AJAC",

View File

@ -43,8 +43,8 @@ class Identite(db.Model):
"optionnel (si present, affiché à la place du nom)" "optionnel (si present, affiché à la place du nom)"
civilite = db.Column(db.String(1), nullable=False) civilite = db.Column(db.String(1), nullable=False)
# données d'état-civil. Si présent remplace les données d'usage dans les documents # données d'état-civil. Si présent remplace les données d'usage dans les documents officiels (bulletins, PV)
# officiels (bulletins, PV): voir nomprenom_etat_civil() # cf nomprenom_etat_civil()
civilite_etat_civil = db.Column(db.String(1), nullable=False, server_default="X") civilite_etat_civil = db.Column(db.String(1), nullable=False, server_default="X")
prenom_etat_civil = db.Column(db.Text(), nullable=False, server_default="") prenom_etat_civil = db.Column(db.Text(), nullable=False, server_default="")
@ -73,17 +73,15 @@ class Identite(db.Model):
passive_deletes=True, passive_deletes=True,
) )
# Relations avec les assiduites et les justificatifs
assiduites = db.relationship("Assiduite", backref="etudiant", lazy="dynamic")
justificatifs = db.relationship("Justificatif", backref="etudiant", lazy="dynamic")
def __repr__(self): def __repr__(self):
return ( return (
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>" f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
) )
def html_link_fiche(self) -> str:
"lien vers la fiche"
return f"""<a class="stdlink" href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id)
}">{self.nomprenom}</a>"""
@classmethod @classmethod
def from_request(cls, etudid=None, code_nip=None) -> "Identite": def from_request(cls, etudid=None, code_nip=None) -> "Identite":
"""Étudiant à partir de l'etudid ou du code_nip, soit """Étudiant à partir de l'etudid ou du code_nip, soit
@ -226,7 +224,7 @@ class Identite(db.Model):
} }
args_dict = {} args_dict = {}
for key, value in args.items(): for key, value in args.items():
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property): if hasattr(cls, key):
# compat scodoc7 (mauvaise idée de l'époque) # compat scodoc7 (mauvaise idée de l'époque)
if key in fs_empty_stored_as_nulls and value == "": if key in fs_empty_stored_as_nulls and value == "":
value = None value = None

View File

@ -145,18 +145,6 @@ class Evaluation(db.Model):
db.session.add(copy) db.session.add(copy)
return copy return copy
def is_matin(self) -> bool:
"Evaluation ayant lieu le matin (faux si pas de date)"
heure_debut_dt = self.heure_debut or datetime.time(8, 00)
# 8:00 au cas ou pas d'heure (note externe?)
return bool(self.jour) and heure_debut_dt < datetime.time(12, 00)
def is_apresmidi(self) -> bool:
"Evaluation ayant lieu l'après midi (faux si pas de date)"
heure_debut_dt = self.heure_debut or datetime.time(8, 00)
# 8:00 au cas ou pas d'heure (note externe?)
return bool(self.jour) and heure_debut_dt >= datetime.time(12, 00)
def set_default_poids(self) -> bool: def set_default_poids(self) -> bool:
"""Initialize les poids bvers les UE à leurs valeurs par défaut """Initialize les poids bvers les UE à leurs valeurs par défaut
C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon. C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
@ -190,10 +178,8 @@ class Evaluation(db.Model):
""" """
L = [] L = []
for ue_id, poids in ue_poids_dict.items(): for ue_id, poids in ue_poids_dict.items():
ue = db.session.get(UniteEns, ue_id) ue = UniteEns.query.get(ue_id)
ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids) L.append(EvaluationUEPoids(evaluation=self, ue=ue, poids=poids))
L.append(ue_poids)
db.session.add(ue_poids)
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
self.moduleimpl.invalidate_evaluations_poids() # inval cache self.moduleimpl.invalidate_evaluations_poids() # inval cache
@ -340,7 +326,7 @@ def check_evaluation_args(args):
jour = args.get("jour", None) jour = args.get("jour", None)
args["jour"] = jour args["jour"] = jour
if jour: if jour:
modimpl = db.session.get(ModuleImpl, moduleimpl_id) modimpl = ModuleImpl.query.get(moduleimpl_id)
formsemestre = modimpl.formsemestre formsemestre = modimpl.formsemestre
y, m, d = [int(x) for x in ndb.DateDMYtoISO(jour).split("-")] y, m, d = [int(x) for x in ndb.DateDMYtoISO(jour).split("-")]
jour = datetime.date(y, m, d) jour = datetime.date(y, m, d)

View File

@ -54,17 +54,14 @@ class ScolarNews(db.Model):
NEWS_APO = "APO" # changements de codes APO NEWS_APO = "APO" # changements de codes APO
NEWS_FORM = "FORM" # modification formation (object=formation_id) NEWS_FORM = "FORM" # modification formation (object=formation_id)
NEWS_INSCR = "INSCR" # inscription d'étudiants (object=None ou formsemestre_id) NEWS_INSCR = "INSCR" # inscription d'étudiants (object=None ou formsemestre_id)
NEWS_JURY = "JURY" # saisie jury
NEWS_MISC = "MISC" # unused NEWS_MISC = "MISC" # unused
NEWS_NOTE = "NOTES" # saisie note (object=moduleimpl_id) NEWS_NOTE = "NOTES" # saisie note (object=moduleimpl_id)
NEWS_SEM = "SEM" # creation semestre (object=None) NEWS_SEM = "SEM" # creation semestre (object=None)
NEWS_MAP = { NEWS_MAP = {
NEWS_ABS: "saisie absence", NEWS_ABS: "saisie absence",
NEWS_APO: "modif. code Apogée", NEWS_APO: "modif. code Apogée",
NEWS_FORM: "modification formation", NEWS_FORM: "modification formation",
NEWS_INSCR: "inscription d'étudiants", NEWS_INSCR: "inscription d'étudiants",
NEWS_JURY: "saisie jury",
NEWS_MISC: "opération", # unused NEWS_MISC: "opération", # unused
NEWS_NOTE: "saisie note", NEWS_NOTE: "saisie note",
NEWS_SEM: "création semestre", NEWS_SEM: "création semestre",
@ -133,10 +130,10 @@ class ScolarNews(db.Model):
return query.order_by(cls.date.desc()).limit(n).all() return query.order_by(cls.date.desc()).limit(n).all()
@classmethod @classmethod
def add(cls, typ, obj=None, text="", url=None, max_frequency=600): def add(cls, typ, obj=None, text="", url=None, max_frequency=0):
"""Enregistre une nouvelle """Enregistre une nouvelle
Si max_frequency, ne génère pas 2 nouvelles "identiques" Si max_frequency, ne génère pas 2 nouvelles "identiques"
à moins de max_frequency secondes d'intervalle (10 minutes par défaut). à moins de max_frequency secondes d'intervalle.
Deux nouvelles sont considérées comme "identiques" si elles ont Deux nouvelles sont considérées comme "identiques" si elles ont
même (obj, typ, user). même (obj, typ, user).
La nouvelle enregistrée est aussi envoyée par mail. La nouvelle enregistrée est aussi envoyée par mail.
@ -156,10 +153,7 @@ class ScolarNews(db.Model):
if last_news: if last_news:
now = datetime.datetime.now(tz=last_news.date.tzinfo) now = datetime.datetime.now(tz=last_news.date.tzinfo)
if (now - last_news.date) < datetime.timedelta(seconds=max_frequency): if (now - last_news.date) < datetime.timedelta(seconds=max_frequency):
# pas de nouvel event, mais met à jour l'heure # on n'enregistre pas
last_news.date = datetime.datetime.now()
db.session.add(last_news)
db.session.commit()
return return
news = ScolarNews( news = ScolarNews(
@ -187,14 +181,14 @@ class ScolarNews(db.Model):
elif self.type == self.NEWS_NOTE: elif self.type == self.NEWS_NOTE:
moduleimpl_id = self.object moduleimpl_id = self.object
if moduleimpl_id: if moduleimpl_id:
modimpl = db.session.get(ModuleImpl, moduleimpl_id) modimpl = ModuleImpl.query.get(moduleimpl_id)
if modimpl is None: if modimpl is None:
return None # module does not exists anymore return None # module does not exists anymore
formsemestre_id = modimpl.formsemestre_id formsemestre_id = modimpl.formsemestre_id
if not formsemestre_id: if not formsemestre_id:
return None return None
formsemestre = db.session.get(FormSemestre, formsemestre_id) formsemestre = FormSemestre.query.get(formsemestre_id)
return formsemestre return formsemestre
def notify_by_mail(self): def notify_by_mail(self):
@ -265,8 +259,11 @@ class ScolarNews(db.Model):
# Informations générales # Informations générales
H.append( H.append(
f"""<div><a class="discretelink" href="{scu.SCO_ANNONCES_WEBSITE}"> f"""<div>
Pour en savoir plus sur ScoDoc voir le site scodoc.org</a>. Pour être informé des évolutions de ScoDoc,
vous pouvez vous
<a class="stdlink" href="{scu.SCO_ANNONCES_WEBSITE}">
abonner à la liste de diffusion</a>.
</div> </div>
""" """
) )

View File

@ -60,7 +60,7 @@ class Formation(db.Model):
return f"""<{self.__class__.__name__}(id={self.id}, dept_id={ return f"""<{self.__class__.__name__}(id={self.id}, dept_id={
self.dept_id}, acronyme={self.acronyme!r}, version={self.version})>""" self.dept_id}, acronyme={self.acronyme!r}, version={self.version})>"""
def html(self) -> str: def to_html(self) -> str:
"titre complet pour affichage" "titre complet pour affichage"
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}""" return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""

View File

@ -16,7 +16,7 @@ from operator import attrgetter
from flask_login import current_user from flask_login import current_user
from flask import flash, g, url_for from flask import flash, g
from sqlalchemy.sql import text from sqlalchemy.sql import text
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -163,14 +163,6 @@ class FormSemestre(db.Model):
def __repr__(self): def __repr__(self):
return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>" return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>"
def html_link_status(self, label=None, title=None) -> str:
"html link to status page"
return f"""<a class="stdlink" href="{
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
formsemestre_id=self.id,)
}" title="{title or ''}">{label or self.titre_mois()}</a>
"""
@classmethod @classmethod
def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre": def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre":
""" "FormSemestre ou 404, cherche uniquement dans le département courant""" """ "FormSemestre ou 404, cherche uniquement dans le département courant"""
@ -305,17 +297,6 @@ class FormSemestre(db.Model):
- et sont associées à l'un des parcours de ce formsemestre - et sont associées à l'un des parcours de ce formsemestre
(ou à aucun, donc tronc commun). (ou à aucun, donc tronc commun).
""" """
# per-request caching
key = (self.id, with_sport)
_cache = getattr(g, "_formsemestre_get_ues_cache", None)
if _cache:
result = _cache.get(key, False)
if result is not False:
return result
else:
g._formsemestre_get_ues_cache = {}
_cache = g._formsemestre_get_ues_cache
formation: Formation = self.formation formation: Formation = self.formation
if formation.is_apc(): if formation.is_apc():
# UEs de tronc commun (sans parcours indiqué) # UEs de tronc commun (sans parcours indiqué)
@ -335,7 +316,8 @@ class FormSemestre(db.Model):
).filter(UniteEns.semestre_idx == self.semestre_id) ).filter(UniteEns.semestre_idx == self.semestre_id)
} }
) )
ues = sorted(sem_ues.values(), key=attrgetter("numero", "acronyme")) ues = sem_ues.values()
return sorted(ues, key=attrgetter("numero"))
else: else:
sem_ues = db.session.query(UniteEns).filter( sem_ues = db.session.query(UniteEns).filter(
ModuleImpl.formsemestre_id == self.id, ModuleImpl.formsemestre_id == self.id,
@ -344,9 +326,7 @@ class FormSemestre(db.Model):
) )
if not with_sport: if not with_sport:
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT) sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
ues = sem_ues.order_by(UniteEns.numero).all() return sem_ues.order_by(UniteEns.numero).all()
_cache[key] = ues
return ues
@cached_property @cached_property
def modimpls_sorted(self) -> list[ModuleImpl]: def modimpls_sorted(self) -> list[ModuleImpl]:
@ -394,7 +374,7 @@ class FormSemestre(db.Model):
), ),
{"formsemestre_id": self.id, "parcours_id": parcours.id}, {"formsemestre_id": self.id, "parcours_id": parcours.id},
) )
return [db.session.get(ModuleImpl, modimpl_id) for modimpl_id in cursor] return [ModuleImpl.query.get(modimpl_id) for modimpl_id in cursor]
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
"""Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)""" """Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)"""
@ -538,11 +518,6 @@ class FormSemestre(db.Model):
return "" return ""
return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape])) return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
def add_etape(self, etape_apo: str):
"Ajoute une étape"
etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=etape_apo)
db.session.add(etape)
def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]: def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
"""Calcule la liste des regroupements cohérents d'UE impliquant ce """Calcule la liste des regroupements cohérents d'UE impliquant ce
formsemestre. formsemestre.
@ -585,17 +560,6 @@ class FormSemestre(db.Model):
user user
) )
def can_change_groups(self, user: User = None) -> bool:
"""Vrai si l'utilisateur (par def. current) peut changer les groupes dans
ce semestre: vérifie permission et verrouillage.
"""
if not self.etat:
return False # semestre verrouillé
user = user or current_user
if user.has_permission(Permission.ScoEtudChangeGroups):
return True # typiquement admin, chef dept
return self.est_responsable(user)
def can_edit_jury(self, user: User = None): def can_edit_jury(self, user: User = None):
"""Vrai si utilisateur (par def. current) peut saisir decision de jury """Vrai si utilisateur (par def. current) peut saisir decision de jury
dans ce semestre: vérifie permission et verrouillage. dans ce semestre: vérifie permission et verrouillage.
@ -818,8 +782,6 @@ class FormSemestre(db.Model):
Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS
et leur nom est le code du parcours (eg "Cyber"). et leur nom est le code du parcours (eg "Cyber").
""" """
if self.formation.referentiel_competence_id is None:
return # safety net
partition = Partition.query.filter_by( partition = Partition.query.filter_by(
formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS
).first() ).first()
@ -843,10 +805,7 @@ class FormSemestre(db.Model):
query = ( query = (
ApcParcours.query.filter_by(code=group.group_name) ApcParcours.query.filter_by(code=group.group_name)
.join(ApcReferentielCompetences) .join(ApcReferentielCompetences)
.filter_by( .filter_by(dept_id=g.scodoc_dept_id)
dept_id=g.scodoc_dept_id,
id=self.formation.referentiel_competence_id,
)
) )
if query.count() != 1: if query.count() != 1:
log( log(
@ -895,12 +854,15 @@ class FormSemestre(db.Model):
.order_by(UniteEns.numero) .order_by(UniteEns.numero)
.all() .all()
) )
vals_annee = ( # issues de cette année scolaire seulement vals_annee = (
ApcValidationAnnee.query.filter_by( ApcValidationAnnee.query.filter_by(
etudid=etudid, etudid=etudid,
annee_scolaire=self.annee_scolaire(), annee_scolaire=self.annee_scolaire(),
referentiel_competence_id=self.formation.referentiel_competence_id, )
).all() .join(ApcValidationAnnee.formsemestre)
.join(FormSemestre.formation)
.filter(Formation.formation_code == self.formation.formation_code)
.all()
) )
H = [] H = []
for vals in (vals_sem, vals_ues, vals_rcues, vals_annee): for vals in (vals_sem, vals_ues, vals_rcues, vals_annee):

View File

@ -8,13 +8,11 @@
"""ScoDoc models: Groups & partitions """ScoDoc models: Groups & partitions
""" """
from operator import attrgetter from operator import attrgetter
from sqlalchemy.exc import IntegrityError
from app import db, log from app import db
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.models import GROUPNAME_STR_LEN from app.models import GROUPNAME_STR_LEN
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
class Partition(db.Model): class Partition(db.Model):
@ -119,81 +117,6 @@ class Partition(db.Model):
.first() .first()
) )
def set_etud_group(self, etud: "Identite", group: "GroupDescr") -> bool:
"""Affect etudid to group_id in given partition.
Raises IntegrityError si conflit,
or ValueError si ce group_id n'est pas dans cette partition
ou que l'étudiant n'est pas inscrit au semestre.
Return True si changement, False s'il était déjà dans ce groupe.
"""
if not group.id in (g.id for g in self.groups):
raise ScoValueError(
f"""Le groupe {group.id} n'est pas dans la partition {
self.partition_name or "tous"}"""
)
if etud.id not in (e.id for e in self.formsemestre.etuds):
raise ScoValueError(
f"""étudiant {etud.nomprenom} non inscrit au formsemestre du groupe {
group.group_name}"""
)
try:
existing_row = (
db.session.query(group_membership)
.filter_by(etudid=etud.id)
.join(GroupDescr)
.filter_by(partition_id=self.id)
.first()
)
if existing_row:
existing_group_id = existing_row[1]
if group.id == existing_group_id:
return False
# Fait le changement avec l'ORM sinon risque élevé de blocage
existing_group = db.session.get(GroupDescr, existing_group_id)
db.session.commit()
group.etuds.append(etud)
existing_group.etuds.remove(etud)
db.session.add(etud)
db.session.add(existing_group)
db.session.add(group)
else:
new_row = group_membership.insert().values(
etudid=etud.id, group_id=group.id
)
db.session.execute(new_row)
db.session.commit()
except IntegrityError:
db.session.rollback()
raise
return True
def create_group(self, group_name="", default=False) -> "GroupDescr":
"Crée un groupe dans cette partition"
if not self.formsemestre.can_change_groups():
raise AccessDenied(
"""Vous n'avez pas le droit d'effectuer cette opération,
ou bien le semestre est verrouillé !"""
)
if group_name:
group_name = group_name.strip()
if not group_name and not default:
raise ValueError("invalid group name: ()")
if not GroupDescr.check_name(self, group_name, default=default):
raise ScoValueError(
f"Le groupe {group_name} existe déjà dans cette partition"
)
numeros = [g.numero if g.numero is not None else 0 for g in self.groups]
if len(numeros) > 0:
new_numero = max(numeros) + 1
else:
new_numero = 0
group = GroupDescr(partition=self, group_name=group_name, numero=new_numero)
db.session.add(group)
db.session.commit()
log(f"create_group: created group_id={group.id}")
#
return group
class GroupDescr(db.Model): class GroupDescr(db.Model):
"""Description d'un groupe d'une partition""" """Description d'un groupe d'une partition"""

View File

@ -122,6 +122,22 @@ class ModuleImpl(db.Model):
raise AccessDenied(f"Modification impossible pour {user}") raise AccessDenied(f"Modification impossible pour {user}")
return False return False
def est_inscrit(self, etud: Identite) -> bool:
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl
Retourne Vrai si c'est le cas, faux sinon
"""
is_module: int = (
ModuleImplInscription.query.filter_by(
etudid=etud.id, moduleimpl_id=self.id
).count()
> 0
)
return is_module
# Enseignants (chargés de TD ou TP) d'un moduleimpl # Enseignants (chargés de TD ou TP) d'un moduleimpl
notes_modules_enseignants = db.Table( notes_modules_enseignants = db.Table(

View File

@ -55,7 +55,7 @@ class Module(db.Model):
secondary=parcours_modules, secondary=parcours_modules,
lazy="subquery", lazy="subquery",
backref=db.backref("modules", lazy=True), backref=db.backref("modules", lazy=True),
order_by="ApcParcours.numero, ApcParcours.code", order_by="ApcParcours.numero",
) )
app_critiques = db.relationship( app_critiques = db.relationship(
@ -198,7 +198,7 @@ class Module(db.Model):
else: else:
# crée nouveau coef: # crée nouveau coef:
if coef != 0.0: if coef != 0.0:
ue = db.session.get(UniteEns, ue_id) ue = UniteEns.query.get(ue_id)
ue_coef = ModuleUECoef(module=self, ue=ue, coef=coef) ue_coef = ModuleUECoef(module=self, ue=ue, coef=coef)
db.session.add(ue_coef) db.session.add(ue_coef)
self.ue_coefs.append(ue_coef) self.ue_coefs.append(ue_coef)
@ -229,19 +229,19 @@ class Module(db.Model):
"""delete coef""" """delete coef"""
if self.formation.has_locked_sems(self.ue.semestre_idx): if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logguer.info( current_app.logguer.info(
"delete_ue_coef: locked formation, ignoring request" f"delete_ue_coef: locked formation, ignoring request"
) )
raise ScoValueError("Formation verrouillée") raise ScoValueError("Formation verrouillée")
ue_coef = db.session.get(ModuleUECoef, (self.id, ue.id)) ue_coef = ModuleUECoef.query.get((self.id, ue.id))
if ue_coef: if ue_coef:
db.session.delete(ue_coef) db.session.delete(ue_coef)
self.formation.invalidate_module_coefs() self.formation.invalidate_module_coefs()
def get_ue_coefs_sorted(self): def get_ue_coefs_sorted(self):
"les coefs d'UE, trié par numéro et acronyme d'UE" "les coefs d'UE, trié par numéro d'UE"
# je n'ai pas su mettre un order_by sur le backref sans avoir # je n'ai pas su mettre un order_by sur le backref sans avoir
# à redéfinir les relationships... # à redéfinir les relationships...
return sorted(self.ue_coefs, key=lambda uc: (uc.ue.numero, uc.ue.acronyme)) return sorted(self.ue_coefs, key=lambda x: x.ue.numero)
def ue_coefs_list( def ue_coefs_list(
self, include_zeros=True, ues: list["UniteEns"] = None self, include_zeros=True, ues: list["UniteEns"] = None

View File

@ -56,8 +56,8 @@ class NotesNotes(db.Model):
"pour debug" "pour debug"
from app.models.evaluations import Evaluation from app.models.evaluations import Evaluation
return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} v={self.value} {self.date.isoformat() return f"""<{self.__class__.__name__} {self.id} v={self.value} {self.date.isoformat()
} {db.session.get(Evaluation, self.evaluation_id) if self.evaluation_id else "X" }>""" } {Evaluation.query.get(self.evaluation_id) if self.evaluation_id else "X" }>"""
class NotesNotesLog(db.Model): class NotesNotesLog(db.Model):

View File

@ -1,7 +1,6 @@
"""ScoDoc 9 models : Unités d'Enseignement (UE) """ScoDoc 9 models : Unités d'Enseignement (UE)
""" """
from flask import g
import pandas as pd import pandas as pd
from app import db, log from app import db, log
@ -9,6 +8,7 @@ from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.models.but_refcomp import ApcNiveau, ApcParcours from app.models.but_refcomp import ApcNiveau, ApcParcours
from app.models.modules import Module from app.models.modules import Module
from app.scodoc.sco_exceptions import ScoFormationConflict
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -58,10 +58,7 @@ class UniteEns(db.Model):
# Une UE appartient soit à tous les parcours (tronc commun), soit à un sous-ensemble # Une UE appartient soit à tous les parcours (tronc commun), soit à un sous-ensemble
parcours = db.relationship( parcours = db.relationship(
ApcParcours, ApcParcours, secondary="ue_parcours", backref=db.backref("ues", lazy=True)
secondary="ue_parcours",
backref=db.backref("ues", lazy=True),
order_by="ApcParcours.numero, ApcParcours.code",
) )
# relations # relations
@ -107,17 +104,6 @@ class UniteEns(db.Model):
If convert_objects, convert all attributes to native types If convert_objects, convert all attributes to native types
(suitable for json encoding). (suitable for json encoding).
""" """
# cache car très utilisé par anciens codes
key = (self.id, convert_objects, with_module_ue_coefs)
_cache = getattr(g, "_ue_to_dict_cache", None)
if _cache:
result = g._ue_to_dict_cache.get(key, False)
if result is not False:
return result
else:
g._ue_to_dict_cache = {}
_cache = g._ue_to_dict_cache
e = dict(self.__dict__) e = dict(self.__dict__)
e.pop("_sa_instance_state", None) e.pop("_sa_instance_state", None)
e.pop("evaluation_ue_poids", None) e.pop("evaluation_ue_poids", None)
@ -144,7 +130,6 @@ class UniteEns(db.Model):
] ]
else: else:
e.pop("module_ue_coefs", None) e.pop("module_ue_coefs", None)
_cache[key] = e
return e return e
def annee(self) -> int: def annee(self) -> int:
@ -192,23 +177,12 @@ class UniteEns(db.Model):
le parcours indiqué. le parcours indiqué.
""" """
if parcour is not None: if parcour is not None:
key = (parcour.id, self.id, only_parcours)
ue_ects_cache = getattr(g, "_ue_ects_cache", None)
if ue_ects_cache:
ects = g._ue_ects_cache.get(key, False)
if ects is not False:
return ects
else:
g._ue_ects_cache = {}
ue_ects_cache = g._ue_ects_cache
ue_parcour = UEParcours.query.filter_by( ue_parcour = UEParcours.query.filter_by(
ue_id=self.id, parcours_id=parcour.id ue_id=self.id, parcours_id=parcour.id
).first() ).first()
if ue_parcour is not None and ue_parcour.ects is not None: if ue_parcour is not None and ue_parcour.ects is not None:
ue_ects_cache[key] = ue_parcour.ects
return ue_parcour.ects return ue_parcour.ects
if only_parcours: if only_parcours:
ue_ects_cache[key] = None
return None return None
return self.ects return self.ects

View File

@ -8,13 +8,10 @@ from app import log
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN from app.models import CODE_STR_LEN
from app.models.events import Scolog from app.models.events import Scolog
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import CODES_UE_VALIDES
class ScolarFormSemestreValidation(db.Model): class ScolarFormSemestreValidation(db.Model):
"""Décisions de jury (sur semestre ou UEs)""" """Décisions de jury"""
__tablename__ = "scolar_formsemestre_validation" __tablename__ = "scolar_formsemestre_validation"
# Assure unicité de la décision: # Assure unicité de la décision:
@ -57,30 +54,18 @@ class ScolarFormSemestreValidation(db.Model):
) )
ue = db.relationship("UniteEns", lazy="select", uselist=False) ue = db.relationship("UniteEns", lazy="select", uselist=False)
etud = db.relationship("Identite", backref="validations")
formsemestre = db.relationship( formsemestre = db.relationship(
"FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id] "FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
) )
def __repr__(self): def __repr__(self):
return f"""{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={ return f"{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"""
def __str__(self): def __str__(self):
if self.ue_id: if self.ue_id:
# Note: si l'objet vient d'être créé, ue_id peut exister mais pas ue ! # Note: si l'objet vient d'être créé, ue_id peut exister mais pas ue !
return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id}: {self.code}"""
} ({self.ue_id}): {self.code}""" return f"""décision sur semestre {self.formsemestre.titre_mois()} du {self.event_date.strftime("%d/%m/%Y")}"""
return f"""décision sur semestre {self.formsemestre.titre_mois()} du {
self.event_date.strftime("%d/%m/%Y")}"""
def delete(self):
"Efface cette validation"
log(f"{self.__class__.__name__}.delete({self})")
etud = self.etud
db.session.delete(self)
db.session.commit()
sco_cache.invalidate_formsemestre_etud(etud)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"as a dict" "as a dict"
@ -88,49 +73,6 @@ class ScolarFormSemestreValidation(db.Model):
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
return d return d
def html(self, detail=False) -> str:
"Affichage html"
if self.ue_id is not None:
moyenne = (
f", moyenne {scu.fmt_note(self.moy_ue)}/20 "
if self.moy_ue is not None
else ""
)
link = (
self.formsemestre.html_link_status(
label=f"{self.formsemestre.titre_formation(with_sem_idx=1)}",
title=self.formsemestre.titre_annee(),
)
if self.formsemestre
else "externe/antérieure"
)
return f"""Validation
{'<span class="redboldtext">externe</span>' if self.is_external else ""}
de l'UE <b>{self.ue.acronyme}</b>
{('parcours <span class="parcours">'
+ ", ".join([p.code for p in self.ue.parcours]))
+ "</span>"
if self.ue.parcours else ""}
{("émise par " + link)}
: <b>{self.code}</b>{moyenne}
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
"""
else:
return f"""Validation du semestre S{
self.formsemestre.semestre_id if self.formsemestre else "?"}
{self.formsemestre.html_link_status() if self.formsemestre else ""}
: <b>{self.code}</b>
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
"""
def ects(self) -> float:
"Les ECTS acquis par cette validation. (0 si ce n'est pas une validation d'UE)"
return (
self.ue.ects
if (self.ue is not None) and (self.code in CODES_UE_VALIDES)
else 0.0
)
class ScolarAutorisationInscription(db.Model): class ScolarAutorisationInscription(db.Model):
"""Autorisation d'inscription dans un semestre""" """Autorisation d'inscription dans un semestre"""
@ -151,7 +93,6 @@ class ScolarAutorisationInscription(db.Model):
db.Integer, db.Integer,
db.ForeignKey("notes_formsemestre.id"), db.ForeignKey("notes_formsemestre.id"),
) )
origin_formsemestre = db.relationship("FormSemestre", lazy="select", uselist=False)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"""{self.__class__.__name__}(id={self.id}, etudid={ return f"""{self.__class__.__name__}(id={self.id}, etudid={
@ -163,21 +104,6 @@ class ScolarAutorisationInscription(db.Model):
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
return d return d
def html(self) -> str:
"Affichage html"
link = (
self.origin_formsemestre.html_link_status(
label=f"{self.origin_formsemestre.titre_formation(with_sem_idx=1)}",
title=self.origin_formsemestre.titre_annee(),
)
if self.origin_formsemestre
else "externe/antérieure"
)
return f"""Autorisation de passage vers <b>S{self.semestre_id}</b> émise par
{link}
le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}
"""
@classmethod @classmethod
def autorise_etud( def autorise_etud(
cls, cls,

View File

@ -36,7 +36,7 @@ Created on Fri Sep 9 09:15:05 2016
@author: barasc @author: barasc
""" """
from app import db, log from app import log
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre from app.models import FormSemestre
@ -487,7 +487,7 @@ def comp_coeff_pond(coeffs, ponderations):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def get_moduleimpl(modimpl_id) -> dict: def get_moduleimpl(modimpl_id) -> dict:
"""Renvoie l'objet modimpl dont l'id est modimpl_id""" """Renvoie l'objet modimpl dont l'id est modimpl_id"""
modimpl = db.session.get(ModuleImpl, modimpl_id) modimpl = ModuleImpl.query.get(modimpl_id)
if modimpl: if modimpl:
return modimpl return modimpl
if SemestreTag.DEBUG: if SemestreTag.DEBUG:

43
app/profiler.py Normal file
View File

@ -0,0 +1,43 @@
from time import time
from datetime import datetime
class Profiler:
OUTPUT: str = "/tmp/scodoc.profiler.csv"
def __init__(self, tag: str) -> None:
self.tag: str = tag
self.start_time: time = None
self.stop_time: time = None
def start(self):
self.start_time = time()
return self
def stop(self):
self.stop_time = time()
return self
def elapsed(self) -> float:
return self.stop_time - self.start_time
def dates(self) -> tuple[datetime, datetime]:
return datetime.fromtimestamp(self.start_time), datetime.fromtimestamp(
self.stop_time
)
def write(self):
with open(Profiler.OUTPUT, "a") as file:
dates: tuple = self.dates()
date_str = (dates[0].isoformat(), dates[1].isoformat())
file.write(f"\n{self.tag},{self.elapsed() : .2}")
@classmethod
def write_in(cls, msg: str):
with open(cls.OUTPUT, "a") as file:
file.write(f"\n# {msg}")
@classmethod
def clear(cls):
with open(cls.OUTPUT, "w") as file:
file.write("")

View File

@ -122,7 +122,6 @@ ABAN = "ABAN"
ABL = "ABL" ABL = "ABL"
ADM = "ADM" # moyenne gen., barres UE, assiduité: sem. validé ADM = "ADM" # moyenne gen., barres UE, assiduité: sem. validé
ADC = "ADC" # admis par compensation (eg moy(S1, S2) > 10) ADC = "ADC" # admis par compensation (eg moy(S1, S2) > 10)
ADSUP = "ADSUP" # BUT: UE ou RCUE validé par niveau supérieur
ADJ = "ADJ" # admis par le jury ADJ = "ADJ" # admis par le jury
ADJR = "ADJR" # UE admise car son RCUE est ADJ ADJR = "ADJR" # UE admise car son RCUE est ADJ
ATT = "ATT" # ATT = "ATT" #
@ -163,7 +162,6 @@ CODES_EXPL = {
ADJ: "Validé par le Jury", ADJ: "Validé par le Jury",
ADJR: "UE validée car son RCUE est validé ADJ par le jury", ADJR: "UE validée car son RCUE est validé ADJ par le jury",
ADM: "Validé", ADM: "Validé",
ADSUP: "UE ou RCUE validé car le niveau supérieur est validé",
AJ: "Ajourné (ou UE/BC de BUT en attente pour problème de moyenne)", AJ: "Ajourné (ou UE/BC de BUT en attente pour problème de moyenne)",
ATB: "Décision en attente d'un autre semestre (au moins une UE sous la barre)", ATB: "Décision en attente d'un autre semestre (au moins une UE sous la barre)",
ATJ: "Décision en attente d'un autre semestre (assiduité insuffisante)", ATJ: "Décision en attente d'un autre semestre (assiduité insuffisante)",
@ -196,23 +194,18 @@ CODES_SEM_ATTENTES = {ATT, ATB, ATJ} # semestre en attente
CODES_SEM_REO = {NAR} # reorientation CODES_SEM_REO = {NAR} # reorientation
# Les codes d'UEs
CODES_JURY_UE = {ADM, CMP, ADJ, ADJR, ADSUP, AJ, ATJ, RAT, DEF, ABAN, DEM, UEBSL}
CODES_UE_VALIDES_DE_DROIT = {ADM, CMP} # validation "de droit" CODES_UE_VALIDES_DE_DROIT = {ADM, CMP} # validation "de droit"
CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR, ADSUP} CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR}
"UE validée" "UE validée"
CODES_UE_CAPITALISANTS = {ADM} CODES_UE_CAPITALISANTS = {ADM}
"UE capitalisée" "UE capitalisée"
CODES_JURY_RCUE = {ADM, ADJ, ADSUP, CMP, AJ, ATJ, RAT, DEF, ABAN}
"codes de jury utilisables sur les RCUEs"
CODES_RCUE_VALIDES_DE_DROIT = {ADM, CMP} CODES_RCUE_VALIDES_DE_DROIT = {ADM, CMP}
CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ, ADSUP} CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ}
"Niveau RCUE validé" "Niveau RCUE validé"
# Pour le BUT: # Pour le BUT:
CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD} # PASD pour enregistrement auto CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD}
CODES_ANNEE_BUT_VALIDES = {ADM, ADSUP}
CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL} CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE
BUT_BARRE_UE = BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE BUT_BARRE_UE = BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE
@ -226,25 +219,17 @@ BUT_CODES_PASSAGE = {
} }
# les codes, du plus "défavorable" à l'étudiant au plus favorable: # les codes, du plus "défavorable" à l'étudiant au plus favorable:
# (valeur par défaut 0) # (valeur par défaut 0)
BUT_CODES_ORDER = { BUT_CODES_ORDERED = {
ABAN: 0,
ABL: 0,
DEM: 0,
DEF: 0,
EXCLU: 0,
NAR: 0, NAR: 0,
UEBSL: 0, DEF: 0,
RAT: 5,
RED: 6,
AJ: 10, AJ: 10,
ATJ: 20, ATJ: 20,
CMP: 50, CMP: 50,
ADC: 50, ADC: 50,
PAS1NCI: 50, PASD: 50,
PASD: 60, PAS1NCI: 60,
ADJR: 90, ADJR: 90,
ADSUP: 90, ADJ: 100,
ADJ: 90,
ADM: 100, ADM: 100,
} }
@ -264,16 +249,6 @@ def code_ue_validant(code: str) -> bool:
return code in CODES_UE_VALIDES return code in CODES_UE_VALIDES
def code_rcue_validant(code: str) -> bool:
"Vrai si ce code d'RCUE est validant"
return code in CODES_RCUE_VALIDES
def code_annee_validant(code: str) -> bool:
"Vrai si code d'année BUT validant"
return code in CODES_ANNEE_BUT_VALIDES
DEVENIR_EXPL = { DEVENIR_EXPL = {
NEXT: "Passage au semestre suivant", NEXT: "Passage au semestre suivant",
REDOANNEE: "Redoublement année", REDOANNEE: "Redoublement année",

View File

@ -88,7 +88,7 @@ class DEFAULT_TABLE_PREFERENCES(object):
return self.values[k] return self.values[k]
class GenTable: class GenTable(object):
"""Simple 2D tables with export to HTML, PDF, Excel, CSV. """Simple 2D tables with export to HTML, PDF, Excel, CSV.
Can be sub-classed to generate fancy formats. Can be sub-classed to generate fancy formats.
""" """
@ -197,9 +197,6 @@ class GenTable:
def __repr__(self): def __repr__(self):
return f"<gen_table( nrows={self.get_nb_rows()}, ncols={self.get_nb_cols()} )>" return f"<gen_table( nrows={self.get_nb_rows()}, ncols={self.get_nb_cols()} )>"
def __len__(self):
return len(self.rows)
def get_nb_cols(self): def get_nb_cols(self):
return len(self.columns_ids) return len(self.columns_ids)

4
app/scodoc/html_sidebar.py Normal file → Executable file
View File

@ -126,7 +126,7 @@ def sidebar(etudid: int = None):
if current_user.has_permission(Permission.ScoAbsChange): if current_user.has_permission(Permission.ScoAbsChange):
H.append( H.append(
f""" f"""
<li><a href="{ url_for('absences.SignaleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li> <li><a href="{ url_for('assiduites.signal_assiduites_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li>
<li><a href="{ url_for('absences.JustifAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li> <li><a href="{ url_for('absences.JustifAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li>
<li><a href="{ url_for('absences.AnnuleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Supprimer</a></li> <li><a href="{ url_for('absences.AnnuleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Supprimer</a></li>
""" """
@ -138,7 +138,7 @@ def sidebar(etudid: int = None):
H.append( H.append(
f""" f"""
<li><a href="{ url_for('absences.CalAbs', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Calendrier</a></li> <li><a href="{ url_for('absences.CalAbs', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Calendrier</a></li>
<li><a href="{ url_for('absences.ListeAbsEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Liste</a></li> <li><a href="{ url_for('assiduites.liste_assiduites_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Liste</a></li>
</ul> </ul>
""" """
) )

41
app/scodoc/sco_abs.py Normal file → Executable file
View File

@ -42,6 +42,8 @@ from app.scodoc import sco_cache
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.models import Assiduite, Justificatif
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
# --- Misc tools.... ------------------ # --- Misc tools.... ------------------
@ -1052,6 +1054,45 @@ def get_abs_count_in_interval(etudid, date_debut_iso, date_fin_iso):
return r return r
def get_assiduites_count_in_interval(etudid, date_debut_iso, date_fin_iso):
"""Les comptes d'absences de cet étudiant entre ces deux dates, incluses:
tuple (nb abs, nb abs justifiées)
Utilise un cache.
"""
key = str(etudid) + "_" + date_debut_iso + "_" + date_fin_iso + "_assiduites"
r = sco_cache.AbsSemEtudCache.get(key)
if not r:
date_debut: datetime.datetime = scu.is_iso_formated(date_debut_iso, True)
date_fin: datetime.datetime = scu.is_iso_formated(date_fin_iso, True)
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
justificatifs: Justificatif = Justificatif.query.filter_by(etudid=etudid)
assiduites = scass.filter_by_date(assiduites, Assiduite, date_debut, date_fin)
justificatifs = scass.filter_by_date(
justificatifs, Justificatif, date_debut, date_fin
)
calculator: scass.CountCalculator = scass.CountCalculator()
calculator.compute_assiduites(assiduites)
nb_abs: dict = calculator.to_dict()["demi"]
abs_just: list[Assiduite] = scass.get_all_justified(
etudid, date_debut, date_fin
)
calculator.reset()
calculator.compute_assiduites(abs_just)
nb_abs_just: dict = calculator.to_dict()["demi"]
r = (nb_abs, nb_abs_just)
ans = sco_cache.AbsSemEtudCache.set(key, r)
if not ans:
log("warning: get_assiduites_count failed to cache")
return r
def invalidate_abs_count(etudid, sem): def invalidate_abs_count(etudid, sem):
"""Invalidate (clear) cached counts""" """Invalidate (clear) cached counts"""
date_debut = sem["date_debut_iso"] date_debut = sem["date_debut_iso"]

View File

@ -51,14 +51,7 @@ from app import log
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_but import ResultatsSemestreBUT
from app.models import ( from app.models import FormSemestre, Identite, ApcValidationAnnee
ApcValidationAnnee,
ApcValidationRCUE,
FormSemestre,
Identite,
ScolarFormSemestreValidation,
)
from app.models.config import ScoDocSiteConfig from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_apogee_reader import ( from app.scodoc.sco_apogee_reader import (
APO_DECIMAL_SEP, APO_DECIMAL_SEP,
@ -71,7 +64,6 @@ from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.codes_cursus import code_semestre_validant from app.scodoc.codes_cursus import code_semestre_validant
from app.scodoc.codes_cursus import ( from app.scodoc.codes_cursus import (
ADSUP,
DEF, DEF,
DEM, DEM,
NAR, NAR,
@ -224,12 +216,7 @@ class ApoEtud(dict):
break break
self.col_elts[code] = elt self.col_elts[code] = elt
if elt is None: if elt is None:
try: self.new_cols[col_id] = self.cols[col_id]
self.new_cols[col_id] = self.cols[col_id]
except KeyError as exc:
raise ScoFormatError(
f"""Fichier Apogee invalide : ligne mal formatée ? <br>colonne <tt>{col_id}</tt> non déclarée ?"""
) from exc
else: else:
try: try:
self.new_cols[col_id] = sco_elts[code][ self.new_cols[col_id] = sco_elts[code][
@ -336,22 +323,14 @@ class ApoEtud(dict):
x.strip() for x in ue["code_apogee"].split(",") x.strip() for x in ue["code_apogee"].split(",")
}: }:
if self.export_res_ues: if self.export_res_ues:
if ( if decisions_ue and ue["ue_id"] in decisions_ue:
decisions_ue and ue["ue_id"] in decisions_ue
) or self.export_res_sdj:
ue_status = res.get_etud_ue_status(etudid, ue["ue_id"]) ue_status = res.get_etud_ue_status(etudid, ue["ue_id"])
if decisions_ue and ue["ue_id"] in decisions_ue: code_decision_ue = decisions_ue[ue["ue_id"]]["code"]
code_decision_ue = decisions_ue[ue["ue_id"]]["code"]
code_decision_ue_apo = ScoDocSiteConfig.get_code_apo(
code_decision_ue
)
else:
code_decision_ue_apo = ""
return dict( return dict(
N=self.fmt_note(ue_status["moy"] if ue_status else ""), N=self.fmt_note(ue_status["moy"] if ue_status else ""),
B=20, B=20,
J="", J="",
R=code_decision_ue_apo, R=ScoDocSiteConfig.get_code_apo(code_decision_ue),
M="", M="",
) )
else: else:
@ -364,17 +343,14 @@ class ApoEtud(dict):
module_code_found = False module_code_found = False
for modimpl in modimpls: for modimpl in modimpls:
module = modimpl["module"] module = modimpl["module"]
if ( if module["code_apogee"] and code in {
res.modimpl_inscr_df[modimpl["moduleimpl_id"]][etudid] x.strip() for x in module["code_apogee"].split(",")
and module["code_apogee"] }:
and code in {x.strip() for x in module["code_apogee"].split(",")}
):
n = res.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) n = res.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid)
if n != "NI" and self.export_res_modules: if n != "NI" and self.export_res_modules:
return dict(N=self.fmt_note(n), B=20, J="", R="") return dict(N=self.fmt_note(n), B=20, J="", R="")
else: else:
module_code_found = True module_code_found = True
if module_code_found: if module_code_found:
return VOID_APO_RES return VOID_APO_RES
# #
@ -497,10 +473,7 @@ class ApoEtud(dict):
) )
def _but_load_validation_annuelle(self): def _but_load_validation_annuelle(self):
"""charge la validation de jury BUT annuelle. "charge la validation de jury BUT annuelle"
Ici impose qu'elle soit issue d'un semestre de l'année en cours
(pas forcément nécessaire, voir selon les retours des équipes ?)
"""
# le semestre impair de l'année scolaire # le semestre impair de l'année scolaire
if self.cur_res.formsemestre.semestre_id % 2: if self.cur_res.formsemestre.semestre_id % 2:
formsemestre = self.cur_res.formsemestre formsemestre = self.cur_res.formsemestre
@ -515,11 +488,11 @@ class ApoEtud(dict):
# ne trouve pas de semestre impair # ne trouve pas de semestre impair
self.validation_annee_but = None self.validation_annee_but = None
return return
self.validation_annee_but: ApcValidationAnnee = ApcValidationAnnee.query.filter_by( self.validation_annee_but: ApcValidationAnnee = (
formsemestre_id=formsemestre.id, ApcValidationAnnee.query.filter_by(
etudid=self.etud["etudid"], formsemestre_id=formsemestre.id, etudid=self.etud["etudid"]
referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id, ).first()
).first() )
self.is_nar = ( self.is_nar = (
self.validation_annee_but and self.validation_annee_but.code == NAR self.validation_annee_but and self.validation_annee_but.code == NAR
) )
@ -919,75 +892,6 @@ class ApoData:
) )
return T return T
def build_adsup_table(self):
"""Construit une table listant les ADSUP émis depuis les formsemestres
NIP nom prenom nom_formsemestre etape UE
"""
validations_ues, validations_rcue = self.list_adsup()
rows = [
{
"code_nip": v.etud.code_nip,
"nom": v.etud.nom,
"prenom": v.etud.prenom,
"formsemestre": v.formsemestre.titre_formation(with_sem_idx=1),
"etape": v.formsemestre.etapes_apo_str(),
"ue": v.ue.acronyme,
}
for v in validations_ues
]
rows += [
{
"code_nip": v.etud.code_nip,
"nom": v.etud.nom,
"prenom": v.etud.prenom,
"formsemestre": v.formsemestre.titre_formation(with_sem_idx=1),
"etape": "", # on ne sait pas à quel étape rattacher le RCUE
"rcue": f"{v.ue1.acronyme}/{v.ue2.acronyme}",
}
for v in validations_rcue
]
return GenTable(
columns_ids=(
"code_nip",
"nom",
"prenom",
"formsemestre",
"etape",
"ue",
"rcue",
),
titles={
"code_nip": "NIP",
"nom": "Nom",
"prenom": "Prénom",
"formsemestre": "Semestre",
"etape": "Etape",
"ue": "UE",
"rcue": "RCUE",
},
rows=rows,
xls_sheet_name="ADSUPs",
)
def list_adsup(
self,
) -> tuple[list[ScolarFormSemestreValidation], list[ApcValidationRCUE]]:
"""Liste les validations ADSUP émises par des formsemestres de cet ensemble"""
validations_ues = (
ScolarFormSemestreValidation.query.filter_by(code=ADSUP)
.filter(ScolarFormSemestreValidation.ue_id != None)
.filter(
ScolarFormSemestreValidation.formsemestre_id.in_(
self.etape_formsemestre_ids
)
)
)
validations_rcue = ApcValidationRCUE.query.filter_by(code=ADSUP).filter(
ApcValidationRCUE.formsemestre_id.in_(self.etape_formsemestre_ids)
)
return validations_ues, validations_rcue
def comp_apo_sems(etape_apogee, annee_scolaire: int) -> list[dict]: def comp_apo_sems(etape_apogee, annee_scolaire: int) -> list[dict]:
""" """
@ -1114,10 +1018,6 @@ def export_csv_to_apogee(
cr_table = apo_data.build_cr_table() cr_table = apo_data.build_cr_table()
cr_xls = cr_table.excel() cr_xls = cr_table.excel()
# ADSUPs
adsup_table = apo_data.build_adsup_table()
adsup_xls = adsup_table.excel() if len(adsup_table) else None
# Create ZIP # Create ZIP
if not dest_zip: if not dest_zip:
data = io.BytesIO() data = io.BytesIO()
@ -1143,7 +1043,6 @@ def export_csv_to_apogee(
log_filename = "scodoc-" + basename + ".log.txt" log_filename = "scodoc-" + basename + ".log.txt"
nar_filename = basename + "-nar" + scu.XLSX_SUFFIX nar_filename = basename + "-nar" + scu.XLSX_SUFFIX
cr_filename = basename + "-decisions" + scu.XLSX_SUFFIX cr_filename = basename + "-decisions" + scu.XLSX_SUFFIX
adsup_filename = f"{basename}-adsups{scu.XLSX_SUFFIX}"
logf = io.StringIO() logf = io.StringIO()
logf.write(f"export_to_apogee du {time.ctime()}\n\n") logf.write(f"export_to_apogee du {time.ctime()}\n\n")
@ -1180,8 +1079,6 @@ def export_csv_to_apogee(
"\n\nElements Apogee inconnus dans ces semestres ScoDoc:\n" "\n\nElements Apogee inconnus dans ces semestres ScoDoc:\n"
+ "\n".join(apo_data.list_unknown_elements()) + "\n".join(apo_data.list_unknown_elements())
) )
if adsup_xls:
logf.write(f"\n\nADSUP générés: {len(adsup_table)}\n")
log(logf.getvalue()) # sortie aussi sur le log ScoDoc log(logf.getvalue()) # sortie aussi sur le log ScoDoc
# Write data to ZIP # Write data to ZIP
@ -1190,8 +1087,6 @@ def export_csv_to_apogee(
if nar_xls: if nar_xls:
dest_zip.writestr(nar_filename, nar_xls) dest_zip.writestr(nar_filename, nar_xls)
dest_zip.writestr(cr_filename, cr_xls) dest_zip.writestr(cr_filename, cr_xls)
if adsup_xls:
dest_zip.writestr(adsup_filename, adsup_xls)
if my_zip: if my_zip:
dest_zip.close() dest_zip.close()

View File

@ -295,15 +295,8 @@ class ApoCSVReadWrite:
filename=self.get_filename(), filename=self.get_filename(),
) )
cols = {} # { col_id : value } cols = {} # { col_id : value }
try: for i, field in enumerate(fields):
for i, field in enumerate(fields): cols[self.col_ids[i]] = field
cols[self.col_ids[i]] = field
except IndexError as exc:
raise
raise ScoFormatError(
f"Fichier Apogee incorrect (colonnes excédentaires ? (<tt>{i}/{field}</tt>))",
filename=self.get_filename(),
) from exc
etud_tuples.append( etud_tuples.append(
ApoEtudTuple( ApoEtudTuple(
nip=fields[0], # id etudiant nip=fields[0], # id etudiant

View File

@ -68,9 +68,9 @@ from app import log, ScoDocJSONEncoder
from app.but import jury_but_pv from app.but import jury_but_pv
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import Departement, FormSemestre from app.models import FormSemestre
from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_exceptions import ScoException, ScoPermissionDenied from app.scodoc.sco_exceptions import ScoPermissionDenied
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_bulletins_pdf from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_groups from app.scodoc import sco_groups
@ -86,6 +86,11 @@ class BaseArchiver(object):
self.archive_type = archive_type self.archive_type = archive_type
self.initialized = False self.initialized = False
self.root = None self.root = None
self.dept_id = None
def set_dept_id(self, dept_id: int):
"set dept"
self.dept_id = dept_id
def initialize(self): def initialize(self):
if self.initialized: if self.initialized:
@ -107,6 +112,8 @@ class BaseArchiver(object):
finally: finally:
scu.GSL.release() scu.GSL.release()
self.initialized = True self.initialized = True
if self.dept_id is None:
self.dept_id = getattr(g, "scodoc_dept_id")
def get_obj_dir(self, oid: int): def get_obj_dir(self, oid: int):
""" """
@ -114,8 +121,7 @@ class BaseArchiver(object):
If directory does not yet exist, create it. If directory does not yet exist, create it.
""" """
self.initialize() self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first() dept_dir = os.path.join(self.root, str(self.dept_id))
dept_dir = os.path.join(self.root, str(dept.id))
try: try:
scu.GSL.acquire() scu.GSL.acquire()
if not os.path.isdir(dept_dir): if not os.path.isdir(dept_dir):
@ -125,12 +131,6 @@ class BaseArchiver(object):
if not os.path.isdir(obj_dir): if not os.path.isdir(obj_dir):
log(f"creating directory {obj_dir}") log(f"creating directory {obj_dir}")
os.mkdir(obj_dir) os.mkdir(obj_dir)
except FileExistsError as exc:
raise ScoException(
f"""BaseArchiver error: obj_dir={obj_dir} exists={
os.path.exists(obj_dir)
} isdir={os.path.isdir(obj_dir)}"""
) from exc
finally: finally:
scu.GSL.release() scu.GSL.release()
return obj_dir return obj_dir
@ -140,8 +140,7 @@ class BaseArchiver(object):
:return: list of archive oids :return: list of archive oids
""" """
self.initialize() self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first() base = os.path.join(self.root, str(self.dept_id)) + os.path.sep
base = os.path.join(self.root, str(dept.id)) + os.path.sep
dirs = glob.glob(base + "*") dirs = glob.glob(base + "*")
return [os.path.split(x)[1] for x in dirs] return [os.path.split(x)[1] for x in dirs]
@ -344,7 +343,7 @@ def do_formsemestre_archive(
if data: if data:
PVArchive.store(archive_id, "Tableau_moyennes" + scu.XLSX_SUFFIX, data) PVArchive.store(archive_id, "Tableau_moyennes" + scu.XLSX_SUFFIX, data)
# Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes) # Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes)
table_html, _, _ = gen_formsemestre_recapcomplet_html_table( table_html, _ = gen_formsemestre_recapcomplet_html_table(
formsemestre, res, include_evaluations=True formsemestre, res, include_evaluations=True
) )
if table_html: if table_html:

View File

@ -0,0 +1,215 @@
"""
Gestion de l'archivage des justificatifs
Ecrit par Matthias HARTMANN
"""
import os
from datetime import datetime
from shutil import rmtree
from app.models import Identite
from app.scodoc.sco_archives import BaseArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import is_iso_formated
class Trace:
"""gestionnaire de la trace des fichiers justificatifs"""
def __init__(self, path: str) -> None:
self.path: str = path + "/_trace.csv"
self.content: dict[str, list[datetime, datetime]] = {}
self.import_from_file()
def import_from_file(self):
"""import trace from file"""
if os.path.isfile(self.path):
with open(self.path, "r", encoding="utf-8") as file:
for line in file.readlines():
csv = line.split(",")
fname: str = csv[0]
entry_date: datetime = is_iso_formated(csv[1], True)
delete_date: datetime = is_iso_formated(csv[2], True)
self.content[fname] = [entry_date, delete_date]
def set_trace(self, *fnames: str, mode: str = "entry"):
"""Ajoute une trace du fichier donné
mode : entry / delete
"""
modes: list[str] = ["entry", "delete"]
for fname in fnames:
if fname in modes:
continue
traced: list[datetime, datetime] = self.content.get(fname, False)
if not traced:
self.content[fname] = [None, None]
traced = self.content[fname]
traced[modes.index(mode)] = datetime.now()
self.save_trace()
def save_trace(self):
"""Enregistre la trace dans le fichier _trace.csv"""
lines: list[str] = []
for fname, traced in self.content.items():
date_fin: datetime or None = traced[1].isoformat() if traced[1] else "None"
lines.append(f"{fname},{traced[0].isoformat()},{date_fin}")
with open(self.path, "w", encoding="utf-8") as file:
file.write("\n".join(lines))
def get_trace(self, fnames: list[str] = ()) -> dict[str, list[datetime, datetime]]:
"""Récupère la trace pour les noms de fichiers.
si aucun nom n'est donné, récupère tous les fichiers"""
if fnames is None or len(fnames) == 0:
return self.content
traced: dict = {}
for fname in fnames:
traced[fname] = self.content.get(fname, None)
return traced
class JustificatifArchiver(BaseArchiver):
"""
TOTALK:
- oid -> etudid
- archive_id -> date de création de l'archive (une archive par dépot de document)
justificatif
<dept_id>
<etudid/oid>
[_trace.csv]
<archive_id>
[_description.txt]
[<filename.ext>]
"""
def __init__(self):
BaseArchiver.__init__(self, archive_type="justificatifs")
def save_justificatif(
self,
etudid: int,
filename: str,
data: bytes or str,
archive_name: str = None,
description: str = "",
) -> str:
"""
Ajoute un fichier dans une archive "justificatif" pour l'etudid donné
Retourne l'archive_name utilisé
"""
self._set_dept(etudid)
if archive_name is None:
archive_id: str = self.create_obj_archive(
oid=etudid, description=description
)
else:
archive_id: str = self.get_id_from_name(etudid, archive_name)
fname: str = self.store(archive_id, filename, data)
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(fname, "entry")
return self.get_archive_name(archive_id), fname
def delete_justificatif(
self,
etudid: int,
archive_name: str,
filename: str = None,
has_trace: bool = True,
):
"""
Supprime une archive ou un fichier particulier de l'archivage de l'étudiant donné
Si trace == True : sauvegarde le nom du/des fichier(s) supprimé(s) dans la trace de l'étudiant
"""
self._set_dept(etudid)
if str(etudid) not in self.list_oids():
raise ValueError(f"Aucune archive pour etudid[{etudid}]")
archive_id = self.get_id_from_name(etudid, archive_name)
if filename is not None:
if filename not in self.list_archive(archive_id):
raise ValueError(
f"filename {filename} inconnu dans l'archive archive_id[{archive_id}] -> etudid[{etudid}]"
)
path: str = os.path.join(self.get_obj_dir(etudid), archive_id, filename)
if os.path.isfile(path):
if has_trace:
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(filename, "delete")
os.remove(path)
else:
if has_trace:
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(*self.list_archive(archive_id), mode="delete")
self.delete_archive(
os.path.join(
self.get_obj_dir(etudid),
archive_id,
)
)
def list_justificatifs(self, archive_name: str, etudid: int) -> list[str]:
"""
Retourne la liste des noms de fichiers dans l'archive donnée
"""
self._set_dept(etudid)
filenames: list[str] = []
archive_id = self.get_id_from_name(etudid, archive_name)
filenames = self.list_archive(archive_id)
return filenames
def get_justificatif_file(self, archive_name: str, etudid: int, filename: str):
"""
Retourne une réponse de téléchargement de fichier si le fichier existe
"""
self._set_dept(etudid)
archive_id: str = self.get_id_from_name(etudid, archive_name)
if filename in self.list_archive(archive_id):
return self.get_archived_file(etudid, archive_name, filename)
raise ScoValueError(
f"Fichier {filename} introuvable dans l'archive {archive_name}"
)
def _set_dept(self, etudid: int):
"""
Mets à jour le dept_id de l'archiver en fonction du département de l'étudiant
"""
etud: Identite = Identite.query.filter_by(id=etudid).first()
self.set_dept_id(etud.dept_id)
def remove_dept_archive(self, dept_id: int = None):
"""
Supprime toutes les archives d'un département (ou de tous les départements)
Supprime aussi les fichiers de trace
"""
self.set_dept_id(1)
self.initialize()
if dept_id is None:
rmtree(self.root, ignore_errors=True)
else:
rmtree(os.path.join(self.root, str(dept_id)), ignore_errors=True)
def get_trace(
self, etudid: int, *fnames: str
) -> dict[str, list[datetime, datetime]]:
"""Récupère la trace des justificatifs de l'étudiant"""
trace = Trace(self.get_obj_dir(etudid))
return trace.get_trace(fnames)

View File

@ -0,0 +1,352 @@
"""
Ecrit par Matthias Hartmann.
"""
from datetime import date, datetime, time, timedelta
import app.scodoc.sco_utils as scu
from app.models.assiduites import Assiduite, Justificatif
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre, FormSemestreInscription
class CountCalculator:
"""Classe qui gére le comptage des assiduités"""
def __init__(
self,
morning: time = time(8, 0),
noon: time = time(12, 0),
after_noon: time = time(14, 00),
evening: time = time(18, 0),
skip_saturday: bool = True,
) -> None:
self.morning: time = morning
self.noon: time = noon
self.after_noon: time = after_noon
self.evening: time = evening
self.skip_saturday: bool = skip_saturday
delta_total: timedelta = datetime.combine(date.min, evening) - datetime.combine(
date.min, morning
)
delta_lunch: timedelta = datetime.combine(
date.min, after_noon
) - datetime.combine(date.min, noon)
self.hour_per_day: float = (delta_total - delta_lunch).total_seconds() / 3600
self.days: list[date] = []
self.half_days: list[tuple[date, bool]] = [] # tuple -> (date, morning:bool)
self.hours: float = 0.0
self.count: int = 0
def reset(self):
"""Remet à zero le compteur"""
self.days = []
self.half_days = []
self.hours = 0.0
self.count = 0
def add_half_day(self, day: date, is_morning: bool = True):
"""Ajoute une demi journée dans le comptage"""
key: tuple[date, bool] = (day, is_morning)
if key not in self.half_days:
self.half_days.append(key)
def add_day(self, day: date):
"""Ajoute un jour dans le comptage"""
if day not in self.days:
self.days.append(day)
def check_in_morning(self, period: tuple[datetime, datetime]) -> bool:
"""Vérifiée si la période donnée fait partie du matin
(Test sur la date de début)
"""
interval_morning: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(period[0].date(), self.morning)),
scu.localize_datetime(datetime.combine(period[0].date(), self.noon)),
)
in_morning: bool = scu.is_period_overlapping(
period, interval_morning, bornes=False
)
return in_morning
def check_in_evening(self, period: tuple[datetime, datetime]) -> bool:
"""Vérifie si la période fait partie de l'aprèm
(test sur la date de début)
"""
interval_evening: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(period[0].date(), self.after_noon)),
scu.localize_datetime(datetime.combine(period[0].date(), self.evening)),
)
in_evening: bool = scu.is_period_overlapping(period, interval_evening)
return in_evening
def compute_long_assiduite(self, assi: Assiduite):
"""Calcule les métriques sur une assiduité longue (plus d'un jour)"""
pointer_date: date = assi.date_debut.date() + timedelta(days=1)
start_hours: timedelta = assi.date_debut - scu.localize_datetime(
datetime.combine(assi.date_debut, self.morning)
)
finish_hours: timedelta = assi.date_fin - scu.localize_datetime(
datetime.combine(assi.date_fin, self.morning)
)
self.add_day(assi.date_debut.date())
self.add_day(assi.date_fin.date())
start_period: tuple[datetime, datetime] = (
assi.date_debut,
scu.localize_datetime(
datetime.combine(assi.date_debut.date(), self.evening)
),
)
finish_period: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(assi.date_fin.date(), self.morning)),
assi.date_fin,
)
hours = 0.0
for period in (start_period, finish_period):
if self.check_in_evening(period):
self.add_half_day(period[0].date(), False)
if self.check_in_morning(period):
self.add_half_day(period[0].date())
while pointer_date < assi.date_fin.date():
if pointer_date.weekday() < (6 - self.skip_saturday):
self.add_day(pointer_date)
self.add_half_day(pointer_date)
self.add_half_day(pointer_date, False)
self.hours += self.hour_per_day
hours += self.hour_per_day
pointer_date += timedelta(days=1)
self.hours += finish_hours.total_seconds() / 3600
self.hours += self.hour_per_day - (start_hours.total_seconds() / 3600)
def compute_assiduites(self, assiduites: Assiduite):
"""Calcule les métriques pour la collection d'assiduité donnée"""
assi: Assiduite
assiduites: list[Assiduite] = (
assiduites.all() if isinstance(assiduites, Assiduite) else assiduites
)
for assi in assiduites:
self.count += 1
delta: timedelta = assi.date_fin - assi.date_debut
if delta.days > 0:
# raise Exception(self.hours)
self.compute_long_assiduite(assi)
continue
period: tuple[datetime, datetime] = (assi.date_debut, assi.date_fin)
deb_date: date = assi.date_debut.date()
if self.check_in_morning(period):
self.add_half_day(deb_date)
if self.check_in_evening(period):
self.add_half_day(deb_date, False)
self.add_day(deb_date)
self.hours += delta.total_seconds() / 3600
def to_dict(self) -> dict[str, object]:
"""Retourne les métriques sous la forme d'un dictionnaire"""
return {
"compte": self.count,
"journee": len(self.days),
"demi": len(self.half_days),
"heure": round(self.hours, 2),
}
def get_assiduites_stats(
assiduites: Assiduite, metric: str = "all", filtered: dict[str, object] = None
) -> Assiduite:
"""Compte les assiduités en fonction des filtres"""
if filtered is not None:
deb, fin = None, None
for key in filtered:
if key == "etat":
assiduites = filter_assiduites_by_etat(assiduites, filtered[key])
elif key == "date_fin":
fin = filtered[key]
elif key == "date_debut":
deb = filtered[key]
elif key == "moduleimpl_id":
assiduites = filter_by_module_impl(assiduites, filtered[key])
elif key == "formsemestre":
assiduites = filter_by_formsemestre(assiduites, filtered[key])
elif key == "est_just":
assiduites = filter_assiduites_by_est_just(assiduites, filtered[key])
elif key == "user_id":
assiduites = filter_by_user_id(assiduites, filtered[key])
if (deb, fin) != (None, None):
assiduites = filter_by_date(assiduites, Assiduite, deb, fin)
calculator: CountCalculator = CountCalculator()
calculator.compute_assiduites(assiduites)
count: dict = calculator.to_dict()
metrics: list[str] = metric.split(",")
output: dict = {}
for key, val in count.items():
if key in metrics:
output[key] = val
return output if output else count
def filter_assiduites_by_etat(assiduites: Assiduite, etat: str) -> Assiduite:
"""
Filtrage d'une collection d'assiduites en fonction de leur état
"""
etats: list[str] = list(etat.split(","))
etats = [scu.EtatAssiduite.get(e, -1) for e in etats]
return assiduites.filter(Assiduite.etat.in_(etats))
def filter_assiduites_by_est_just(
assiduites: Assiduite, est_just: bool
) -> Justificatif:
"""
Filtrage d'une collection d'assiduites en fonction de s'ils sont justifiés
"""
return assiduites.filter_by(est_just=est_just)
def filter_by_user_id(
collection: Assiduite or Justificatif,
user_id: int,
) -> Justificatif:
"""
Filtrage d'une collection en fonction de l'user_id
"""
return collection.filter_by(user_id=user_id)
def filter_by_date(
collection: Assiduite or Justificatif,
collection_cls: Assiduite or Justificatif,
date_deb: datetime = None,
date_fin: datetime = None,
strict: bool = False,
):
"""
Filtrage d'une collection d'assiduites en fonction d'une date
"""
if date_deb is None:
date_deb = datetime.min
if date_fin is None:
date_fin = datetime.max
date_deb = scu.localize_datetime(date_deb)
date_fin = scu.localize_datetime(date_fin)
if not strict:
return collection.filter(
collection_cls.date_debut <= date_fin, collection_cls.date_fin >= date_deb
)
return collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_deb
)
def filter_justificatifs_by_etat(
justificatifs: Justificatif, etat: str
) -> Justificatif:
"""
Filtrage d'une collection de justificatifs en fonction de leur état
"""
etats: list[str] = list(etat.split(","))
etats = [scu.EtatJustificatif.get(e, -1) for e in etats]
return justificatifs.filter(Justificatif.etat.in_(etats))
def filter_by_module_impl(
assiduites: Assiduite, module_impl_id: int or None
) -> Assiduite:
"""
Filtrage d'une collection d'assiduites en fonction de l'ID du module_impl
"""
return assiduites.filter(Assiduite.moduleimpl_id == module_impl_id)
def filter_by_formsemestre(assiduites_query: Assiduite, formsemestre: FormSemestre):
"""
Filtrage d'une collection d'assiduites en fonction d'un formsemestre
"""
if formsemestre is None:
return assiduites_query.filter(False)
assiduites_query = (
assiduites_query.join(Identite, Assiduite.etudid == Identite.id)
.join(
FormSemestreInscription,
Identite.id == FormSemestreInscription.etudid,
)
.filter(FormSemestreInscription.formsemestre_id == formsemestre.id)
)
assiduites_query = assiduites_query.filter(
Assiduite.date_debut >= formsemestre.date_debut
)
return assiduites_query.filter(Assiduite.date_fin <= formsemestre.date_fin)
def justifies(justi: Justificatif, obj: bool = False) -> list[int]:
"""
Retourne la liste des assiduite_id qui sont justifié par la justification
Une assiduité est justifiée si elle est COMPLETEMENT ou PARTIELLEMENT comprise dans la plage du justificatif
et que l'état du justificatif est "valide"
renvoie des id si obj == False, sinon les Assiduités
"""
if justi.etat != scu.EtatJustificatif.VALIDE:
return []
assiduites_query: Assiduite = Assiduite.query.join(
Justificatif, Assiduite.etudid == Justificatif.etudid
).filter(
Assiduite.date_debut <= justi.date_fin,
Assiduite.date_fin >= justi.date_debut,
)
if not obj:
return [assi.id for assi in assiduites_query.all()]
return assiduites_query
def get_all_justified(
etudid: int, date_deb: datetime = None, date_fin: datetime = None
) -> list[Assiduite]:
"""Retourne toutes les assiduités justifiées sur une période"""
if date_deb is None:
date_deb = datetime.min
if date_fin is None:
date_fin = datetime.max
date_deb = scu.localize_datetime(date_deb)
date_fin = scu.localize_datetime(date_fin)
justified = Assiduite.query.filter_by(est_just=True, etudid=etudid)
after = filter_by_date(
justified,
Assiduite,
date_deb,
date_fin,
)
return after

View File

@ -38,7 +38,7 @@ from flask import flash, render_template, url_for
from flask_json import json_response from flask_json import json_response
from flask_login import current_user from flask_login import current_user
from app import db, email from app import email
from app import log from app import log
from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import json_error
from app.but import bulletin_but from app.but import bulletin_but
@ -354,7 +354,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
"modules_capitalized" "modules_capitalized"
] = [] # modules de l'UE capitalisée (liste vide si pas capitalisée) ] = [] # modules de l'UE capitalisée (liste vide si pas capitalisée)
if ue_status["is_capitalized"] and ue_status["formsemestre_id"] is not None: if ue_status["is_capitalized"] and ue_status["formsemestre_id"] is not None:
sem_origin = db.session.get(FormSemestre, ue_status["formsemestre_id"]) sem_origin = FormSemestre.query.get(ue_status["formsemestre_id"])
u[ u[
"ue_descr_txt" "ue_descr_txt"
] = f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}' ] = f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}'
@ -369,9 +369,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
) )
if ue_status["moy"] != "NA": if ue_status["moy"] != "NA":
# détail des modules de l'UE capitalisée # détail des modules de l'UE capitalisée
formsemestre_cap = db.session.get( formsemestre_cap = FormSemestre.query.get(ue_status["formsemestre_id"])
FormSemestre, ue_status["formsemestre_id"]
)
nt_cap: NotesTableCompat = res_sem.load_formsemestre_results( nt_cap: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre_cap formsemestre_cap
) )
@ -751,7 +749,7 @@ def etud_descr_situation_semestre(
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
parcour_id = res.etuds_parcour_id[etudid] parcour_id = res.etuds_parcour_id[etudid]
parcour: ApcParcours = ( parcour: ApcParcours = (
db.session.get(ApcParcours, parcour_id) if parcour_id is not None else None ApcParcours.query.get(parcour_id) if parcour_id is not None else None
) )
if parcour: if parcour:
infos["parcours_titre"] = parcour.libelle or "" infos["parcours_titre"] = parcour.libelle or ""
@ -930,7 +928,7 @@ def formsemestre_bulletinetud(
""" """
format = format or "html" format = format or "html"
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
if not formsemestre: if not formsemestre:
raise ScoValueError(f"semestre {formsemestre_id} inconnu !") raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
@ -945,7 +943,7 @@ def formsemestre_bulletinetud(
)[0] )[0]
if format not in {"html", "pdfmail"}: if format not in {"html", "pdfmail"}:
filename = scu.bul_filename(formsemestre, etud) filename = scu.bul_filename(formsemestre, etud, format)
mime, suffix = scu.get_mime_suffix(format) mime, suffix = scu.get_mime_suffix(format)
return scu.send_file(bulletin, filename, mime=mime, suffix=suffix) return scu.send_file(bulletin, filename, mime=mime, suffix=suffix)
elif format == "pdfmail": elif format == "pdfmail":
@ -1240,7 +1238,7 @@ def make_menu_autres_operations(
"enabled": current_user.has_permission(Permission.ScoImplement), "enabled": current_user.has_permission(Permission.ScoImplement),
}, },
{ {
"title": "Gérer les validations d'UEs antérieures", "title": "Enregistrer une validation d'UE antérieure",
"endpoint": "notes.formsemestre_validate_previous_ue", "endpoint": "notes.formsemestre_validate_previous_ue",
"args": { "args": {
"formsemestre_id": formsemestre.id, "formsemestre_id": formsemestre.id,

View File

@ -33,7 +33,7 @@ import json
from flask import abort from flask import abort
from app import db, ScoDocJSONEncoder from app import ScoDocJSONEncoder
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import but_validations from app.models import but_validations
@ -245,7 +245,7 @@ def formsemestre_bulletinetud_published_dict(
u["module"] = [] u["module"] = []
# Structure UE/Matière/Module # Structure UE/Matière/Module
# Recodé en 2022 # Recodé en 2022
ue = db.session.get(UniteEns, ue_id) ue = UniteEns.query.get(ue_id)
u["matiere"] = [ u["matiere"] = [
{ {
"matiere_id": mat.id, "matiere_id": mat.id,

View File

@ -54,7 +54,7 @@ import traceback
from flask import g from flask import g
import app import app
from app import db, log from app import log
from app.scodoc import notesdb as ndb from app.scodoc import notesdb as ndb
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException from app.scodoc.sco_exceptions import ScoException
@ -266,7 +266,7 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa
# appel via API ou tests sans dept: # appel via API ou tests sans dept:
formsemestre = None formsemestre = None
if formsemestre_id: if formsemestre_id:
formsemestre = db.session.get(FormSemestre, formsemestre_id) formsemestre = FormSemestre.query.get(formsemestre_id)
if formsemestre is None: if formsemestre is None:
raise ScoException("invalidate_formsemestre: departement must be set") raise ScoException("invalidate_formsemestre: departement must be set")
app.set_sco_dept(formsemestre.departement.acronym, open_cnx=False) app.set_sco_dept(formsemestre.departement.acronym, open_cnx=False)
@ -315,19 +315,6 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa
SemBulletinsPDFCache.invalidate_sems(formsemestre_ids) SemBulletinsPDFCache.invalidate_sems(formsemestre_ids)
def invalidate_formsemestre_etud(etud: "Identite"):
"""Invalide tous les formsemestres auxquels l'étudiant est inscrit"""
from app.models import FormSemestre, FormSemestreInscription
inscriptions = (
FormSemestreInscription.query.filter_by(etudid=etud.id)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
)
for inscription in inscriptions:
invalidate_formsemestre(inscription.formsemestre_id)
class DeferredSemCacheManager: class DeferredSemCacheManager:
"""Contexte pour effectuer des opérations indépendantes dans la """Contexte pour effectuer des opérations indépendantes dans la
même requete qui invalident le cache. Par exemple, quand on inscrit même requete qui invalident le cache. Par exemple, quand on inscrit

View File

@ -949,7 +949,6 @@ def do_formsemestre_validate_ue(
"ue_id": ue_id, "ue_id": ue_id,
"semestre_id": semestre_id, "semestre_id": semestre_id,
"is_external": is_external, "is_external": is_external,
"moy_ue": moy_ue,
} }
if date: if date:
args["event_date"] = date args["event_date"] = date
@ -966,13 +965,14 @@ def do_formsemestre_validate_ue(
cursor.execute("delete from scolar_formsemestre_validation where " + cond, args) cursor.execute("delete from scolar_formsemestre_validation where " + cond, args)
# insert # insert
args["code"] = code args["code"] = code
if (code == ADM) and (moy_ue is None): if code == ADM:
# stocke la moyenne d'UE capitalisée: if moy_ue is None:
ue_status = nt.get_etud_ue_status(etudid, ue_id) # stocke la moyenne d'UE capitalisée:
args["moy_ue"] = ue_status["moy"] if ue_status else "" ue_status = nt.get_etud_ue_status(etudid, ue_id)
moy_ue = ue_status["moy"] if ue_status else ""
args["moy_ue"] = moy_ue
log("formsemestre_validate_ue: create %s" % args) log("formsemestre_validate_ue: create %s" % args)
if code is not None: if code != None:
scolar_formsemestre_validation_create(cnx, args) scolar_formsemestre_validation_create(cnx, args)
else: else:
log("formsemestre_validate_ue: code is None, not recording validation") log("formsemestre_validate_ue: code is None, not recording validation")

View File

@ -82,7 +82,7 @@ def html_edit_formation_apc(
if None in ects: if None in ects:
ects_by_sem[semestre_idx] = '<span class="missing_ue_ects">manquant</span>' ects_by_sem[semestre_idx] = '<span class="missing_ue_ects">manquant</span>'
else: else:
ects_by_sem[semestre_idx] = f"{sum(ects):g}" ects_by_sem[semestre_idx] = sum(ects)
arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags() arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags()

View File

@ -103,7 +103,7 @@ def do_formation_delete(formation_id):
"""delete a formation (and all its UE, matieres, modules) """delete a formation (and all its UE, matieres, modules)
Warning: delete all ues, will ask if there are validations ! Warning: delete all ues, will ask if there are validations !
""" """
formation: Formation = db.session.get(Formation, formation_id) formation: Formation = Formation.query.get(formation_id)
if formation is None: if formation is None:
return return
acronyme = formation.acronyme acronyme = formation.acronyme
@ -132,7 +132,6 @@ def do_formation_delete(formation_id):
typ=ScolarNews.NEWS_FORM, typ=ScolarNews.NEWS_FORM,
obj=formation_id, obj=formation_id,
text=f"Suppression de la formation {acronyme}", text=f"Suppression de la formation {acronyme}",
max_frequency=0,
) )
@ -330,7 +329,6 @@ def do_formation_create(args: dict) -> Formation:
typ=ScolarNews.NEWS_FORM, typ=ScolarNews.NEWS_FORM,
text=f"""Création de la formation { text=f"""Création de la formation {
formation.titre} ({formation.acronyme}) version {formation.version}""", formation.titre} ({formation.acronyme}) version {formation.version}""",
max_frequency=0,
) )
return formation return formation

View File

@ -30,13 +30,13 @@
""" """
import flask import flask
from flask import g, url_for, request from flask import g, url_for, request
from app.models.events import ScolarNews
from app import db, log from app.models.formations import Matiere
from app.models import Formation, Matiere, UniteEns, ScolarNews
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import log
from app.models import Formation
from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
ScoValueError, ScoValueError,
@ -73,7 +73,7 @@ def do_matiere_edit(*args, **kw):
# edit # edit
_matiereEditor.edit(cnx, *args, **kw) _matiereEditor.edit(cnx, *args, **kw)
formation_id = sco_edit_ue.ue_list({"ue_id": mat["ue_id"]})[0]["formation_id"] formation_id = sco_edit_ue.ue_list({"ue_id": mat["ue_id"]})[0]["formation_id"]
db.session.get(Formation, formation_id).invalidate_cached_sems() Formation.query.get(formation_id).invalidate_cached_sems()
def do_matiere_create(args): def do_matiere_create(args):
@ -88,11 +88,12 @@ def do_matiere_create(args):
r = _matiereEditor.create(cnx, args) r = _matiereEditor.create(cnx, args)
# news # news
formation = db.session.get(Formation, ue["formation_id"]) formation = Formation.query.get(ue["formation_id"])
ScolarNews.add( ScolarNews.add(
typ=ScolarNews.NEWS_FORM, typ=ScolarNews.NEWS_FORM,
obj=ue["formation_id"], obj=ue["formation_id"],
text=f"Modification de la formation {formation.acronyme}", text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
) )
formation.invalidate_cached_sems() formation.invalidate_cached_sems()
return r return r
@ -100,12 +101,13 @@ def do_matiere_create(args):
def matiere_create(ue_id=None): def matiere_create(ue_id=None):
"""Creation d'une matiere""" """Creation d'une matiere"""
ue: UniteEns = UniteEns.query.get_or_404(ue_id) from app.scodoc import sco_edit_ue
default_numero = max([mat.numero for mat in ue.matieres] or [9]) + 1
UE = sco_edit_ue.ue_list(args={"ue_id": ue_id})[0]
H = [ H = [
html_sco_header.sco_header(page_title="Création d'une matière"), html_sco_header.sco_header(page_title="Création d'une matière"),
f"""<h2>Création d'une matière dans l'UE {ue.titre} ({ue.acronyme})</h2> """<h2>Création d'une matière dans l'UE %(titre)s (%(acronyme)s)</h2>""" % UE,
<p class="help">Les matières sont des groupes de modules dans une UE """<p class="help">Les matières sont des groupes de modules dans une UE
d'une formation donnée. Les matières servent surtout pour la d'une formation donnée. Les matières servent surtout pour la
présentation (bulletins, etc) mais <em>n'ont pas de rôle dans le calcul présentation (bulletins, etc) mais <em>n'ont pas de rôle dans le calcul
des notes.</em> des notes.</em>
@ -125,21 +127,13 @@ associé.
scu.get_request_args(), scu.get_request_args(),
( (
("ue_id", {"input_type": "hidden", "default": ue_id}), ("ue_id", {"input_type": "hidden", "default": ue_id}),
( ("titre", {"size": 30, "explanation": "nom de la matière."}),
"titre",
{
"size": 30,
"explanation": "nom de la matière.",
},
),
( (
"numero", "numero",
{ {
"size": 2, "size": 2,
"explanation": "numéro (1,2,3,4...) pour affichage", "explanation": "numéro (1,2,3,4...) pour affichage",
"type": "int", "type": "int",
"default": default_numero,
"allow_null": False,
}, },
), ),
), ),
@ -147,7 +141,7 @@ associé.
) )
dest_url = url_for( dest_url = url_for(
"notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation_id "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=UE["formation_id"]
) )
if tf[0] == 0: if tf[0] == 0:
@ -200,11 +194,12 @@ def do_matiere_delete(oid):
_matiereEditor.delete(cnx, oid) _matiereEditor.delete(cnx, oid)
# news # news
formation = db.session.get(Formation, ue["formation_id"]) formation = Formation.query.get(ue["formation_id"])
ScolarNews.add( ScolarNews.add(
typ=ScolarNews.NEWS_FORM, typ=ScolarNews.NEWS_FORM,
obj=ue["formation_id"], obj=ue["formation_id"],
text=f"Modification de la formation {formation.acronyme}", text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
) )
formation.invalidate_cached_sems() formation.invalidate_cached_sems()

View File

@ -98,10 +98,10 @@ def module_list(*args, **kw):
def do_module_create(args) -> int: def do_module_create(args) -> int:
"Create a module. Returns id of new object." "Create a module. Returns id of new object."
formation = db.session.get(Formation, args["formation_id"]) formation = Formation.query.get(args["formation_id"])
# refuse de créer un module APC avec semestres incohérents: # refuse de créer un module APC avec semestres incohérents:
if formation.is_apc(): if formation.is_apc():
ue = db.session.get(UniteEns, args["ue_id"]) ue = UniteEns.query.get(args["ue_id"])
if int(args.get("semestre_id", 0)) != ue.semestre_idx: if int(args.get("semestre_id", 0)) != ue.semestre_idx:
raise ScoValueError("Formation incompatible: contacter le support ScoDoc") raise ScoValueError("Formation incompatible: contacter le support ScoDoc")
# create # create
@ -114,6 +114,7 @@ def do_module_create(args) -> int:
typ=ScolarNews.NEWS_FORM, typ=ScolarNews.NEWS_FORM,
obj=formation.id, obj=formation.id,
text=f"Modification de la formation {formation.acronyme}", text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
) )
formation.invalidate_cached_sems() formation.invalidate_cached_sems()
return module_id return module_id
@ -185,6 +186,7 @@ def do_module_delete(oid):
typ=ScolarNews.NEWS_FORM, typ=ScolarNews.NEWS_FORM,
obj=mod["formation_id"], obj=mod["formation_id"],
text=f"Modification de la formation {formation.acronyme}", text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
) )
formation.invalidate_cached_sems() formation.invalidate_cached_sems()
@ -248,7 +250,7 @@ def do_module_edit(vals: dict) -> None:
# edit # edit
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
_moduleEditor.edit(cnx, vals) _moduleEditor.edit(cnx, vals)
db.session.get(Formation, mod["formation_id"]).invalidate_cached_sems() Formation.query.get(mod["formation_id"]).invalidate_cached_sems()
def check_module_code_unicity(code, field, formation_id, module_id=None): def check_module_code_unicity(code, field, formation_id, module_id=None):
@ -659,7 +661,6 @@ def module_edit(
"explanation": "numéro (1, 2, 3, 4, ...) pour ordre d'affichage", "explanation": "numéro (1, 2, 3, 4, ...) pour ordre d'affichage",
"type": "int", "type": "int",
"default": default_num, "default": default_num,
"allow_null": False,
}, },
), ),
] ]
@ -805,7 +806,7 @@ def module_edit(
if create: if create:
if not matiere_id: if not matiere_id:
# formulaire avec choix UE de rattachement # formulaire avec choix UE de rattachement
ue = db.session.get(UniteEns, tf[2]["ue_id"]) ue = UniteEns.query.get(tf[2]["ue_id"])
if ue is None: if ue is None:
raise ValueError("UE invalide") raise ValueError("UE invalide")
matiere = ue.matieres.first() matiere = ue.matieres.first()
@ -819,7 +820,7 @@ def module_edit(
tf[2]["semestre_id"] = ue.semestre_idx tf[2]["semestre_id"] = ue.semestre_idx
module_id = do_module_create(tf[2]) module_id = do_module_create(tf[2])
module = db.session.get(Module, module_id) module = Module.query.get(module_id)
else: # EDITION MODULE else: # EDITION MODULE
# l'UE de rattachement peut changer # l'UE de rattachement peut changer
tf[2]["ue_id"], tf[2]["matiere_id"] = tf[2]["ue_matiere_id"].split("!") tf[2]["ue_id"], tf[2]["matiere_id"] = tf[2]["ue_matiere_id"].split("!")
@ -837,7 +838,7 @@ def module_edit(
) )
# En APC, force le semestre égal à celui de l'UE # En APC, force le semestre égal à celui de l'UE
if is_apc: if is_apc:
selected_ue = db.session.get(UniteEns, tf[2]["ue_id"]) selected_ue = UniteEns.query.get(tf[2]["ue_id"])
if selected_ue is None: if selected_ue is None:
raise ValueError("UE invalide") raise ValueError("UE invalide")
tf[2]["semestre_id"] = selected_ue.semestre_idx tf[2]["semestre_id"] = selected_ue.semestre_idx
@ -853,13 +854,13 @@ def module_edit(
module.parcours = formation.referentiel_competence.parcours.all() module.parcours = formation.referentiel_competence.parcours.all()
else: else:
module.parcours = [ module.parcours = [
db.session.get(ApcParcours, int(parcour_id_str)) ApcParcours.query.get(int(parcour_id_str))
for parcour_id_str in tf[2]["parcours"] for parcour_id_str in tf[2]["parcours"]
] ]
# Modifie les AC # Modifie les AC
if "app_critiques" in tf[2]: if "app_critiques" in tf[2]:
module.app_critiques = [ module.app_critiques = [
db.session.get(ApcAppCritique, int(ac_id_str)) ApcAppCritique.query.get(int(ac_id_str))
for ac_id_str in tf[2]["app_critiques"] for ac_id_str in tf[2]["app_critiques"]
] ]
db.session.add(module) db.session.add(module)

View File

@ -36,7 +36,8 @@ from flask import flash, render_template, url_for
from flask import g, request from flask import g, request
from flask_login import current_user from flask_login import current_user
from app import db, log from app import db
from app import log
from app.but import apc_edit_ue from app.but import apc_edit_ue
from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN
from app.models import ( from app.models import (
@ -136,14 +137,15 @@ def do_ue_create(args):
ue_id = _ueEditor.create(cnx, args) ue_id = _ueEditor.create(cnx, args)
log(f"do_ue_create: created {ue_id} with {args}") log(f"do_ue_create: created {ue_id} with {args}")
formation: Formation = db.session.get(Formation, args["formation_id"]) formation: Formation = Formation.query.get(args["formation_id"])
formation.invalidate_module_coefs() formation.invalidate_module_coefs()
# news # news
formation = db.session.get(Formation, args["formation_id"]) formation = Formation.query.get(args["formation_id"])
ScolarNews.add( ScolarNews.add(
typ=ScolarNews.NEWS_FORM, typ=ScolarNews.NEWS_FORM,
obj=args["formation_id"], obj=args["formation_id"],
text=f"Modification de la formation {formation.acronyme}", text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
) )
formation.invalidate_cached_sems() formation.invalidate_cached_sems()
return ue_id return ue_id
@ -228,6 +230,7 @@ def do_ue_delete(ue: UniteEns, delete_validations=False, force=False):
typ=ScolarNews.NEWS_FORM, typ=ScolarNews.NEWS_FORM,
obj=formation.id, obj=formation.id,
text=f"Modification de la formation {formation.acronyme}", text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
) )
# #
if not force: if not force:
@ -283,7 +286,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
} }
submitlabel = "Créer cette UE" submitlabel = "Créer cette UE"
can_change_semestre_id = True can_change_semestre_id = True
formation = db.session.get(Formation, formation_id) formation = Formation.query.get(formation_id)
if not formation: if not formation:
raise ScoValueError(f"Formation inexistante ! (id={formation_id})") raise ScoValueError(f"Formation inexistante ! (id={formation_id})")
cursus = formation.get_cursus() cursus = formation.get_cursus()
@ -440,7 +443,6 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
{ {
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"title": "UE externe", "title": "UE externe",
"readonly": not create, # ne permet pas de transformer une UE existante en externe
"explanation": "réservé pour les capitalisations d'UE effectuées à l'extérieur de l'établissement", "explanation": "réservé pour les capitalisations d'UE effectuées à l'extérieur de l'établissement",
}, },
), ),
@ -501,7 +503,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
else: else:
clone_form = "" clone_form = ""
bonus_div = """<div id="bonus_description"></div>""" bonus_div = """<div id="bonus_description"></div>"""
ue_div = """<div id="ue_list_code" class="sco_box sco_green_bg"></div>""" ue_div = """<div id="ue_list_code"></div>"""
return ( return (
"\n".join(H) "\n".join(H)
+ tf[1] + tf[1]
@ -542,11 +544,9 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"semestre_id": tf[2]["semestre_idx"], "semestre_id": tf[2]["semestre_idx"],
}, },
) )
ue = db.session.get(UniteEns, ue_id) ue = UniteEns.query.get(ue_id)
flash(f"UE créée (code {ue.ue_code})") flash(f"UE créée (code {ue.ue_code})")
else: else:
if not tf[2]["numero"]:
tf[2]["numero"] = 0
do_ue_edit(tf[2]) do_ue_edit(tf[2])
flash("UE modifiée") flash("UE modifiée")
@ -596,7 +596,7 @@ def next_ue_numero(formation_id, semestre_id=None):
"""Numero d'une nouvelle UE dans cette formation. """Numero d'une nouvelle UE dans cette formation.
Si le semestre est specifie, cherche les UE ayant des modules de ce semestre Si le semestre est specifie, cherche les UE ayant des modules de ce semestre
""" """
formation = db.session.get(Formation, formation_id) formation = Formation.query.get(formation_id)
ues = ue_list(args={"formation_id": formation_id}) ues = ue_list(args={"formation_id": formation_id})
if not ues: if not ues:
return 0 return 0
@ -660,7 +660,7 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
""" """
from app.scodoc import sco_formsemestre_validation from app.scodoc import sco_formsemestre_validation
formation: Formation = db.session.get(Formation, formation_id) formation: Formation = Formation.query.get(formation_id)
if not formation: if not formation:
raise ScoValueError("invalid formation_id") raise ScoValueError("invalid formation_id")
parcours = formation.get_cursus() parcours = formation.get_cursus()
@ -756,7 +756,7 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
], ],
page_title=f"Programme {formation.acronyme} v{formation.version}", page_title=f"Programme {formation.acronyme} v{formation.version}",
), ),
f"""<h2>{formation.html()} {lockicon} f"""<h2>{formation.to_html()} {lockicon}
</h2> </h2>
""", """,
] ]
@ -1009,7 +1009,12 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
<p><ul>""" <p><ul>"""
) )
for formsemestre in formsemestres: for formsemestre in formsemestres:
H.append(f"""<li>{formsemestre.html_link_status()}""") H.append(
f"""<li><a class="stdlink" href="{
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id
)}">{formsemestre.titre_mois()}</a>"""
)
if not formsemestre.etat: if not formsemestre.etat:
H.append(" [verrouillé]") H.append(" [verrouillé]")
else: else:
@ -1376,12 +1381,13 @@ def _ue_table_modules(
return "\n".join(H) return "\n".join(H)
def ue_sharing_code(ue_code: str = "", ue_id: int = None, hide_ue_id: int = None): def ue_sharing_code(ue_code=None, ue_id=None, hide_ue_id=None):
"""HTML list of UE sharing this code """HTML list of UE sharing this code
Either ue_code or ue_id may be specified. Either ue_code or ue_id may be specified.
hide_ue_id spécifie un id à retirer de la liste. hide_ue_id spécifie un id à retirer de la liste.
""" """
if ue_id is not None: ue_code = str(ue_code)
if ue_id:
ue = UniteEns.query.get_or_404(ue_id) ue = UniteEns.query.get_or_404(ue_id)
if not ue_code: if not ue_code:
ue_code = ue.ue_code ue_code = ue.ue_code
@ -1400,36 +1406,29 @@ def ue_sharing_code(ue_code: str = "", ue_id: int = None, hide_ue_id: int = None
.filter_by(dept_id=g.scodoc_dept_id) .filter_by(dept_id=g.scodoc_dept_id)
) )
if hide_ue_id is not None: # enlève l'ue de depart if hide_ue_id: # enlève l'ue de depart
q_ues = q_ues.filter(UniteEns.id != hide_ue_id) q_ues = q_ues.filter(UniteEns.id != hide_ue_id)
ues = q_ues.all() ues = q_ues.all()
msg = " dans les formations du département "
if not ues: if not ues:
if ue_id is not None: if ue_id:
return f"""<span class="ue_share">Seule UE avec code { return (
ue_code if ue_code is not None else '-'}{msg}</span>""" f"""<span class="ue_share">Seule UE avec code {ue_code or '-'}</span>"""
)
else: else:
return f"""<span class="ue_share">Aucune UE avec code { return f"""<span class="ue_share">Aucune UE avec code {ue_code or '-'}</span>"""
ue_code if ue_code is not None else '-'}{msg}</span>"""
H = [] H = []
if ue_id: if ue_id:
H.append( H.append(
f"""<span class="ue_share">Pour information, autres UEs avec le code { f"""<span class="ue_share">Autres UE avec le code {ue_code or '-'}:</span>"""
ue_code if ue_code is not None else '-'}{msg}:</span>"""
) )
else: else:
H.append( H.append(f"""<span class="ue_share">UE avec le code {ue_code or '-'}:</span>""")
f"""<span class="ue_share">UE avec le code {
ue_code if ue_code is not None else '-'}{msg}:</span>"""
)
H.append("<ul>") H.append("<ul>")
for ue in ues: for ue in ues:
H.append( H.append(
f"""<li>{ue.acronyme} ({ue.titre}) dans f"""<li>{ue.acronyme} ({ue.titre}) dans <a class="stdlink"
<a class="stdlink" href="{ href="{url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)}"
url_for("notes.ue_table",
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)}"
>{ue.formation.acronyme} ({ue.formation.titre})</a>, version {ue.formation.version} >{ue.formation.acronyme} ({ue.formation.titre})</a>, version {ue.formation.version}
</li> </li>
""" """
@ -1461,7 +1460,7 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
_ueEditor.edit(cnx, args) _ueEditor.edit(cnx, args)
formation = db.session.get(Formation, ue["formation_id"]) formation = Formation.query.get(ue["formation_id"])
if not dont_invalidate_cache: if not dont_invalidate_cache:
# Invalide les semestres utilisant cette formation # Invalide les semestres utilisant cette formation
# ainsi que les poids et coefs # ainsi que les poids et coefs

View File

@ -62,9 +62,7 @@ def format_etud_ident(etud):
else: else:
etud["prenom_etat_civil"] = "" etud["prenom_etat_civil"] = ""
etud["civilite_str"] = format_civilite(etud["civilite"]) etud["civilite_str"] = format_civilite(etud["civilite"])
etud["civilite_etat_civil_str"] = format_civilite( etud["civilite_etat_civil_str"] = format_civilite(etud["civilite_etat_civil"])
etud.get("civilite_etat_civil", "X")
)
# Nom à afficher: # Nom à afficher:
if etud["nom_usuel"]: if etud["nom_usuel"]:
etud["nom_disp"] = etud["nom_usuel"] etud["nom_disp"] = etud["nom_usuel"]
@ -147,7 +145,7 @@ def format_civilite(civilite):
def format_etat_civil(etud: dict): def format_etat_civil(etud: dict):
if etud["prenom_etat_civil"]: if etud["prenom_etat_civil"]:
civ = {"M": "M.", "F": "Mme", "X": ""}[etud.get("civilite_etat_civil", "X")] civ = {"M": "M.", "F": "Mme", "X": ""}[etud["civilite_etat_civil"]]
return f'{civ} {etud["prenom_etat_civil"]} {etud["nom"]}' return f'{civ} {etud["prenom_etat_civil"]} {etud["nom"]}'
else: else:
return etud["nomprenom"] return etud["nomprenom"]
@ -262,7 +260,7 @@ def identite_list(cnx, *a, **kw):
def identite_edit_nocheck(cnx, args): def identite_edit_nocheck(cnx, args):
"""Modifie les champs mentionnes dans args, sans verification ni notification.""" """Modifie les champs mentionnes dans args, sans verification ni notification."""
etud = db.session.get(Identite, args["etudid"]) etud = Identite.query.get(args["etudid"])
etud.from_dict(args) etud.from_dict(args)
db.session.commit() db.session.commit()
@ -671,7 +669,6 @@ def create_etud(cnx, args: dict = None):
typ=ScolarNews.NEWS_INSCR, typ=ScolarNews.NEWS_INSCR,
text='Nouvel étudiant <a href="%(url)s">%(nomprenom)s</a>' % etud, text='Nouvel étudiant <a href="%(url)s">%(nomprenom)s</a>' % etud,
url=etud["url"], url=etud["url"],
max_frequency=0,
) )
return etud return etud

View File

@ -129,7 +129,7 @@ def do_evaluation_create(
) )
args = locals() args = locals()
log("do_evaluation_create: args=" + str(args)) log("do_evaluation_create: args=" + str(args))
modimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id) modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
if modimpl is None: if modimpl is None:
raise ValueError("module not found") raise ValueError("module not found")
check_evaluation_args(args) check_evaluation_args(args)
@ -252,11 +252,12 @@ def do_evaluation_delete(evaluation_id):
def do_evaluation_get_all_notes( def do_evaluation_get_all_notes(
evaluation_id, table="notes_notes", filter_suppressed=True, by_uid=None evaluation_id, table="notes_notes", filter_suppressed=True, by_uid=None
): ):
"""Toutes les notes pour une évaluation: { etudid : { 'value' : value, 'date' : date ... }} """Toutes les notes pour une evaluation: { etudid : { 'value' : value, 'date' : date ... }}
Attention: inclut aussi les notes des étudiants qui ne sont plus inscrits au module. Attention: inclut aussi les notes des étudiants qui ne sont plus inscrits au module.
""" """
# pas de cache pour (rares) appels via undo_notes ou specifiant un enseignant do_cache = (
do_cache = filter_suppressed and table == "notes_notes" and (by_uid is None) filter_suppressed and table == "notes_notes" and (by_uid is None)
) # pas de cache pour (rares) appels via undo_notes ou specifiant un enseignant
if do_cache: if do_cache:
r = sco_cache.EvaluationCache.get(evaluation_id) r = sco_cache.EvaluationCache.get(evaluation_id)
if r is not None: if r is not None:

View File

@ -37,8 +37,11 @@ from flask_login import current_user
from flask import request from flask import request
from app import db from app import db
from app.models import Evaluation, FormSemestre, ModuleImpl from app import log
from app import models
from app.models.evaluations import Evaluation
from app.models.formsemestre import FormSemestre
from app.models.moduleimpls import ModuleImpl
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
@ -59,7 +62,7 @@ def evaluation_create_form(
): ):
"Formulaire création/édition d'une évaluation (pas de ses notes)" "Formulaire création/édition d'une évaluation (pas de ses notes)"
if evaluation_id is not None: if evaluation_id is not None:
evaluation: Evaluation = db.session.get(Evaluation, evaluation_id) evaluation: Evaluation = models.Evaluation.query.get(evaluation_id)
if evaluation is None: if evaluation is None:
raise ScoValueError("Cette évaluation n'existe pas ou plus !") raise ScoValueError("Cette évaluation n'existe pas ou plus !")
moduleimpl_id = evaluation.moduleimpl_id moduleimpl_id = evaluation.moduleimpl_id
@ -360,7 +363,7 @@ def evaluation_create_form(
evaluation_id = sco_evaluation_db.do_evaluation_create(**tf[2]) evaluation_id = sco_evaluation_db.do_evaluation_create(**tf[2])
if is_apc: if is_apc:
# Set poids # Set poids
evaluation = db.session.get(Evaluation, evaluation_id) evaluation = models.Evaluation.query.get(evaluation_id)
for ue in sem_ues: for ue in sem_ues:
evaluation.set_ue_poids(ue, tf[2][f"poids_{ue.id}"]) evaluation.set_ue_poids(ue, tf[2][f"poids_{ue.id}"])
db.session.add(evaluation) db.session.add(evaluation)

View File

@ -12,7 +12,6 @@ Sur une idée de Pascal Bouron, de Lyon.
import time import time
from flask import g, url_for from flask import g, url_for
from app import db
from app.models import Evaluation, FormSemestre from app.models import Evaluation, FormSemestre
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
@ -114,7 +113,7 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
rows.append(row) rows.append(row)
line_idx += 1 line_idx += 1
for evaluation_id in modimpl_results.evals_notes: for evaluation_id in modimpl_results.evals_notes:
e = db.session.get(Evaluation, evaluation_id) e = Evaluation.query.get(evaluation_id)
eval_etat = modimpl_results.evaluations_etat[evaluation_id] eval_etat = modimpl_results.evaluations_etat[evaluation_id]
row = { row = {
"type": "", "type": "",

View File

@ -433,7 +433,7 @@ def excel_simple_table(
return ws.generate() return ws.generate()
def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, lines): def excel_feuille_saisie(e, titreannee, description, lines):
"""Genere feuille excel pour saisie des notes. """Genere feuille excel pour saisie des notes.
E: evaluation (dict) E: evaluation (dict)
lines: liste de tuples lines: liste de tuples
@ -512,20 +512,18 @@ def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, line
# description evaluation # description evaluation
ws.append_single_cell_row(scu.unescape_html(description), style_titres) ws.append_single_cell_row(scu.unescape_html(description), style_titres)
ws.append_single_cell_row( ws.append_single_cell_row(
"Evaluation du %s (coef. %g)" "Evaluation du %s (coef. %g)" % (e["jour"], e["coefficient"]), style
% (evaluation.jour or "sans date", evaluation.coefficient or 0.0),
style,
) )
# ligne blanche # ligne blanche
ws.append_blank_row() ws.append_blank_row()
# code et titres colonnes # code et titres colonnes
ws.append_row( ws.append_row(
[ [
ws.make_cell("!%s" % evaluation.id, style_ro), ws.make_cell("!%s" % e["evaluation_id"], style_ro),
ws.make_cell("Nom", style_titres), ws.make_cell("Nom", style_titres),
ws.make_cell("Prénom", style_titres), ws.make_cell("Prénom", style_titres),
ws.make_cell("Groupe", style_titres), ws.make_cell("Groupe", style_titres),
ws.make_cell("Note sur %g" % (evaluation.note_max or 0.0), style_titres), ws.make_cell("Note sur %g" % e["note_max"], style_titres),
ws.make_cell("Remarque", style_titres), ws.make_cell("Remarque", style_titres),
] ]
) )

View File

@ -28,15 +28,15 @@
"""Table recap formation (avec champs éditables) """Table recap formation (avec champs éditables)
""" """
import io import io
from zipfile import ZipFile from zipfile import ZipFile, BadZipfile
from flask import Response from flask import Response
from flask import send_file, url_for from flask import send_file, url_for
from flask import g, request from flask import g, request
from flask_login import current_user from flask_login import current_user
from app import db from app.models import Formation, FormSemestre, UniteEns, Module
from app.models import Formation, FormSemestre, Matiere, Module, UniteEns from app.models.formations import Matiere
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
@ -178,7 +178,7 @@ def export_recap_formations_annee_scolaire(annee_scolaire):
) )
formation_ids = {formsemestre.formation.id for formsemestre in formsemestres} formation_ids = {formsemestre.formation.id for formsemestre in formsemestres}
for formation_id in formation_ids: for formation_id in formation_ids:
formation = db.session.get(Formation, formation_id) formation = Formation.query.get(formation_id)
xls = formation_table_recap(formation_id, format="xlsx").data xls = formation_table_recap(formation_id, format="xlsx").data
filename = ( filename = (
scu.sanitize_filename(formation.get_titre_version()) + scu.XLSX_SUFFIX scu.sanitize_filename(formation.get_titre_version()) + scu.XLSX_SUFFIX

View File

@ -200,31 +200,31 @@ def do_formsemestres_associate_new_version(
# New formation: # New formation:
( (
new_formation_id, formation_id,
modules_old2new, modules_old2new,
ues_old2new, ues_old2new,
) = sco_formations.formation_create_new_version(formation_id, redirect=False) ) = sco_formations.formation_create_new_version(formation_id, redirect=False)
# Log new ues: # Log new ues:
for ue_id in ues_old2new: for ue_id in ues_old2new:
ue = db.session.get(UniteEns, ue_id) ue = UniteEns.query.get(ue_id)
new_ue = db.session.get(UniteEns, ues_old2new[ue_id]) new_ue = UniteEns.query.get(ues_old2new[ue_id])
assert ue.semestre_idx == new_ue.semestre_idx assert ue.semestre_idx == new_ue.semestre_idx
log(f"{ue} -> {new_ue}") log(f"{ue} -> {new_ue}")
# Log new modules # Log new modules
for module_id in modules_old2new: for module_id in modules_old2new:
mod = db.session.get(Module, module_id) mod = Module.query.get(module_id)
new_mod = db.session.get(Module, modules_old2new[module_id]) new_mod = Module.query.get(modules_old2new[module_id])
assert mod.semestre_id == new_mod.semestre_id assert mod.semestre_id == new_mod.semestre_id
log(f"{mod} -> {new_mod}") log(f"{mod} -> {new_mod}")
# re-associate # re-associate
for formsemestre_id in formsemestre_ids: for formsemestre_id in formsemestre_ids:
formsemestre = FormSemestre.get_formsemestre(formsemestre_id) formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
formsemestre.formation_id = new_formation_id formsemestre.formation_id = formation_id
db.session.add(formsemestre) db.session.add(formsemestre)
_reassociate_moduleimpls(formsemestre, ues_old2new, modules_old2new) _reassociate_moduleimpls(formsemestre, ues_old2new, modules_old2new)
db.session.commit() db.session.commit()
return new_formation_id return formation_id
def _reassociate_moduleimpls( def _reassociate_moduleimpls(
@ -246,12 +246,8 @@ def _reassociate_moduleimpls(
Evaluation.moduleimpl_id == ModuleImpl.id, Evaluation.moduleimpl_id == ModuleImpl.id,
ModuleImpl.formsemestre_id == formsemestre.id, ModuleImpl.formsemestre_id == formsemestre.id,
): ):
if poids.ue_id in ues_old2new: poids.ue_id = ues_old2new[poids.ue_id]
poids.ue_id = ues_old2new[poids.ue_id] db.session.add(poids)
db.session.add(poids)
else:
# poids vers une UE qui n'est pas ou plus dans notre formation
db.session.delete(poids)
# update decisions: # update decisions:
for event in ScolarEvent.query.filter_by(formsemestre_id=formsemestre.id): for event in ScolarEvent.query.filter_by(formsemestre_id=formsemestre.id):
@ -262,9 +258,8 @@ def _reassociate_moduleimpls(
for validation in ScolarFormSemestreValidation.query.filter_by( for validation in ScolarFormSemestreValidation.query.filter_by(
formsemestre_id=formsemestre.id formsemestre_id=formsemestre.id
): ):
if (validation.ue_id is not None) and validation.ue_id in ues_old2new: if validation.ue_id is not None:
validation.ue_id = ues_old2new[validation.ue_id] validation.ue_id = ues_old2new[validation.ue_id]
# si l'UE n'est pas ou plus dans notre formation, laisse.
db.session.add(validation) db.session.add(validation)
db.session.commit() db.session.commit()

View File

@ -163,7 +163,7 @@ def formation_export_dict(
if tags: if tags:
mod["tags"] = [{"name": x} for x in tags] mod["tags"] = [{"name": x} for x in tags]
# #
module: Module = db.session.get(Module, module_id) module: Module = Module.query.get(module_id)
if module.is_apc(): if module.is_apc():
# Exporte les coefficients # Exporte les coefficients
if ue_reference_style == "id": if ue_reference_style == "id":
@ -359,7 +359,7 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False):
referentiel_competence_id, ue_info[1] referentiel_competence_id, ue_info[1]
) )
ue_id = sco_edit_ue.do_ue_create(ue_info[1]) ue_id = sco_edit_ue.do_ue_create(ue_info[1])
ue: UniteEns = db.session.get(UniteEns, ue_id) ue: UniteEns = UniteEns.query.get(ue_id)
assert ue assert ue
if xml_ue_id: if xml_ue_id:
ues_old2new[xml_ue_id] = ue_id ues_old2new[xml_ue_id] = ue_id
@ -424,7 +424,7 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False):
if xml_module_id: if xml_module_id:
modules_old2new[int(xml_module_id)] = mod_id modules_old2new[int(xml_module_id)] = mod_id
if len(mod_info) > 2: if len(mod_info) > 2:
module: Module = db.session.get(Module, mod_id) module: Module = Module.query.get(mod_id)
tag_names = [] tag_names = []
ue_coef_dict = {} ue_coef_dict = {}
for child in mod_info[2]: for child in mod_info[2]:
@ -626,9 +626,7 @@ def formation_list_table() -> GenTable:
def formation_create_new_version(formation_id, redirect=True): def formation_create_new_version(formation_id, redirect=True):
"duplicate formation, with new version number" "duplicate formation, with new version number"
formation = Formation.query.get_or_404(formation_id) formation = Formation.query.get_or_404(formation_id)
resp = formation_export( resp = formation_export(formation_id, export_ids=True, format="xml")
formation_id, export_ids=True, export_external_ues=True, format="xml"
)
xml_data = resp.get_data(as_text=True) xml_data = resp.get_data(as_text=True)
new_id, modules_old2new, ues_old2new = formation_import_xml( new_id, modules_old2new, ues_old2new = formation_import_xml(
xml_data, use_local_refcomp=True xml_data, use_local_refcomp=True
@ -638,7 +636,6 @@ def formation_create_new_version(formation_id, redirect=True):
typ=ScolarNews.NEWS_FORM, typ=ScolarNews.NEWS_FORM,
obj=new_id, obj=new_id,
text=f"Nouvelle version de la formation {formation.acronyme}", text=f"Nouvelle version de la formation {formation.acronyme}",
max_frequency=0,
) )
if redirect: if redirect:
flash("Nouvelle version !") flash("Nouvelle version !")

View File

@ -261,7 +261,6 @@ def do_formsemestre_create(args, silent=False):
typ=ScolarNews.NEWS_SEM, typ=ScolarNews.NEWS_SEM,
text='Création du semestre <a href="%(url)s">%(titre)s</a>' % args, text='Création du semestre <a href="%(url)s">%(titre)s</a>' % args,
url=args["url"], url=args["url"],
max_frequency=0,
) )
return formsemestre_id return formsemestre_id

View File

@ -793,16 +793,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
{tf[1]} {tf[1]}
""" """
elif tf[0] == -1: elif tf[0] == -1:
if formsemestre: return "<h4>annulation</h4>"
return redirect(
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
)
)
else:
return redirect(url_for("notes.index_html", scodoc_dept=g.scodoc_dept))
else: else:
if tf[2]["gestion_compensation_lst"]: if tf[2]["gestion_compensation_lst"]:
tf[2]["gestion_compensation"] = True tf[2]["gestion_compensation"] = True
@ -950,7 +941,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
formsemestre = FormSemestre.get_formsemestre(formsemestre_id) formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if "parcours" in tf[2]: if "parcours" in tf[2]:
formsemestre.parcours = [ formsemestre.parcours = [
db.session.get(ApcParcours, int(parcour_id_str)) ApcParcours.query.get(int(parcour_id_str))
for parcour_id_str in tf[2]["parcours"] for parcour_id_str in tf[2]["parcours"]
] ]
db.session.add(formsemestre) db.session.add(formsemestre)
@ -1044,7 +1035,7 @@ def formsemestre_delete_moduleimpls(formsemestre_id, module_ids_to_del):
ok = True ok = True
msg = [] msg = []
for module_id in module_ids_to_del: for module_id in module_ids_to_del:
module = db.session.get(Module, module_id) module = Module.query.get(module_id)
if module is None: if module is None:
continue # ignore invalid ids continue # ignore invalid ids
modimpls = ModuleImpl.query.filter_by( modimpls = ModuleImpl.query.filter_by(
@ -1224,7 +1215,7 @@ def do_formsemestre_clone(
args["etat"] = 1 # non verrouillé args["etat"] = 1 # non verrouillé
formsemestre_id = sco_formsemestre.do_formsemestre_create(args) formsemestre_id = sco_formsemestre.do_formsemestre_create(args)
log(f"created formsemestre {formsemestre_id}") log(f"created formsemestre {formsemestre_id}")
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
# 2- create moduleimpls # 2- create moduleimpls
mods_orig = sco_moduleimpl.moduleimpl_list(formsemestre_id=orig_formsemestre_id) mods_orig = sco_moduleimpl.moduleimpl_list(formsemestre_id=orig_formsemestre_id)
for mod_orig in mods_orig: for mod_orig in mods_orig:
@ -1342,18 +1333,11 @@ Ceci n'est possible que si :
cancelbutton="Annuler", cancelbutton="Annuler",
) )
if tf[0] == 0: if tf[0] == 0:
has_decisions, message = formsemestre_has_decisions_or_compensations( if formsemestre_has_decisions_or_compensations(formsemestre):
formsemestre
)
if has_decisions:
H.append( H.append(
f"""<p><b>Ce semestre ne peut pas être supprimé !</b></p> """<p><b>Ce semestre ne peut pas être supprimé !
<p>il y a des décisions de jury ou des compensations par d'autres semestres: (il y a des décisions de jury ou des compensations par d'autres semestres)</b>
</p> </p>"""
<ul>
<li>{message}</li>
</ul>
"""
) )
else: else:
H.append(tf[1]) H.append(tf[1])
@ -1388,46 +1372,32 @@ def formsemestre_delete2(formsemestre_id, dialog_confirmed=False):
return flask.redirect(scu.ScoURL()) return flask.redirect(scu.ScoURL())
def formsemestre_has_decisions_or_compensations( def formsemestre_has_decisions_or_compensations(formsemestre: FormSemestre):
formsemestre: FormSemestre,
) -> tuple[bool, str]:
"""True if decision de jury (sem. UE, RCUE, année) émanant de ce semestre """True if decision de jury (sem. UE, RCUE, année) émanant de ce semestre
ou compensation de ce semestre par d'autres semestres ou compensation de ce semestre par d'autres semestres
ou autorisations de passage. ou autorisations de passage.
""" """
# Validations de semestre ou d'UEs # Validations de semestre ou d'UEs
nb_validations = ScolarFormSemestreValidation.query.filter_by( if ScolarFormSemestreValidation.query.filter_by(
formsemestre_id=formsemestre.id formsemestre_id=formsemestre.id
).count() ).count():
if nb_validations: return True
return True, f"{nb_validations} validations de semestre ou d'UE" if ScolarFormSemestreValidation.query.filter_by(
nb_validations = ScolarFormSemestreValidation.query.filter_by(
compense_formsemestre_id=formsemestre.id compense_formsemestre_id=formsemestre.id
).count() ).count():
if nb_validations: return True
return True, f"{nb_validations} compensations utilisées dans d'autres semestres"
# Autorisations d'inscription: # Autorisations d'inscription:
nb_validations = ScolarAutorisationInscription.query.filter_by( if ScolarAutorisationInscription.query.filter_by(
origin_formsemestre_id=formsemestre.id origin_formsemestre_id=formsemestre.id
).count() ).count():
if nb_validations: return True
return (
True,
f"{nb_validations} autorisations d'inscriptions émanant de ce semestre",
)
# Validations d'années BUT # Validations d'années BUT
nb_validations = ApcValidationAnnee.query.filter_by( if ApcValidationAnnee.query.filter_by(formsemestre_id=formsemestre.id).count():
formsemestre_id=formsemestre.id return True
).count()
if nb_validations:
return True, f"{nb_validations} validations d'année BUT utilisant ce semestre"
# Validations de RCUEs # Validations de RCUEs
nb_validations = ApcValidationRCUE.query.filter_by( if ApcValidationRCUE.query.filter_by(formsemestre_id=formsemestre.id).count():
formsemestre_id=formsemestre.id return True
).count() return False
if nb_validations:
return True, f"{nb_validations} validations de RCUE utilisant ce semestre"
return False, ""
def do_formsemestre_delete(formsemestre_id): def do_formsemestre_delete(formsemestre_id):
@ -1530,7 +1500,6 @@ def do_formsemestre_delete(formsemestre_id):
typ=ScolarNews.NEWS_SEM, typ=ScolarNews.NEWS_SEM,
obj=formsemestre_id, obj=formsemestre_id,
text="Suppression du semestre %(titre)s" % sem, text="Suppression du semestre %(titre)s" % sem,
max_frequency=0,
) )

View File

@ -517,7 +517,7 @@ def _record_ue_validations_and_coefs(
) )
assert code is None or (note) # si code validant, il faut une note assert code is None or (note) # si code validant, il faut une note
sco_formsemestre_validation.do_formsemestre_validate_previous_ue( sco_formsemestre_validation.do_formsemestre_validate_previous_ue(
formsemestre, formsemestre.id,
etud.id, etud.id,
ue.id, ue.id,
note, note,

View File

@ -175,7 +175,9 @@ def do_formsemestre_demission(
) )
db.session.add(event) db.session.add(event)
db.session.commit() db.session.commit()
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) sco_cache.invalidate_formsemestre(
formsemestre_id=formsemestre_id
) # > démission ou défaillance
if etat_new == scu.DEMISSION: if etat_new == scu.DEMISSION:
flash("Démission enregistrée") flash("Démission enregistrée")
elif etat_new == scu.DEF: elif etat_new == scu.DEF:
@ -208,7 +210,7 @@ def do_formsemestre_desinscription(etudid, formsemestre_id):
if nt.etud_has_decision(etudid): if nt.etud_has_decision(etudid):
raise ScoValueError( raise ScoValueError(
f"""désinscription impossible: l'étudiant {etud.nomprenom} a """désinscription impossible: l'étudiant {etud.nomprenom} a
une décision de jury (la supprimer avant si nécessaire)""" une décision de jury (la supprimer avant si nécessaire)"""
) )

62
app/scodoc/sco_formsemestre_status.py Normal file → Executable file
View File

@ -36,20 +36,14 @@ from flask import request
from flask import flash, redirect, render_template, url_for from flask import flash, redirect, render_template, url_for
from flask_login import current_user from flask_login import current_user
from app import db, log from app import log
from app.but.cursus_but import formsemestre_warning_apc_setup from app.but.cursus_but import formsemestre_warning_apc_setup
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_common import ResultatsSemestre from app.comp.res_common import ResultatsSemestre
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import ( from app.models import Evaluation, Formation, Module, ModuleImpl, NotesNotes
Evaluation, from app.models.etudiants import Identite
Formation, from app.models.formsemestre import FormSemestre
FormSemestre,
Identite,
Module,
ModuleImpl,
NotesNotes,
)
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
@ -260,7 +254,7 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str:
}, },
] ]
# debug : # debug :
if current_app.config["DEBUG"]: if current_app.config["ENV"] == "development":
menu_semestre.append( menu_semestre.append(
{ {
"title": "Vérifier l'intégrité", "title": "Vérifier l'intégrité",
@ -600,7 +594,6 @@ def formsemestre_description_table(
formsemestre: FormSemestre = FormSemestre.query.filter_by( formsemestre: FormSemestre = FormSemestre.query.filter_by(
id=formsemestre_id, dept_id=g.scodoc_dept_id id=formsemestre_id, dept_id=g.scodoc_dept_id
).first_or_404() ).first_or_404()
is_apc = formsemestre.formation.is_apc()
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id) use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id)
parcours = codes_cursus.get_cursus_from_code(formsemestre.formation.type_parcours) parcours = codes_cursus.get_cursus_from_code(formsemestre.formation.type_parcours)
@ -614,7 +607,7 @@ def formsemestre_description_table(
else: else:
ues = formsemestre.get_ues() ues = formsemestre.get_ues()
columns_ids += [f"ue_{ue.id}" for ue in ues] columns_ids += [f"ue_{ue.id}" for ue in ues]
if sco_preferences.get_preference("bul_show_ects", formsemestre_id) and not is_apc: if sco_preferences.get_preference("bul_show_ects", formsemestre_id):
columns_ids += ["ects"] columns_ids += ["ects"]
columns_ids += ["Inscrits", "Responsable", "Enseignants"] columns_ids += ["Inscrits", "Responsable", "Enseignants"]
if with_evals: if with_evals:
@ -641,7 +634,6 @@ def formsemestre_description_table(
sum_coef = 0 sum_coef = 0
sum_ects = 0 sum_ects = 0
last_ue_id = None last_ue_id = None
formsemestre_parcours_ids = {p.id for p in formsemestre.parcours}
for modimpl in formsemestre.modimpls_sorted: for modimpl in formsemestre.modimpls_sorted:
# Ligne UE avec ECTS: # Ligne UE avec ECTS:
ue = modimpl.module.ue ue = modimpl.module.ue
@ -668,7 +660,7 @@ def formsemestre_description_table(
ue_info[ ue_info[
f"_{k}_td_attrs" f"_{k}_td_attrs"
] = f'style="background-color: {ue.color} !important;"' ] = f'style="background-color: {ue.color} !important;"'
if not is_apc: if not formsemestre.formation.is_apc():
# n'affiche la ligne UE qu'en formation classique # n'affiche la ligne UE qu'en formation classique
# car l'UE de rattachement n'a pas d'intérêt en BUT # car l'UE de rattachement n'a pas d'intérêt en BUT
rows.append(ue_info) rows.append(ue_info)
@ -709,17 +701,8 @@ def formsemestre_description_table(
for ue in ues: for ue in ues:
row[f"ue_{ue.id}"] = coef_dict.get(ue.id, 0.0) or "" row[f"ue_{ue.id}"] = coef_dict.get(ue.id, 0.0) or ""
if with_parcours: if with_parcours:
# Intersection des parcours du module avec ceux du formsemestre
row["parcours"] = ", ".join( row["parcours"] = ", ".join(
[ sorted([pa.code for pa in modimpl.module.parcours])
pa.code
for pa in (
modimpl.module.parcours
if modimpl.module.parcours
else modimpl.formsemestre.parcours
)
if pa.id in formsemestre_parcours_ids
]
) )
rows.append(row) rows.append(row)
@ -759,7 +742,7 @@ def formsemestre_description_table(
e["publish_incomplete_str"] = "Non" e["publish_incomplete_str"] = "Non"
e["_publish_incomplete_str_td_attrs"] = 'style="color: red;"' e["_publish_incomplete_str_td_attrs"] = 'style="color: red;"'
# Poids vers UEs (en APC) # Poids vers UEs (en APC)
evaluation: Evaluation = db.session.get(Evaluation, e["evaluation_id"]) evaluation: Evaluation = Evaluation.query.get(e["evaluation_id"])
for ue_id, poids in evaluation.get_ue_poids_dict().items(): for ue_id, poids in evaluation.get_ue_poids_dict().items():
e[f"ue_{ue_id}"] = poids or "" e[f"ue_{ue_id}"] = poids or ""
e[f"_ue_{ue_id}_class"] = "poids" e[f"_ue_{ue_id}_class"] = "poids"
@ -846,9 +829,9 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
</td> </td>
<td> <td>
<form action="{url_for( <form action="{url_for(
"absences.SignaleAbsenceGrSemestre", scodoc_dept=g.scodoc_dept "assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept
)}" method="get"> )}" method="get">
<input type="hidden" name="datefin" value="{ <input type="hidden" name="date" value="{
formsemestre.date_fin.strftime("%d/%m/%Y")}"/> formsemestre.date_fin.strftime("%d/%m/%Y")}"/>
<input type="hidden" name="group_ids" value="%(group_id)s"/> <input type="hidden" name="group_ids" value="%(group_id)s"/>
<input type="hidden" name="destination" value="{destination}"/> <input type="hidden" name="destination" value="{destination}"/>
@ -865,8 +848,8 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
</select> </select>
<a href="{ <a href="{
url_for("absences.choix_semaine", scodoc_dept=g.scodoc_dept) url_for("assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept)
}?group_id=%(group_id)s">saisie par semaine</a> }?group_ids=%(group_id)s&jour={datetime.date.today().isoformat()}&formsemestre_id={formsemestre.id}">saisie par semaine</a>
</form></td> </form></td>
""" """
else: else:
@ -881,15 +864,11 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
H.append("<h4>Tous les étudiants</h4>") H.append("<h4>Tous les étudiants</h4>")
else: else:
H.append("<h4>Groupes de %(partition_name)s</h4>" % partition) H.append("<h4>Groupes de %(partition_name)s</h4>" % partition)
partition_is_empty = True
groups = sco_groups.get_partition_groups(partition) groups = sco_groups.get_partition_groups(partition)
if groups: if groups:
H.append("<table>") H.append("<table>")
for group in groups: for group in groups:
n_members = len(sco_groups.get_group_members(group["group_id"])) n_members = len(sco_groups.get_group_members(group["group_id"]))
if n_members == 0:
continue # skip empty groups
partition_is_empty = False
group["url_etat"] = url_for( group["url_etat"] = url_for(
"absences.EtatAbsencesGr", "absences.EtatAbsencesGr",
group_ids=group["group_id"], group_ids=group["group_id"],
@ -922,14 +901,13 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
H.append("</tr>") H.append("</tr>")
H.append("</table>") H.append("</table>")
if partition_is_empty: else:
H.append('<p class="help indent">Aucun groupe peuplé dans cette partition') H.append('<p class="help indent">Aucun groupe dans cette partition')
if sco_groups.sco_permissions_check.can_change_groups(formsemestre.id): if sco_groups.sco_permissions_check.can_change_groups(formsemestre.id):
H.append( H.append(
f""" (<a href="{url_for("scolar.partition_editor", f""" (<a href="{url_for("scolar.affect_groups",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id, partition_id=partition["partition_id"])
edit_partition=1)
}" class="stdlink">créer</a>)""" }" class="stdlink">créer</a>)"""
) )
H.append("</p>") H.append("</p>")
@ -981,7 +959,7 @@ def html_expr_diagnostic(diagnostics):
def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None): def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None):
"""En-tête HTML des pages "semestre" """ """En-tête HTML des pages "semestre" """
sem: FormSemestre = db.session.get(FormSemestre, formsemestre_id) sem: FormSemestre = FormSemestre.query.get(formsemestre_id)
if not sem: if not sem:
raise ScoValueError("Semestre inexistant (il a peut être été supprimé ?)") raise ScoValueError("Semestre inexistant (il a peut être été supprimé ?)")
formation: Formation = sem.formation formation: Formation = sem.formation
@ -1232,7 +1210,7 @@ def formsemestre_tableau_modules(
H = [] H = []
prev_ue_id = None prev_ue_id = None
for modimpl in modimpls: for modimpl in modimpls:
mod: Module = db.session.get(Module, modimpl["module_id"]) mod: Module = Module.query.get(modimpl["module_id"])
moduleimpl_status_url = url_for( moduleimpl_status_url = url_for(
"notes.moduleimpl_status", "notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,

View File

@ -31,17 +31,15 @@ import time
import flask import flask
from flask import url_for, flash, g, request from flask import url_for, flash, g, request
from flask_login import current_user
import sqlalchemy as sa
from app.models.etudiants import Identite from app.models.etudiants import Identite
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import db, log from app import db, log
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import Formation, FormSemestre, UniteEns, ScolarNews from app.models import Formation, FormSemestre, UniteEns
from app.models.notes import etud_has_notes_attente from app.models.notes import etud_has_notes_attente
from app.models.validations import ( from app.models.validations import (
ScolarAutorisationInscription, ScolarAutorisationInscription,
@ -67,8 +65,6 @@ from app.scodoc.sco_cursus_dut import etud_est_inscrit_ue
from app.scodoc import sco_photos from app.scodoc import sco_photos
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_pv_dict from app.scodoc import sco_pv_dict
from app.scodoc.sco_permissions import Permission
# ------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------
def formsemestre_validation_etud_form( def formsemestre_validation_etud_form(
@ -400,7 +396,7 @@ def formsemestre_validation_etud(
selected_choice = choice selected_choice = choice
break break
if not selected_choice: if not selected_choice:
raise ValueError(f"code choix invalide ! ({codechoice})") raise ValueError("code choix invalide ! (%s)" % codechoice)
# #
Se.valide_decision(selected_choice) # enregistre Se.valide_decision(selected_choice) # enregistre
return _redirect_valid_choice( return _redirect_valid_choice(
@ -515,7 +511,7 @@ def decisions_possible_rows(Se, assiduite, subtitle="", trclass=""):
def formsemestre_recap_parcours_table( def formsemestre_recap_parcours_table(
situation_etud_cursus: sco_cursus_dut.SituationEtudCursus, Se,
etudid, etudid,
with_links=False, with_links=False,
with_all_columns=True, with_all_columns=True,
@ -553,18 +549,16 @@ def formsemestre_recap_parcours_table(
""" """
) )
# titres des UE # titres des UE
H.append("<th></th>" * situation_etud_cursus.nb_max_ue) H.append("<th></th>" * Se.nb_max_ue)
# #
if with_links: if with_links:
H.append("<th></th>") H.append("<th></th>")
H.append("<th></th></tr>") H.append("<th></th></tr>")
num_sem = 0 num_sem = 0
for sem in situation_etud_cursus.get_semestres(): for sem in Se.get_semestres():
is_prev = situation_etud_cursus.prev and ( is_prev = Se.prev and (Se.prev["formsemestre_id"] == sem["formsemestre_id"])
situation_etud_cursus.prev["formsemestre_id"] == sem["formsemestre_id"] is_cur = Se.formsemestre_id == sem["formsemestre_id"]
)
is_cur = situation_etud_cursus.formsemestre_id == sem["formsemestre_id"]
num_sem += 1 num_sem += 1
dpv = sco_pv_dict.dict_pvjury(sem["formsemestre_id"], etudids=[etudid]) dpv = sco_pv_dict.dict_pvjury(sem["formsemestre_id"], etudids=[etudid])
@ -576,7 +570,7 @@ def formsemestre_recap_parcours_table(
else: else:
ass = "" ass = ""
formsemestre = db.session.get(FormSemestre, sem["formsemestre_id"]) formsemestre = FormSemestre.query.get(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if is_cur: if is_cur:
type_sem = "*" # now unused type_sem = "*" # now unused
@ -587,7 +581,7 @@ def formsemestre_recap_parcours_table(
else: else:
type_sem = "" type_sem = ""
class_sem = "sem_autre" class_sem = "sem_autre"
if sem["formation_code"] != situation_etud_cursus.formation.formation_code: if sem["formation_code"] != Se.formation.formation_code:
class_sem += " sem_autre_formation" class_sem += " sem_autre_formation"
if sem["bul_bgcolor"]: if sem["bul_bgcolor"]:
bgcolor = sem["bul_bgcolor"] bgcolor = sem["bul_bgcolor"]
@ -651,7 +645,7 @@ def formsemestre_recap_parcours_table(
H.append("<td><em>en cours</em></td>") H.append("<td><em>en cours</em></td>")
H.append(f"""<td class="rcp_nonass">{ass}</td>""") # abs H.append(f"""<td class="rcp_nonass">{ass}</td>""") # abs
# acronymes UEs auxquelles l'étudiant est inscrit (ou capitalisé) # acronymes UEs auxquelles l'étudiant est inscrit (ou capitalisé)
ues = list(nt.etud_ues(etudid)) # nb: en BUT, les UE "dispensées" sont incluses ues = list(nt.etud_ues(etudid))
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
etud_ue_status = {ue.id: nt.get_etud_ue_status(etudid, ue.id) for ue in ues} etud_ue_status = {ue.id: nt.get_etud_ue_status(etudid, ue.id) for ue in ues}
if not nt.is_apc: if not nt.is_apc:
@ -665,10 +659,8 @@ def formsemestre_recap_parcours_table(
for ue in ues: for ue in ues:
H.append(f"""<td class="ue_acro"><span>{ue.acronyme}</span></td>""") H.append(f"""<td class="ue_acro"><span>{ue.acronyme}</span></td>""")
if len(ues) < situation_etud_cursus.nb_max_ue: if len(ues) < Se.nb_max_ue:
H.append( H.append(f"""<td colspan="{Se.nb_max_ue - len(ues)}"></td>""")
f"""<td colspan="{situation_etud_cursus.nb_max_ue - len(ues)}"></td>"""
)
# indique le semestre compensé par celui ci: # indique le semestre compensé par celui ci:
if decision_sem and decision_sem["compense_formsemestre_id"]: if decision_sem and decision_sem["compense_formsemestre_id"]:
csem = sco_formsemestre.get_formsemestre( csem = sco_formsemestre.get_formsemestre(
@ -693,7 +685,7 @@ def formsemestre_recap_parcours_table(
if not sem["etat"]: # locked if not sem["etat"]: # locked
lockicon = scu.icontag("lock32_img", title="verrouillé", border="0") lockicon = scu.icontag("lock32_img", title="verrouillé", border="0")
default_sem_info += lockicon default_sem_info += lockicon
if sem["formation_code"] != situation_etud_cursus.formation.formation_code: if sem["formation_code"] != Se.formation.formation_code:
default_sem_info += f"""Autre formation: {sem["formation_code"]}""" default_sem_info += f"""Autre formation: {sem["formation_code"]}"""
H.append( H.append(
'<td class="datefin">%s</td><td class="sem_info">%s</td>' '<td class="datefin">%s</td><td class="sem_info">%s</td>'
@ -730,21 +722,14 @@ def formsemestre_recap_parcours_table(
explanation_ue.append( explanation_ue.append(
f"""Capitalisée le {ue_status["event_date"] or "?"}.""" f"""Capitalisée le {ue_status["event_date"] or "?"}."""
) )
# Dispense BUT ?
if (etudid, ue.id) in nt.dispense_ues:
moy_ue_txt = "" if (ue_status and ue_status["is_capitalized"]) else ""
explanation_ue.append("non inscrit (dispense)")
else:
moy_ue_txt = scu.fmt_note(moy_ue)
H.append( H.append(
f"""<td class="{class_ue}" title="{ f"""<td class="{class_ue}" title="{
" ".join(explanation_ue) " ".join(explanation_ue)
}">{moy_ue_txt}</td>""" }">{scu.fmt_note(moy_ue)}</td>"""
)
if len(ues) < situation_etud_cursus.nb_max_ue:
H.append(
f"""<td colspan="{situation_etud_cursus.nb_max_ue - len(ues)}"></td>"""
) )
if len(ues) < Se.nb_max_ue:
H.append(f"""<td colspan="{Se.nb_max_ue - len(ues)}"></td>""")
H.append("<td></td>") H.append("<td></td>")
if with_links: if with_links:
@ -1006,26 +991,16 @@ def do_formsemestre_validation_auto(formsemestre_id):
) )
nb_valid += 1 nb_valid += 1
log( log(
f"do_formsemestre_validation_auto: {nb_valid} validations, {len(conflicts)} conflicts" "do_formsemestre_validation_auto: %d validations, %d conflicts"
% (nb_valid, len(conflicts))
) )
ScolarNews.add( H = [html_sco_header.sco_header(page_title="Saisie automatique")]
typ=ScolarNews.NEWS_JURY, H.append(
obj=formsemestre.id, """<h2>Saisie automatique des décisions du semestre %s</h2>
text=f"""Calcul jury automatique du semestre {formsemestre.html_link_status()
} ({nb_valid} décisions)""",
url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
)
H = [
f"""{html_sco_header.sco_header(page_title="Saisie automatique")}
<h2>Saisie automatique des décisions du semestre {formsemestre.titre_annee()}</h2>
<p>Opération effectuée.</p> <p>Opération effectuée.</p>
<p>{nb_valid} étudiants validés sur {len(etudids)}</p> <p>%d étudiants validés (sur %s)</p>"""
""" % (sem["titreannee"], nb_valid, len(etudids))
] )
if conflicts: if conflicts:
H.append( H.append(
f"""<p><b>Attention:</b> {len(conflicts)} étudiants non modifiés f"""<p><b>Attention:</b> {len(conflicts)} étudiants non modifiés
@ -1084,44 +1059,64 @@ def formsemestre_validation_suppress_etud(formsemestre_id, etudid):
) # > suppr. decision jury (peut affecter de plusieurs semestres utilisant UE capitalisée) ) # > suppr. decision jury (peut affecter de plusieurs semestres utilisant UE capitalisée)
def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite): def formsemestre_validate_previous_ue(formsemestre_id, etudid):
"""Form. saisie UE validée hors ScoDoc """Form. saisie UE validée hors ScoDoc
(pour étudiants arrivant avec un UE antérieurement validée). (pour étudiants arrivant avec un UE antérieurement validée).
""" """
formation: Formation = formsemestre.formation from app.scodoc import sco_formations
# Toutes les UEs non bonus de cette formation sont présentées etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
# avec indice de semestre <= semestre courant ou NULL sem = sco_formsemestre.get_formsemestre(formsemestre_id)
ues = formation.ues.filter( formation: Formation = Formation.query.get_or_404(sem["formation_id"])
UniteEns.type != UE_SPORT, H = [
db.or_( html_sco_header.sco_header(
UniteEns.semestre_idx == None, page_title="Validation UE",
UniteEns.semestre_idx <= formsemestre.semestre_id, javascripts=["js/validate_previous_ue.js"],
), ),
).order_by(UniteEns.semestre_idx, UniteEns.numero) '<table style="width: 100%"><tr><td>',
"""<h2 class="formsemestre">%s: validation d'une UE antérieure</h2>"""
ue_names = ["Choisir..."] + [ % etud["nomprenom"],
f"""{('S'+str(ue.semestre_idx)+' : ') if ue.semestre_idx is not None else ''
}{ue.acronyme} {ue.titre} ({ue.ue_code or ""})"""
for ue in ues
]
ue_ids = [""] + [ue.id for ue in ues]
form_descr = [
("etudid", {"input_type": "hidden"}),
("formsemestre_id", {"input_type": "hidden"}),
( (
"ue_id", '</td><td style="text-align: right;"><a href="%s">%s</a></td></tr></table>'
{ % (
"input_type": "menu", url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid),
"title": "Unité d'Enseignement (UE)", sco_photos.etud_photo_html(etud, title="fiche de %s" % etud["nom"]),
"allow_null": False, )
"allowed_values": ue_ids,
"labels": ue_names,
},
), ),
f"""<p class="help">Utiliser cette page pour enregistrer une UE validée antérieurement,
<em>dans un semestre hors ScoDoc</em>.</p>
<p><b>Les UE validées dans ScoDoc sont déjà
automatiquement prises en compte</b>. Cette page n'est utile que pour les étudiants ayant
suivi un début de cursus dans <b>un autre établissement</b>, ou bien dans un semestre géré <b>sans
ScoDoc</b> et qui <b>redouble</b> ce semestre
(<em>ne pas utiliser pour les semestres précédents !</em>).
</p>
<p>Notez que l'UE est validée, avec enregistrement immédiat de la décision et
l'attribution des ECTS.</p>
<p>On ne peut prendre en compte ici que les UE du cursus <b>{formation.titre}</b></p>
""",
] ]
if not formation.is_apc():
form_descr.append( # Toutes les UE de cette formation sont présentées (même celles des autres semestres)
ues = formation.ues.order_by(UniteEns.numero)
ue_names = ["Choisir..."] + [f"{ue.acronyme} {ue.titre}" for ue in ues]
ue_ids = [""] + [ue.id for ue in ues]
tf = TrivialFormulator(
request.base_url,
scu.get_request_args(),
(
("etudid", {"input_type": "hidden"}),
("formsemestre_id", {"input_type": "hidden"}),
(
"ue_id",
{
"input_type": "menu",
"title": "Unité d'Enseignement (UE)",
"allow_null": False,
"allowed_values": ue_ids,
"labels": ue_names,
},
),
( (
"semestre_id", "semestre_id",
{ {
@ -1132,185 +1127,69 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
"allowed_values": [""] + [x for x in range(11)], "allowed_values": [""] + [x for x in range(11)],
"labels": ["-"] + list(range(11)), "labels": ["-"] + list(range(11)),
}, },
) ),
) (
ue_codes = sorted(codes_cursus.CODES_JURY_UE) "date",
form_descr += [ {
( "input_type": "date",
"date", "size": 9,
{ "explanation": "j/m/a",
"input_type": "date", "default": time.strftime("%d/%m/%Y"),
"size": 9, },
"explanation": "j/m/a", ),
"default": time.strftime("%d/%m/%Y"), (
}, "moy_ue",
{
"type": "float",
"allow_null": False,
"min_value": 0,
"max_value": 20,
"title": "Moyenne (/20) obtenue dans cette UE:",
},
),
), ),
( cancelbutton="Annuler",
"moy_ue",
{
"type": "float",
"allow_null": False,
"min_value": 0,
"max_value": 20,
"title": "Moyenne (/20) obtenue dans cette UE:",
},
),
(
"code_jury",
{
"input_type": "menu",
"title": "Code jury",
"explanation": " code donné par le jury (ADM si validée normalement)",
"allow_null": True,
"allowed_values": [""] + ue_codes,
"labels": ["-"] + ue_codes,
"default": ADM,
},
),
]
tf = TrivialFormulator(
request.base_url,
scu.get_request_args(),
form_descr,
cancelbutton="Revenir au bulletin",
submitlabel="Enregistrer validation d'UE", submitlabel="Enregistrer validation d'UE",
) )
if tf[0] == 0: if tf[0] == 0:
return f""" X = """
{html_sco_header.sco_header( <div id="ue_list_etud_validations"><!-- filled by get_etud_ue_cap_html --></div>
page_title="Validation UE antérieure", <div id="ue_list_code"><!-- filled by ue_sharing_code --></div>
javascripts=["js/validate_previous_ue.js"], """
cssstyles=["css/jury_delete_manual.css"], warn, ue_multiples = check_formation_ues(formation.id)
etudid=etud.id, return "\n".join(H) + tf[1] + X + warn + html_sco_header.sco_footer()
formsemestre_id=formsemestre.id, elif tf[0] == -1:
)} return flask.redirect(
<h2 class="formsemestre">Gestion des validations d'UEs antérieures scu.NotesURL()
de {etud.html_link_fiche()} + "/formsemestre_status?formsemestre_id="
</h2> + str(formsemestre_id)
)
<p class="help">Utiliser cette page pour enregistrer des UEs validées antérieurement, else:
<em>dans un semestre hors ScoDoc</em>.</p> if tf[2]["semestre_id"]:
<p class="expl"><b>Les UE validées dans ScoDoc sont semestre_id = int(tf[2]["semestre_id"])
automatiquement prises en compte</b>. else:
</p> semestre_id = None
<p>Cette page est surtout utile pour les étudiants ayant do_formsemestre_validate_previous_ue(
suivi un début de cursus dans <b>un autre établissement</b>, ou qui formsemestre_id,
ont suivi une UE à l'étranger ou dans un semestre géré <b>sans ScoDoc</b>. etudid,
</p> tf[2]["ue_id"],
<p>Pour les semestres précédents gérés avec ScoDoc, passer par la page jury normale. tf[2]["moy_ue"],
</p> tf[2]["date"],
<p>Notez que l'UE est validée, avec enregistrement immédiat de la décision et semestre_id=semestre_id,
l'attribution des ECTS si le code jury est validant (ADM). )
</p> flash("Validation d'UE enregistrée")
<p>On ne peut valider ici que les UEs du cursus <b>{formation.titre}</b></p>
{_get_etud_ue_cap_html(etud, formsemestre)}
<div class="sco_box">
<div class="sco_box_title">
Enregistrer une UE antérieure
</div>
{tf[1]}
</div>
<div id="ue_list_code" class="sco_box sco_green_bg">
<!-- filled by ue_sharing_code -->
</div>
{check_formation_ues(formation.id)[0]}
{html_sco_header.sco_footer()}
"""
dest_url = url_for(
"notes.formsemestre_validate_previous_ue",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
etudid=etud.id,
)
if tf[0] == -1:
return flask.redirect( return flask.redirect(
url_for( url_for(
"notes.formsemestre_bulletinetud", "notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id, formsemestre_id=formsemestre_id,
etudid=etud.id, etudid=etudid,
) )
) )
if tf[2].get("semestre_id"):
semestre_id = int(tf[2]["semestre_id"])
else:
semestre_id = None
if tf[2]["code_jury"] not in CODES_JURY_UE:
flash("Code UE invalide")
return flask.redirect(dest_url)
do_formsemestre_validate_previous_ue(
formsemestre,
etud.id,
tf[2]["ue_id"],
tf[2]["moy_ue"],
tf[2]["date"],
code=tf[2]["code_jury"],
semestre_id=semestre_id,
)
flash("Validation d'UE enregistrée")
return flask.redirect(dest_url)
def _get_etud_ue_cap_html(etud: Identite, formsemestre: FormSemestre) -> str:
"""HTML listant les validations d'UEs pour cet étudiant dans des formations de même
code que celle du formsemestre indiqué.
"""
validations: list[ScolarFormSemestreValidation] = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.join(UniteEns)
.join(Formation)
.filter_by(formation_code=formsemestre.formation.formation_code)
.order_by(
sa.desc(UniteEns.semestre_idx),
UniteEns.acronyme,
sa.desc(ScolarFormSemestreValidation.event_date),
)
.all()
)
if not validations:
return ""
H = [
f"""<div class="sco_box sco_lightgreen_bg ue_list_etud_validations">
<div class="sco_box_title">Validations d'UEs dans cette formation</div>
<div class="help">Liste de toutes les UEs validées par {etud.html_link_fiche()},
sur des semestres ou déclarées comme "antérieures" (externes).
</div>
<ul class="liste_validations">"""
]
for validation in validations:
if validation.formsemestre_id is None:
origine = " enregistrée d'un parcours antérieur (hors ScoDoc)"
else:
origine = f", du semestre {formsemestre.html_link_status()}"
if validation.semestre_id is not None:
origine += f" (<b>S{validation.semestre_id}</b>)"
H.append(f"""<li>{validation.html()}""")
if (validation.formsemestre and validation.formsemestre.can_edit_jury()) or (
current_user and current_user.has_permission(Permission.ScoEtudInscrit)
):
H.append(
f"""
<form class="inline-form">
<button
data-v_id="{validation.id}" data-type="validation_ue" data-etudid="{etud.id}"
>effacer</button>
</form>
""",
)
else:
H.append(scu.icontag("lock_img", border="0", title="Semestre verrouillé"))
H.append("</li>")
H.append("</ul></div>")
return "\n".join(H)
def do_formsemestre_validate_previous_ue( def do_formsemestre_validate_previous_ue(
formsemestre: FormSemestre, formsemestre_id,
etudid, etudid,
ue_id, ue_id,
moy_ue, moy_ue,
@ -1323,20 +1202,21 @@ def do_formsemestre_validate_previous_ue(
Si le coefficient est spécifié, modifie le coefficient de Si le coefficient est spécifié, modifie le coefficient de
cette UE (utile seulement pour les semestres extérieurs). cette UE (utile seulement pour les semestres extérieurs).
""" """
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
ue: UniteEns = UniteEns.query.get_or_404(ue_id) ue: UniteEns = UniteEns.query.get_or_404(ue_id)
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
if ue_coefficient is not None: if ue_coefficient != None:
sco_formsemestre.do_formsemestre_uecoef_edit_or_create( sco_formsemestre.do_formsemestre_uecoef_edit_or_create(
cnx, formsemestre.id, ue_id, ue_coefficient cnx, formsemestre_id, ue_id, ue_coefficient
) )
else: else:
sco_formsemestre.do_formsemestre_uecoef_delete(cnx, formsemestre.id, ue_id) sco_formsemestre.do_formsemestre_uecoef_delete(cnx, formsemestre_id, ue_id)
sco_cursus_dut.do_formsemestre_validate_ue( sco_cursus_dut.do_formsemestre_validate_ue(
cnx, cnx,
nt, nt,
formsemestre.id, # "importe" cette UE dans le semestre (new 3/2015) formsemestre_id, # "importe" cette UE dans le semestre (new 3/2015)
etudid, etudid,
ue_id, ue_id,
code, code,
@ -1374,6 +1254,62 @@ def _invalidate_etud_formation_caches(etudid, formation_id):
) # > modif decision UE (inval tous semestres avec cet etudiant, ok mais conservatif) ) # > modif decision UE (inval tous semestres avec cet etudiant, ok mais conservatif)
def get_etud_ue_cap_html(etudid, formsemestre_id, ue_id):
"""Ramene bout de HTML pour pouvoir supprimer une validation de cette UE"""
valids = ndb.SimpleDictFetch(
"""SELECT SFV.*
FROM scolar_formsemestre_validation SFV
WHERE ue_id=%(ue_id)s
AND etudid=%(etudid)s""",
{"etudid": etudid, "ue_id": ue_id},
)
if not valids:
return ""
H = [
'<div class="existing_valids"><span>Validations existantes pour cette UE:</span><ul>'
]
for valid in valids:
valid["event_date"] = ndb.DateISOtoDMY(valid["event_date"])
if valid["moy_ue"] != None:
valid["m"] = ", moyenne %(moy_ue)g/20" % valid
else:
valid["m"] = ""
if valid["formsemestre_id"]:
sem = sco_formsemestre.get_formsemestre(valid["formsemestre_id"])
valid["s"] = ", du semestre %s" % sem["titreannee"]
else:
valid["s"] = " enregistrée d'un parcours antérieur (hors ScoDoc)"
if valid["semestre_id"]:
valid["s"] += " (<b>S%d</b>)" % valid["semestre_id"]
valid["ds"] = formsemestre_id
H.append(
'<li>%(code)s%(m)s%(s)s, le %(event_date)s <a class="stdlink" href="etud_ue_suppress_validation?etudid=%(etudid)s&ue_id=%(ue_id)s&formsemestre_id=%(ds)s" title="supprime cette validation">effacer</a></li>'
% valid
)
H.append("</ul></div>")
return "\n".join(H)
def etud_ue_suppress_validation(etudid, formsemestre_id, ue_id):
"""Suppress a validation (ue_id, etudid) and redirect to formsemestre"""
log("etud_ue_suppress_validation( %s, %s, %s)" % (etudid, formsemestre_id, ue_id))
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(
"DELETE FROM scolar_formsemestre_validation WHERE etudid=%(etudid)s and ue_id=%(ue_id)s",
{"etudid": etudid, "ue_id": ue_id},
)
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
_invalidate_etud_formation_caches(etudid, sem["formation_id"])
return flask.redirect(
scu.NotesURL()
+ "/formsemestre_validate_previous_ue?etudid=%s&formsemestre_id=%s"
% (etudid, formsemestre_id)
)
def check_formation_ues(formation_id): def check_formation_ues(formation_id):
"""Verifie que les UE d'une formation sont chacune utilisée dans un seul semestre_id """Verifie que les UE d'une formation sont chacune utilisée dans un seul semestre_id
Si ce n'est pas le cas, c'est probablement (mais pas forcément) une erreur de Si ce n'est pas le cas, c'est probablement (mais pas forcément) une erreur de

View File

@ -34,6 +34,7 @@ Optimisation possible:
""" """
import collections import collections
import operator
import time import time
from xml.etree import ElementTree from xml.etree import ElementTree
@ -44,14 +45,15 @@ from flask import g, request
from flask import url_for, make_response from flask import url_for, make_response
from sqlalchemy.sql import text from sqlalchemy.sql import text
from app import cache, db, log from app import db
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, Identite, Scolog from app.models import FormSemestre, Identite
from app.models import GROUPNAME_STR_LEN, SHORT_STR_LEN from app.models import GROUPNAME_STR_LEN, SHORT_STR_LEN
from app.models.groups import GroupDescr, Partition, group_membership from app.models.groups import GroupDescr, Partition
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app import log, cache
from app.scodoc.scolog import logdb from app.scodoc.scolog import logdb
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_cache from app.scodoc import sco_cache
@ -92,7 +94,7 @@ groupEditor = ndb.EditableTable(
group_list = groupEditor.list group_list = groupEditor.list
def get_group(group_id: int) -> dict: # OBSOLETE ! def get_group(group_id: int) -> dict:
"""Returns group object, with partition""" """Returns group object, with partition"""
r = ndb.SimpleDictFetch( r = ndb.SimpleDictFetch(
"""SELECT gd.id AS group_id, gd.*, p.id AS partition_id, p.* """SELECT gd.id AS group_id, gd.*, p.id AS partition_id, p.*
@ -122,7 +124,7 @@ def group_delete(group_id: int):
) )
def get_partition(partition_id): # OBSOLETE def get_partition(partition_id):
r = ndb.SimpleDictFetch( r = ndb.SimpleDictFetch(
"""SELECT p.id AS partition_id, p.* """SELECT p.id AS partition_id, p.*
FROM partition p FROM partition p
@ -198,7 +200,7 @@ def get_formsemestre_etuds_groups(formsemestre_id: int) -> dict:
return d return d
def get_partition_groups(partition): # OBSOLETE ! def get_partition_groups(partition):
"""List of groups in this partition (list of dicts). """List of groups in this partition (list of dicts).
Some groups may be empty.""" Some groups may be empty."""
return ndb.SimpleDictFetch( return ndb.SimpleDictFetch(
@ -241,7 +243,7 @@ def get_default_group(formsemestre_id, fix_if_missing=False):
return group.id return group.id
# debug check # debug check
if len(r) != 1: if len(r) != 1:
log(f"invalid group structure for {formsemestre_id}: {len(r)}") raise ScoException(f"invalid group structure for {formsemestre_id}")
group_id = r[0]["group_id"] group_id = r[0]["group_id"]
return group_id return group_id
@ -450,7 +452,7 @@ def get_etud_formsemestre_groups(
), ),
{"etudid": etud.id, "formsemestre_id": formsemestre.id}, {"etudid": etud.id, "formsemestre_id": formsemestre.id},
) )
return [db.session.get(GroupDescr, group_id) for group_id in cursor] return [GroupDescr.query.get(group_id) for group_id in cursor]
# Ancienne fonction: # Ancienne fonction:
@ -560,10 +562,10 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
x_group = Element( x_group = Element(
"group", "group",
partition_id=str(partition_id), partition_id=str(partition_id),
partition_name=partition["partition_name"] or "", partition_name=partition["partition_name"],
groups_editable=str(int(partition["groups_editable"])), groups_editable=str(int(partition["groups_editable"])),
group_id=str(group["group_id"]), group_id=str(group["group_id"]),
group_name=group["group_name"] or "", group_name=group["group_name"],
) )
x_response.append(x_group) x_response.append(x_group)
for e in get_group_members(group["group_id"]): for e in get_group_members(group["group_id"]):
@ -572,10 +574,10 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
Element( Element(
"etud", "etud",
etudid=str(e["etudid"]), etudid=str(e["etudid"]),
civilite=etud["civilite_str"] or "", civilite=etud["civilite_str"],
sexe=etud["civilite_str"] or "", # compat sexe=etud["civilite_str"], # compat
nom=sco_etud.format_nom(etud["nom"] or ""), nom=sco_etud.format_nom(etud["nom"]),
prenom=sco_etud.format_prenom(etud["prenom"] or ""), prenom=sco_etud.format_prenom(etud["prenom"]),
origin=_comp_etud_origin(etud, formsemestre), origin=_comp_etud_origin(etud, formsemestre),
) )
) )
@ -587,7 +589,7 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
x_group = Element( x_group = Element(
"group", "group",
partition_id=str(partition_id), partition_id=str(partition_id),
partition_name=partition["partition_name"] or "", partition_name=partition["partition_name"],
groups_editable=str(int(partition["groups_editable"])), groups_editable=str(int(partition["groups_editable"])),
group_id="_none_", group_id="_none_",
group_name="", group_name="",
@ -599,9 +601,9 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
Element( Element(
"etud", "etud",
etudid=str(etud["etudid"]), etudid=str(etud["etudid"]),
sexe=etud["civilite_str"] or "", sexe=etud["civilite_str"],
nom=sco_etud.format_nom(etud["nom"] or ""), nom=sco_etud.format_nom(etud["nom"]),
prenom=sco_etud.format_prenom(etud["prenom"] or ""), prenom=sco_etud.format_prenom(etud["prenom"]),
origin=_comp_etud_origin(etud, formsemestre), origin=_comp_etud_origin(etud, formsemestre),
) )
) )
@ -635,7 +637,7 @@ def _comp_etud_origin(etud: dict, cur_formsemestre: FormSemestre):
return "" # parcours normal, ne le signale pas return "" # parcours normal, ne le signale pas
def set_group(etudid: int, group_id: int) -> bool: # OBSOLETE ! def set_group(etudid: int, group_id: int) -> bool:
"""Inscrit l'étudiant au groupe. """Inscrit l'étudiant au groupe.
Return True if ok, False si deja inscrit. Return True if ok, False si deja inscrit.
Warning: Warning:
@ -662,33 +664,55 @@ def set_group(etudid: int, group_id: int) -> bool: # OBSOLETE !
return True return True
def change_etud_group_in_partition(etudid: int, group: GroupDescr) -> bool: def change_etud_group_in_partition(etudid: int, group_id: int, partition: dict = None):
"""Inscrit etud au groupe """Inscrit etud au groupe de cette partition,
(et le désinscrit d'autres groupes de cette partition) et le desinscrit d'autres groupes de cette partition.
Return True si changement, False s'il était déjà dans ce groupe.
""" """
etud: Identite = Identite.query.get_or_404(etudid) log("change_etud_group_in_partition: etudid=%s group_id=%s" % (etudid, group_id))
if not group.partition.set_etud_group(etud, group): # 0- La partition
return # pas de changement group = get_group(group_id)
if partition:
# verifie que le groupe est bien dans cette partition:
if group["partition_id"] != partition["partition_id"]:
raise ValueError(
"inconsistent group/partition (group_id=%s, partition_id=%s)"
% (group_id, partition["partition_id"])
)
else:
partition = get_partition(group["partition_id"])
# 1- Supprime membership dans cette partition
ndb.SimpleQuery(
"""DELETE FROM group_membership gm
WHERE EXISTS
(SELECT 1 FROM group_descr gd
WHERE gm.etudid = %(etudid)s
AND gm.group_id = gd.id
AND gd.partition_id = %(partition_id)s)
""",
{"etudid": etudid, "partition_id": partition["partition_id"]},
)
# 2- associe au nouveau groupe
set_group(etudid, group_id)
# - log # 3- log
formsemestre: FormSemestre = group.partition.formsemestre formsemestre_id = partition["formsemestre_id"]
log(f"change_etud_group_in_partition: etudid={etudid} group={group}") cnx = ndb.GetDBConnexion()
Scolog.logdb( logdb(
cnx,
method="changeGroup", method="changeGroup",
etudid=etudid, etudid=etudid,
msg=f"""formsemestre_id={formsemestre.id}, partition_name={ msg="formsemestre_id=%s,partition_name=%s, group_name=%s"
group.partition.partition_name or ""}, group_name={group.group_name or ""}""", % (formsemestre_id, partition["partition_name"], group["group_name"]),
commit=True,
) )
cnx.commit()
# - Update parcours # 5- Update parcours
if group.partition.partition_name == scu.PARTITION_PARCOURS: formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
formsemestre.update_inscriptions_parcours_from_groups() formsemestre.update_inscriptions_parcours_from_groups()
# - invalidate cache # 6- invalidate cache
sco_cache.invalidate_formsemestre( sco_cache.invalidate_formsemestre(
formsemestre_id=formsemestre.id formsemestre_id=formsemestre_id
) # > change etud group ) # > change etud group
@ -705,6 +729,7 @@ def setGroups(
Ne peux pas modifier les groupes des partitions non éditables. Ne peux pas modifier les groupes des partitions non éditables.
""" """
from app.scodoc import sco_formsemestre
def xml_error(msg, code=404): def xml_error(msg, code=404):
data = ( data = (
@ -714,27 +739,26 @@ def setGroups(
response.headers["Content-Type"] = scu.XML_MIMETYPE response.headers["Content-Type"] = scu.XML_MIMETYPE
return response return response
partition: Partition = db.session.get(Partition, partition_id) partition = get_partition(partition_id)
if not partition.groups_editable and (groupsToCreate or groupsToDelete): if not partition["groups_editable"] and (groupsToCreate or groupsToDelete):
msg = "setGroups: partition non editable" msg = "setGroups: partition non editable"
log(msg) log(msg)
return xml_error(msg, code=403) return xml_error(msg, code=403)
formsemestre_id = partition["formsemestre_id"]
if not sco_permissions_check.can_change_groups(partition.formsemestre.id): formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if not sco_permissions_check.can_change_groups(formsemestre_id):
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
log("***setGroups: partition_id=%s" % partition_id) log("***setGroups: partition_id=%s" % partition_id)
log("groupsLists=%s" % groupsLists) log("groupsLists=%s" % groupsLists)
log("groupsToCreate=%s" % groupsToCreate) log("groupsToCreate=%s" % groupsToCreate)
log("groupsToDelete=%s" % groupsToDelete) log("groupsToDelete=%s" % groupsToDelete)
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
if not partition.formsemestre.etat: if not sem["etat"]:
raise AccessDenied("Modification impossible: semestre verrouillé") raise AccessDenied("Modification impossible: semestre verrouillé")
groupsToDelete = [g for g in groupsToDelete.split(";") if g] groupsToDelete = [g for g in groupsToDelete.split(";") if g]
etud_groups = formsemestre_get_etud_groupnames( etud_groups = formsemestre_get_etud_groupnames(formsemestre_id, attr="group_id")
partition.formsemestre.id, attr="group_id"
)
for line in groupsLists.split("\n"): # for each group_id (one per line) for line in groupsLists.split("\n"): # for each group_id (one per line)
fs = line.split(";") fs = line.split(";")
group_id = fs[0].strip() group_id = fs[0].strip()
@ -745,23 +769,26 @@ def setGroups(
except ValueError: except ValueError:
log(f"setGroups: ignoring invalid group_id={group_id}") log(f"setGroups: ignoring invalid group_id={group_id}")
continue continue
group: GroupDescr = GroupDescr.query.get_or_404(group_id) group = get_group(group_id)
# Anciens membres du groupe: # Anciens membres du groupe:
old_members_set = {etud.id for etud in group.etuds} old_members = get_group_members(group_id)
old_members_set = set([x["etudid"] for x in old_members])
# Place dans ce groupe les etudiants indiqués: # Place dans ce groupe les etudiants indiqués:
for etudid_str in fs[1:-1]: for etudid_str in fs[1:-1]:
etudid = int(etudid_str) etudid = int(etudid_str)
if etudid in old_members_set: if etudid in old_members_set:
# était dans ce groupe, l'enlever old_members_set.remove(
old_members_set.remove(etudid) etudid
) # a nouveau dans ce groupe, pas besoin de l'enlever
if (etudid not in etud_groups) or ( if (etudid not in etud_groups) or (
group_id != etud_groups[etudid].get(partition_id, "") group_id != etud_groups[etudid].get(partition_id, "")
): # pas le meme groupe qu'actuel ): # pas le meme groupe qu'actuel
change_etud_group_in_partition(etudid, group) change_etud_group_in_partition(etudid, group_id, partition)
# Retire les anciens membres: # Retire les anciens membres:
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
for etudid in old_members_set: for etudid in old_members_set:
log("removing %s from group %s" % (etudid, group_id))
ndb.SimpleQuery( ndb.SimpleQuery(
"DELETE FROM group_membership WHERE etudid=%(etudid)s and group_id=%(group_id)s", "DELETE FROM group_membership WHERE etudid=%(etudid)s and group_id=%(group_id)s",
{"etudid": etudid, "group_id": group_id}, {"etudid": etudid, "group_id": group_id},
@ -771,8 +798,8 @@ def setGroups(
cnx, cnx,
method="removeFromGroup", method="removeFromGroup",
etudid=etudid, etudid=etudid,
msg=f"""formsemestre_id={partition.formsemestre.id},partition_name={ msg="formsemestre_id=%s,partition_name=%s, group_name=%s"
partition.partition_name}, group_name={group.group_name}""", % (formsemestre_id, partition["partition_name"], group["group_name"]),
) )
# Supprime les groupes indiqués comme supprimés: # Supprime les groupes indiqués comme supprimés:
@ -792,10 +819,10 @@ def setGroups(
return xml_error(msg, code=404) return xml_error(msg, code=404)
# Place dans ce groupe les etudiants indiqués: # Place dans ce groupe les etudiants indiqués:
for etudid in fs[1:-1]: for etudid in fs[1:-1]:
change_etud_group_in_partition(etudid, group) change_etud_group_in_partition(etudid, group.id, partition)
# Update parcours # Update parcours
partition.formsemestre.update_inscriptions_parcours_from_groups() formsemestre.update_inscriptions_parcours_from_groups()
data = ( data = (
'<?xml version="1.0" encoding="utf-8"?><response>Groupes enregistrés</response>' '<?xml version="1.0" encoding="utf-8"?><response>Groupes enregistrés</response>'
@ -808,7 +835,6 @@ def setGroups(
def create_group(partition_id, group_name="", default=False) -> GroupDescr: def create_group(partition_id, group_name="", default=False) -> GroupDescr:
"""Create a new group in this partition. """Create a new group in this partition.
If default, create default partition (with no name) If default, create default partition (with no name)
Obsolete: utiliser Partition.create_group
""" """
partition = Partition.query.get_or_404(partition_id) partition = Partition.query.get_or_404(partition_id)
if not sco_permissions_check.can_change_groups(partition.formsemestre_id): if not sco_permissions_check.can_change_groups(partition.formsemestre_id):
@ -830,7 +856,7 @@ def create_group(partition_id, group_name="", default=False) -> GroupDescr:
group = GroupDescr(partition=partition, group_name=group_name, numero=new_numero) group = GroupDescr(partition=partition, group_name=group_name, numero=new_numero)
db.session.add(group) db.session.add(group)
db.session.commit() db.session.commit()
log(f"create_group: created group_id={group.id}") log("create_group: created group_id={group.id}")
# #
return group return group
@ -950,20 +976,10 @@ def edit_partition_form(formsemestre_id=None):
} }
</script> </script>
""", """,
f"""<h2>Partitions du semestre</h2> r"""<h2>Partitions du semestre</h2>
<p class="help">
👉💡 vous pourriez essayer <a href="{
url_for("scolar.partition_editor",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
}" class="stdlink">le nouvel éditeur</a>
</p>
<form name="editpart" id="editpart" method="POST" action="partition_create"> <form name="editpart" id="editpart" method="POST" action="partition_create">
<div id="epmsg"></div> <div id="epmsg"></div>
<table> <table><tr class="eptit"><th></th><th></th><th></th><th>Partition</th><th>Groupes</th><th></th><th></th><th></th></tr>
<tr class="eptit">
<th></th><th></th><th></th><th>Partition</th><th>Groupes</th><th></th><th></th><th></th>
</tr>
""", """,
] ]
i = 0 i = 0
@ -1384,16 +1400,14 @@ def groups_auto_repartition(partition_id=None):
"""Reparti les etudiants dans des groupes dans une partition, en respectant le niveau """Reparti les etudiants dans des groupes dans une partition, en respectant le niveau
et la mixité. et la mixité.
""" """
partition: Partition = Partition.query.get_or_404(partition_id) partition = get_partition(partition_id)
if not partition.groups_editable: if not partition["groups_editable"]:
raise AccessDenied("Partition non éditable") raise AccessDenied("Partition non éditable")
formsemestre_id = partition.formsemestre_id formsemestre_id = partition["formsemestre_id"]
formsemestre = partition.formsemestre formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# renvoie sur page édition partitions et groupes # renvoie sur page édition groupes
dest_url = url_for( dest_url = url_for(
"scolar.partition_editor", "scolar.affect_groups", scodoc_dept=g.scodoc_dept, partition_id=partition_id
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
) )
if not sco_permissions_check.can_change_groups(formsemestre_id): if not sco_permissions_check.can_change_groups(formsemestre_id):
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
@ -1413,14 +1427,12 @@ def groups_auto_repartition(partition_id=None):
H = [ H = [
html_sco_header.sco_header(page_title="Répartition des groupes"), html_sco_header.sco_header(page_title="Répartition des groupes"),
f"""<h2>Répartition des groupes de {partition.partition_name}</h2> "<h2>Répartition des groupes de %s</h2>" % partition["partition_name"],
<p>Semestre {formsemestre.titre_annee()}</p> f"<p>Semestre {formsemestre.titre_annee()}</p>",
<p class="help">Les groupes existants seront <b>effacés</b> et remplacés par """<p class="help">Les groupes existants seront <b>effacés</b> et remplacés par
ceux créés ici. La répartition aléatoire tente d'uniformiser le niveau ceux créés ici. La répartition aléatoire tente d'uniformiser le niveau
des groupes (en utilisant la dernière moyenne générale disponible pour des groupes (en utilisant la dernière moyenne générale disponible pour
chaque étudiant) et de maximiser la mixité de chaque groupe. chaque étudiant) et de maximiser la mixité de chaque groupe.</p>""",
</p>
""",
] ]
tf = TrivialFormulator( tf = TrivialFormulator(
@ -1438,23 +1450,25 @@ def groups_auto_repartition(partition_id=None):
return flask.redirect(dest_url) return flask.redirect(dest_url)
else: else:
# form submission # form submission
log(f"groups_auto_repartition({partition})") log(
group_names = tf[2]["groupNames"] "groups_auto_repartition( partition_id=%s partition_name=%s"
group_names = sorted({x.strip() for x in group_names.split(",")}) % (partition_id, partition["partition_name"])
)
groupNames = tf[2]["groupNames"]
group_names = sorted(set([x.strip() for x in groupNames.split(",")]))
# Détruit les groupes existant de cette partition # Détruit les groupes existant de cette partition
for group in partition.groups: for old_group in get_partition_groups(partition):
db.session.delete(group) group_delete(old_group["group_id"])
db.session.commit()
# Crée les nouveaux groupes # Crée les nouveaux groupes
groups = [] group_ids = []
for group_name in group_names: for group_name in group_names:
if group_name.strip(): if group_name.strip():
groups.append(partition.create_group(group_name)) group_ids.append(create_group(partition_id, group_name).id)
# #
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
identdict = nt.identdict identdict = nt.identdict
# build: { civilite : liste etudids trie par niveau croissant } # build: { civilite : liste etudids trie par niveau croissant }
civilites = {x["civilite"] for x in identdict.values()} civilites = set([x["civilite"] for x in identdict.values()])
listes = {} listes = {}
for civilite in civilites: for civilite in civilites:
listes[civilite] = [ listes[civilite] = [
@ -1467,19 +1481,16 @@ def groups_auto_repartition(partition_id=None):
# affect aux groupes: # affect aux groupes:
n = len(identdict) n = len(identdict)
igroup = 0 igroup = 0
nbgroups = len(groups) nbgroups = len(group_ids)
while n > 0: while n > 0:
log(f"n={n}")
for civilite in civilites: for civilite in civilites:
log(f"civilite={civilite}")
if len(listes[civilite]): if len(listes[civilite]):
n -= 1 n -= 1
etudid = listes[civilite].pop()[1] etudid = listes[civilite].pop()[1]
group = groups[igroup] group_id = group_ids[igroup]
igroup = (igroup + 1) % nbgroups igroup = (igroup + 1) % nbgroups
log(f"in {etudid} in group {group.id}") change_etud_group_in_partition(etudid, group_id, partition)
change_etud_group_in_partition(etudid, group) log("%s in group %s" % (etudid, group_id))
log(f"{etudid} in group {group.id}")
return flask.redirect(dest_url) return flask.redirect(dest_url)
@ -1487,13 +1498,15 @@ def _get_prev_moy(etudid, formsemestre_id):
"""Donne la derniere moyenne generale calculee pour cette étudiant, """Donne la derniere moyenne generale calculee pour cette étudiant,
ou 0 si on n'en trouve pas (nouvel inscrit,...). ou 0 si on n'en trouve pas (nouvel inscrit,...).
""" """
from app.scodoc import sco_cursus_dut
info = sco_etud.get_etud_info(etudid=etudid, filled=True) info = sco_etud.get_etud_info(etudid=etudid, filled=True)
if not info: if not info:
raise ScoValueError("etudiant invalide: etudid=%s" % etudid) raise ScoValueError("etudiant invalide: etudid=%s" % etudid)
etud = info[0] etud = info[0]
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
if Se.prev: if Se.prev:
prev_sem = db.session.get(FormSemestre, Se.prev["formsemestre_id"]) prev_sem = FormSemestre.query.get(Se.prev["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_sem) nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_sem)
return nt.get_etud_moy_gen(etudid) return nt.get_etud_moy_gen(etudid)
else: else:
@ -1507,11 +1520,10 @@ def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"):
Si la partition existe déjà, ses groupes sont mis à jour (les groupes devenant Si la partition existe déjà, ses groupes sont mis à jour (les groupes devenant
vides ne sont pas supprimés). vides ne sont pas supprimés).
""" """
# A RE-ECRIRE pour utiliser les modèles.
from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_inscriptions
partition_name = str(partition_name) partition_name = str(partition_name)
log(f"create_etapes_partition({formsemestre_id})") log("create_etapes_partition(%s)" % formsemestre_id)
ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
args={"formsemestre_id": formsemestre_id} args={"formsemestre_id": formsemestre_id}
) )
@ -1530,17 +1542,20 @@ def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"):
pid = partition_create( pid = partition_create(
formsemestre_id, partition_name=partition_name, redirect=False formsemestre_id, partition_name=partition_name, redirect=False
) )
partition: Partition = db.session.get(Partition, pid) partition = get_partition(pid)
groups = partition.groups groups = get_partition_groups(partition)
groups_by_names = {g.group_name: g for g in groups} groups_by_names = {g["group_name"]: g for g in groups}
for etape in etapes: for etape in etapes:
if etape not in groups_by_names: if not (etape in groups_by_names):
new_group = create_group(pid, etape) new_group = create_group(pid, etape)
groups_by_names[etape] = new_group g = get_group(new_group.id) # XXX transition: recupere old style dict
groups_by_names[etape] = g
# Place les etudiants dans les groupes # Place les etudiants dans les groupes
for i in ins: for i in ins:
if i["etape"]: if i["etape"]:
change_etud_group_in_partition(i["etudid"], groups_by_names[i["etape"]]) change_etud_group_in_partition(
i["etudid"], groups_by_names[i["etape"]]["group_id"], partition
)
def do_evaluation_listeetuds_groups( def do_evaluation_listeetuds_groups(

View File

@ -36,12 +36,16 @@ import time
from flask import g, url_for from flask import g, url_for
from app import db, log import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app import log
from app.models import ScolarNews, GroupDescr from app.models import ScolarNews, GroupDescr
from app.models.etudiants import input_civilite from app.models.etudiants import input_civilite
from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_excel import COLORS from app.scodoc.sco_excel import COLORS
from app.scodoc.sco_formsemestre_inscriptions import (
do_formsemestre_inscription_with_modules,
)
from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
AccessDenied, AccessDenied,
ScoFormatError, ScoFormatError,
@ -51,6 +55,7 @@ from app.scodoc.sco_exceptions import (
ScoLockedFormError, ScoLockedFormError,
ScoGenError, ScoGenError,
) )
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_etud from app.scodoc import sco_etud
@ -58,11 +63,6 @@ from app.scodoc import sco_groups
from app.scodoc import sco_excel from app.scodoc import sco_excel
from app.scodoc import sco_groups_view from app.scodoc import sco_groups_view
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
import app.scodoc.notesdb as ndb
from app.scodoc.sco_formsemestre_inscriptions import (
do_formsemestre_inscription_with_modules,
)
import app.scodoc.sco_utils as scu
# format description (in tools/) # format description (in tools/)
FORMAT_FILE = "format_import_etudiants.txt" FORMAT_FILE = "format_import_etudiants.txt"
@ -480,7 +480,6 @@ def scolars_import_excel_file(
text="Inscription de %d étudiants" # peuvent avoir ete inscrits a des semestres differents text="Inscription de %d étudiants" # peuvent avoir ete inscrits a des semestres differents
% len(created_etudids), % len(created_etudids),
obj=formsemestre_id, obj=formsemestre_id,
max_frequency=0,
) )
log("scolars_import_excel_file: completing transaction") log("scolars_import_excel_file: completing transaction")
@ -639,10 +638,10 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
fields = adm_get_fields(titles, formsemestre_id) fields = adm_get_fields(titles, formsemestre_id)
idx_nom = None idx_nom = None
idx_prenom = None idx_prenom = None
for idx, field in fields.items(): for idx in fields:
if field[0] == "nom": if fields[idx][0] == "nom":
idx_nom = idx idx_nom = idx
if field[0] == "prenom": if fields[idx][0] == "prenom":
idx_prenom = idx idx_prenom = idx
if (idx_nom is None) or (idx_prenom is None): if (idx_nom is None) or (idx_prenom is None):
log("fields indices=" + ", ".join([str(x) for x in fields])) log("fields indices=" + ", ".join([str(x) for x in fields]))
@ -664,20 +663,21 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
# Retrouve l'étudiant parmi ceux du semestre par (nom, prenom) # Retrouve l'étudiant parmi ceux du semestre par (nom, prenom)
nom = adm_normalize_string(line[idx_nom]) nom = adm_normalize_string(line[idx_nom])
prenom = adm_normalize_string(line[idx_prenom]) prenom = adm_normalize_string(line[idx_prenom])
if (nom, prenom) not in etuds_by_nomprenom: if not (nom, prenom) in etuds_by_nomprenom:
msg = f"""Étudiant <b>{line[idx_nom]} {line[idx_prenom]} inexistant</b>""" log(
diag.append(msg) "unable to find %s %s among members" % (line[idx_nom], line[idx_prenom])
)
else: else:
etud = etuds_by_nomprenom[(nom, prenom)] etud = etuds_by_nomprenom[(nom, prenom)]
cur_adm = sco_etud.admission_list(cnx, args={"etudid": etud["etudid"]})[0] cur_adm = sco_etud.admission_list(cnx, args={"etudid": etud["etudid"]})[0]
# peuple les champs presents dans le tableau # peuple les champs presents dans le tableau
args = {} args = {}
for idx, field in fields.items(): for idx in fields:
field_name, convertor = field field_name, convertor = fields[idx]
if field_name in modifiable_fields: if field_name in modifiable_fields:
try: try:
val = convertor(line[idx]) val = convertor(line[idx])
except ValueError as exc: except ValueError:
raise ScoFormatError( raise ScoFormatError(
'scolars_import_admission: valeur invalide, ligne %d colonne %s: "%s"' 'scolars_import_admission: valeur invalide, ligne %d colonne %s: "%s"'
% (nline, field_name, line[idx]), % (nline, field_name, line[idx]),
@ -686,7 +686,7 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id, formsemestre_id=formsemestre_id,
), ),
) from exc )
if val is not None: # note: ne peut jamais supprimer une valeur if val is not None: # note: ne peut jamais supprimer une valeur
args[field_name] = val args[field_name] = val
if args: if args:
@ -719,10 +719,10 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
) )
for group_id in group_ids: for group_id in group_ids:
group = db.session.get(GroupDescr, group_id) group = GroupDescr.query.get(group_id)
if group.partition.groups_editable: if group.partition.groups_editable:
sco_groups.change_etud_group_in_partition( sco_groups.change_etud_group_in_partition(
args["etudid"], group args["etudid"], group_id
) )
else: else:
log("scolars_import_admission: partition non editable") log("scolars_import_admission: partition non editable")

View File

@ -35,13 +35,14 @@ from flask import url_for, g, request
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import db, log from app import log
from app.models import Formation, FormSemestre, GroupDescr from app.models import Formation, FormSemestre
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import codes_cursus from app.scodoc import codes_cursus
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.scodoc import sco_formations
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups from app.scodoc import sco_groups
@ -176,8 +177,7 @@ def do_inscrit(sem, etudids, inscrit_groupes=False):
(la liste doit avoir été vérifiée au préalable) (la liste doit avoir été vérifiée au préalable)
En option: inscrit aux mêmes groupes que dans le semestre origine En option: inscrit aux mêmes groupes que dans le semestre origine
""" """
# TODO à ré-écrire pour utiliser le smodèle, notamment GroupDescr formsemestre: FormSemestre = FormSemestre.query.get(sem["formsemestre_id"])
formsemestre: FormSemestre = db.session.get(FormSemestre, sem["formsemestre_id"])
formsemestre.setup_parcours_groups() formsemestre.setup_parcours_groups()
log(f"do_inscrit (inscrit_groupes={inscrit_groupes}): {etudids}") log(f"do_inscrit (inscrit_groupes={inscrit_groupes}): {etudids}")
for etudid in etudids: for etudid in etudids:
@ -220,10 +220,11 @@ def do_inscrit(sem, etudids, inscrit_groupes=False):
# Inscrit aux groupes # Inscrit aux groupes
for partition_group in partition_groups: for partition_group in partition_groups:
group: GroupDescr = db.session.get( sco_groups.change_etud_group_in_partition(
GroupDescr, partition_group["group_id"] etudid,
partition_group["group_id"],
partition_group,
) )
sco_groups.change_etud_group_in_partition(etudid, group)
def do_desinscrit(sem, etudids): def do_desinscrit(sem, etudids):
@ -415,10 +416,10 @@ def formsemestre_inscr_passage(
): # il y a au moins une vraie partition ): # il y a au moins une vraie partition
H.append( H.append(
f"""<li><a class="stdlink" href="{ f"""<li><a class="stdlink" href="{
url_for("scolar.partition_editor", scodoc_dept=g.scodoc_dept, url_for("scolar.affect_groups",
formsemestre_id=formsemestre_id) scodoc_dept=g.scodoc_dept, partition_id=partition["partition_id"])
}">Répartir les groupes de {partition["partition_name"]}</a></li> }">Répartir les groupes de {partition["partition_name"]}</a></li>
""" """
) )
# #
@ -435,7 +436,7 @@ def _build_page(
inscrit_groupes=False, inscrit_groupes=False,
ignore_jury=False, ignore_jury=False,
): ):
formsemestre: FormSemestre = db.session.get(FormSemestre, sem["formsemestre_id"]) formsemestre: FormSemestre = FormSemestre.query.get(sem["formsemestre_id"])
inscrit_groupes = int(inscrit_groupes) inscrit_groupes = int(inscrit_groupes)
ignore_jury = int(ignore_jury) ignore_jury = int(ignore_jury)
if inscrit_groupes: if inscrit_groupes:

View File

@ -33,7 +33,7 @@ import numpy as np
import flask import flask
from flask import url_for, g, request from flask import url_for, g, request
from app import db, log from app import log
from app import models from app import models
from app.comp import res_sem from app.comp import res_sem
from app.comp import moy_mod from app.comp import moy_mod
@ -79,7 +79,7 @@ def do_evaluation_listenotes(
return "<p>Aucune évaluation !</p>", "ScoDoc" return "<p>Aucune évaluation !</p>", "ScoDoc"
E = evals[0] # il y a au moins une evaluation E = evals[0] # il y a au moins une evaluation
modimpl = db.session.get(ModuleImpl, E["moduleimpl_id"]) modimpl = ModuleImpl.query.get(E["moduleimpl_id"])
# description de l'evaluation # description de l'evaluation
if mode == "eval": if mode == "eval":
H = [sco_evaluations.evaluation_describe(evaluation_id=evaluation_id)] H = [sco_evaluations.evaluation_describe(evaluation_id=evaluation_id)]
@ -624,7 +624,7 @@ def _make_table_notes(
] ]
commentkeys = list(key_mgr.items()) # [ (comment, key), ... ] commentkeys = list(key_mgr.items()) # [ (comment, key), ... ]
commentkeys.sort(key=lambda x: int(x[1])) commentkeys.sort(key=lambda x: int(x[1]))
for comment, key in commentkeys: for (comment, key) in commentkeys:
C.append( C.append(
'<span class="colcomment">(%s)</span> <em>%s</em><br>' % (key, comment) '<span class="colcomment">(%s)</span> <em>%s</em><br>' % (key, comment)
) )
@ -673,7 +673,7 @@ def _add_eval_columns(
sum_notes = 0 sum_notes = 0
notes = [] # liste des notes numeriques, pour calcul histogramme uniquement notes = [] # liste des notes numeriques, pour calcul histogramme uniquement
evaluation_id = e["evaluation_id"] evaluation_id = e["evaluation_id"]
e_o = db.session.get(Evaluation, evaluation_id) # XXX en attendant ré-écriture e_o = Evaluation.query.get(evaluation_id) # XXX en attendant ré-écriture
inscrits = e_o.moduleimpl.formsemestre.etudids_actifs # set d'etudids inscrits = e_o.moduleimpl.formsemestre.etudids_actifs # set d'etudids
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)

View File

@ -31,16 +31,15 @@
from flask_login import current_user from flask_login import current_user
import psycopg2 import psycopg2
from app import db
from app.models import Formation
from app.scodoc import scolog
from app.scodoc import sco_formsemestre
from app.scodoc import sco_cache
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import ScoValueError, AccessDenied from app.scodoc.sco_exceptions import ScoValueError, AccessDenied
from app import log
from app import models
from app.scodoc import scolog
from app.scodoc import sco_formsemestre
from app.scodoc import sco_cache
# --- Gestion des "Implémentations de Modules" # --- Gestion des "Implémentations de Modules"
# Un "moduleimpl" correspond a la mise en oeuvre d'un module # Un "moduleimpl" correspond a la mise en oeuvre d'un module
@ -171,7 +170,7 @@ def moduleimpl_withmodule_list(
mi["matiere"] = matieres[matiere_id] mi["matiere"] = matieres[matiere_id]
mod = modimpls[0]["module"] mod = modimpls[0]["module"]
formation = db.session.get(Formation, mod["formation_id"]) formation = models.Formation.query.get(mod["formation_id"])
if formation.is_apc(): if formation.is_apc():
# tri par numero_module # tri par numero_module

View File

@ -28,13 +28,12 @@
"""Opérations d'inscriptions aux modules (interface pour gérer options ou parcours) """Opérations d'inscriptions aux modules (interface pour gérer options ou parcours)
""" """
import collections import collections
from operator import attrgetter from operator import itemgetter
import flask import flask
from flask import url_for, g, request from flask import url_for, g, request
from flask_login import current_user from flask_login import current_user
from app import db, log
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import ( from app.models import (
@ -44,6 +43,9 @@ from app.models import (
ScolarFormSemestreValidation, ScolarFormSemestreValidation,
UniteEns, UniteEns,
) )
from app import log
from app.tables import list_etuds
from app.scodoc.scolog import logdb from app.scodoc.scolog import logdb
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import htmlutils from app.scodoc import htmlutils
@ -60,7 +62,6 @@ import app.scodoc.notesdb as ndb
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.tables import list_etuds
def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False):
@ -519,7 +520,7 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
else set() else set()
) )
ues = sorted( ues = sorted(
(db.session.get(UniteEns, ue_id) for ue_id in ue_ids), (UniteEns.query.get(ue_id) for ue_id in ue_ids),
key=lambda u: (u.numero or 0, u.acronyme), key=lambda u: (u.numero or 0, u.acronyme),
) )
H.append( H.append(
@ -552,11 +553,8 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
>{etud.nomprenom}</a></td>""" >{etud.nomprenom}</a></td>"""
) )
# Parcours: # Parcours:
if partition_parcours: group = partition_parcours.get_etud_group(etud.id)
group = partition_parcours.get_etud_group(etud.id) parcours_name = group.group_name if group else ""
parcours_name = group.group_name if group else ""
else:
parcours_name = ""
H.append(f"""<td class="parcours">{parcours_name}</td>""") H.append(f"""<td class="parcours">{parcours_name}</td>""")
# UEs: # UEs:
for ue in ues: for ue in ues:
@ -580,7 +578,7 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
.all() .all()
) )
validations_ue.sort( validations_ue.sort(
key=lambda v: codes_cursus.BUT_CODES_ORDER.get(v.code, 0) key=lambda v: codes_cursus.BUT_CODES_ORDERED.get(v.code, 0)
) )
validation = validations_ue[-1] if validations_ue else None validation = validations_ue[-1] if validations_ue else None
expl_validation = ( expl_validation = (
@ -670,7 +668,7 @@ def descr_inscrs_module(moduleimpl_id, set_all, partitions):
gr.append((partition["partition_name"], grp)) gr.append((partition["partition_name"], grp))
# #
d = [] d = []
for partition_name, grp in gr: for (partition_name, grp) in gr:
if grp: if grp:
d.append("groupes de %s: %s" % (partition_name, ", ".join(grp))) d.append("groupes de %s: %s" % (partition_name, ", ".join(grp)))
r = [] r = []
@ -682,25 +680,25 @@ def descr_inscrs_module(moduleimpl_id, set_all, partitions):
return False, len(ins), " et ".join(r) return False, len(ins), " et ".join(r)
def _fmt_etud_set(etudids, max_list_size=7) -> str: def _fmt_etud_set(ins, max_list_size=7):
# max_list_size est le nombre max de noms d'etudiants listés # max_list_size est le nombre max de noms d'etudiants listés
# au delà, on indique juste le nombre, sans les noms. # au delà, on indique juste le nombre, sans les noms.
if len(etudids) > max_list_size: if len(ins) > max_list_size:
return f"{len(etudids)} étudiants" return "%d étudiants" % len(ins)
etuds = [] etuds = []
for etudid in etudids: for etudid in ins:
etud = db.session.get(Identite, etudid) etuds.append(sco_etud.get_etud_info(etudid=etudid, filled=True)[0])
if etud: etuds.sort(key=itemgetter("nom"))
etuds.append(etud)
return ", ".join( return ", ".join(
[ [
f"""<a class="discretelink" href="{ '<a class="discretelink" href="%s">%s</a>'
% (
url_for( url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"]
) ),
}">{etud.nomprenom}</a>""" etud["nomprenom"],
for etud in sorted(etuds, key=attrgetter("sort_key")) )
for etud in etuds
] ]
) )

View File

@ -57,7 +57,6 @@ from app.scodoc import sco_moduleimpl
from app.scodoc import sco_permissions_check from app.scodoc import sco_permissions_check
from app.tables import list_etuds from app.tables import list_etuds
# menu evaluation dans moduleimpl # menu evaluation dans moduleimpl
def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str: def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
"Menu avec actions sur une evaluation" "Menu avec actions sur une evaluation"
@ -227,7 +226,7 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
) )
arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags() arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags()
# #
module_resp = db.session.get(User, modimpl.responsable_id) module_resp = User.query.get(modimpl.responsable_id)
mod_type_name = scu.MODULE_TYPE_NAMES[module.module_type] mod_type_name = scu.MODULE_TYPE_NAMES[module.module_type]
H = [ H = [
html_sco_header.sco_header( html_sco_header.sco_header(
@ -529,7 +528,7 @@ def _ligne_evaluation(
) -> str: ) -> str:
"""Ligne <tr> décrivant une évaluation dans le tableau de bord moduleimpl.""" """Ligne <tr> décrivant une évaluation dans le tableau de bord moduleimpl."""
H = [] H = []
# evaluation: Evaluation = db.session.get(Evaluation, eval_dict["evaluation_id"]) # evaluation: Evaluation = Evaluation.query.get(eval_dict["evaluation_id"])
etat = sco_evaluations.do_evaluation_etat( etat = sco_evaluations.do_evaluation_etat(
evaluation.id, evaluation.id,
partition_id=partition_id, partition_id=partition_id,
@ -733,7 +732,7 @@ def _ligne_evaluation(
) )
if etat["moy"]: if etat["moy"]:
H.append( H.append(
f"""<b>{etat["moy"]} / 20</b> f"""<b>{etat["moy"]} / {evaluation.note_max:g}</b>
&nbsp; (<a class="stdlink" href="{ &nbsp; (<a class="stdlink" href="{
url_for('notes.evaluation_listenotes', url_for('notes.evaluation_listenotes',
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id) scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
@ -838,7 +837,7 @@ def _evaluation_poids_html(evaluation: Evaluation, max_poids: float = 0.0) -> st
"></div> "></div>
</div>""" </div>"""
for ue, poids in ( for ue, poids in (
(db.session.get(UniteEns, ue_id), poids) (UniteEns.query.get(ue_id), poids)
for ue_id, poids in ue_poids.items() for ue_id, poids in ue_poids.items()
) )
] ]

View File

@ -33,8 +33,10 @@
from flask import abort, url_for, g, render_template, request from flask import abort, url_for, g, render_template, request
from flask_login import current_user from flask_login import current_user
from app import db, log import app.scodoc.sco_utils as scu
from app.but import cursus_but import app.scodoc.notesdb as ndb
from app import log
from app.but import cursus_but, jury_but_view
from app.models.etudiants import Identite, make_etud_args from app.models.etudiants import Identite, make_etud_args
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
@ -55,17 +57,13 @@ from app.scodoc.sco_bulletins import etud_descr_situation_semestre
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_formsemestre_validation import formsemestre_recap_parcours_table from app.scodoc.sco_formsemestre_validation import formsemestre_recap_parcours_table
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
def _menu_scolarite( def _menu_scolarite(authuser, sem: dict, etudid: int):
authuser, formsemestre: FormSemestre, etudid: int, etat_inscription: str
):
"""HTML pour menu "scolarite" pour un etudiant dans un semestre. """HTML pour menu "scolarite" pour un etudiant dans un semestre.
Le contenu du menu depend des droits de l'utilisateur et de l'état de l'étudiant. Le contenu du menu depend des droits de l'utilisateur et de l'état de l'étudiant.
""" """
locked = not formsemestre.etat locked = not sem["etat"]
if locked: if locked:
lockicon = scu.icontag("lock32_img", title="verrouillé", border="0") lockicon = scu.icontag("lock32_img", title="verrouillé", border="0")
return lockicon # no menu return lockicon # no menu
@ -73,10 +71,10 @@ def _menu_scolarite(
Permission.ScoEtudInscrit Permission.ScoEtudInscrit
) and not authuser.has_permission(Permission.ScoEtudChangeGroups): ) and not authuser.has_permission(Permission.ScoEtudChangeGroups):
return "" # no menu return "" # no menu
ins = sem["ins"]
args = {"etudid": etudid, "formsemestre_id": ins["formsemestre_id"]}
args = {"etudid": etudid, "formsemestre_id": formsemestre.id} if ins["etat"] != "D":
if etat_inscription != scu.DEMISSION:
dem_title = "Démission" dem_title = "Démission"
dem_url = "scolar.form_dem" dem_url = "scolar.form_dem"
else: else:
@ -84,14 +82,14 @@ def _menu_scolarite(
dem_url = "scolar.do_cancel_dem" dem_url = "scolar.do_cancel_dem"
# Note: seul un etudiant inscrit (I) peut devenir défaillant. # Note: seul un etudiant inscrit (I) peut devenir défaillant.
if etat_inscription != codes_cursus.DEF: if ins["etat"] != codes_cursus.DEF:
def_title = "Déclarer défaillance" def_title = "Déclarer défaillance"
def_url = "scolar.form_def" def_url = "scolar.form_def"
elif etat_inscription == codes_cursus.DEF: elif ins["etat"] == codes_cursus.DEF:
def_title = "Annuler la défaillance" def_title = "Annuler la défaillance"
def_url = "scolar.do_cancel_def" def_url = "scolar.do_cancel_def"
def_enabled = ( def_enabled = (
(etat_inscription != scu.DEMISSION) (ins["etat"] != "D")
and authuser.has_permission(Permission.ScoEtudInscrit) and authuser.has_permission(Permission.ScoEtudInscrit)
and not locked and not locked
) )
@ -130,12 +128,6 @@ def _menu_scolarite(
"enabled": authuser.has_permission(Permission.ScoEtudInscrit) "enabled": authuser.has_permission(Permission.ScoEtudInscrit)
and not locked, and not locked,
}, },
{
"title": "Gérer les validations d'UEs antérieures",
"endpoint": "notes.formsemestre_validate_previous_ue",
"args": args,
"enabled": formsemestre.can_edit_jury(),
},
{ {
"title": "Inscrire à un autre semestre", "title": "Inscrire à un autre semestre",
"endpoint": "notes.formsemestre_inscription_with_modules_form", "endpoint": "notes.formsemestre_inscription_with_modules_form",
@ -258,10 +250,8 @@ def ficheEtud(etudid=None):
info["last_formsemestre_id"] = "" info["last_formsemestre_id"] = ""
sem_info = {} sem_info = {}
for sem in info["sems"]: for sem in info["sems"]:
formsemestre: FormSemestre = db.session.get(
FormSemestre, sem["formsemestre_id"]
)
if sem["ins"]["etat"] != scu.INSCRIT: if sem["ins"]["etat"] != scu.INSCRIT:
formsemestre: FormSemestre = FormSemestre.query.get(sem["formsemestre_id"])
descr, _ = etud_descr_situation_semestre( descr, _ = etud_descr_situation_semestre(
etudid, etudid,
formsemestre, formsemestre,
@ -293,7 +283,7 @@ def ficheEtud(etudid=None):
) )
grlink = ", ".join(grlinks) grlink = ", ".join(grlinks)
# infos ajoutées au semestre dans le parcours (groupe, menu) # infos ajoutées au semestre dans le parcours (groupe, menu)
menu = _menu_scolarite(authuser, formsemestre, etudid, sem["ins"]["etat"]) menu = _menu_scolarite(authuser, sem, etudid)
if menu: if menu:
sem_info[sem["formsemestre_id"]] = ( sem_info[sem["formsemestre_id"]] = (
"<table><tr><td>" + grlink + "</td><td>" + menu + "</td></tr></table>" "<table><tr><td>" + grlink + "</td><td>" + menu + "</td></tr></table>"
@ -313,39 +303,16 @@ def ficheEtud(etudid=None):
) )
info[ info[
"link_bul_pdf" "link_bul_pdf"
] = f""" ] = f"""<span class="link_bul_pdf"><a class="stdlink" href="{
<span class="link_bul_pdf"> url_for("notes.etud_bulletins_pdf", scodoc_dept=g.scodoc_dept, etudid=etudid)
<a class="stdlink" href="{ }">tous les bulletins</a></span>"""
url_for("notes.etud_bulletins_pdf", scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Tous les bulletins</a>
</span>
"""
last_formsemestre: FormSemestre = db.session.get(
FormSemestre, info["sems"][0]["formsemestre_id"]
)
if last_formsemestre.formation.is_apc() and last_formsemestre.semestre_id > 2:
info[
"link_bul_pdf"
] += f"""
<span class="link_bul_pdf">
<a class="stdlink" href="{
url_for("notes.validation_rcues", scodoc_dept=g.scodoc_dept, etudid=etudid, formsemestre_id=last_formsemestre.id)
}">Visualiser les compétences BUT</a>
</span>
"""
if authuser.has_permission(Permission.ScoEtudInscrit): if authuser.has_permission(Permission.ScoEtudInscrit):
info[ info[
"link_inscrire_ailleurs" "link_inscrire_ailleurs"
] = f"""<span class="link_bul_pdf"><a class="stdlink" href="{ ] = f"""<span class="link_bul_pdf"><a class="stdlink" href="{
url_for("notes.formsemestre_inscription_with_modules_form", url_for("notes.formsemestre_inscription_with_modules_form",
scodoc_dept=g.scodoc_dept, etudid=etudid) scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Inscrire à un autre semestre</a></span> }">inscrire à un autre semestre</a></span>"""
<span class="link_bul_pdf"><a class="stdlink" href="{
url_for("notes.jury_delete_manual",
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Éditer toutes décisions de jury</a></span>
"""
else: else:
info["link_inscrire_ailleurs"] = "" info["link_inscrire_ailleurs"] = ""
else: else:
@ -370,18 +337,17 @@ def ficheEtud(etudid=None):
if not sco_permissions_check.can_suppress_annotation(a["id"]): if not sco_permissions_check.can_suppress_annotation(a["id"]):
a["dellink"] = "" a["dellink"] = ""
else: else:
a["dellink"] = ( a[
'<td class="annodel"><a href="doSuppressAnnotation?etudid=%s&annotation_id=%s">%s</a></td>' "dellink"
% ( ] = '<td class="annodel"><a href="doSuppressAnnotation?etudid=%s&annotation_id=%s">%s</a></td>' % (
etudid, etudid,
a["id"], a["id"],
scu.icontag( scu.icontag(
"delete_img", "delete_img",
border="0", border="0",
alt="suppress", alt="suppress",
title="Supprimer cette annotation", title="Supprimer cette annotation",
), ),
)
) )
author = sco_users.user_info(a["author"]) author = sco_users.user_info(a["author"])
alist.append( alist.append(
@ -480,7 +446,7 @@ def ficheEtud(etudid=None):
info[ info[
"inscriptions_mkup" "inscriptions_mkup"
] = f"""<div class="ficheinscriptions" id="ficheinscriptions"> ] = f"""<div class="ficheinscriptions" id="ficheinscriptions">
<div class="fichetitre">Cursus</div>{info["liste_inscriptions"]} <div class="fichetitre">Parcours</div>{info["liste_inscriptions"]}
{info["link_bul_pdf"]} {info["link_inscrire_ailleurs"]} {info["link_bul_pdf"]} {info["link_inscrire_ailleurs"]}
</div>""" </div>"""
@ -508,26 +474,11 @@ def ficheEtud(etudid=None):
last_sem = FormSemestre.query.get_or_404(info["sems"][0]["formsemestre_id"]) last_sem = FormSemestre.query.get_or_404(info["sems"][0]["formsemestre_id"])
if last_sem.formation.is_apc(): if last_sem.formation.is_apc():
but_cursus = cursus_but.EtudCursusBUT(etud, last_sem.formation) but_cursus = cursus_but.EtudCursusBUT(etud, last_sem.formation)
info[ info["but_cursus_mkup"] = render_template(
"but_cursus_mkup" "but/cursus_etud.j2",
] = f""" cursus=but_cursus,
<div class="section_but"> scu=scu,
{render_template( )
"but/cursus_etud.j2",
cursus=but_cursus,
scu=scu,
)}
<div class="link_validation_rcues">
<a href="{url_for("notes.validation_rcues",
scodoc_dept=g.scodoc_dept, etudid=etudid,
formsemestre_id=last_formsemestre.id)}"
title="Visualiser les compétences BUT"
>
<img src="/ScoDoc/static/icons/parcours-but.png" alt="validation_rcues" height="100px"/>
</a>
</div>
</div>
"""
tmpl = """<div class="menus_etud">%(menus_etud)s</div> tmpl = """<div class="menus_etud">%(menus_etud)s</div>
<div class="ficheEtud" id="ficheEtud"><table> <div class="ficheEtud" id="ficheEtud"><table>

View File

@ -84,8 +84,6 @@ def SU(s: str) -> str:
s = html.unescape(s) s = html.unescape(s)
# Remplace les <br> par des <br/> # Remplace les <br> par des <br/>
s = re.sub(r"<br\s*>", "<br/>", s) s = re.sub(r"<br\s*>", "<br/>", s)
# And substitute unicode characters not supported by ReportLab
s = s.replace("", "-")
return s return s

View File

@ -6,7 +6,6 @@
from flask import g from flask import g
from flask_login import current_user from flask_login import current_user
from app import db
from app.auth.models import User from app.auth.models import User
from app.models import FormSemestre from app.models import FormSemestre
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
@ -132,10 +131,7 @@ def check_access_diretud(formsemestre_id, required_permission=Permission.ScoImpl
"<h2>Opération non autorisée pour %s</h2>" % current_user, "<h2>Opération non autorisée pour %s</h2>" % current_user,
"<p>Responsable de ce semestre : <b>%s</b></p>" "<p>Responsable de ce semestre : <b>%s</b></p>"
% ", ".join( % ", ".join(
[ [User.query.get(i).get_prenomnom() for i in sem["responsables"]]
db.session.get(User, i).get_prenomnom()
for i in sem["responsables"]
]
), ),
footer, footer,
] ]
@ -146,9 +142,7 @@ def check_access_diretud(formsemestre_id, required_permission=Permission.ScoImpl
def can_change_groups(formsemestre_id: int) -> bool: def can_change_groups(formsemestre_id: int) -> bool:
"""Vrai si l'utilisateur peut changer les groupes dans ce semestre "Vrai si l'utilisateur peut changer les groupes dans ce semestre"
Obsolete: utiliser FormSemestre.can_change_groups
"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.etat: if not formsemestre.etat:
return False # semestre verrouillé return False # semestre verrouillé

4
app/scodoc/sco_photos.py Normal file → Executable file
View File

@ -148,11 +148,11 @@ def get_photo_image(etudid=None, size="small"):
filename = photo_pathname(etud.photo_filename, size=size) filename = photo_pathname(etud.photo_filename, size=size)
if not filename: if not filename:
filename = UNKNOWN_IMAGE_PATH filename = UNKNOWN_IMAGE_PATH
r = _http_jpeg_file(filename) r = build_image_response(filename)
return r return r
def _http_jpeg_file(filename): def build_image_response(filename):
"""returns an image as a Flask response""" """returns an image as a Flask response"""
st = os.stat(filename) st = os.stat(filename)
last_modified = st.st_mtime # float timestamp last_modified = st.st_mtime # float timestamp

View File

@ -489,7 +489,6 @@ def _normalize_apo_fields(infolist):
recode les champs: paiementinscription (-> booleen), datefinalisationinscription (date) recode les champs: paiementinscription (-> booleen), datefinalisationinscription (date)
ajoute le champs 'paiementinscription_str' : 'ok', 'Non' ou '?' ajoute le champs 'paiementinscription_str' : 'ok', 'Non' ou '?'
ajoute les champs 'etape' (= None) et 'prenom' ('') s'ils ne sont pas présents. ajoute les champs 'etape' (= None) et 'prenom' ('') s'ils ne sont pas présents.
ajoute le champ 'civilite_etat_civil' (='X'), et 'prenom_etat_civil' (='') si non présent.
""" """
for infos in infolist: for infos in infolist:
if "paiementinscription" in infos: if "paiementinscription" in infos:
@ -521,15 +520,6 @@ def _normalize_apo_fields(infolist):
if "prenom" not in infos: if "prenom" not in infos:
infos["prenom"] = "" infos["prenom"] = ""
if "civilite_etat_civil" not in infos:
infos["civilite_etat_civil"] = "X"
if "civilite_etat_civil" not in infos:
infos["civilite_etat_civil"] = "X"
if "prenom_etat_civil" not in infos:
infos["prenom_etat_civil"] = ""
return infolist return infolist

View File

@ -111,11 +111,13 @@ get_base_preferences(formsemestre_id)
""" """
import flask import flask
from flask import current_app, flash, g, request, url_for from flask import current_app, flash, g, request, url_for
from app import db, log
from app.models import Departement from app.models import Departement
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app import log
from app.scodoc.sco_exceptions import ScoValueError, ScoException from app.scodoc.sco_exceptions import ScoValueError, ScoException
from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.TrivialFormulator import TrivialFormulator
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
@ -272,7 +274,7 @@ class BasePreferences(object):
) )
def __init__(self, dept_id: int): def __init__(self, dept_id: int):
dept = db.session.get(Departement, dept_id) dept = Departement.query.get(dept_id)
if not dept: if not dept:
raise ScoValueError(f"BasePreferences: Invalid departement: {dept_id}") raise ScoValueError(f"BasePreferences: Invalid departement: {dept_id}")
self.dept_id = dept.id self.dept_id = dept.id

View File

@ -30,7 +30,7 @@
""" """
from operator import itemgetter from operator import itemgetter
from app import db from app import log
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import ( from app.models import (
@ -63,32 +63,25 @@ def dict_pvjury(
Si with_parcours_decisions: ajoute infos sur code decision jury de tous les semestre du parcours Si with_parcours_decisions: ajoute infos sur code decision jury de tous les semestre du parcours
Résultat: Résultat:
{ {
'date' : str = date de la decision la plus recente, format dd/mm/yyyy, 'date' : date de la decision la plus recente,
'formsemestre' : dict = formsemestre, 'formsemestre' : sem,
'is_apc' : bool, 'is_apc' : bool,
'formation' : { 'acronyme' :, 'titre': ... } 'formation' : { 'acronyme' :, 'titre': ... }
'decisions' : [ 'decisions' : { [ { 'identite' : {'nom' :, 'prenom':, ...,},
{ 'etat' : I ou D ou DEF
'identite' : {'nom' :, 'prenom':, ...,}, 'decision_sem' : {'code':, 'code_prev': },
'etat' : I ou D ou DEF 'decisions_ue' : { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' :,
'decision_sem' : {'code':, 'code_prev': }, 'acronyme', 'numero': } },
'decisions_ue' : { 'autorisations' : [ { 'semestre_id' : { ... } } ],
ue_id : { 'validation_parcours' : True si parcours validé (diplome obtenu)
'code' : ADM|CMP|AJ, 'prev_code' : code (calculé slt si with_prev),
'ects' : float, 'mention' : mention (en fct moy gen),
'event_date' :str = "dd/mm/yyyy", 'sum_ects' : total ECTS acquis dans ce semestre (incluant les UE capitalisées)
}, 'sum_ects_capitalises' : somme des ECTS des UE capitalisees
}, }
'autorisations' : [ { 'semestre_id' : { ... } } ], ]
'validation_parcours' : True si parcours validé (diplome obtenu) },
'prev_code' : code (calculé slt si with_prev), 'decisions_dict' : { etudid : decision (comme ci-dessus) },
'mention' : mention (en fct moy gen),
'sum_ects' : total ECTS acquis dans ce semestre (incluant les UE capitalisées)
'sum_ects_capitalises' : somme des ECTS des UE capitalisees
},
...
],
'decisions_dict' : { etudid : decision (comme ci-dessus) },
} }
""" """
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
@ -260,7 +253,7 @@ def _comp_ects_by_ue_code(nt, decisions_ue):
ects_by_ue_code = {} ects_by_ue_code = {}
for ue_id in decisions_ue: for ue_id in decisions_ue:
d = decisions_ue[ue_id] d = decisions_ue[ue_id]
ue = db.session.get(UniteEns, ue_id) ue = UniteEns.query.get(ue_id)
ects_by_ue_code[ue.ue_code] = d["ects"] ects_by_ue_code[ue.ue_code] = d["ects"]
return ects_by_ue_code return ects_by_ue_code

View File

@ -42,7 +42,6 @@ from reportlab.platypus import PageBreak, Table, Image
from reportlab.platypus.doctemplate import BaseDocTemplate from reportlab.platypus.doctemplate import BaseDocTemplate
from reportlab.lib import styles from reportlab.lib import styles
from app import db
from app.models import FormSemestre, Identite from app.models import FormSemestre, Identite
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -71,7 +70,7 @@ def pdf_lettres_individuelles(
if not dpv: if not dpv:
return "" return ""
# #
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
prefs = sco_preferences.SemPreferences(formsemestre_id) prefs = sco_preferences.SemPreferences(formsemestre_id)
params = { params = {
"date_jury": date_jury, "date_jury": date_jury,

Some files were not shown because too many files have changed in this diff Show More