1
0
Fork 0

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 users_bp
from app.views import absences_bp
from app.views import assiduites_bp
from app.api import api_bp
from app.api import api_web_bp
@ -340,6 +341,9 @@ def create_app(config_class=DevConfig):
app.register_blueprint(
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_web_bp, url_prefix="/ScoDoc/<scodoc_dept>/api")

View File

@ -2,7 +2,8 @@
"""
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.sco_exceptions import ScoException
@ -34,9 +35,26 @@ def requested_format(default_format="json", allowed_formats=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 (
absences,
assiduites,
billets_absences,
departements,
etudiants,
@ -44,6 +62,7 @@ from app.api import (
formations,
formsemestres,
jury,
justificatifs,
logos,
partitions,
semset,

View File

@ -8,7 +8,6 @@
from flask_json import as_json
from app import db
from app.api import api_bp as bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_error
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:
return json_error(404, message="etudiant inexistant")
# 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:
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
"""
from datetime import datetime
from operator import attrgetter
from flask import g, request
from flask_json import as_json
from flask_login import current_user
from flask_login import login_required
from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import VARCHAR
from sqlalchemy import desc, or_
import app
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.decorators import scodoc, permission_required
from app.models import (
@ -32,8 +31,7 @@ from app.scodoc import sco_bulletins
from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
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:
# @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()
@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/nip/<string:nip>", 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:
# 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)
)
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/nip/<string:nip>/formsemestres")
@bp.route("/etudiant/ine/<string:ine>/formsemestres")

View File

@ -8,7 +8,7 @@
ScoDoc 9 API : accès aux évaluations
"""
from flask import g, request
from flask import g
from flask_json import as_json
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.decorators import scodoc, permission_required
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
import app.scodoc.sco_utils as scu
@ -28,7 +28,7 @@ import app.scodoc.sco_utils as scu
@scodoc
@permission_required(Permission.ScoView)
@as_json
def evaluation(evaluation_id: int):
def the_eval(evaluation_id: int):
"""Description d'une évaluation.
{
@ -93,22 +93,24 @@ def evaluations(moduleimpl_id: int):
@as_json
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 :
{
"11": {
"etudid": 11,
"1": {
"id": 1,
"etudid": 10,
"evaluation_id": 1,
"value": 15.0,
"comment": "",
"date": "Wed, 20 Apr 2022 06:49:05 GMT",
"uid": 2
},
"12": {
"etudid": 12,
"2": {
"id": 2,
"etudid": 1,
"evaluation_id": 1,
"value": 12.0,
"comment": "",
@ -126,8 +128,8 @@ def evaluation_notes(evaluation_id: int):
.filter_by(dept_id=g.scodoc_dept_id)
)
evaluation = query.first_or_404()
dept = evaluation.moduleimpl.formsemestre.departement
the_eval = query.first_or_404()
dept = the_eval.moduleimpl.formsemestre.departement
app.set_sco_dept(dept.acronym)
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.
note = notes[etudid]
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"]
# in JS, keys must be string, not integers
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", "")
)
return notes

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_login import current_user, login_required
from flask_login import login_required
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR, tools
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
from app.scodoc.sco_exceptions import ScoException
from app.but import jury_but_results
from app.models import (
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.models import FormSemestre
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@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):
"""Décisions du jury des étudiants du formsemestre."""
# APC, pair:
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
if formsemestre.formation.is_apc():
app.set_sco_dept(formsemestre.departement.acronym)
rows = jury_but_results.get_jury_but_results(formsemestre)
return rows
else:
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_json import as_json
from flask_login import login_required
import sqlalchemy as sa
from sqlalchemy.exc import IntegrityError
import app
from app import db, log
@ -25,7 +23,6 @@ from app.models import GroupDescr, Partition, Scolog
from app.models.groups import group_membership
from app.scodoc import sco_cache
from app.scodoc import sco_groups
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
@ -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}:
return json_error(404, "etud non inscrit au formsemestre du groupe")
try:
sco_groups.change_etud_group_in_partition(etudid, group)
except ScoValueError as exc:
return json_error(404, exc.args[0])
except IntegrityError:
return json_error(404, "échec de l'enregistrement")
sco_groups.change_etud_group_in_partition(
etudid, group_id, group.partition.to_dict()
)
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()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
db.session.execute(
sa.text(
"""DELETE FROM group_membership
WHERE etudid=:etudid
and group_id IN (
SELECT id FROM group_descr WHERE partition_id = :partition_id
);
"""
),
{"etudid": etudid, "partition_id": partition_id},
)
Scolog.logdb(
method="partition_remove_etud",
etudid=etud.id,
msg=f"Retrait de la partition {partition.partition_name}",
commit=False,
groups = (
GroupDescr.query.filter_by(partition_id=partition_id)
.join(group_membership)
.filter_by(etudid=etudid)
)
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()
# Update parcours
partition.formsemestre.update_inscriptions_parcours_from_groups()
@ -282,7 +271,7 @@ def partition_remove_etud(partition_id: int, etudid: int):
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@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
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
"""
user: User = db.session.get(User, uid)
user: User = User.query.get(uid)
if user is None:
return json_error(404, "user not found")
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
import flask_login
from app import db, login
from app import login
from app.auth.models import User
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_utils import json_error
@ -39,7 +39,7 @@ def basic_auth_error(status):
@login.user_loader
def load_user(uid: str) -> User:
"flask-login: accès à un utilisateur"
return db.session.get(User, int(uid))
return User.query.get(int(uid))
@token_auth.verify_token

View File

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

View File

@ -12,7 +12,6 @@ import datetime
import numpy as np
from flask import g, has_request_context, url_for
from app import db
from app.comp.res_but import ResultatsSemestreBUT
from app.models import Evaluation, FormSemestre, Identite
from app.models.groups import GroupDescr
@ -159,7 +158,7 @@ class BulletinBUT:
[etud.id]
].iterrows():
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
if ue.acronyme in d:
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 "-"
t = {
"titre": f"{ue_acronym} - {ue['titre']}",
"moyenne": Paragraph(
f"""<para align=right><b>{moy_ue or "-"}</b></para>"""
),
"moyenne": Paragraph(f"""<para align=right><b>{moy_ue}</b></para>"""),
"_css_row_class": "note_bold",
"_pdf_row_markup": ["b"],
"_pdf_style": [

View File

@ -14,14 +14,17 @@ Classe raccordant avec ScoDoc 7:
"""
import collections
from operator import attrgetter
from typing import Union
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_compat import NotesTableCompat
from app.comp import res_sem
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
@ -34,6 +37,7 @@ from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
RegroupementCoherentUE,
)
from app.models.etudiants import Identite
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.validations import ScolarFormSemestreValidation
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.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
@ -69,7 +72,6 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
class EtudCursusBUT:
"""L'état de l'étudiant dans son cursus BUT
Liste des niveaux validés/à valider
(utilisé pour le résumé sur la fiche étudiant)
"""
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"
self.parcour: ApcParcours = self.inscriptions[-1].parcour
"Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
self.niveaux_by_annee: dict[int, list[ApcNiveau]] = {}
"{ annee:int : liste des niveaux à valider }"
self.niveaux_by_annee = {}
"{ annee : liste des niveaux à valider }"
self.niveaux: dict[int, ApcNiveau] = {}
"cache les niveaux"
for annee in (1, 2, 3):
@ -116,6 +118,21 @@ class EtudCursusBUT:
self.niveaux.update(
{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 = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
@ -128,8 +145,8 @@ class EtudCursusBUT:
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation.code]
sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
@ -189,28 +206,6 @@ class EtudCursusBUT:
)
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:
"""L'état des étudiants d'un formsemestre dans leur cursus BUT
@ -251,9 +246,7 @@ class FormSemestreCursusBUT:
parcour = None
else:
if parcour_id not in self.parcours_by_id:
self.parcours_by_id[parcour_id] = db.session.get(
ApcParcours, parcour_id
)
self.parcours_by_id[parcour_id] = ApcParcours.query.get(parcour_id)
parcour = self.parcours_by_id[parcour_id]
return self.get_niveaux_parcours_by_annee(parcour)
@ -310,8 +303,8 @@ class FormSemestreCursusBUT:
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
@ -347,8 +340,8 @@ class FormSemestreCursusBUT:
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
@ -365,66 +358,6 @@ class FormSemestreCursusBUT:
"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(
formsemestre: FormSemestre, res: ResultatsSemestreBUT
) -> str:
@ -480,122 +413,3 @@ def formsemestre_warning_apc_setup(
</p>
</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.but import jury_but
from app.but.cursus_but import but_ects_valides
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
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
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 = {
"nom": "Code" if anonymous else "Nom",
"cursus": "Cursus",
@ -159,7 +153,7 @@ def pvjury_table_but(
etudid=etud.id,
),
"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 "-",
"niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
if deca

View File

@ -48,9 +48,9 @@ def _get_jury_but_etud_result(
# --- Les RCUEs
rcue_list = []
if deca:
for dec_rcue in deca.get_decisions_rcues_annee():
rcue = dec_rcue.rcue
if rcue.complete: # n'exporte que les RCUEs complets
for rcue in deca.rcues_annee:
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
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_ue2 = deca.decisions_ues[rcue.ue_2.id]
rcue_dict = {

View File

@ -6,22 +6,24 @@
"""Jury BUT: calcul des décisions de jury annuelles "automatiques"
"""
from flask import g, url_for
from app import db
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.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but(
formsemestre: FormSemestre, only_adm: bool = True
formsemestre: FormSemestre, only_adm: bool = True, no_overwrite: bool = True
) -> int:
"""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
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
de droit: ADM ou CMP.
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:
etud = Identite.get_etud(etudid)
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()
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

View File

@ -31,11 +31,9 @@ from app.models import (
UniteEns,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
ScolarNews,
)
from app.models.config import ScoDocSiteConfig
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 import sco_preferences
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>
"""
)
for dec_rcue in deca.get_decisions_rcues_annee():
rcue = dec_rcue.rcue
niveau = rcue.niveau
for niveau in deca.niveaux_competences:
H.append(
f"""<div class="but_niveau_titre">
<div title="{niveau.competence.titre_long}">{niveau.competence.titre}</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,
# qui
# qui seront toujours en readonly sur le formsemestre de l'année précédente du redoublant
ues_ro = [
(
ue_impair,
rcue.ue_cur_impair is None,
(deca.a_cheval and deca.formsemestre_id != deca.formsemestre_impair.id),
),
(
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:
@ -145,22 +153,17 @@ def _gen_but_select(
code_valide: str,
disabled: bool = False,
klass: str = "",
data: dict = None,
code_valide_label: str = "",
data: dict = {},
) -> str:
"Le menu html select avec les codes"
# if disabled: # mauvaise idée car le disabled est traité en JS
# return f"""<div class="but_code {klass}">{code_valide}</div>"""
data = data or {}
options_htm = "\n".join(
[
f"""<option value="{code}"
{'selected' if code == code_valide else ''}
class="{'recorded' if code == code_valide else ''}"
>{code
if ((code != code_valide) or not code_valide_label)
else code_valide_label
}</option>"""
>{code}</option>"""
for code in codes
]
)
@ -199,54 +202,20 @@ def _gen_but_niveau_ue(
</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:
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
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">
<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>
"""
else:
scoplement = ""
ue_class = "" # 'recorded' if dec_ue.code_valide is not None else ''
if dec_ue.code_valide is not None and dec_ue.codes:
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}
return f"""<div class="but_niveau_ue {
'recorded' if dec_ue.code_valide is not None else ''}
{'annee_prec' if annee_prec else ''}
">
<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:
if dec_rcue is None or not dec_rcue.rcue.complete:
if dec_rcue is None:
return """
<div class="but_niveau_rcue niveau_vide with_scoplement">
<div></div>
@ -275,25 +244,13 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
</div>
"""
code_propose_menu = dec_rcue.code_valide # le code enregistré
code_valide_label = code_propose_menu
if dec_rcue.validation:
if dec_rcue.code_valide == dec_rcue.codes[0]:
descr_validation = dec_rcue.validation.html()
else: # on une validation enregistrée différence de celle proposée
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"
scoplement = (
f"""<div class="scoplement">{
dec_rcue.validation.to_html()
}</div>"""
if dec_rcue.validation
else ""
)
# Déjà enregistré ?
niveau_rcue_class = ""
@ -313,11 +270,10 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
<div class="but_code">
{_gen_but_select("code_rcue_"+str(niveau.id),
dec_rcue.codes,
code_propose_menu,
dec_rcue.code_valide,
disabled=True,
klass="manual code_rcue",
data = { "niveau_id" : str(niveau.id)},
code_valide_label = code_valide_label,
data = { "niveau_id" : str(niveau.id)}
)}
</div>
</div>
@ -395,16 +351,6 @@ def jury_but_semestriel(
flash(
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(
url_for(
"notes.formsemestre_validation_but",
@ -448,7 +394,7 @@ def jury_but_semestriel(
{warning}
</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 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.sco_utils import ModuleType
@ -194,7 +194,7 @@ class BonusSportAdditif(BonusSport):
# les points au dessus du seuil sont comptés (defaut: seuil_moy_gen):
seuil_comptage = None
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
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
@ -435,11 +435,8 @@ class BonusAmiens(BonusSportAdditif):
class BonusBesanconVesoul(BonusSportAdditif):
"""Bonus IUT Besançon - Vesoul pour les UE libres
<p>Le bonus est compris entre 0 et 0,2 points.
et est reporté sur 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>Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,2 point
sur toutes les moyennes d'UE.
</p>
"""
@ -447,7 +444,7 @@ class BonusBesanconVesoul(BonusSportAdditif):
displayed_name = "IUT de Besançon - Vesoul"
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 0.0 # tous les points sont comptés
proportion_point = 1
proportion_point = 1e10 # infini
bonus_max = 0.2
@ -743,7 +740,6 @@ class BonusGrenobleIUT1(BonusSportMultiplicatif):
name = "bonus_iut1grenoble_2017"
displayed_name = "IUT de Grenoble 1"
# C'est un bonus "multiplicatif": on l'exprime en additif,
# sur chaque moyenne d'UE m_0
# Augmenter de 5% correspond à multiplier par a=1.05
@ -786,7 +782,6 @@ class BonusIUTRennes1(BonusSportAdditif):
seuil_moy_gen = 10.0
proportion_point = 1 / 20.0
classic_use_bonus_ues = False
# S'applique aussi en classic, sur la moy. gen.
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus"""
@ -827,32 +822,16 @@ class BonusStMalo(BonusIUTRennes1):
class BonusLaRocheSurYon(BonusSportAdditif):
"""Bonus IUT de La Roche-sur-Yon
<p>
<b>La note saisie s'applique directement</b>: si on saisit 0,2, un bonus de 0,2 points est appliqué
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>
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.
"""
name = "bonus_larochesuryon"
displayed_name = "IUT de La Roche-sur-Yon"
seuil_moy_gen = 0.0
seuil_comptage = 0.0
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""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)
proportion_point = 1e10 # le moindre point sature le bonus
bonus_max = 0.2 # à 0.2
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):
"""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
amplitude = 0.01 / 4 # 4pt => 1%
factor_max = 0.1 # 10% max
# Modifié 2022-11-29: calculer chaque bonus
# (de 1 à 3 modules) séparément.
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
# 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):
"""Dictionnaire des classes de bonus
(liste les sous-classes de BonusSport ayant un nom)

View File

@ -10,17 +10,8 @@ import pandas as pd
import sqlalchemy as sa
from app import db
from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns
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 codes_cursus
@ -90,7 +81,7 @@ class ValidationsSemestre(ResultatsCache):
# UEs: { etudid : { ue_id : {"code":, "ects":, "event_date":} }}
decisions_jury_ues = {}
# Parcoure les décisions d'UE:
# Parcours les décisions d'UE:
for decision in (
decisions_jury_q.filter(db.text("ue_id is not NULL"))
.join(UniteEns)
@ -181,79 +172,3 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
with db.engine.begin() as connection:
df = pd.read_sql_query(query, connection, params=params, index_col="etudid")
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
qui ont des notes ATT.
"""
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
moduleimpl = ModuleImpl.query.get(self.moduleimpl_id)
self.etudids = self._etudids()
# --- Calcul nombre d'inscrits pour déterminer les évaluations "completes":
@ -225,8 +225,8 @@ class ModuleImplResults:
"""
return [
inscr.etudid
for inscr in db.session.get(
ModuleImpl, self.moduleimpl_id
for inscr in ModuleImpl.query.get(
self.moduleimpl_id
).formsemestre.inscriptions
]
@ -319,16 +319,10 @@ class ModuleImplResultsAPC(ModuleImplResults):
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
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_ues = evals_poids_df.shape[1]
if evals_poids_df.shape[0] != nb_evals:
# 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})
"""
)
assert evals_poids_df.shape[0] == nb_evals # compat notes/poids
if nb_etuds == 0:
return pd.DataFrame(index=[], columns=evals_poids_df.columns)
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)
"""
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()
ues = modimpl.formsemestre.get_ues(with_sport=False)
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)
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
if nb_etuds == 0:
return pd.Series()

View File

@ -30,10 +30,7 @@
import numpy as np
import pandas as pd
from flask import flash, g, url_for
from markupsafe import Markup
from app import db
from flask import flash, g, Markup, url_for
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)
except TypeError:
if None in ects:
formation = db.session.get(Formation, formation_id)
formation = Formation.query.get(formation_id)
flash(
Markup(
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
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
numérique) en tenant compte des ex-aequos.

View File

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

View File

@ -10,17 +10,17 @@ import time
import numpy as np
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.res_compat import NotesTableCompat
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.but_refcomp import ApcParcours, ApcNiveau
from app.models.ues import DispenseUE, UniteEns
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
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
@ -44,8 +44,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
"""Parcours de chaque étudiant { etudid : parcour_id }"""
self.ues_ids_by_parcour: dict[set[int]] = {}
"""{ 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():
t0 = time.time()
self.compute()
@ -289,9 +288,9 @@ class ResultatsSemestreBUT(NotesTableCompat):
if ref_comp is None:
return set()
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:
parcour: ApcParcours = db.session.get(ApcParcours, parcour_id)
parcour: ApcParcours = ApcParcours.query.get(parcour_id)
annee = (self.formsemestre.semestre_id + 1) // 2
niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp)
# Les UEs du formsemestre associées à ces niveaux:
@ -307,13 +306,12 @@ class ResultatsSemestreBUT(NotesTableCompat):
return ues_ids
def etud_has_decision(self, etudid) -> bool:
"""True s'il y a une décision (quelconque) de jury
émanant de ce formsemestre pour cet étudiant.
def etud_has_decision(self, etudid):
"""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.
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)
or ApcValidationAnnee.query.filter_by(
formsemestre_id=self.formsemestre.id, etudid=etudid
@ -322,40 +320,3 @@ class ResultatsSemestreBUT(NotesTableCompat):
formsemestre_id=self.formsemestre.id, etudid=etudid
).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 app import db
from app.comp import res_sem
from app.comp.res_cache import ResultatsCache
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 import sco_utils as scu
# Il faut bien distinguer
# - ce qui est caché de façon persistente (via redis):
# ce sont les attributs listés dans `_cached_attrs`
@ -139,7 +137,7 @@ class ResultatsSemestre(ResultatsCache):
def etud_ues(self, etudid: int) -> Generator[UniteEns]:
"""Liste des UE auxquelles l'étudiant est inscrit
(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:
"""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.
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()
if ue.type == UE_SPORT:
@ -383,11 +381,7 @@ class ResultatsSemestre(ResultatsCache):
was_capitalized = False
if etudid in self.validations.ue_capitalisees.index:
ue_cap = self._get_etud_ue_cap(etudid, ue)
if (
ue_cap
and (ue_cap["moy_ue"] is not None)
and not np.isnan(ue_cap["moy_ue"])
):
if ue_cap and not np.isnan(ue_cap["moy_ue"]):
was_capitalized = True
if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_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 self.is_apc:
# 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
if coef_ue is None:
orig_sem = FormSemestre.get_formsemestre(ue_cap["formsemestre_id"])

View File

@ -9,10 +9,9 @@
from functools import cached_property
import pandas as pd
from flask import flash, g, url_for
from markupsafe import Markup
from flask import flash, g, Markup, url_for
from app import db, log
from app import log
from app.comp import moy_sem
from app.comp.aux_stats import StatsMoyenne
from app.comp.res_common import ResultatsSemestre
@ -284,12 +283,12 @@ class NotesTableCompat(ResultatsSemestre):
]
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.
prend aussi en compte les autorisations de passage.
Sous-classée en BUT pour les RCUEs et années.
"""
return bool(
return (
self.get_etud_decisions_ue(etudid)
or self.get_etud_decision_sem(etudid)
or ScolarAutorisationInscription.query.filter_by(
@ -394,7 +393,7 @@ class NotesTableCompat(ResultatsSemestre):
de ce module.
É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)
if not modimpl_results:
return [] # safeguard

View File

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

View File

@ -164,10 +164,7 @@ class EntrepriseStageApprentissage(db.Model):
entreprise_id = db.Column(
db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade")
)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
)
etudid = db.Column(db.Integer)
type_offre = db.Column(db.Text)
date_debut = 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")
)
annee = db.Column(db.Integer)
montant = db.Column(db.Float)
montant = db.Column(db.Integer)
notes = db.Column(db.Text)

View File

@ -28,6 +28,7 @@ from app.entreprises.forms import (
ContactCreationForm,
ContactModificationForm,
StageApprentissageCreationForm,
StageApprentissageModificationForm,
EnvoiOffreForm,
AjoutFichierForm,
TaxeApprentissageForm,
@ -238,7 +239,7 @@ def delete_validation_entreprise(entreprise_id):
text=f"Non validation de la fiche entreprise ({entreprise.nom})",
)
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 render_template(
"entreprises/form_confirmation.j2",
@ -769,7 +770,7 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id):
)
db.session.add(log)
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(
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.commit()
flash("L'offre a été supprimée de la fiche entreprise.")
flash("L'offre a été supprimé de la fiche entreprise.")
return redirect(
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)
def add_stage_apprentissage(entreprise_id):
"""
Permet d'ajouter un étudiant ayant réalisé un stage ou alternance
sur la fiche de l'entreprise
Permet d'ajouter un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise
"""
entreprise = Entreprise.query.filter_by(
id=entreprise_id, visible=True
@ -1484,8 +1484,15 @@ def add_stage_apprentissage(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id)
)
if form.validate_on_submit():
etudid = form.etudid.data
etudiant = Identite.query.get_or_404(etudid)
etudiant_nomcomplet = form.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_nomcomplet)
.first()
)
formation = etudiant.inscription_courante_date(
form.date_debut.data, form.date_fin.data
)
@ -1531,7 +1538,7 @@ def add_stage_apprentissage(entreprise_id):
@permission_required(Permission.RelationsEntreprisesChange)
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(
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(
description=f"etudiant {stage_apprentissage.etudid} inconnue"
)
form = StageApprentissageCreationForm()
form = StageApprentissageModificationForm()
if request.method == "POST" and form.cancel.data:
return redirect(
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id)
)
if form.validate_on_submit():
etudid = form.etudid.data
etudiant = Identite.query.get_or_404(etudid)
etudiant_nomcomplet = form.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_nomcomplet)
.first()
)
formation = etudiant.inscription_courante_date(
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,
)
stage_apprentissage.notes = form.notes.data.strip()
db.session.add(stage_apprentissage)
log = EntrepriseHistorique(
authenticated_user=current_user.user_name,
entreprise_id=stage_apprentissage.entreprise_id,
@ -1580,9 +1593,7 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
)
)
elif request.method == "GET":
form.etudiant.data = f"""{sco_etud.format_nom(etudiant.nom)} {
sco_etud.format_prenom(etudiant.prenom)}"""
form.etudid.data = etudiant.id
form.etudiant.data = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}"
form.type_offre.data = stage_apprentissage.type_offre
form.date_debut.data = stage_apprentissage.date_debut
form.date_fin.data = stage_apprentissage.date_fin

View File

@ -65,7 +65,6 @@ class CodesDecisionsForm(FlaskForm):
ADJ = _build_code_field("ADJ")
ADJR = _build_code_field("ADJR")
ADM = _build_code_field("ADM")
ADSUP = _build_code_field("ADSUP")
AJ = _build_code_field("AJ")
ATB = _build_code_field("ATB")
ATJ = _build_code_field("ATJ")
@ -82,8 +81,7 @@ class CodesDecisionsForm(FlaskForm):
NOTES_FMT = StringField(
label="Format notes exportées",
description="""Format des notes. Par défaut
<tt style="font-family: monotype;">%3.2f</tt> (deux chiffres après la virgule)""",
description="""Format des notes. Par défaut <tt style="font-family: monotype;">%3.2f</tt> (deux chiffres après la virgule)""",
validators=[
validators.Length(
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.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
from operator import attrgetter
from flask import g
from flask_sqlalchemy.query import Query
from sqlalchemy.orm import class_mapper
import sqlalchemy
@ -94,11 +93,6 @@ class ApcReferentielCompetences(db.Model, XMLModel):
backref="referentiel_competence",
order_by="Formation.acronyme, Formation.version",
)
validations_annee = db.relationship(
"ApcValidationAnnee",
backref="referentiel_competence",
lazy="dynamic",
)
def __repr__(self):
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={
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):
"as a dict, recursif (ou non) sur les AC"
return {
@ -397,9 +388,7 @@ class ApcNiveau(db.Model, XMLModel):
return (
ApcParcours.query.join(ApcAnneeParcours)
.filter_by(ordre=annee)
.join(ApcParcoursNiveauCompetence)
.join(ApcCompetence)
.join(ApcNiveau)
.join(ApcParcoursNiveauCompetence, ApcCompetence, ApcNiveau)
.filter_by(id=self.id)
.order_by(ApcParcours.numero, ApcParcours.code)
.all()
@ -423,20 +412,6 @@ class ApcNiveau(db.Model, XMLModel):
(dans ce cas, spécifier referentiel_competence)
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}:
raise ValueError("annee invalide pour un parcours BUT")
referentiel_competence = (
@ -453,13 +428,10 @@ class ApcNiveau(db.Model, XMLModel):
)
if competence is not None:
query = query.filter(ApcCompetence.id == competence.id)
result = query.all()
_cache[key] = result
return result
return query.all()
annee_parcour: ApcAnneeParcours = parcour.annees.filter_by(ordre=annee).first()
if not annee_parcour:
_cache[key] = []
return []
if competence is None:
@ -471,17 +443,9 @@ class ApcNiveau(db.Model, XMLModel):
for pn in parcour_niveaux
]
else:
niveaux: list[ApcNiveau] = (
ApcNiveau.query.filter_by(annee=f"BUT{int(annee)}")
.join(ApcCompetence)
.filter_by(id=competence.id)
.join(ApcParcoursNiveauCompetence)
.filter(ApcParcoursNiveauCompetence.niveau == ApcNiveau.ordre)
.join(ApcAnneeParcours)
.filter_by(parcours_id=parcour.id)
.all()
)
_cache[key] = niveaux
niveaux: list[ApcNiveau] = competence.niveaux.filter_by(
annee=f"BUT{int(annee)}"
).all()
return niveaux
@ -623,8 +587,7 @@ class ApcParcours(db.Model, XMLModel):
def query_competences(self) -> Query:
"Les compétences associées à ce parcours"
return (
ApcCompetence.query.join(ApcParcoursNiveauCompetence)
.join(ApcAnneeParcours)
ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
.filter_by(parcours_id=self.id)
.order_by(ApcCompetence.numero)
)
@ -633,8 +596,7 @@ class ApcParcours(db.Model, XMLModel):
"La compétence de titre donné dans ce parcours, ou None"
return (
ApcCompetence.query.filter_by(titre=titre)
.join(ApcParcoursNiveauCompetence)
.join(ApcAnneeParcours)
.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
.filter_by(parcours_id=self.id)
.order_by(ApcCompetence.numero)
.first()

View File

@ -2,6 +2,9 @@
"""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.models import CODE_STR_LEN
@ -10,6 +13,8 @@ from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
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):
@ -17,7 +22,7 @@ class ApcValidationRCUE(db.Model):
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"
@ -36,7 +41,7 @@ class ApcValidationRCUE(db.Model):
formsemestre_id = db.Column(
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:
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)
@ -61,7 +66,7 @@ class ApcValidationRCUE(db.Model):
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
def html(self) -> str:
def to_html(self) -> str:
"description en HTML"
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
<b>{self.code}</b>
@ -82,10 +87,6 @@ class ApcValidationRCUE(db.Model):
"as a dict"
d = dict(self.__dict__)
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
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):
"""Validation des années du BUT"""
__tablename__ = "apc_validation_annee"
# Assure unicité de la décision:
__table_args__ = (
db.UniqueConstraint("etudid", "ordre", "referentiel_competence_id"),
)
__table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire", "ordre"),)
id = db.Column(db.Integer, primary_key=True)
etudid = db.Column(
db.Integer,
@ -128,11 +319,8 @@ class ApcValidationAnnee(db.Model):
formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True
)
"le semestre origine, normalement l'IMPAIR (le 1er) de l'année"
referentiel_competence_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
)
annee_scolaire = db.Column(db.Integer, nullable=False) # eg 2021
"le semestre IMPAIR (le 1er) de l'année"
annee_scolaire = db.Column(db.Integer, nullable=False) # 2021
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
@ -150,50 +338,25 @@ class ApcValidationAnnee(db.Model):
"dict pour bulletins"
return {
"annee_scolaire": self.annee_scolaire,
"date": self.date.isoformat() if self.date else "",
"date": self.date.isoformat(),
"code": self.code,
"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:
"""
Un dict avec les décisions de jury BUT enregistrées:
- 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.
"""
decisions = {}
# --- RCUEs: seulement sur semestres pairs XXX à améliorer
if formsemestre.semestre_id % 2 == 0:
# validations émises depuis ce formsemestre:
validations_rcues = (
ApcValidationRCUE.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id
)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
.order_by(UniteEns.numero, UniteEns.acronyme)
validations_rcues = ApcValidationRCUE.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id
)
decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues]
titres_rcues = []
@ -215,11 +378,16 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
decisions["descr_decisions_rcue"] = ""
decisions["descr_decisions_niveaux"] = ""
# --- Année: prend la validation pour l'année scolaire de ce semestre
validation = ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
).first()
validation = (
ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
)
.join(ApcValidationAnnee.formsemestre)
.join(FormSemestre.formation)
.filter(Formation.formation_code == formsemestre.formation.formation_code)
.first()
)
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
else:

View File

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

View File

@ -43,8 +43,8 @@ class Identite(db.Model):
"optionnel (si present, affiché à la place du nom)"
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
# officiels (bulletins, PV): voir nomprenom_etat_civil()
# données d'état-civil. Si présent remplace les données d'usage dans les documents officiels (bulletins, PV)
# cf nomprenom_etat_civil()
civilite_etat_civil = db.Column(db.String(1), nullable=False, server_default="X")
prenom_etat_civil = db.Column(db.Text(), nullable=False, server_default="")
@ -73,17 +73,15 @@ class Identite(db.Model):
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):
return (
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
def from_request(cls, etudid=None, code_nip=None) -> "Identite":
"""Étudiant à partir de l'etudid ou du code_nip, soit
@ -226,7 +224,7 @@ class Identite(db.Model):
}
args_dict = {}
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)
if key in fs_empty_stored_as_nulls and value == "":
value = None

View File

@ -145,18 +145,6 @@ class Evaluation(db.Model):
db.session.add(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:
"""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.
@ -190,10 +178,8 @@ class Evaluation(db.Model):
"""
L = []
for ue_id, poids in ue_poids_dict.items():
ue = db.session.get(UniteEns, ue_id)
ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids)
L.append(ue_poids)
db.session.add(ue_poids)
ue = UniteEns.query.get(ue_id)
L.append(EvaluationUEPoids(evaluation=self, ue=ue, poids=poids))
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
self.moduleimpl.invalidate_evaluations_poids() # inval cache
@ -340,7 +326,7 @@ def check_evaluation_args(args):
jour = args.get("jour", None)
args["jour"] = jour
if jour:
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
modimpl = ModuleImpl.query.get(moduleimpl_id)
formsemestre = modimpl.formsemestre
y, m, d = [int(x) for x in ndb.DateDMYtoISO(jour).split("-")]
jour = datetime.date(y, m, d)

View File

@ -54,17 +54,14 @@ class ScolarNews(db.Model):
NEWS_APO = "APO" # changements de codes APO
NEWS_FORM = "FORM" # modification formation (object=formation_id)
NEWS_INSCR = "INSCR" # inscription d'étudiants (object=None ou formsemestre_id)
NEWS_JURY = "JURY" # saisie jury
NEWS_MISC = "MISC" # unused
NEWS_NOTE = "NOTES" # saisie note (object=moduleimpl_id)
NEWS_SEM = "SEM" # creation semestre (object=None)
NEWS_MAP = {
NEWS_ABS: "saisie absence",
NEWS_APO: "modif. code Apogée",
NEWS_FORM: "modification formation",
NEWS_INSCR: "inscription d'étudiants",
NEWS_JURY: "saisie jury",
NEWS_MISC: "opération", # unused
NEWS_NOTE: "saisie note",
NEWS_SEM: "création semestre",
@ -133,10 +130,10 @@ class ScolarNews(db.Model):
return query.order_by(cls.date.desc()).limit(n).all()
@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
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
même (obj, typ, user).
La nouvelle enregistrée est aussi envoyée par mail.
@ -156,10 +153,7 @@ class ScolarNews(db.Model):
if last_news:
now = datetime.datetime.now(tz=last_news.date.tzinfo)
if (now - last_news.date) < datetime.timedelta(seconds=max_frequency):
# pas de nouvel event, mais met à jour l'heure
last_news.date = datetime.datetime.now()
db.session.add(last_news)
db.session.commit()
# on n'enregistre pas
return
news = ScolarNews(
@ -187,14 +181,14 @@ class ScolarNews(db.Model):
elif self.type == self.NEWS_NOTE:
moduleimpl_id = self.object
if moduleimpl_id:
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
modimpl = ModuleImpl.query.get(moduleimpl_id)
if modimpl is None:
return None # module does not exists anymore
formsemestre_id = modimpl.formsemestre_id
if not formsemestre_id:
return None
formsemestre = db.session.get(FormSemestre, formsemestre_id)
formsemestre = FormSemestre.query.get(formsemestre_id)
return formsemestre
def notify_by_mail(self):
@ -265,8 +259,11 @@ class ScolarNews(db.Model):
# Informations générales
H.append(
f"""<div><a class="discretelink" href="{scu.SCO_ANNONCES_WEBSITE}">
Pour en savoir plus sur ScoDoc voir le site scodoc.org</a>.
f"""<div>
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>
"""
)

View File

@ -60,7 +60,7 @@ class Formation(db.Model):
return f"""<{self.__class__.__name__}(id={self.id}, dept_id={
self.dept_id}, acronyme={self.acronyme!r}, version={self.version})>"""
def html(self) -> str:
def to_html(self) -> str:
"titre complet pour affichage"
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 import flash, g, url_for
from flask import flash, g
from sqlalchemy.sql import text
import app.scodoc.sco_utils as scu
@ -163,14 +163,6 @@ class FormSemestre(db.Model):
def __repr__(self):
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
def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre":
""" "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
(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
if formation.is_apc():
# UEs de tronc commun (sans parcours indiqué)
@ -335,7 +316,8 @@ class FormSemestre(db.Model):
).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:
sem_ues = db.session.query(UniteEns).filter(
ModuleImpl.formsemestre_id == self.id,
@ -344,9 +326,7 @@ class FormSemestre(db.Model):
)
if not with_sport:
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
ues = sem_ues.order_by(UniteEns.numero).all()
_cache[key] = ues
return ues
return sem_ues.order_by(UniteEns.numero).all()
@cached_property
def modimpls_sorted(self) -> list[ModuleImpl]:
@ -394,7 +374,7 @@ class FormSemestre(db.Model):
),
{"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):
"""Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)"""
@ -538,11 +518,6 @@ class FormSemestre(db.Model):
return ""
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]]:
"""Calcule la liste des regroupements cohérents d'UE impliquant ce
formsemestre.
@ -585,17 +560,6 @@ class FormSemestre(db.Model):
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):
"""Vrai si utilisateur (par def. current) peut saisir decision de jury
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
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(
formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS
).first()
@ -843,10 +805,7 @@ class FormSemestre(db.Model):
query = (
ApcParcours.query.filter_by(code=group.group_name)
.join(ApcReferentielCompetences)
.filter_by(
dept_id=g.scodoc_dept_id,
id=self.formation.referentiel_competence_id,
)
.filter_by(dept_id=g.scodoc_dept_id)
)
if query.count() != 1:
log(
@ -895,12 +854,15 @@ class FormSemestre(db.Model):
.order_by(UniteEns.numero)
.all()
)
vals_annee = ( # issues de cette année scolaire seulement
vals_annee = (
ApcValidationAnnee.query.filter_by(
etudid=etudid,
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 = []
for vals in (vals_sem, vals_ues, vals_rcues, vals_annee):

View File

@ -8,13 +8,11 @@
"""ScoDoc models: Groups & partitions
"""
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 GROUPNAME_STR_LEN
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
class Partition(db.Model):
@ -119,81 +117,6 @@ class Partition(db.Model):
.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):
"""Description d'un groupe d'une partition"""

View File

@ -122,6 +122,22 @@ class ModuleImpl(db.Model):
raise AccessDenied(f"Modification impossible pour {user}")
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
notes_modules_enseignants = db.Table(

View File

@ -55,7 +55,7 @@ class Module(db.Model):
secondary=parcours_modules,
lazy="subquery",
backref=db.backref("modules", lazy=True),
order_by="ApcParcours.numero, ApcParcours.code",
order_by="ApcParcours.numero",
)
app_critiques = db.relationship(
@ -198,7 +198,7 @@ class Module(db.Model):
else:
# crée nouveau coef:
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)
db.session.add(ue_coef)
self.ue_coefs.append(ue_coef)
@ -229,19 +229,19 @@ class Module(db.Model):
"""delete coef"""
if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logguer.info(
"delete_ue_coef: locked formation, ignoring request"
f"delete_ue_coef: locked formation, ignoring request"
)
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:
db.session.delete(ue_coef)
self.formation.invalidate_module_coefs()
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
# à 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(
self, include_zeros=True, ues: list["UniteEns"] = None

View File

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

View File

@ -1,7 +1,6 @@
"""ScoDoc 9 models : Unités d'Enseignement (UE)
"""
from flask import g
import pandas as pd
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.but_refcomp import ApcNiveau, ApcParcours
from app.models.modules import Module
from app.scodoc.sco_exceptions import ScoFormationConflict
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
parcours = db.relationship(
ApcParcours,
secondary="ue_parcours",
backref=db.backref("ues", lazy=True),
order_by="ApcParcours.numero, ApcParcours.code",
ApcParcours, secondary="ue_parcours", backref=db.backref("ues", lazy=True)
)
# relations
@ -107,17 +104,6 @@ class UniteEns(db.Model):
If convert_objects, convert all attributes to native types
(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.pop("_sa_instance_state", None)
e.pop("evaluation_ue_poids", None)
@ -144,7 +130,6 @@ class UniteEns(db.Model):
]
else:
e.pop("module_ue_coefs", None)
_cache[key] = e
return e
def annee(self) -> int:
@ -192,23 +177,12 @@ class UniteEns(db.Model):
le parcours indiqué.
"""
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_id=self.id, parcours_id=parcour.id
).first()
if ue_parcour is not None and ue_parcour.ects is not None:
ue_ects_cache[key] = ue_parcour.ects
return ue_parcour.ects
if only_parcours:
ue_ects_cache[key] = None
return None
return self.ects

View File

@ -8,13 +8,10 @@ from app import log
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN
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):
"""Décisions de jury (sur semestre ou UEs)"""
"""Décisions de jury"""
__tablename__ = "scolar_formsemestre_validation"
# Assure unicité de la décision:
@ -57,30 +54,18 @@ class ScolarFormSemestreValidation(db.Model):
)
ue = db.relationship("UniteEns", lazy="select", uselist=False)
etud = db.relationship("Identite", backref="validations")
formsemestre = db.relationship(
"FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
)
def __repr__(self):
return f"""{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={
self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"""
return f"{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
def __str__(self):
if self.ue_id:
# 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
} ({self.ue_id}): {self.code}"""
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)
return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id}: {self.code}"""
return f"""décision sur semestre {self.formsemestre.titre_mois()} du {self.event_date.strftime("%d/%m/%Y")}"""
def to_dict(self) -> dict:
"as a dict"
@ -88,49 +73,6 @@ class ScolarFormSemestreValidation(db.Model):
d.pop("_sa_instance_state", None)
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):
"""Autorisation d'inscription dans un semestre"""
@ -151,7 +93,6 @@ class ScolarAutorisationInscription(db.Model):
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
)
origin_formsemestre = db.relationship("FormSemestre", lazy="select", uselist=False)
def __repr__(self) -> str:
return f"""{self.__class__.__name__}(id={self.id}, etudid={
@ -163,21 +104,6 @@ class ScolarAutorisationInscription(db.Model):
d.pop("_sa_instance_state", None)
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
def autorise_etud(
cls,

View File

@ -36,7 +36,7 @@ Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
from app import db, log
from app import log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
@ -487,7 +487,7 @@ def comp_coeff_pond(coeffs, ponderations):
# -----------------------------------------------------------------------------
def get_moduleimpl(modimpl_id) -> dict:
"""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:
return modimpl
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"
ADM = "ADM" # moyenne gen., barres UE, assiduité: sem. validé
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
ADJR = "ADJR" # UE admise car son RCUE est ADJ
ATT = "ATT" #
@ -163,7 +162,6 @@ CODES_EXPL = {
ADJ: "Validé par le Jury",
ADJR: "UE validée car son RCUE est validé ADJ par le jury",
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)",
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)",
@ -196,23 +194,18 @@ CODES_SEM_ATTENTES = {ATT, ATB, ATJ} # semestre en attente
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 = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR, ADSUP}
CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR}
"UE validée"
CODES_UE_CAPITALISANTS = {ADM}
"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 = CODES_RCUE_VALIDES_DE_DROIT | {ADJ, ADSUP}
CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ}
"Niveau RCUE validé"
# Pour le BUT:
CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD} # PASD pour enregistrement auto
CODES_ANNEE_BUT_VALIDES = {ADM, ADSUP}
CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD}
CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
BUT_BARRE_UE8 = 8.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:
# (valeur par défaut 0)
BUT_CODES_ORDER = {
ABAN: 0,
ABL: 0,
DEM: 0,
DEF: 0,
EXCLU: 0,
BUT_CODES_ORDERED = {
NAR: 0,
UEBSL: 0,
RAT: 5,
RED: 6,
DEF: 0,
AJ: 10,
ATJ: 20,
CMP: 50,
ADC: 50,
PAS1NCI: 50,
PASD: 60,
PASD: 50,
PAS1NCI: 60,
ADJR: 90,
ADSUP: 90,
ADJ: 90,
ADJ: 100,
ADM: 100,
}
@ -264,16 +249,6 @@ def code_ue_validant(code: str) -> bool:
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 = {
NEXT: "Passage au semestre suivant",
REDOANNEE: "Redoublement année",

View File

@ -88,7 +88,7 @@ class DEFAULT_TABLE_PREFERENCES(object):
return self.values[k]
class GenTable:
class GenTable(object):
"""Simple 2D tables with export to HTML, PDF, Excel, CSV.
Can be sub-classed to generate fancy formats.
"""
@ -197,9 +197,6 @@ class GenTable:
def __repr__(self):
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):
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):
H.append(
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.AnnuleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Supprimer</a></li>
"""
@ -138,7 +138,7 @@ def sidebar(etudid: int = None):
H.append(
f"""
<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>
"""
)

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_formsemestre_inscriptions
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
# --- Misc tools.... ------------------
@ -1052,6 +1054,45 @@ def get_abs_count_in_interval(etudid, date_debut_iso, date_fin_iso):
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):
"""Invalidate (clear) cached counts"""
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.res_compat import NotesTableCompat
from app.comp.res_but import ResultatsSemestreBUT
from app.models import (
ApcValidationAnnee,
ApcValidationRCUE,
FormSemestre,
Identite,
ScolarFormSemestreValidation,
)
from app.models import FormSemestre, Identite, ApcValidationAnnee
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_apogee_reader import (
APO_DECIMAL_SEP,
@ -71,7 +64,6 @@ from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.codes_cursus import code_semestre_validant
from app.scodoc.codes_cursus import (
ADSUP,
DEF,
DEM,
NAR,
@ -224,12 +216,7 @@ class ApoEtud(dict):
break
self.col_elts[code] = elt
if elt is None:
try:
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
self.new_cols[col_id] = self.cols[col_id]
else:
try:
self.new_cols[col_id] = sco_elts[code][
@ -336,22 +323,14 @@ class ApoEtud(dict):
x.strip() for x in ue["code_apogee"].split(",")
}:
if self.export_res_ues:
if (
decisions_ue and ue["ue_id"] in decisions_ue
) or self.export_res_sdj:
if decisions_ue and ue["ue_id"] in decisions_ue:
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_apo = ScoDocSiteConfig.get_code_apo(
code_decision_ue
)
else:
code_decision_ue_apo = ""
code_decision_ue = decisions_ue[ue["ue_id"]]["code"]
return dict(
N=self.fmt_note(ue_status["moy"] if ue_status else ""),
B=20,
J="",
R=code_decision_ue_apo,
R=ScoDocSiteConfig.get_code_apo(code_decision_ue),
M="",
)
else:
@ -364,17 +343,14 @@ class ApoEtud(dict):
module_code_found = False
for modimpl in modimpls:
module = modimpl["module"]
if (
res.modimpl_inscr_df[modimpl["moduleimpl_id"]][etudid]
and module["code_apogee"]
and code in {x.strip() for x in module["code_apogee"].split(",")}
):
if 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)
if n != "NI" and self.export_res_modules:
return dict(N=self.fmt_note(n), B=20, J="", R="")
else:
module_code_found = True
if module_code_found:
return VOID_APO_RES
#
@ -497,10 +473,7 @@ class ApoEtud(dict):
)
def _but_load_validation_annuelle(self):
"""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 ?)
"""
"charge la validation de jury BUT annuelle"
# le semestre impair de l'année scolaire
if self.cur_res.formsemestre.semestre_id % 2:
formsemestre = self.cur_res.formsemestre
@ -515,11 +488,11 @@ class ApoEtud(dict):
# ne trouve pas de semestre impair
self.validation_annee_but = None
return
self.validation_annee_but: ApcValidationAnnee = ApcValidationAnnee.query.filter_by(
formsemestre_id=formsemestre.id,
etudid=self.etud["etudid"],
referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id,
).first()
self.validation_annee_but: ApcValidationAnnee = (
ApcValidationAnnee.query.filter_by(
formsemestre_id=formsemestre.id, etudid=self.etud["etudid"]
).first()
)
self.is_nar = (
self.validation_annee_but and self.validation_annee_but.code == NAR
)
@ -919,75 +892,6 @@ class ApoData:
)
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]:
"""
@ -1114,10 +1018,6 @@ def export_csv_to_apogee(
cr_table = apo_data.build_cr_table()
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
if not dest_zip:
data = io.BytesIO()
@ -1143,7 +1043,6 @@ def export_csv_to_apogee(
log_filename = "scodoc-" + basename + ".log.txt"
nar_filename = basename + "-nar" + scu.XLSX_SUFFIX
cr_filename = basename + "-decisions" + scu.XLSX_SUFFIX
adsup_filename = f"{basename}-adsups{scu.XLSX_SUFFIX}"
logf = io.StringIO()
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".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
# Write data to ZIP
@ -1190,8 +1087,6 @@ def export_csv_to_apogee(
if nar_xls:
dest_zip.writestr(nar_filename, nar_xls)
dest_zip.writestr(cr_filename, cr_xls)
if adsup_xls:
dest_zip.writestr(adsup_filename, adsup_xls)
if my_zip:
dest_zip.close()

View File

@ -295,15 +295,8 @@ class ApoCSVReadWrite:
filename=self.get_filename(),
)
cols = {} # { col_id : value }
try:
for i, field in enumerate(fields):
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
for i, field in enumerate(fields):
cols[self.col_ids[i]] = field
etud_tuples.append(
ApoEtudTuple(
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.comp import res_sem
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.sco_exceptions import ScoException, ScoPermissionDenied
from app.scodoc.sco_exceptions import ScoPermissionDenied
from app.scodoc import html_sco_header
from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_groups
@ -86,6 +86,11 @@ class BaseArchiver(object):
self.archive_type = archive_type
self.initialized = False
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):
if self.initialized:
@ -107,6 +112,8 @@ class BaseArchiver(object):
finally:
scu.GSL.release()
self.initialized = True
if self.dept_id is None:
self.dept_id = getattr(g, "scodoc_dept_id")
def get_obj_dir(self, oid: int):
"""
@ -114,8 +121,7 @@ class BaseArchiver(object):
If directory does not yet exist, create it.
"""
self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first()
dept_dir = os.path.join(self.root, str(dept.id))
dept_dir = os.path.join(self.root, str(self.dept_id))
try:
scu.GSL.acquire()
if not os.path.isdir(dept_dir):
@ -125,12 +131,6 @@ class BaseArchiver(object):
if not os.path.isdir(obj_dir):
log(f"creating directory {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:
scu.GSL.release()
return obj_dir
@ -140,8 +140,7 @@ class BaseArchiver(object):
:return: list of archive oids
"""
self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first()
base = os.path.join(self.root, str(dept.id)) + os.path.sep
base = os.path.join(self.root, str(self.dept_id)) + os.path.sep
dirs = glob.glob(base + "*")
return [os.path.split(x)[1] for x in dirs]
@ -344,7 +343,7 @@ def do_formsemestre_archive(
if 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)
table_html, _, _ = gen_formsemestre_recapcomplet_html_table(
table_html, _ = gen_formsemestre_recapcomplet_html_table(
formsemestre, res, include_evaluations=True
)
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_login import current_user
from app import db, email
from app import email
from app import log
from app.scodoc.sco_utils import json_error
from app.but import bulletin_but
@ -354,7 +354,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
"modules_capitalized"
] = [] # 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:
sem_origin = db.session.get(FormSemestre, ue_status["formsemestre_id"])
sem_origin = FormSemestre.query.get(ue_status["formsemestre_id"])
u[
"ue_descr_txt"
] = 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":
# détail des modules de l'UE capitalisée
formsemestre_cap = db.session.get(
FormSemestre, ue_status["formsemestre_id"]
)
formsemestre_cap = FormSemestre.query.get(ue_status["formsemestre_id"])
nt_cap: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre_cap
)
@ -751,7 +749,7 @@ def etud_descr_situation_semestre(
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
parcour_id = res.etuds_parcour_id[etudid]
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:
infos["parcours_titre"] = parcour.libelle or ""
@ -930,7 +928,7 @@ def formsemestre_bulletinetud(
"""
format = format or "html"
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
if not formsemestre:
raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
@ -945,7 +943,7 @@ def formsemestre_bulletinetud(
)[0]
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)
return scu.send_file(bulletin, filename, mime=mime, suffix=suffix)
elif format == "pdfmail":
@ -1240,7 +1238,7 @@ def make_menu_autres_operations(
"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",
"args": {
"formsemestre_id": formsemestre.id,

View File

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

View File

@ -54,7 +54,7 @@ import traceback
from flask import g
import app
from app import db, log
from app import log
from app.scodoc import notesdb as ndb
from app.scodoc import sco_utils as scu
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:
formsemestre = None
if formsemestre_id:
formsemestre = db.session.get(FormSemestre, formsemestre_id)
formsemestre = FormSemestre.query.get(formsemestre_id)
if formsemestre is None:
raise ScoException("invalidate_formsemestre: departement must be set")
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)
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:
"""Contexte pour effectuer des opérations indépendantes dans la
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,
"semestre_id": semestre_id,
"is_external": is_external,
"moy_ue": moy_ue,
}
if date:
args["event_date"] = date
@ -966,13 +965,14 @@ def do_formsemestre_validate_ue(
cursor.execute("delete from scolar_formsemestre_validation where " + cond, args)
# insert
args["code"] = code
if (code == ADM) and (moy_ue is None):
# stocke la moyenne d'UE capitalisée:
ue_status = nt.get_etud_ue_status(etudid, ue_id)
args["moy_ue"] = ue_status["moy"] if ue_status else ""
if code == ADM:
if moy_ue is None:
# stocke la moyenne d'UE capitalisée:
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)
if code is not None:
if code != None:
scolar_formsemestre_validation_create(cnx, args)
else:
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:
ects_by_sem[semestre_idx] = '<span class="missing_ue_ects">manquant</span>'
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()

View File

@ -103,7 +103,7 @@ def do_formation_delete(formation_id):
"""delete a formation (and all its UE, matieres, modules)
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:
return
acronyme = formation.acronyme
@ -132,7 +132,6 @@ def do_formation_delete(formation_id):
typ=ScolarNews.NEWS_FORM,
obj=formation_id,
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,
text=f"""Création de la formation {
formation.titre} ({formation.acronyme}) version {formation.version}""",
max_frequency=0,
)
return formation

View File

@ -30,13 +30,13 @@
"""
import flask
from flask import g, url_for, request
from app import db, log
from app.models import Formation, Matiere, UniteEns, ScolarNews
from app.models.events import ScolarNews
from app.models.formations import Matiere
import app.scodoc.notesdb as ndb
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.sco_exceptions import (
ScoValueError,
@ -73,7 +73,7 @@ def do_matiere_edit(*args, **kw):
# edit
_matiereEditor.edit(cnx, *args, **kw)
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):
@ -88,11 +88,12 @@ def do_matiere_create(args):
r = _matiereEditor.create(cnx, args)
# news
formation = db.session.get(Formation, ue["formation_id"])
formation = Formation.query.get(ue["formation_id"])
ScolarNews.add(
typ=ScolarNews.NEWS_FORM,
obj=ue["formation_id"],
text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
)
formation.invalidate_cached_sems()
return r
@ -100,12 +101,13 @@ def do_matiere_create(args):
def matiere_create(ue_id=None):
"""Creation d'une matiere"""
ue: UniteEns = UniteEns.query.get_or_404(ue_id)
default_numero = max([mat.numero for mat in ue.matieres] or [9]) + 1
from app.scodoc import sco_edit_ue
UE = sco_edit_ue.ue_list(args={"ue_id": ue_id})[0]
H = [
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>
<p class="help">Les matières sont des groupes de modules dans une UE
"""<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
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
des notes.</em>
@ -125,21 +127,13 @@ associé.
scu.get_request_args(),
(
("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",
{
"size": 2,
"explanation": "numéro (1,2,3,4...) pour affichage",
"type": "int",
"default": default_numero,
"allow_null": False,
},
),
),
@ -147,7 +141,7 @@ associé.
)
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:
@ -200,11 +194,12 @@ def do_matiere_delete(oid):
_matiereEditor.delete(cnx, oid)
# news
formation = db.session.get(Formation, ue["formation_id"])
formation = Formation.query.get(ue["formation_id"])
ScolarNews.add(
typ=ScolarNews.NEWS_FORM,
obj=ue["formation_id"],
text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
)
formation.invalidate_cached_sems()

View File

@ -98,10 +98,10 @@ def module_list(*args, **kw):
def do_module_create(args) -> int:
"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:
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:
raise ScoValueError("Formation incompatible: contacter le support ScoDoc")
# create
@ -114,6 +114,7 @@ def do_module_create(args) -> int:
typ=ScolarNews.NEWS_FORM,
obj=formation.id,
text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
)
formation.invalidate_cached_sems()
return module_id
@ -185,6 +186,7 @@ def do_module_delete(oid):
typ=ScolarNews.NEWS_FORM,
obj=mod["formation_id"],
text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
)
formation.invalidate_cached_sems()
@ -248,7 +250,7 @@ def do_module_edit(vals: dict) -> None:
# edit
cnx = ndb.GetDBConnexion()
_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):
@ -659,7 +661,6 @@ def module_edit(
"explanation": "numéro (1, 2, 3, 4, ...) pour ordre d'affichage",
"type": "int",
"default": default_num,
"allow_null": False,
},
),
]
@ -805,7 +806,7 @@ def module_edit(
if create:
if not matiere_id:
# 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:
raise ValueError("UE invalide")
matiere = ue.matieres.first()
@ -819,7 +820,7 @@ def module_edit(
tf[2]["semestre_id"] = ue.semestre_idx
module_id = do_module_create(tf[2])
module = db.session.get(Module, module_id)
module = Module.query.get(module_id)
else: # EDITION MODULE
# l'UE de rattachement peut changer
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
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:
raise ValueError("UE invalide")
tf[2]["semestre_id"] = selected_ue.semestre_idx
@ -853,13 +854,13 @@ def module_edit(
module.parcours = formation.referentiel_competence.parcours.all()
else:
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"]
]
# Modifie les AC
if "app_critiques" in tf[2]:
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"]
]
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_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.models import APO_CODE_STR_LEN, SHORT_STR_LEN
from app.models import (
@ -136,14 +137,15 @@ def do_ue_create(args):
ue_id = _ueEditor.create(cnx, 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()
# news
formation = db.session.get(Formation, args["formation_id"])
formation = Formation.query.get(args["formation_id"])
ScolarNews.add(
typ=ScolarNews.NEWS_FORM,
obj=args["formation_id"],
text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
)
formation.invalidate_cached_sems()
return ue_id
@ -228,6 +230,7 @@ def do_ue_delete(ue: UniteEns, delete_validations=False, force=False):
typ=ScolarNews.NEWS_FORM,
obj=formation.id,
text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
)
#
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"
can_change_semestre_id = True
formation = db.session.get(Formation, formation_id)
formation = Formation.query.get(formation_id)
if not formation:
raise ScoValueError(f"Formation inexistante ! (id={formation_id})")
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",
"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",
},
),
@ -501,7 +503,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
else:
clone_form = ""
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 (
"\n".join(H)
+ 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"],
},
)
ue = db.session.get(UniteEns, ue_id)
ue = UniteEns.query.get(ue_id)
flash(f"UE créée (code {ue.ue_code})")
else:
if not tf[2]["numero"]:
tf[2]["numero"] = 0
do_ue_edit(tf[2])
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.
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})
if not ues:
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
formation: Formation = db.session.get(Formation, formation_id)
formation: Formation = Formation.query.get(formation_id)
if not formation:
raise ScoValueError("invalid formation_id")
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}",
),
f"""<h2>{formation.html()} {lockicon}
f"""<h2>{formation.to_html()} {lockicon}
</h2>
""",
]
@ -1009,7 +1009,12 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
<p><ul>"""
)
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:
H.append(" [verrouillé]")
else:
@ -1376,12 +1381,13 @@ def _ue_table_modules(
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
Either ue_code or ue_id may be specified.
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)
if not 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)
)
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)
ues = q_ues.all()
msg = " dans les formations du département "
if not ues:
if ue_id is not None:
return f"""<span class="ue_share">Seule UE avec code {
ue_code if ue_code is not None else '-'}{msg}</span>"""
if ue_id:
return (
f"""<span class="ue_share">Seule UE avec code {ue_code or '-'}</span>"""
)
else:
return f"""<span class="ue_share">Aucune UE avec code {
ue_code if ue_code is not None else '-'}{msg}</span>"""
return f"""<span class="ue_share">Aucune UE avec code {ue_code or '-'}</span>"""
H = []
if ue_id:
H.append(
f"""<span class="ue_share">Pour information, autres UEs avec le code {
ue_code if ue_code is not None else '-'}{msg}:</span>"""
f"""<span class="ue_share">Autres UE avec le code {ue_code or '-'}:</span>"""
)
else:
H.append(
f"""<span class="ue_share">UE avec le code {
ue_code if ue_code is not None else '-'}{msg}:</span>"""
)
H.append(f"""<span class="ue_share">UE avec le code {ue_code or '-'}:</span>""")
H.append("<ul>")
for ue in ues:
H.append(
f"""<li>{ue.acronyme} ({ue.titre}) dans
<a class="stdlink" href="{
url_for("notes.ue_table",
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)}"
f"""<li>{ue.acronyme} ({ue.titre}) dans <a class="stdlink"
href="{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}
</li>
"""
@ -1461,7 +1460,7 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
cnx = ndb.GetDBConnexion()
_ueEditor.edit(cnx, args)
formation = db.session.get(Formation, ue["formation_id"])
formation = Formation.query.get(ue["formation_id"])
if not dont_invalidate_cache:
# Invalide les semestres utilisant cette formation
# ainsi que les poids et coefs

View File

@ -62,9 +62,7 @@ def format_etud_ident(etud):
else:
etud["prenom_etat_civil"] = ""
etud["civilite_str"] = format_civilite(etud["civilite"])
etud["civilite_etat_civil_str"] = format_civilite(
etud.get("civilite_etat_civil", "X")
)
etud["civilite_etat_civil_str"] = format_civilite(etud["civilite_etat_civil"])
# Nom à afficher:
if etud["nom_usuel"]:
etud["nom_disp"] = etud["nom_usuel"]
@ -147,7 +145,7 @@ def format_civilite(civilite):
def format_etat_civil(etud: dict):
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"]}'
else:
return etud["nomprenom"]
@ -262,7 +260,7 @@ def identite_list(cnx, *a, **kw):
def identite_edit_nocheck(cnx, args):
"""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)
db.session.commit()
@ -671,7 +669,6 @@ def create_etud(cnx, args: dict = None):
typ=ScolarNews.NEWS_INSCR,
text='Nouvel étudiant <a href="%(url)s">%(nomprenom)s</a>' % etud,
url=etud["url"],
max_frequency=0,
)
return etud

View File

@ -129,7 +129,7 @@ def do_evaluation_create(
)
args = locals()
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:
raise ValueError("module not found")
check_evaluation_args(args)
@ -252,11 +252,12 @@ def do_evaluation_delete(evaluation_id):
def do_evaluation_get_all_notes(
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.
"""
# pas de cache pour (rares) appels via undo_notes ou specifiant un enseignant
do_cache = filter_suppressed and table == "notes_notes" and (by_uid is None)
do_cache = (
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:
r = sco_cache.EvaluationCache.get(evaluation_id)
if r is not None:

View File

@ -37,8 +37,11 @@ from flask_login import current_user
from flask import request
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
from app.scodoc.sco_utils import ModuleType
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)"
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:
raise ScoValueError("Cette évaluation n'existe pas ou plus !")
moduleimpl_id = evaluation.moduleimpl_id
@ -360,7 +363,7 @@ def evaluation_create_form(
evaluation_id = sco_evaluation_db.do_evaluation_create(**tf[2])
if is_apc:
# Set poids
evaluation = db.session.get(Evaluation, evaluation_id)
evaluation = models.Evaluation.query.get(evaluation_id)
for ue in sem_ues:
evaluation.set_ue_poids(ue, tf[2][f"poids_{ue.id}"])
db.session.add(evaluation)

View File

@ -12,7 +12,6 @@ Sur une idée de Pascal Bouron, de Lyon.
import time
from flask import g, url_for
from app import db
from app.models import Evaluation, FormSemestre
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
@ -114,7 +113,7 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
rows.append(row)
line_idx += 1
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]
row = {
"type": "",

View File

@ -433,7 +433,7 @@ def excel_simple_table(
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.
E: evaluation (dict)
lines: liste de tuples
@ -512,20 +512,18 @@ def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, line
# description evaluation
ws.append_single_cell_row(scu.unescape_html(description), style_titres)
ws.append_single_cell_row(
"Evaluation du %s (coef. %g)"
% (evaluation.jour or "sans date", evaluation.coefficient or 0.0),
style,
"Evaluation du %s (coef. %g)" % (e["jour"], e["coefficient"]), style
)
# ligne blanche
ws.append_blank_row()
# code et titres colonnes
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("Prénom", 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),
]
)

View File

@ -28,15 +28,15 @@
"""Table recap formation (avec champs éditables)
"""
import io
from zipfile import ZipFile
from zipfile import ZipFile, BadZipfile
from flask import Response
from flask import send_file, url_for
from flask import g, request
from flask_login import current_user
from app import db
from app.models import Formation, FormSemestre, Matiere, Module, UniteEns
from app.models import Formation, FormSemestre, UniteEns, Module
from app.models.formations import Matiere
from app.scodoc.gen_tables import GenTable
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}
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
filename = (
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_id,
formation_id,
modules_old2new,
ues_old2new,
) = sco_formations.formation_create_new_version(formation_id, redirect=False)
# Log new ues:
for ue_id in ues_old2new:
ue = db.session.get(UniteEns, ue_id)
new_ue = db.session.get(UniteEns, ues_old2new[ue_id])
ue = UniteEns.query.get(ue_id)
new_ue = UniteEns.query.get(ues_old2new[ue_id])
assert ue.semestre_idx == new_ue.semestre_idx
log(f"{ue} -> {new_ue}")
# Log new modules
for module_id in modules_old2new:
mod = db.session.get(Module, module_id)
new_mod = db.session.get(Module, modules_old2new[module_id])
mod = Module.query.get(module_id)
new_mod = Module.query.get(modules_old2new[module_id])
assert mod.semestre_id == new_mod.semestre_id
log(f"{mod} -> {new_mod}")
# re-associate
for formsemestre_id in formsemestre_ids:
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
formsemestre.formation_id = new_formation_id
formsemestre.formation_id = formation_id
db.session.add(formsemestre)
_reassociate_moduleimpls(formsemestre, ues_old2new, modules_old2new)
db.session.commit()
return new_formation_id
return formation_id
def _reassociate_moduleimpls(
@ -246,12 +246,8 @@ def _reassociate_moduleimpls(
Evaluation.moduleimpl_id == ModuleImpl.id,
ModuleImpl.formsemestre_id == formsemestre.id,
):
if poids.ue_id in ues_old2new:
poids.ue_id = ues_old2new[poids.ue_id]
db.session.add(poids)
else:
# poids vers une UE qui n'est pas ou plus dans notre formation
db.session.delete(poids)
poids.ue_id = ues_old2new[poids.ue_id]
db.session.add(poids)
# update decisions:
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(
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]
# si l'UE n'est pas ou plus dans notre formation, laisse.
db.session.add(validation)
db.session.commit()

View File

@ -163,7 +163,7 @@ def formation_export_dict(
if 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():
# Exporte les coefficients
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]
)
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
if xml_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:
modules_old2new[int(xml_module_id)] = mod_id
if len(mod_info) > 2:
module: Module = db.session.get(Module, mod_id)
module: Module = Module.query.get(mod_id)
tag_names = []
ue_coef_dict = {}
for child in mod_info[2]:
@ -626,9 +626,7 @@ def formation_list_table() -> GenTable:
def formation_create_new_version(formation_id, redirect=True):
"duplicate formation, with new version number"
formation = Formation.query.get_or_404(formation_id)
resp = formation_export(
formation_id, export_ids=True, export_external_ues=True, format="xml"
)
resp = formation_export(formation_id, export_ids=True, format="xml")
xml_data = resp.get_data(as_text=True)
new_id, modules_old2new, ues_old2new = formation_import_xml(
xml_data, use_local_refcomp=True
@ -638,7 +636,6 @@ def formation_create_new_version(formation_id, redirect=True):
typ=ScolarNews.NEWS_FORM,
obj=new_id,
text=f"Nouvelle version de la formation {formation.acronyme}",
max_frequency=0,
)
if redirect:
flash("Nouvelle version !")

View File

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

View File

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

View File

@ -175,7 +175,9 @@ def do_formsemestre_demission(
)
db.session.add(event)
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:
flash("Démission enregistrée")
elif etat_new == scu.DEF:
@ -208,7 +210,7 @@ def do_formsemestre_desinscription(etudid, formsemestre_id):
if nt.etud_has_decision(etudid):
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)"""
)

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

View File

@ -31,17 +31,15 @@ import time
import flask
from flask import url_for, flash, g, request
from flask_login import current_user
import sqlalchemy as sa
from app.models.etudiants import Identite
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
from app import db, log
from app.comp import res_sem
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.validations import (
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_preferences
from app.scodoc import sco_pv_dict
from app.scodoc.sco_permissions import Permission
# ------------------------------------------------------------------------------------
def formsemestre_validation_etud_form(
@ -400,7 +396,7 @@ def formsemestre_validation_etud(
selected_choice = choice
break
if not selected_choice:
raise ValueError(f"code choix invalide ! ({codechoice})")
raise ValueError("code choix invalide ! (%s)" % codechoice)
#
Se.valide_decision(selected_choice) # enregistre
return _redirect_valid_choice(
@ -515,7 +511,7 @@ def decisions_possible_rows(Se, assiduite, subtitle="", trclass=""):
def formsemestre_recap_parcours_table(
situation_etud_cursus: sco_cursus_dut.SituationEtudCursus,
Se,
etudid,
with_links=False,
with_all_columns=True,
@ -553,18 +549,16 @@ def formsemestre_recap_parcours_table(
"""
)
# titres des UE
H.append("<th></th>" * situation_etud_cursus.nb_max_ue)
H.append("<th></th>" * Se.nb_max_ue)
#
if with_links:
H.append("<th></th>")
H.append("<th></th></tr>")
num_sem = 0
for sem in situation_etud_cursus.get_semestres():
is_prev = situation_etud_cursus.prev and (
situation_etud_cursus.prev["formsemestre_id"] == sem["formsemestre_id"]
)
is_cur = situation_etud_cursus.formsemestre_id == sem["formsemestre_id"]
for sem in Se.get_semestres():
is_prev = Se.prev and (Se.prev["formsemestre_id"] == sem["formsemestre_id"])
is_cur = Se.formsemestre_id == sem["formsemestre_id"]
num_sem += 1
dpv = sco_pv_dict.dict_pvjury(sem["formsemestre_id"], etudids=[etudid])
@ -576,7 +570,7 @@ def formsemestre_recap_parcours_table(
else:
ass = ""
formsemestre = db.session.get(FormSemestre, sem["formsemestre_id"])
formsemestre = FormSemestre.query.get(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if is_cur:
type_sem = "*" # now unused
@ -587,7 +581,7 @@ def formsemestre_recap_parcours_table(
else:
type_sem = ""
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"
if 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(f"""<td class="rcp_nonass">{ass}</td>""") # abs
# 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()
etud_ue_status = {ue.id: nt.get_etud_ue_status(etudid, ue.id) for ue in ues}
if not nt.is_apc:
@ -665,10 +659,8 @@ def formsemestre_recap_parcours_table(
for ue in ues:
H.append(f"""<td class="ue_acro"><span>{ue.acronyme}</span></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>""")
# indique le semestre compensé par celui ci:
if decision_sem and decision_sem["compense_formsemestre_id"]:
csem = sco_formsemestre.get_formsemestre(
@ -693,7 +685,7 @@ def formsemestre_recap_parcours_table(
if not sem["etat"]: # locked
lockicon = scu.icontag("lock32_img", title="verrouillé", border="0")
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"]}"""
H.append(
'<td class="datefin">%s</td><td class="sem_info">%s</td>'
@ -730,21 +722,14 @@ def formsemestre_recap_parcours_table(
explanation_ue.append(
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(
f"""<td class="{class_ue}" title="{
" ".join(explanation_ue)
}">{moy_ue_txt}</td>"""
)
if len(ues) < situation_etud_cursus.nb_max_ue:
H.append(
f"""<td colspan="{situation_etud_cursus.nb_max_ue - len(ues)}"></td>"""
}">{scu.fmt_note(moy_ue)}</td>"""
)
if len(ues) < Se.nb_max_ue:
H.append(f"""<td colspan="{Se.nb_max_ue - len(ues)}"></td>""")
H.append("<td></td>")
if with_links:
@ -1006,26 +991,16 @@ def do_formsemestre_validation_auto(formsemestre_id):
)
nb_valid += 1
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(
typ=ScolarNews.NEWS_JURY,
obj=formsemestre.id,
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>
H = [html_sco_header.sco_header(page_title="Saisie automatique")]
H.append(
"""<h2>Saisie automatique des décisions du semestre %s</h2>
<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:
H.append(
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)
def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite):
def formsemestre_validate_previous_ue(formsemestre_id, etudid):
"""Form. saisie UE validée hors ScoDoc
(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
# avec indice de semestre <= semestre courant ou NULL
ues = formation.ues.filter(
UniteEns.type != UE_SPORT,
db.or_(
UniteEns.semestre_idx == None,
UniteEns.semestre_idx <= formsemestre.semestre_id,
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
formation: Formation = Formation.query.get_or_404(sem["formation_id"])
H = [
html_sco_header.sco_header(
page_title="Validation UE",
javascripts=["js/validate_previous_ue.js"],
),
).order_by(UniteEns.semestre_idx, UniteEns.numero)
ue_names = ["Choisir..."] + [
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"}),
'<table style="width: 100%"><tr><td>',
"""<h2 class="formsemestre">%s: validation d'une UE antérieure</h2>"""
% etud["nomprenom"],
(
"ue_id",
{
"input_type": "menu",
"title": "Unité d'Enseignement (UE)",
"allow_null": False,
"allowed_values": ue_ids,
"labels": ue_names,
},
'</td><td style="text-align: right;"><a href="%s">%s</a></td></tr></table>'
% (
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid),
sco_photos.etud_photo_html(etud, title="fiche de %s" % etud["nom"]),
)
),
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",
{
@ -1132,185 +1127,69 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
"allowed_values": [""] + [x for x in range(11)],
"labels": ["-"] + list(range(11)),
},
)
)
ue_codes = sorted(codes_cursus.CODES_JURY_UE)
form_descr += [
(
"date",
{
"input_type": "date",
"size": 9,
"explanation": "j/m/a",
"default": time.strftime("%d/%m/%Y"),
},
),
(
"date",
{
"input_type": "date",
"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:",
},
),
),
(
"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",
cancelbutton="Annuler",
submitlabel="Enregistrer validation d'UE",
)
if tf[0] == 0:
return f"""
{html_sco_header.sco_header(
page_title="Validation UE antérieure",
javascripts=["js/validate_previous_ue.js"],
cssstyles=["css/jury_delete_manual.css"],
etudid=etud.id,
formsemestre_id=formsemestre.id,
)}
<h2 class="formsemestre">Gestion des validations d'UEs antérieures
de {etud.html_link_fiche()}
</h2>
<p class="help">Utiliser cette page pour enregistrer des UEs validées antérieurement,
<em>dans un semestre hors ScoDoc</em>.</p>
<p class="expl"><b>Les UE validées dans ScoDoc sont
automatiquement prises en compte</b>.
</p>
<p>Cette page est surtout utile pour les étudiants ayant
suivi un début de cursus dans <b>un autre établissement</b>, ou qui
ont suivi une UE à l'étranger ou dans un semestre géré <b>sans ScoDoc</b>.
</p>
<p>Pour les semestres précédents gérés avec ScoDoc, passer par la page jury normale.
</p>
<p>Notez que l'UE est validée, avec enregistrement immédiat de la décision et
l'attribution des ECTS si le code jury est validant (ADM).
</p>
<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:
X = """
<div id="ue_list_etud_validations"><!-- filled by get_etud_ue_cap_html --></div>
<div id="ue_list_code"><!-- filled by ue_sharing_code --></div>
"""
warn, ue_multiples = check_formation_ues(formation.id)
return "\n".join(H) + tf[1] + X + warn + html_sco_header.sco_footer()
elif tf[0] == -1:
return flask.redirect(
scu.NotesURL()
+ "/formsemestre_status?formsemestre_id="
+ str(formsemestre_id)
)
else:
if tf[2]["semestre_id"]:
semestre_id = int(tf[2]["semestre_id"])
else:
semestre_id = None
do_formsemestre_validate_previous_ue(
formsemestre_id,
etudid,
tf[2]["ue_id"],
tf[2]["moy_ue"],
tf[2]["date"],
semestre_id=semestre_id,
)
flash("Validation d'UE enregistrée")
return flask.redirect(
url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
etudid=etud.id,
formsemestre_id=formsemestre_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(
formsemestre: FormSemestre,
formsemestre_id,
etudid,
ue_id,
moy_ue,
@ -1323,20 +1202,21 @@ def do_formsemestre_validate_previous_ue(
Si le coefficient est spécifié, modifie le coefficient de
cette UE (utile seulement pour les semestres extérieurs).
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
ue: UniteEns = UniteEns.query.get_or_404(ue_id)
cnx = ndb.GetDBConnexion()
if ue_coefficient is not None:
if ue_coefficient != None:
sco_formsemestre.do_formsemestre_uecoef_edit_or_create(
cnx, formsemestre.id, ue_id, ue_coefficient
cnx, formsemestre_id, ue_id, ue_coefficient
)
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(
cnx,
nt,
formsemestre.id, # "importe" cette UE dans le semestre (new 3/2015)
formsemestre_id, # "importe" cette UE dans le semestre (new 3/2015)
etudid,
ue_id,
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)
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):
"""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

View File

@ -34,6 +34,7 @@ Optimisation possible:
"""
import collections
import operator
import time
from xml.etree import ElementTree
@ -44,14 +45,15 @@ from flask import g, request
from flask import url_for, make_response
from sqlalchemy.sql import text
from app import cache, db, log
from app import db
from app.comp import res_sem
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.groups import GroupDescr, Partition, group_membership
from app.models.groups import GroupDescr, Partition
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app import log, cache
from app.scodoc.scolog import logdb
from app.scodoc import html_sco_header
from app.scodoc import sco_cache
@ -92,7 +94,7 @@ groupEditor = ndb.EditableTable(
group_list = groupEditor.list
def get_group(group_id: int) -> dict: # OBSOLETE !
def get_group(group_id: int) -> dict:
"""Returns group object, with partition"""
r = ndb.SimpleDictFetch(
"""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(
"""SELECT p.id AS partition_id, p.*
FROM partition p
@ -198,7 +200,7 @@ def get_formsemestre_etuds_groups(formsemestre_id: int) -> dict:
return d
def get_partition_groups(partition): # OBSOLETE !
def get_partition_groups(partition):
"""List of groups in this partition (list of dicts).
Some groups may be empty."""
return ndb.SimpleDictFetch(
@ -241,7 +243,7 @@ def get_default_group(formsemestre_id, fix_if_missing=False):
return group.id
# debug check
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"]
return group_id
@ -450,7 +452,7 @@ def get_etud_formsemestre_groups(
),
{"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:
@ -560,10 +562,10 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
x_group = Element(
"group",
partition_id=str(partition_id),
partition_name=partition["partition_name"] or "",
partition_name=partition["partition_name"],
groups_editable=str(int(partition["groups_editable"])),
group_id=str(group["group_id"]),
group_name=group["group_name"] or "",
group_name=group["group_name"],
)
x_response.append(x_group)
for e in get_group_members(group["group_id"]):
@ -572,10 +574,10 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
Element(
"etud",
etudid=str(e["etudid"]),
civilite=etud["civilite_str"] or "",
sexe=etud["civilite_str"] or "", # compat
nom=sco_etud.format_nom(etud["nom"] or ""),
prenom=sco_etud.format_prenom(etud["prenom"] or ""),
civilite=etud["civilite_str"],
sexe=etud["civilite_str"], # compat
nom=sco_etud.format_nom(etud["nom"]),
prenom=sco_etud.format_prenom(etud["prenom"]),
origin=_comp_etud_origin(etud, formsemestre),
)
)
@ -587,7 +589,7 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
x_group = Element(
"group",
partition_id=str(partition_id),
partition_name=partition["partition_name"] or "",
partition_name=partition["partition_name"],
groups_editable=str(int(partition["groups_editable"])),
group_id="_none_",
group_name="",
@ -599,9 +601,9 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
Element(
"etud",
etudid=str(etud["etudid"]),
sexe=etud["civilite_str"] or "",
nom=sco_etud.format_nom(etud["nom"] or ""),
prenom=sco_etud.format_prenom(etud["prenom"] or ""),
sexe=etud["civilite_str"],
nom=sco_etud.format_nom(etud["nom"]),
prenom=sco_etud.format_prenom(etud["prenom"]),
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
def set_group(etudid: int, group_id: int) -> bool: # OBSOLETE !
def set_group(etudid: int, group_id: int) -> bool:
"""Inscrit l'étudiant au groupe.
Return True if ok, False si deja inscrit.
Warning:
@ -662,33 +664,55 @@ def set_group(etudid: int, group_id: int) -> bool: # OBSOLETE !
return True
def change_etud_group_in_partition(etudid: int, group: GroupDescr) -> bool:
"""Inscrit etud au groupe
(et le désinscrit d'autres groupes de cette partition)
Return True si changement, False s'il était déjà dans ce groupe.
def change_etud_group_in_partition(etudid: int, group_id: int, partition: dict = None):
"""Inscrit etud au groupe de cette partition,
et le desinscrit d'autres groupes de cette partition.
"""
etud: Identite = Identite.query.get_or_404(etudid)
if not group.partition.set_etud_group(etud, group):
return # pas de changement
log("change_etud_group_in_partition: etudid=%s group_id=%s" % (etudid, group_id))
# 0- La partition
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
formsemestre: FormSemestre = group.partition.formsemestre
log(f"change_etud_group_in_partition: etudid={etudid} group={group}")
Scolog.logdb(
# 3- log
formsemestre_id = partition["formsemestre_id"]
cnx = ndb.GetDBConnexion()
logdb(
cnx,
method="changeGroup",
etudid=etudid,
msg=f"""formsemestre_id={formsemestre.id}, partition_name={
group.partition.partition_name or ""}, group_name={group.group_name or ""}""",
commit=True,
msg="formsemestre_id=%s,partition_name=%s, group_name=%s"
% (formsemestre_id, partition["partition_name"], group["group_name"]),
)
cnx.commit()
# - Update parcours
if group.partition.partition_name == scu.PARTITION_PARCOURS:
formsemestre.update_inscriptions_parcours_from_groups()
# 5- Update parcours
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
formsemestre.update_inscriptions_parcours_from_groups()
# - invalidate cache
# 6- invalidate cache
sco_cache.invalidate_formsemestre(
formsemestre_id=formsemestre.id
formsemestre_id=formsemestre_id
) # > change etud group
@ -705,6 +729,7 @@ def setGroups(
Ne peux pas modifier les groupes des partitions non éditables.
"""
from app.scodoc import sco_formsemestre
def xml_error(msg, code=404):
data = (
@ -714,27 +739,26 @@ def setGroups(
response.headers["Content-Type"] = scu.XML_MIMETYPE
return response
partition: Partition = db.session.get(Partition, partition_id)
if not partition.groups_editable and (groupsToCreate or groupsToDelete):
partition = get_partition(partition_id)
if not partition["groups_editable"] and (groupsToCreate or groupsToDelete):
msg = "setGroups: partition non editable"
log(msg)
return xml_error(msg, code=403)
if not sco_permissions_check.can_change_groups(partition.formsemestre.id):
formsemestre_id = 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 !")
log("***setGroups: partition_id=%s" % partition_id)
log("groupsLists=%s" % groupsLists)
log("groupsToCreate=%s" % groupsToCreate)
log("groupsToDelete=%s" % groupsToDelete)
if not partition.formsemestre.etat:
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
if not sem["etat"]:
raise AccessDenied("Modification impossible: semestre verrouillé")
groupsToDelete = [g for g in groupsToDelete.split(";") if g]
etud_groups = formsemestre_get_etud_groupnames(
partition.formsemestre.id, attr="group_id"
)
etud_groups = formsemestre_get_etud_groupnames(formsemestre_id, attr="group_id")
for line in groupsLists.split("\n"): # for each group_id (one per line)
fs = line.split(";")
group_id = fs[0].strip()
@ -745,23 +769,26 @@ def setGroups(
except ValueError:
log(f"setGroups: ignoring invalid group_id={group_id}")
continue
group: GroupDescr = GroupDescr.query.get_or_404(group_id)
group = get_group(group_id)
# 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:
for etudid_str in fs[1:-1]:
etudid = int(etudid_str)
if etudid in old_members_set:
# était dans ce groupe, l'enlever
old_members_set.remove(etudid)
old_members_set.remove(
etudid
) # a nouveau dans ce groupe, pas besoin de l'enlever
if (etudid not in etud_groups) or (
group_id != etud_groups[etudid].get(partition_id, "")
): # 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:
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
for etudid in old_members_set:
log("removing %s from group %s" % (etudid, group_id))
ndb.SimpleQuery(
"DELETE FROM group_membership WHERE etudid=%(etudid)s and group_id=%(group_id)s",
{"etudid": etudid, "group_id": group_id},
@ -771,8 +798,8 @@ def setGroups(
cnx,
method="removeFromGroup",
etudid=etudid,
msg=f"""formsemestre_id={partition.formsemestre.id},partition_name={
partition.partition_name}, group_name={group.group_name}""",
msg="formsemestre_id=%s,partition_name=%s, group_name=%s"
% (formsemestre_id, partition["partition_name"], group["group_name"]),
)
# Supprime les groupes indiqués comme supprimés:
@ -792,10 +819,10 @@ def setGroups(
return xml_error(msg, code=404)
# Place dans ce groupe les etudiants indiqués:
for etudid in fs[1:-1]:
change_etud_group_in_partition(etudid, group)
change_etud_group_in_partition(etudid, group.id, partition)
# Update parcours
partition.formsemestre.update_inscriptions_parcours_from_groups()
formsemestre.update_inscriptions_parcours_from_groups()
data = (
'<?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:
"""Create a new group in this partition.
If default, create default partition (with no name)
Obsolete: utiliser Partition.create_group
"""
partition = Partition.query.get_or_404(partition_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)
db.session.add(group)
db.session.commit()
log(f"create_group: created group_id={group.id}")
log("create_group: created group_id={group.id}")
#
return group
@ -950,20 +976,10 @@ def edit_partition_form(formsemestre_id=None):
}
</script>
""",
f"""<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>
r"""<h2>Partitions du semestre</h2>
<form name="editpart" id="editpart" method="POST" action="partition_create">
<div id="epmsg"></div>
<table>
<tr class="eptit">
<th></th><th></th><th></th><th>Partition</th><th>Groupes</th><th></th><th></th><th></th>
</tr>
<table><tr class="eptit"><th></th><th></th><th></th><th>Partition</th><th>Groupes</th><th></th><th></th><th></th></tr>
""",
]
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
et la mixité.
"""
partition: Partition = Partition.query.get_or_404(partition_id)
if not partition.groups_editable:
partition = get_partition(partition_id)
if not partition["groups_editable"]:
raise AccessDenied("Partition non éditable")
formsemestre_id = partition.formsemestre_id
formsemestre = partition.formsemestre
# renvoie sur page édition partitions et groupes
formsemestre_id = partition["formsemestre_id"]
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# renvoie sur page édition groupes
dest_url = url_for(
"scolar.partition_editor",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
"scolar.affect_groups", scodoc_dept=g.scodoc_dept, partition_id=partition_id
)
if not sco_permissions_check.can_change_groups(formsemestre_id):
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 = [
html_sco_header.sco_header(page_title="Répartition des groupes"),
f"""<h2>Répartition des groupes de {partition.partition_name}</h2>
<p>Semestre {formsemestre.titre_annee()}</p>
<p class="help">Les groupes existants seront <b>effacés</b> et remplacés par
"<h2>Répartition des groupes de %s</h2>" % partition["partition_name"],
f"<p>Semestre {formsemestre.titre_annee()}</p>",
"""<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
des groupes (en utilisant la dernière moyenne générale disponible pour
chaque étudiant) et de maximiser la mixité de chaque groupe.
</p>
""",
chaque étudiant) et de maximiser la mixité de chaque groupe.</p>""",
]
tf = TrivialFormulator(
@ -1438,23 +1450,25 @@ def groups_auto_repartition(partition_id=None):
return flask.redirect(dest_url)
else:
# form submission
log(f"groups_auto_repartition({partition})")
group_names = tf[2]["groupNames"]
group_names = sorted({x.strip() for x in group_names.split(",")})
log(
"groups_auto_repartition( partition_id=%s partition_name=%s"
% (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
for group in partition.groups:
db.session.delete(group)
db.session.commit()
for old_group in get_partition_groups(partition):
group_delete(old_group["group_id"])
# Crée les nouveaux groupes
groups = []
group_ids = []
for group_name in group_names:
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)
identdict = nt.identdict
# 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 = {}
for civilite in civilites:
listes[civilite] = [
@ -1467,19 +1481,16 @@ def groups_auto_repartition(partition_id=None):
# affect aux groupes:
n = len(identdict)
igroup = 0
nbgroups = len(groups)
nbgroups = len(group_ids)
while n > 0:
log(f"n={n}")
for civilite in civilites:
log(f"civilite={civilite}")
if len(listes[civilite]):
n -= 1
etudid = listes[civilite].pop()[1]
group = groups[igroup]
group_id = group_ids[igroup]
igroup = (igroup + 1) % nbgroups
log(f"in {etudid} in group {group.id}")
change_etud_group_in_partition(etudid, group)
log(f"{etudid} in group {group.id}")
change_etud_group_in_partition(etudid, group_id, partition)
log("%s in group %s" % (etudid, group_id))
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,
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)
if not info:
raise ScoValueError("etudiant invalide: etudid=%s" % etudid)
etud = info[0]
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
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)
return nt.get_etud_moy_gen(etudid)
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
vides ne sont pas supprimés).
"""
# A RE-ECRIRE pour utiliser les modèles.
from app.scodoc import sco_formsemestre_inscriptions
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(
args={"formsemestre_id": formsemestre_id}
)
@ -1530,17 +1542,20 @@ def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"):
pid = partition_create(
formsemestre_id, partition_name=partition_name, redirect=False
)
partition: Partition = db.session.get(Partition, pid)
groups = partition.groups
groups_by_names = {g.group_name: g for g in groups}
partition = get_partition(pid)
groups = get_partition_groups(partition)
groups_by_names = {g["group_name"]: g for g in groups}
for etape in etapes:
if etape not in groups_by_names:
if not (etape in groups_by_names):
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
for i in ins:
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(

View File

@ -36,12 +36,16 @@ import time
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.etudiants import input_civilite
from app.scodoc.gen_tables import GenTable
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 (
AccessDenied,
ScoFormatError,
@ -51,6 +55,7 @@ from app.scodoc.sco_exceptions import (
ScoLockedFormError,
ScoGenError,
)
from app.scodoc import html_sco_header
from app.scodoc import sco_cache
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_groups_view
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_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
% len(created_etudids),
obj=formsemestre_id,
max_frequency=0,
)
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)
idx_nom = None
idx_prenom = None
for idx, field in fields.items():
if field[0] == "nom":
for idx in fields:
if fields[idx][0] == "nom":
idx_nom = idx
if field[0] == "prenom":
if fields[idx][0] == "prenom":
idx_prenom = idx
if (idx_nom is None) or (idx_prenom is None):
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)
nom = adm_normalize_string(line[idx_nom])
prenom = adm_normalize_string(line[idx_prenom])
if (nom, prenom) not in etuds_by_nomprenom:
msg = f"""Étudiant <b>{line[idx_nom]} {line[idx_prenom]} inexistant</b>"""
diag.append(msg)
if not (nom, prenom) in etuds_by_nomprenom:
log(
"unable to find %s %s among members" % (line[idx_nom], line[idx_prenom])
)
else:
etud = etuds_by_nomprenom[(nom, prenom)]
cur_adm = sco_etud.admission_list(cnx, args={"etudid": etud["etudid"]})[0]
# peuple les champs presents dans le tableau
args = {}
for idx, field in fields.items():
field_name, convertor = field
for idx in fields:
field_name, convertor = fields[idx]
if field_name in modifiable_fields:
try:
val = convertor(line[idx])
except ValueError as exc:
except ValueError:
raise ScoFormatError(
'scolars_import_admission: valeur invalide, ligne %d colonne %s: "%s"'
% (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,
formsemestre_id=formsemestre_id,
),
) from exc
)
if val is not None: # note: ne peut jamais supprimer une valeur
args[field_name] = val
if args:
@ -719,10 +719,10 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
)
for group_id in group_ids:
group = db.session.get(GroupDescr, group_id)
group = GroupDescr.query.get(group_id)
if group.partition.groups_editable:
sco_groups.change_etud_group_in_partition(
args["etudid"], group
args["etudid"], group_id
)
else:
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.sco_utils as scu
from app import db, log
from app.models import Formation, FormSemestre, GroupDescr
from app import log
from app.models import Formation, FormSemestre
from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
from app.scodoc import sco_etud
from app.scodoc import sco_formations
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions
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)
En option: inscrit aux mêmes groupes que dans le semestre origine
"""
# TODO à ré-écrire pour utiliser le smodèle, notamment GroupDescr
formsemestre: FormSemestre = db.session.get(FormSemestre, sem["formsemestre_id"])
formsemestre: FormSemestre = FormSemestre.query.get(sem["formsemestre_id"])
formsemestre.setup_parcours_groups()
log(f"do_inscrit (inscrit_groupes={inscrit_groupes}): {etudids}")
for etudid in etudids:
@ -220,10 +220,11 @@ def do_inscrit(sem, etudids, inscrit_groupes=False):
# Inscrit aux groupes
for partition_group in partition_groups:
group: GroupDescr = db.session.get(
GroupDescr, partition_group["group_id"]
sco_groups.change_etud_group_in_partition(
etudid,
partition_group["group_id"],
partition_group,
)
sco_groups.change_etud_group_in_partition(etudid, group)
def do_desinscrit(sem, etudids):
@ -415,10 +416,10 @@ def formsemestre_inscr_passage(
): # il y a au moins une vraie partition
H.append(
f"""<li><a class="stdlink" href="{
url_for("scolar.partition_editor", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id)
}">Répartir les groupes de {partition["partition_name"]}</a></li>
"""
url_for("scolar.affect_groups",
scodoc_dept=g.scodoc_dept, partition_id=partition["partition_id"])
}">Répartir les groupes de {partition["partition_name"]}</a></li>
"""
)
#
@ -435,7 +436,7 @@ def _build_page(
inscrit_groupes=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)
ignore_jury = int(ignore_jury)
if inscrit_groupes:

View File

@ -33,7 +33,7 @@ import numpy as np
import flask
from flask import url_for, g, request
from app import db, log
from app import log
from app import models
from app.comp import res_sem
from app.comp import moy_mod
@ -79,7 +79,7 @@ def do_evaluation_listenotes(
return "<p>Aucune évaluation !</p>", "ScoDoc"
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
if mode == "eval":
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.sort(key=lambda x: int(x[1]))
for comment, key in commentkeys:
for (comment, key) in commentkeys:
C.append(
'<span class="colcomment">(%s)</span> <em>%s</em><br>' % (key, comment)
)
@ -673,7 +673,7 @@ def _add_eval_columns(
sum_notes = 0
notes = [] # liste des notes numeriques, pour calcul histogramme uniquement
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
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)

View File

@ -31,16 +31,15 @@
from flask_login import current_user
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.notesdb as ndb
from app.scodoc.sco_permissions import Permission
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"
# Un "moduleimpl" correspond a la mise en oeuvre d'un module
@ -171,7 +170,7 @@ def moduleimpl_withmodule_list(
mi["matiere"] = matieres[matiere_id]
mod = modimpls[0]["module"]
formation = db.session.get(Formation, mod["formation_id"])
formation = models.Formation.query.get(mod["formation_id"])
if formation.is_apc():
# tri par numero_module

View File

@ -28,13 +28,12 @@
"""Opérations d'inscriptions aux modules (interface pour gérer options ou parcours)
"""
import collections
from operator import attrgetter
from operator import itemgetter
import flask
from flask import url_for, g, request
from flask_login import current_user
from app import db, log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import (
@ -44,6 +43,9 @@ from app.models import (
ScolarFormSemestreValidation,
UniteEns,
)
from app import log
from app.tables import list_etuds
from app.scodoc.scolog import logdb
from app.scodoc import html_sco_header
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_permissions import Permission
import app.scodoc.sco_utils as scu
from app.tables import list_etuds
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()
)
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),
)
H.append(
@ -552,11 +553,8 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
>{etud.nomprenom}</a></td>"""
)
# Parcours:
if partition_parcours:
group = partition_parcours.get_etud_group(etud.id)
parcours_name = group.group_name if group else ""
else:
parcours_name = ""
group = partition_parcours.get_etud_group(etud.id)
parcours_name = group.group_name if group else ""
H.append(f"""<td class="parcours">{parcours_name}</td>""")
# UEs:
for ue in ues:
@ -580,7 +578,7 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
.all()
)
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
expl_validation = (
@ -670,7 +668,7 @@ def descr_inscrs_module(moduleimpl_id, set_all, partitions):
gr.append((partition["partition_name"], grp))
#
d = []
for partition_name, grp in gr:
for (partition_name, grp) in gr:
if grp:
d.append("groupes de %s: %s" % (partition_name, ", ".join(grp)))
r = []
@ -682,25 +680,25 @@ def descr_inscrs_module(moduleimpl_id, set_all, partitions):
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
# au delà, on indique juste le nombre, sans les noms.
if len(etudids) > max_list_size:
return f"{len(etudids)} étudiants"
if len(ins) > max_list_size:
return "%d étudiants" % len(ins)
etuds = []
for etudid in etudids:
etud = db.session.get(Identite, etudid)
if etud:
etuds.append(etud)
for etudid in ins:
etuds.append(sco_etud.get_etud_info(etudid=etudid, filled=True)[0])
etuds.sort(key=itemgetter("nom"))
return ", ".join(
[
f"""<a class="discretelink" href="{
'<a class="discretelink" href="%s">%s</a>'
% (
url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id
)
}">{etud.nomprenom}</a>"""
for etud in sorted(etuds, key=attrgetter("sort_key"))
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"]
),
etud["nomprenom"],
)
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.tables import list_etuds
# menu evaluation dans moduleimpl
def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
"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()
#
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]
H = [
html_sco_header.sco_header(
@ -529,7 +528,7 @@ def _ligne_evaluation(
) -> str:
"""Ligne <tr> décrivant une évaluation dans le tableau de bord moduleimpl."""
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(
evaluation.id,
partition_id=partition_id,
@ -733,7 +732,7 @@ def _ligne_evaluation(
)
if etat["moy"]:
H.append(
f"""<b>{etat["moy"]} / 20</b>
f"""<b>{etat["moy"]} / {evaluation.note_max:g}</b>
&nbsp; (<a class="stdlink" href="{
url_for('notes.evaluation_listenotes',
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>"""
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()
)
]

View File

@ -33,8 +33,10 @@
from flask import abort, url_for, g, render_template, request
from flask_login import current_user
from app import db, log
from app.but import cursus_but
import app.scodoc.sco_utils as scu
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.formsemestre import FormSemestre
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_formsemestre_validation import formsemestre_recap_parcours_table
from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
def _menu_scolarite(
authuser, formsemestre: FormSemestre, etudid: int, etat_inscription: str
):
def _menu_scolarite(authuser, sem: dict, etudid: int):
"""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.
"""
locked = not formsemestre.etat
locked = not sem["etat"]
if locked:
lockicon = scu.icontag("lock32_img", title="verrouillé", border="0")
return lockicon # no menu
@ -73,10 +71,10 @@ def _menu_scolarite(
Permission.ScoEtudInscrit
) and not authuser.has_permission(Permission.ScoEtudChangeGroups):
return "" # no menu
ins = sem["ins"]
args = {"etudid": etudid, "formsemestre_id": ins["formsemestre_id"]}
args = {"etudid": etudid, "formsemestre_id": formsemestre.id}
if etat_inscription != scu.DEMISSION:
if ins["etat"] != "D":
dem_title = "Démission"
dem_url = "scolar.form_dem"
else:
@ -84,14 +82,14 @@ def _menu_scolarite(
dem_url = "scolar.do_cancel_dem"
# 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_url = "scolar.form_def"
elif etat_inscription == codes_cursus.DEF:
elif ins["etat"] == codes_cursus.DEF:
def_title = "Annuler la défaillance"
def_url = "scolar.do_cancel_def"
def_enabled = (
(etat_inscription != scu.DEMISSION)
(ins["etat"] != "D")
and authuser.has_permission(Permission.ScoEtudInscrit)
and not locked
)
@ -130,12 +128,6 @@ def _menu_scolarite(
"enabled": authuser.has_permission(Permission.ScoEtudInscrit)
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",
"endpoint": "notes.formsemestre_inscription_with_modules_form",
@ -258,10 +250,8 @@ def ficheEtud(etudid=None):
info["last_formsemestre_id"] = ""
sem_info = {}
for sem in info["sems"]:
formsemestre: FormSemestre = db.session.get(
FormSemestre, sem["formsemestre_id"]
)
if sem["ins"]["etat"] != scu.INSCRIT:
formsemestre: FormSemestre = FormSemestre.query.get(sem["formsemestre_id"])
descr, _ = etud_descr_situation_semestre(
etudid,
formsemestre,
@ -293,7 +283,7 @@ def ficheEtud(etudid=None):
)
grlink = ", ".join(grlinks)
# 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:
sem_info[sem["formsemestre_id"]] = (
"<table><tr><td>" + grlink + "</td><td>" + menu + "</td></tr></table>"
@ -313,39 +303,16 @@ def ficheEtud(etudid=None):
)
info[
"link_bul_pdf"
] = f"""
<span class="link_bul_pdf">
<a class="stdlink" href="{
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>
"""
] = f"""<span class="link_bul_pdf"><a class="stdlink" href="{
url_for("notes.etud_bulletins_pdf", scodoc_dept=g.scodoc_dept, etudid=etudid)
}">tous les bulletins</a></span>"""
if authuser.has_permission(Permission.ScoEtudInscrit):
info[
"link_inscrire_ailleurs"
] = f"""<span class="link_bul_pdf"><a class="stdlink" href="{
url_for("notes.formsemestre_inscription_with_modules_form",
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">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>
"""
}">inscrire à un autre semestre</a></span>"""
else:
info["link_inscrire_ailleurs"] = ""
else:
@ -370,18 +337,17 @@ def ficheEtud(etudid=None):
if not sco_permissions_check.can_suppress_annotation(a["id"]):
a["dellink"] = ""
else:
a["dellink"] = (
'<td class="annodel"><a href="doSuppressAnnotation?etudid=%s&annotation_id=%s">%s</a></td>'
% (
etudid,
a["id"],
scu.icontag(
"delete_img",
border="0",
alt="suppress",
title="Supprimer cette annotation",
),
)
a[
"dellink"
] = '<td class="annodel"><a href="doSuppressAnnotation?etudid=%s&annotation_id=%s">%s</a></td>' % (
etudid,
a["id"],
scu.icontag(
"delete_img",
border="0",
alt="suppress",
title="Supprimer cette annotation",
),
)
author = sco_users.user_info(a["author"])
alist.append(
@ -480,7 +446,7 @@ def ficheEtud(etudid=None):
info[
"inscriptions_mkup"
] = 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"]}
</div>"""
@ -508,26 +474,11 @@ def ficheEtud(etudid=None):
last_sem = FormSemestre.query.get_or_404(info["sems"][0]["formsemestre_id"])
if last_sem.formation.is_apc():
but_cursus = cursus_but.EtudCursusBUT(etud, last_sem.formation)
info[
"but_cursus_mkup"
] = f"""
<div class="section_but">
{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>
"""
info["but_cursus_mkup"] = render_template(
"but/cursus_etud.j2",
cursus=but_cursus,
scu=scu,
)
tmpl = """<div class="menus_etud">%(menus_etud)s</div>
<div class="ficheEtud" id="ficheEtud"><table>

View File

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

View File

@ -6,7 +6,6 @@
from flask import g
from flask_login import current_user
from app import db
from app.auth.models import User
from app.models import FormSemestre
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,
"<p>Responsable de ce semestre : <b>%s</b></p>"
% ", ".join(
[
db.session.get(User, i).get_prenomnom()
for i in sem["responsables"]
]
[User.query.get(i).get_prenomnom() for i in sem["responsables"]]
),
footer,
]
@ -146,9 +142,7 @@ def check_access_diretud(formsemestre_id, required_permission=Permission.ScoImpl
def can_change_groups(formsemestre_id: int) -> bool:
"""Vrai si l'utilisateur peut changer les groupes dans ce semestre
Obsolete: utiliser FormSemestre.can_change_groups
"""
"Vrai si l'utilisateur peut changer les groupes dans ce semestre"
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.etat:
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)
if not filename:
filename = UNKNOWN_IMAGE_PATH
r = _http_jpeg_file(filename)
r = build_image_response(filename)
return r
def _http_jpeg_file(filename):
def build_image_response(filename):
"""returns an image as a Flask response"""
st = os.stat(filename)
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)
ajoute le champs 'paiementinscription_str' : 'ok', 'Non' ou '?'
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:
if "paiementinscription" in infos:
@ -521,15 +520,6 @@ def _normalize_apo_fields(infolist):
if "prenom" not in infos:
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

View File

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

View File

@ -30,7 +30,7 @@
"""
from operator import itemgetter
from app import db
from app import log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
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
Résultat:
{
'date' : str = date de la decision la plus recente, format dd/mm/yyyy,
'formsemestre' : dict = formsemestre,
'is_apc' : bool,
'formation' : { 'acronyme' :, 'titre': ... }
'decisions' : [
{
'identite' : {'nom' :, 'prenom':, ...,},
'etat' : I ou D ou DEF
'decision_sem' : {'code':, 'code_prev': },
'decisions_ue' : {
ue_id : {
'code' : ADM|CMP|AJ,
'ects' : float,
'event_date' :str = "dd/mm/yyyy",
},
},
'autorisations' : [ { 'semestre_id' : { ... } } ],
'validation_parcours' : True si parcours validé (diplome obtenu)
'prev_code' : code (calculé slt si with_prev),
'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) },
'date' : date de la decision la plus recente,
'formsemestre' : sem,
'is_apc' : bool,
'formation' : { 'acronyme' :, 'titre': ... }
'decisions' : { [ { 'identite' : {'nom' :, 'prenom':, ...,},
'etat' : I ou D ou DEF
'decision_sem' : {'code':, 'code_prev': },
'decisions_ue' : { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' :,
'acronyme', 'numero': } },
'autorisations' : [ { 'semestre_id' : { ... } } ],
'validation_parcours' : True si parcours validé (diplome obtenu)
'prev_code' : code (calculé slt si with_prev),
'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)
@ -260,7 +253,7 @@ def _comp_ects_by_ue_code(nt, decisions_ue):
ects_by_ue_code = {}
for ue_id in decisions_ue:
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"]
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.lib import styles
from app import db
from app.models import FormSemestre, Identite
import app.scodoc.sco_utils as scu
@ -71,7 +70,7 @@ def pdf_lettres_individuelles(
if not dpv:
return ""
#
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
prefs = sco_preferences.SemPreferences(formsemestre_id)
params = {
"date_jury": date_jury,

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