1
0
forked from ScoDoc/ScoDoc

Compare commits

...

140 Commits

Author SHA1 Message Date
632e285d26 9.5.8 avec qq fix backportés 2023-08-11 09:23:44 +02:00
96531f839c Fix: tests unitaires test_but_jury.py. + check in package building script. 2023-08-11 09:23:06 +02:00
1dfe754793 Fix CLI flask delete-dept -f 2023-08-11 09:22:35 +02:00
e0188ebc2d Fix: calcul de la liste des niveaux associés à une année de parcours 2023-08-11 09:20:01 +02:00
c79eb6410a Correction affectation parcours (report sco96) 2023-07-24 21:38:04 +02:00
92e75e11f2 Merge pull request 'Correctif affectations groupes' (#676) from lehmann/ScoDoc-Front:master into master
Reviewed-on: https://scodoc.org/git/ScoDoc/ScoDoc/pulls/676
2023-07-24 21:26:36 +02:00
0dfc27e072 Correctif affectations groupes 2023-07-24 21:24:44 +02:00
c3cc316777 Optimisation: ajout cache par requete a FormSemestre.get_ues() 2023-07-24 16:48:41 +02:00
b04812f812 Merge pull request 'Groups auto assignment' (#675) from lehmann/ScoDoc-Front:master into master
Reviewed-on: https://scodoc.org/git/ScoDoc/ScoDoc/pulls/675
2023-07-24 16:42:00 +02:00
febc15c4c8 Groups auto assignment 2023-07-22 22:40:09 +02:00
a58919d8b4 Utilisation systématique du nouvel éditeur de partition 2023-07-21 09:25:39 +02:00
8be0ab0678 Corrige liste codes RCUEs valides. Tri codes sur page doc. 2023-07-20 17:27:41 +02:00
b913119b58 ajout explications 2023-07-18 16:47:26 +02:00
0573081711 Fix: enregistrement des RCUEs antérieurs 2023-07-18 16:41:02 +02:00
f414ec1c0d Cosmetic (fiche etud) 2023-07-17 22:03:11 +02:00
1b297580c9 Fix: changement de référentiel de compétences 2023-07-17 15:09:17 +02:00
538a5427ba Mise à jour 2023 des référentiels de compétences BUT 2023-07-17 14:23:50 +02:00
30666fc822 Robustifie old XMLgetGroupsInPartition 2023-07-17 14:04:42 +02:00
8614a29f9b validation_rcues: affiche ECTS et améliore menu 2023-07-17 10:37:12 +02:00
5cdfb360fa cosmetic 2023-07-16 21:57:42 +02:00
f08a4130dd Nouvelle page de visu/saisie des décisions RCUEs: validation_rcues 2023-07-16 19:59:45 +02:00
24bb78bdfd lien groups_auto_repartition sur éditeur de partitions 2023-07-14 07:16:33 +02:00
ea9c6a6ef2 Fix typo (ancien éditeur groupes) 2023-07-13 20:13:15 +02:00
8ee95cc2e5 Améliore partition_remove_etud en cas d'accès concurrents 2023-07-13 20:01:42 +02:00
c933f010d4 Fix: affichage moyenne UE bonus si inscrit mais sans note: - et non None 2023-07-13 18:54:51 +02:00
2c93c35aa7 Fix: robustifie export xml de groupes sans noms 2023-07-13 12:55:19 +02:00
417b3b8383 Fix: test bloquant sur structire groupe defaut 2023-07-12 22:36:03 +02:00
814a3802e9 Fix: association d'un formsemetre à une nouvelle formation si des éléments font référence à des UEs qui ne sont plus dans la formation du semestre (sic) 2023-07-12 22:11:33 +02:00
1de265536e Debian package building script: consider only local version 2023-07-12 15:14:39 +02:00
ea1e5cfb89 Fix: filename suffix bulletins 2023-07-12 14:06:34 +02:00
5c78c1cf9f Fix: capitalisation des UEs classiques 2023-07-11 22:06:24 +02:00
9bfebfc8a2 Modifs pour SA 2.0 (à reporter en 9.5)
(cherry picked from commit 38f93cae99)
2023-07-11 09:48:06 +02:00
2e5add1c48 Fix: creation matieres sans numeros 2023-07-10 14:32:16 +02:00
52282c98cb Fix for SQLAlchemy 2 2023-07-10 14:02:21 +02:00
dba48f32eb add 404 2023-07-09 21:57:50 +02:00
8b37f661d6 Améliore code gestion des groupes et corrige qq bugs 2023-07-08 16:35:32 +02:00
d88e41b83e Fix change group/import etud admission 2023-07-08 13:57:44 +02:00
41a791282a Fix: jury formation classique en cas de défaillance 2023-07-06 16:24:52 +02:00
6c56b921e8 Bonus La Roche-sur-Yon: modification de la règle. 2023-07-06 12:08:00 +02:00
428a34e6ba Améliore traitement erreur si id invalide envoyé à formsemestre_validation_but 2023-07-06 09:57:51 +02:00
c5a702e6d1 Fix: creation semestre-> renvoi sur annulation 2023-07-05 19:18:41 +02:00
0824598aa4 Début de travaux pour améliorer le backend groupes/partitions. 2023-07-05 19:15:33 +02:00
90c1454b21 Robustifie code effacement décision 2023-07-04 23:33:40 +02:00
9b3df5febf Fix: creation semestre 2023-07-04 23:31:44 +02:00
a2c5be22cb Fix: enregistre moyenne des UEs antérieures non ADM 2023-07-04 23:26:14 +02:00
cf0d3c06c4 PDF: substitute - for HYPHEN (U+2010) 2023-07-04 22:54:55 +02:00
e963ca52f5 Fix: édition validations antérieures lorsqu'il y a des validations sans semestres 2023-07-04 08:23:09 +02:00
2cc911eb0d Fix: jury BUT2 2023-07-02 22:25:26 +02:00
84d1ed6c85 Modif bonus sport Besançon-Vesoul 2023-07-02 18:01:59 +02:00
5f06b190a2 Export Apogée: ADSUPS RCUEs + fix export moy. UE quand pas de décision de jury 2023-07-02 17:03:06 +02:00
937a96d086 Jury BUT: cas du passage en BUT3 avec BUT1 non validé. Corrige validation ADSUP UEs. Test unitaire: geii90. 2023-07-02 16:02:09 +02:00
10de8c4cc2 Export Apogee: ajout table ADSUPs 2023-07-02 12:10:32 +02:00
da7f9a334f Export Apogée: setup via test unitaire, maquette de test. Corrige cas 2 modules avec même code. 2023-07-01 17:42:04 +02:00
0dda9157eb Adapte tests unitaires - OK 2023-07-01 12:36:30 +02:00
90fd45a572 Jury BUT: Message info sur utilisation UE cap. dans RCUEs 2023-07-01 12:19:06 +02:00
ad4e4e33ec Calcul ECTS dans le parcours BUT 2023-07-01 08:21:40 +02:00
87316f057e Bonus Sport&Culture IUT Lyon 3 2023-06-30 23:00:48 +02:00
7a1dfcbb63 Ajout ECTS totaux dans PV jury BUT pdf 2023-06-30 22:33:07 +02:00
3325b41690 Interface pour UE externes et éditions des validations 2023-06-30 17:26:41 +02:00
35fb269a41 tests unitaires jury BUT: Ajout de cas en GEII 2023-06-30 14:24:55 +02:00
b4c68cea10 Fix: calcul des UEs de BUT1 non validées pour passage en BUT3 2023-06-30 14:23:05 +02:00
77e4c4f726 Corrige migration des validations d'année BUT. 2023-06-30 12:10:41 +02:00
61b46db4dd Jury BUT: modification validation année: unqiue sur ref. comp. 2023-06-30 09:34:29 +02:00
75d0170b4a Fix: ue_sharing_code 2023-06-29 23:26:14 +02:00
ee95a6178a WIP: jury BUT: adaptations des tests unitaires, traite semestre par semestre 2023-06-29 23:24:36 +02:00
ebe7dd8f73 WIP: jury BUT: reprise UEs antérieures 2023-06-29 21:17:03 +02:00
5d30b9233b WIP: nouvelles gestion jury BUT. 2023-06-28 21:25:38 +02:00
IDK
4b49fd5ed9 Adapte les tests unitaires jury BUT 2023-06-28 21:25:01 +02:00
IDK
7ed521e4f5 WIP: nouveaux RCUEs 2023-06-28 21:22:39 +02:00
71ffb33175 Modifie contrainte sur ApcValidationAnnee (une modif plus sérieuse est nécessaire) 2023-06-28 10:09:56 +02:00
IDK
0c9d202e09 Nouvelle gestion RCUE 2023-06-27 23:22:32 +02:00
e190756b98 Fix temporaire: jury BUT: propose toujours le code année RED en mode manuel 2023-06-27 18:33:56 +02:00
37845750a6 Merge branch 'jury_but' of https://scodoc.org/git/viennet/ScoDoc 2023-06-27 18:29:52 +02:00
70049da38f Fix: typo validation manuelle 2023-06-27 18:28:40 +02:00
b2bd659c47 Ajout ADSUP au transcodage Apogée. Cosmetic flash. 2023-06-25 21:05:19 +02:00
658fb3595d Fix: typo calcul auto jury BUT 2023-06-25 12:29:36 +02:00
f9961498bf un detail 2023-06-25 11:51:58 +02:00
66983ff767 Améliore UI gestion des UE antérieures 2023-06-25 11:49:11 +02:00
52db344926 Fix #582: moy UE fiche etud si dispense. 2023-06-23 17:51:17 +02:00
87cc4c06d6 Ajout recap. parcours BUT sur page saisie jury 2023-06-23 15:35:52 +02:00
c9babcd8c2 news pour opérations jury. Implements #668 2023-06-23 10:38:01 +02:00
d57b6638ea N'affiche pas les niveaux inexistants sur le résumé parcours 2023-06-22 21:15:23 +02:00
438caf1052 Jury BUT:
- Modification gestion de l'enregistrement des codes.
- Signale quand un RCUE change de code.
- Calcul auto du jury: peut modifier les décisions RCUE.
2023-06-22 19:00:56 +02:00
c45abc33cc Modification priorité codes jury: PASD > PAS1NCI 2023-06-22 08:22:37 +02:00
cc39e4a862 Jury BUT: modification menu choix décision RCUE redoublants 2023-06-22 08:00:23 +02:00
b501233ba4 Improve critical error handling in moy_ue 2023-06-21 17:25:40 +02:00
9067424f8f Fix: affichage dans édition ECTS UEs par parcours 2023-06-21 17:19:27 +02:00
83218e39b6 Jury BUT: corrige enregistrement décisions d'annee BUT manuelles 2023-06-21 16:47:24 +02:00
544abba758 Jury BUT: présente toujours NAR sur année. Tri les codes dans les menus. 2023-06-21 13:27:46 +02:00
ff73ba8a5b Enhance exception handling 2023-06-21 13:09:29 +02:00
d2fbbad84b Modify caching of ApcNiveaux 2023-06-21 13:09:04 +02:00
7c9f07c36e Optimisation: table recap jury (x3) 2023-06-21 12:33:45 +02:00
c8a974d460 Améliore tri jury_delete_manual et table recap (rang) 2023-06-21 09:51:13 +02:00
ccc589f4d5 Modifie effacement décisions annuelles BUT et RCUE. Améliore affichage décisions 2023-06-20 21:05:04 +02:00
f4277a1336 Jury BUT: effacement décision année + 2 petits bugs mineurs 2023-06-20 19:56:20 +02:00
8156cce4be Fix typo 2023-06-20 14:26:01 +02:00
47cf5962f9 Table jury: affichage stats codes annuels octroyés sous la table 2023-06-20 12:14:16 +02:00
d1d83e0327 Database creation: add unaccent postgresql extension. Tests unitaires OK. 2023-06-20 07:51:40 +02:00
027224a7b3 version 2023-06-19 22:33:27 +02:00
91b86f30a5 fix html typos 2023-06-19 22:32:04 +02:00
b026349e74 Affichage et suppression possible de toutes les décisions de jury 2023-06-19 22:07:31 +02:00
fdfffb70be Table jury BUT: ajout explication sur col RCUEs 2023-06-18 21:42:14 +02:00
de23302b3e Jury BUT: ajout colonne décision année sur table récap. 2023-06-18 21:20:02 +02:00
756c46df0b Suppressions de décisions de jury 2023-06-18 09:37:13 +02:00
84d40091a8 Fix: ordre des RCUE sur les bulletins 2023-06-17 14:56:04 +02:00
f9b4539231 Fix: mise à jour base postgres 2023-06-16 07:54:28 +02:00
021b4ec5f8 Jury BUT: condition de passage de S5: toutes UEs de BUT1 validées. 2023-06-15 21:53:05 +02:00
e3b979fc10 Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into jury_but 2023-06-15 17:16:01 +02:00
44dffea8d2 Fix: tri des coefs. de modules apc 2023-06-15 17:14:37 +02:00
008dd9b50e Fix enregistrement jury année BUT et passage en mode auto 2023-06-15 16:50:22 +02:00
e46ae76399 BUT: jury: validation des niveaux inférieurs. WIP 2023-06-15 08:49:05 +02:00
73023b7806 Fix: clonage formation avec UE BUT externes 2023-06-13 21:28:37 +02:00
c73581c52f Tableau bord module: n'affiche pas saisie abs pour groupes vides 2023-06-09 20:36:31 +02:00
c547990eef Fix: affichage moyenne évalution / 20 2023-06-09 19:39:39 +02:00
c6f3ad448a Editeur partitions: Cache boite non affectés quand elle est vide 2023-06-07 18:50:34 +02:00
f4c776e9f3 code cleaning 2023-06-07 18:43:44 +02:00
cf876ca0d3 Fix: update_inscriptions_parcours_from_groups (restreint au ref. comp. courant) 2023-06-07 17:05:33 +02:00
f979eb137c Retire bonus masters IG, inadapté aux besoins 2023-06-07 13:19:49 +02:00
fda11298b4 Bonus pour masters Institut Galilée (USPN) 2023-06-06 22:12:21 +02:00
0322603a22 Tri parcours par numero et code; améliore table description semestre. 2023-06-03 23:18:54 +02:00
fb4cabee3b - Amélioration enregistrement note.
- Nouveau point API: /evaluation/<int:evaluation_id>/notes/set
- Corrige API /evaluation/<int:evaluation_id>/notes
- Modernisation de code.
- Améliore tests unitaires APi evaluation.
2023-06-03 22:43:04 +02:00
bfa8cf1683 Parcours devient Cursus sur la fiche etud. 2023-06-01 20:13:29 +02:00
42b232dd59 Fix: export pdf/excel table recap formation (typo) 2023-06-01 19:54:45 +02:00
a8ab0cb48c - Fix: invalidation cache après annulaition DEM ou DEF
- Améliore explication lorsqu'il est impossible de supprimer un semestre.
2023-06-01 19:48:52 +02:00
feb799cc20 Fix #655 (affichage ScoValueError) 2023-06-01 19:04:02 +02:00
bf738d1706 ARE: Fix #637 (form montant taxe) 2023-06-01 18:57:55 +02:00
656bf4d25e ARE: Taxe apprentissage en float 2023-06-01 18:25:54 +02:00
8e1cb055f6 - corrige saisi stage sur entreprise (fix #642)
- clé étrangère sur Identite dans EntrepriseStageApprentissage
- nouveau mécanisme pour le choix d'étudiant via auto-completion
  (ajout de autoComplete.js-10.2.7)
- nouveau point d'API: /etudiants/name/<string:start> (et son test unitaire)
2023-06-01 17:58:30 +02:00
111c400333 Merge pull request 'correction de l indentation' (#654) from jmplace/ScoDoc-Lille:pb_synchro_etat_civil into master
Reviewed-on: https://scodoc.org/git/ScoDoc/ScoDoc/pulls/654
2023-06-01 16:35:59 +02:00
9579cd73c2 correction de l indentation 2023-06-01 15:59:23 +02:00
c5f5cb7daa Fix regression: saisie notes tableur 2023-05-31 14:24:36 +02:00
5f0ac236d7 More debug info (save_note : mystère) 2023-05-31 12:25:40 +02:00
63cf7dfc42 Fix: nb d'UE à valider pour formations APC sans inscriptions aux parcours 2023-05-30 22:49:41 +02:00
9a4f7abfa8 add debug infos to two exception 2023-05-30 22:23:19 +02:00
79d92dc9ac Fix regression: inscriptions modules si pas de parcours 2023-05-30 01:15:48 +02:00
1693bb6c6c Fix regression: modif etudiant / changement photo 2023-05-29 18:23:32 +02:00
753578813e Nettoyage code + exception save note 2023-05-29 16:04:41 +02:00
cf72686ce4 Add formation BUT Info pour tests 2023-05-28 17:56:28 +02:00
4a3910adcf Merge pull request 'complement etat_civil' (#650) from jmplace/ScoDoc-Lille:complement_etat_civil into master
Reviewed-on: https://scodoc.org/git/ScoDoc/ScoDoc/pulls/650
2023-05-28 14:56:55 +02:00
e5a620a9ea complement etat_civil 2023-05-25 18:48:21 +02:00
197 changed files with 19578 additions and 3698 deletions

View File

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

View File

@ -8,16 +8,17 @@
API : accès aux étudiants API : accès aux étudiants
""" """
from datetime import datetime from datetime import datetime
from operator import attrgetter
from flask import g, request from flask import g, request
from flask_json import as_json from flask_json import as_json
from flask_login import current_user from flask_login import current_user
from flask_login import login_required from flask_login import login_required
from sqlalchemy import desc, or_ from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import VARCHAR
import app import app
from app.api import api_bp as bp, api_web_bp from app.api import api_bp as bp, api_web_bp
from app.scodoc.sco_utils import json_error
from app.api import tools from app.api import tools
from app.decorators import scodoc, permission_required from app.decorators import scodoc, permission_required
from app.models import ( from app.models import (
@ -31,6 +32,8 @@ from app.scodoc import sco_bulletins
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error, suppress_accents
# Un exemple: # Un exemple:
# @bp.route("/api_function/<int:arg>") # @bp.route("/api_function/<int:arg>")
@ -164,12 +167,39 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
) )
if not None in allowed_depts: if not None in allowed_depts:
# restreint aux départements autorisés: # restreint aux départements autorisés:
etuds = etuds.join(Departement).filter( query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts) or_(Departement.acronym == acronym for acronym in allowed_depts)
) )
return [etud.to_dict_api() for etud in query] return [etud.to_dict_api() for etud in query]
@bp.route("/etudiants/name/<string:start>")
@api_web_bp.route("/etudiants/name/<string:start>")
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants_by_name(start: str = "", min_len=3, limit=32):
"""Liste des étudiants dont le nom débute par start.
Si start fait moins de min_len=3 caractères, liste vide.
La casse et les accents sont ignorés.
"""
if len(start) < min_len:
return []
start = suppress_accents(start).lower()
query = Identite.query.filter(
func.lower(func.unaccent(Identite.nom, type_=VARCHAR)).ilike(start + "%")
)
allowed_depts = current_user.get_depts_with_permission(Permission.ScoView)
if not None in allowed_depts:
# restreint aux départements autorisés:
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit)
# Note: on raffine le tri pour les caractères spéciaux et nom usuel ici:
return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))]
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres") @bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@bp.route("/etudiant/nip/<string:nip>/formsemestres") @bp.route("/etudiant/nip/<string:nip>/formsemestres")
@bp.route("/etudiant/ine/<string:ine>/formsemestres") @bp.route("/etudiant/ine/<string:ine>/formsemestres")

View File

@ -8,7 +8,7 @@
ScoDoc 9 API : accès aux évaluations ScoDoc 9 API : accès aux évaluations
""" """
from flask import g from flask import g, request
from flask_json import as_json from flask_json import as_json
from flask_login import login_required from flask_login import login_required
@ -17,7 +17,7 @@ import app
from app.api import api_bp as bp, api_web_bp from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required from app.decorators import scodoc, permission_required
from app.models import Evaluation, ModuleImpl, FormSemestre from app.models import Evaluation, ModuleImpl, FormSemestre
from app.scodoc import sco_evaluation_db from app.scodoc import sco_evaluation_db, sco_saisie_notes
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -28,7 +28,7 @@ import app.scodoc.sco_utils as scu
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def the_eval(evaluation_id: int): def evaluation(evaluation_id: int):
"""Description d'une évaluation. """Description d'une évaluation.
{ {
@ -93,24 +93,22 @@ def evaluations(moduleimpl_id: int):
@as_json @as_json
def evaluation_notes(evaluation_id: int): def evaluation_notes(evaluation_id: int):
""" """
Retourne la liste des notes à partir de l'id d'une évaluation donnée Retourne la liste des notes de l'évaluation
evaluation_id : l'id d'une évaluation evaluation_id : l'id de l'évaluation
Exemple de résultat : Exemple de résultat :
{ {
"1": { "11": {
"id": 1, "etudid": 11,
"etudid": 10,
"evaluation_id": 1, "evaluation_id": 1,
"value": 15.0, "value": 15.0,
"comment": "", "comment": "",
"date": "Wed, 20 Apr 2022 06:49:05 GMT", "date": "Wed, 20 Apr 2022 06:49:05 GMT",
"uid": 2 "uid": 2
}, },
"2": { "12": {
"id": 2, "etudid": 12,
"etudid": 1,
"evaluation_id": 1, "evaluation_id": 1,
"value": 12.0, "value": 12.0,
"comment": "", "comment": "",
@ -128,8 +126,8 @@ def evaluation_notes(evaluation_id: int):
.filter_by(dept_id=g.scodoc_dept_id) .filter_by(dept_id=g.scodoc_dept_id)
) )
the_eval = query.first_or_404() evaluation = query.first_or_404()
dept = the_eval.moduleimpl.formsemestre.departement dept = evaluation.moduleimpl.formsemestre.departement
app.set_sco_dept(dept.acronym) app.set_sco_dept(dept.acronym)
notes = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) notes = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
@ -137,7 +135,49 @@ def evaluation_notes(evaluation_id: int):
# "ABS", "EXC", etc mais laisse les notes sur le barème de l'éval. # "ABS", "EXC", etc mais laisse les notes sur le barème de l'éval.
note = notes[etudid] note = notes[etudid]
note["value"] = scu.fmt_note(note["value"], keep_numeric=True) note["value"] = scu.fmt_note(note["value"], keep_numeric=True)
note["note_max"] = the_eval.note_max note["note_max"] = evaluation.note_max
del note["id"] del note["id"]
return notes # 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", "")
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,67 @@
##############################################################################
# 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()}",
)

253
app/but/rcue.py Normal file
View File

@ -0,0 +1,253 @@
##############################################################################
# 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
# }

117
app/but/validations_view.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -65,6 +65,7 @@ class CodesDecisionsForm(FlaskForm):
ADJ = _build_code_field("ADJ") ADJ = _build_code_field("ADJ")
ADJR = _build_code_field("ADJR") ADJR = _build_code_field("ADJR")
ADM = _build_code_field("ADM") ADM = _build_code_field("ADM")
ADSUP = _build_code_field("ADSUP")
AJ = _build_code_field("AJ") AJ = _build_code_field("AJ")
ATB = _build_code_field("ATB") ATB = _build_code_field("ATB")
ATJ = _build_code_field("ATJ") ATJ = _build_code_field("ATJ")
@ -81,7 +82,8 @@ class CodesDecisionsForm(FlaskForm):
NOTES_FMT = StringField( NOTES_FMT = StringField(
label="Format notes exportées", label="Format notes exportées",
description="""Format des notes. Par défaut <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=[
validators.Length( validators.Length(
max=SHORT_STR_LEN, max=SHORT_STR_LEN,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -295,8 +295,15 @@ class ApoCSVReadWrite:
filename=self.get_filename(), filename=self.get_filename(),
) )
cols = {} # { col_id : value } cols = {} # { col_id : value }
for i, field in enumerate(fields): try:
cols[self.col_ids[i]] = field 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
etud_tuples.append( etud_tuples.append(
ApoEtudTuple( ApoEtudTuple(
nip=fields[0], # id etudiant nip=fields[0], # id etudiant

View File

@ -70,7 +70,7 @@ from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import Departement, FormSemestre from app.models import Departement, FormSemestre
from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_exceptions import ScoPermissionDenied from app.scodoc.sco_exceptions import ScoException, ScoPermissionDenied
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_bulletins_pdf from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_groups from app.scodoc import sco_groups
@ -125,6 +125,12 @@ class BaseArchiver(object):
if not os.path.isdir(obj_dir): if not os.path.isdir(obj_dir):
log(f"creating directory {obj_dir}") log(f"creating directory {obj_dir}")
os.mkdir(obj_dir) os.mkdir(obj_dir)
except FileExistsError as exc:
raise ScoException(
f"""BaseArchiver error: obj_dir={obj_dir} exists={
os.path.exists(obj_dir)
} isdir={os.path.isdir(obj_dir)}"""
) from exc
finally: finally:
scu.GSL.release() scu.GSL.release()
return obj_dir return obj_dir
@ -338,7 +344,7 @@ def do_formsemestre_archive(
if data: if data:
PVArchive.store(archive_id, "Tableau_moyennes" + scu.XLSX_SUFFIX, data) PVArchive.store(archive_id, "Tableau_moyennes" + scu.XLSX_SUFFIX, data)
# Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes) # Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes)
table_html, _ = gen_formsemestre_recapcomplet_html_table( table_html, _, _ = gen_formsemestre_recapcomplet_html_table(
formsemestre, res, include_evaluations=True formsemestre, res, include_evaluations=True
) )
if table_html: if table_html:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,6 +27,7 @@
"""Tableau récapitulatif des notes d'un semestre """Tableau récapitulatif des notes d'un semestre
""" """
import collections
import datetime import datetime
import time import time
from xml.etree import ElementTree from xml.etree import ElementTree
@ -109,7 +110,7 @@ def formsemestre_recapcomplet(
force_publishing=force_publishing, force_publishing=force_publishing,
) )
table_html, table = _formsemestre_recapcomplet_to_html( table_html, table, freq_codes_annuels = _formsemestre_recapcomplet_to_html(
formsemestre, formsemestre,
filename=filename, filename=filename,
mode_jury=mode_jury, mode_jury=mode_jury,
@ -142,7 +143,7 @@ def formsemestre_recapcomplet(
H.append( H.append(
'<select name="tabformat" onchange="document.f.submit()" class="noprint">' '<select name="tabformat" onchange="document.f.submit()" class="noprint">'
) )
for (fmt, label) in ( for fmt, label in (
("html", "Tableau"), ("html", "Tableau"),
("evals", "Avec toutes les évaluations"), ("evals", "Avec toutes les évaluations"),
("xlsx", "Excel (non formaté)"), ("xlsx", "Excel (non formaté)"),
@ -186,7 +187,7 @@ def formsemestre_recapcomplet(
</li> </li>
<li><a class="stdlink" href="{url_for('notes.formsemestre_jury_but_erase', <li><a class="stdlink" href="{url_for('notes.formsemestre_jury_but_erase',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, only_one_sem=1) scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, only_one_sem=1)
}">Effacer <em>toutes</em> les décisions de jury (BUT) du semestre</a> }">Effacer <em>toutes</em> les décisions de jury BUT issues de ce semestre</a>
</li> </li>
""" """
) )
@ -215,33 +216,37 @@ def formsemestre_recapcomplet(
""" """
) )
if mode_jury and table and sum(table.freq_codes_annuels.values()) > 0: if mode_jury and freq_codes_annuels and sum(freq_codes_annuels.values()) > 0:
nb_etud_avec_decision_annuelle = (
sum(freq_codes_annuels.values()) - freq_codes_annuels["total"]
)
H.append( H.append(
f""" f"""
<div class="jury_stats"> <div class="jury_stats">
<div>Nb d'étudiants avec décision annuelle: <div><b>Nb d'étudiants avec décision annuelle:</b>
{sum(table.freq_codes_annuels.values())} / {len(table)} {nb_etud_avec_decision_annuelle} / {freq_codes_annuels["total"]}
</div> </div>
<div><b>Codes annuels octroyés:</b></div>
<table class="jury_stats_codes">
""" """
) )
for code in sorted(table.freq_codes_annuels.keys()): if nb_etud_avec_decision_annuelle > 0:
H.append( H.append(
f"""<tr> """<div><b>Codes annuels octroyés:</b></div>
<td>{code}</td> <table class="jury_stats_codes">
<td style="text-align:right">{table.freq_codes_annuels[code]}</td> """
<td style="text-align:right">{
(100*table.freq_codes_annuels[code] / len(table)):2.1f}%
</td>
</tr>"""
) )
H.append( for code in sorted(freq_codes_annuels.keys()):
""" if code != "total":
</table> H.append(
</div> f"""<tr>
""" <td>{code}</td>
) <td style="text-align:right">{freq_codes_annuels[code]}</td>
<td style="text-align:right">{
(100*freq_codes_annuels[code] / freq_codes_annuels["total"]):2.1f}%
</td>
</tr>"""
)
H.append("""</table>""")
H.append("""</div>""")
# Légende # Légende
H.append( H.append(
""" """
@ -251,6 +256,7 @@ def formsemestre_recapcomplet(
<div><tt>~</tt></div><div>valeur manquante</div> <div><tt>~</tt></div><div>valeur manquante</div>
<div><tt>=</tt></div><div>UE dispensée</div> <div><tt>=</tt></div><div>UE dispensée</div>
<div><tt>nan</tt></div><div>valeur non disponible</div> <div><tt>nan</tt></div><div>valeur non disponible</div>
<div>📍</div><div>code jury non enregistré</div>
</div> </div>
</div> </div>
""" """
@ -271,12 +277,12 @@ def _formsemestre_recapcomplet_to_html(
filename: str = "", filename: str = "",
mode_jury=False, # saisie décisions jury mode_jury=False, # saisie décisions jury
selected_etudid=None, selected_etudid=None,
) -> tuple[str, TableRecap]: ) -> tuple[str, TableRecap, collections.Counter]:
"""Le tableau recap en html""" """Le tableau recap en html"""
if tabformat not in ("html", "evals"): if tabformat not in ("html", "evals"):
raise ScoValueError("invalid table format") raise ScoValueError("invalid table format")
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
table_html, table = gen_formsemestre_recapcomplet_html_table( table_html, table, freq_codes_annuels = gen_formsemestre_recapcomplet_html_table(
formsemestre, formsemestre,
res, res,
include_evaluations=(tabformat == "evals"), include_evaluations=(tabformat == "evals"),
@ -284,7 +290,7 @@ def _formsemestre_recapcomplet_to_html(
filename=filename, filename=filename,
selected_etudid=selected_etudid, selected_etudid=selected_etudid,
) )
return table_html, table return table_html, table, freq_codes_annuels
def _formsemestre_recapcomplet_to_file( def _formsemestre_recapcomplet_to_file(
@ -446,9 +452,9 @@ def gen_formsemestre_recapcomplet_html_table(
mode_jury=False, mode_jury=False,
filename="", filename="",
selected_etudid=None, selected_etudid=None,
) -> tuple[str, TableRecap]: ) -> tuple[str, TableRecap, collections.Counter]:
"""Construit table recap pour le BUT """Construit table recap pour le BUT
Cache le résultat pour le semestre (sauf en mode jury). Cache le résultat pour le semestre.
Note: on cache le HTML et non l'objet Table. Note: on cache le HTML et non l'objet Table.
Si mode_jury, occultera colonnes modules (en js) Si mode_jury, occultera colonnes modules (en js)
@ -460,6 +466,7 @@ def gen_formsemestre_recapcomplet_html_table(
""" """
table = None table = None
table_html = None table_html = None
table_html_cached = None
cache_class = { cache_class = {
(True, True): sco_cache.TableJuryWithEvalsCache, (True, True): sco_cache.TableJuryWithEvalsCache,
(True, False): sco_cache.TableJuryCache, (True, False): sco_cache.TableJuryCache,
@ -467,8 +474,8 @@ def gen_formsemestre_recapcomplet_html_table(
(False, False): sco_cache.TableRecapCache, (False, False): sco_cache.TableRecapCache,
}[(bool(mode_jury), bool(include_evaluations))] }[(bool(mode_jury), bool(include_evaluations))]
if not selected_etudid: if not selected_etudid:
table_html = cache_class.get(formsemestre.id) table_html_cached = cache_class.get(formsemestre.id)
if table_html is None: if table_html_cached is None:
table = _gen_formsemestre_recapcomplet_table( table = _gen_formsemestre_recapcomplet_table(
res, res,
include_evaluations, include_evaluations,
@ -477,9 +484,14 @@ def gen_formsemestre_recapcomplet_html_table(
selected_etudid=selected_etudid, selected_etudid=selected_etudid,
) )
table_html = table.html() table_html = table.html()
cache_class.set(formsemestre.id, table_html) freq_codes_annuels = (
table.freq_codes_annuels if hasattr(table, "freq_codes_annuels") else None
)
cache_class.set(formsemestre.id, (table_html, freq_codes_annuels))
else:
table_html, freq_codes_annuels = table_html_cached
return table_html, table return table_html, table, freq_codes_annuels
def _gen_formsemestre_recapcomplet_table( def _gen_formsemestre_recapcomplet_table(

View File

@ -33,11 +33,9 @@ from collections import defaultdict
from flask import request from flask import request
from app import db
from app.but import jury_but from app.but import jury_but
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre from app.models import FormSemestre
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestreInscription from app.models.formsemestre import FormSemestreInscription
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -170,7 +168,7 @@ def but_indicateurs_by_bac(formsemestre: FormSemestre) -> dict[str:dict]:
if deca and deca.formsemestre_impair if deca and deca.formsemestre_impair
} }
for formsemestre_id_precedent in formsemestre_id_precedents: for formsemestre_id_precedent in formsemestre_id_precedents:
formsemestre_impair = FormSemestre.query.get(formsemestre_id_precedent) formsemestre_impair = db.session.get(FormSemestre, formsemestre_id_precedent)
suffix = ( suffix = (
f"S{formsemestre_impair.semestre_id}" f"S{formsemestre_impair.semestre_id}"
if len(formsemestre_id_precedents) == 1 if len(formsemestre_id_precedents) == 1

View File

@ -36,41 +36,49 @@ import flask
from flask import g, url_for, request from flask import g, url_for, request
from flask_login import current_user from flask_login import current_user
from app import db, log
from app.auth.models import User from app.auth.models import User
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import Evaluation, FormSemestre from app.models import (
from app.models import ModuleImpl, ScolarNews Evaluation,
FormSemestre,
Module,
ModuleImpl,
NotesNotes,
ScolarNews,
)
from app.models.etudiants import Identite from app.models.etudiants import Identite
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
import app.scodoc.notesdb as ndb
from app import log
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
AccessDenied, AccessDenied,
InvalidNoteValue, InvalidNoteValue,
NoteProcessError, NoteProcessError,
ScoGenError, ScoBugCatcher,
ScoException,
ScoInvalidParamError, ScoInvalidParamError,
ScoValueError, ScoValueError,
) )
from app.scodoc.TrivialFormulator import TrivialFormulator, TF
from app.scodoc import html_sco_header, sco_users from app.scodoc import html_sco_header, sco_users
from app.scodoc import htmlutils from app.scodoc import htmlutils
from app.scodoc import sco_abs from app.scodoc import sco_abs
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_edit_module from app.scodoc import sco_edit_module
from app.scodoc import sco_evaluations from app.scodoc import sco_etud
from app.scodoc import sco_evaluation_db from app.scodoc import sco_evaluation_db
from app.scodoc import sco_evaluations
from app.scodoc import sco_excel from app.scodoc import sco_excel
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc import sco_groups_view from app.scodoc import sco_groups_view
from app.scodoc import sco_moduleimpl from app.scodoc import sco_moduleimpl
from app.scodoc import sco_permissions_check from app.scodoc import sco_permissions_check
from app.scodoc import sco_undo_notes from app.scodoc import sco_undo_notes
from app.scodoc import sco_etud import app.scodoc.notesdb as ndb
from app.scodoc.TrivialFormulator import TrivialFormulator, TF
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import json_error
from app.scodoc.sco_utils import ModuleType
def convert_note_from_string( def convert_note_from_string(
@ -128,29 +136,30 @@ def _displayNote(val):
return val return val
def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict): def _check_notes(notes: list[(int, float)], evaluation: Evaluation):
# XXX typehint : float or str # XXX typehint : float or str
"""notes is a list of tuples (etudid, value) """notes is a list of tuples (etudid, value)
mod is the module (used to ckeck type, for malus) mod is the module (used to ckeck type, for malus)
returns list of valid notes (etudid, float value) returns list of valid notes (etudid, float value)
and 4 lists of etudid: invalids, withoutnotes, absents, tosuppress, existingjury and 4 lists of etudid: etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress
""" """
note_max = evaluation["note_max"] note_max = evaluation.note_max or 0.0
if mod["module_type"] in ( module: Module = evaluation.moduleimpl.module
if module.module_type in (
scu.ModuleType.STANDARD, scu.ModuleType.STANDARD,
scu.ModuleType.RESSOURCE, scu.ModuleType.RESSOURCE,
scu.ModuleType.SAE, scu.ModuleType.SAE,
): ):
note_min = scu.NOTES_MIN note_min = scu.NOTES_MIN
elif mod["module_type"] == ModuleType.MALUS: elif module.module_type == ModuleType.MALUS:
note_min = -20.0 note_min = -20.0
else: else:
raise ValueError("Invalid module type") # bug raise ValueError("Invalid module type") # bug
L = [] # liste (etudid, note) des notes ok (ou absent) valid_notes = [] # liste (etudid, note) des notes ok (ou absent)
invalids = [] # etudid avec notes invalides etudids_invalids = [] # etudid avec notes invalides
withoutnotes = [] # etudid sans notes (champs vides) etudids_without_notes = [] # etudid sans notes (champs vides)
absents = [] # etudid absents etudids_absents = [] # etudid absents
tosuppress = [] # etudids avec ancienne note à supprimer etudid_to_suppress = [] # etudids avec ancienne note à supprimer
for etudid, note in notes: for etudid, note in notes:
note = str(note).strip().upper() note = str(note).strip().upper()
@ -166,31 +175,34 @@ def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict):
note_max, note_max,
note_min=note_min, note_min=note_min,
etudid=etudid, etudid=etudid,
absents=absents, absents=etudids_absents,
tosuppress=tosuppress, tosuppress=etudid_to_suppress,
invalids=invalids, invalids=etudids_invalids,
) )
if not invalid: if not invalid:
L.append((etudid, value)) valid_notes.append((etudid, value))
else: else:
withoutnotes.append(etudid) etudids_without_notes.append(etudid)
return L, invalids, withoutnotes, absents, tosuppress return (
valid_notes,
etudids_invalids,
etudids_without_notes,
etudids_absents,
etudid_to_suppress,
)
def do_evaluation_upload_xls(): def do_evaluation_upload_xls():
""" """
Soumission d'un fichier XLS (evaluation_id, notefile) Soumission d'un fichier XLS (evaluation_id, notefile)
""" """
authuser = current_user
vals = scu.get_request_args() vals = scu.get_request_args()
evaluation_id = int(vals["evaluation_id"]) evaluation_id = int(vals["evaluation_id"])
comment = vals["comment"] comment = vals["comment"]
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0] # Check access (admin, respformation, and responsable_id)
# Check access if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id):
# (admin, respformation, and responsable_id) raise AccessDenied(f"Modification des notes impossible pour {current_user}")
if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]):
raise AccessDenied("Modification des notes impossible pour %s" % authuser)
# #
diag, lines = sco_excel.excel_file_to_list(vals["notefile"]) diag, lines = sco_excel.excel_file_to_list(vals["notefile"])
try: try:
@ -239,14 +251,16 @@ def do_evaluation_upload_xls():
if etudid: if etudid:
notes.append((etudid, val)) notes.append((etudid, val))
ni += 1 ni += 1
except: except Exception as exc:
diag.append( diag.append(
f"""Erreur: Ligne invalide ! (erreur ligne {ni})<br>{lines[ni]}""" f"""Erreur: Ligne invalide ! (erreur ligne {ni})<br>{lines[ni]}"""
) )
raise InvalidNoteValue() raise InvalidNoteValue() from exc
# -- check values # -- check values
L, invalids, withoutnotes, absents, _ = _check_notes(notes, E, M["module"]) valid_notes, invalids, withoutnotes, absents, _ = _check_notes(
if len(invalids): notes, evaluation
)
if invalids:
diag.append( diag.append(
f"Erreur: la feuille contient {len(invalids)} notes invalides</p>" f"Erreur: la feuille contient {len(invalids)} notes invalides</p>"
) )
@ -258,37 +272,33 @@ def do_evaluation_upload_xls():
diag.append("Notes invalides pour: " + ", ".join(etudsnames)) diag.append("Notes invalides pour: " + ", ".join(etudsnames))
raise InvalidNoteValue() raise InvalidNoteValue()
else: else:
nb_changed, nb_suppress, existing_decisions = notes_add( etudids_changed, nb_suppress, etudids_with_decisions = notes_add(
authuser, evaluation_id, L, comment current_user, evaluation_id, valid_notes, comment
) )
# news # news
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[ module: Module = evaluation.moduleimpl.module
0 status_url = url_for(
]
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
mod["moduleimpl_id"] = M["moduleimpl_id"]
mod["url"] = url_for(
"notes.moduleimpl_status", "notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
moduleimpl_id=mod["moduleimpl_id"], moduleimpl_id=evaluation.moduleimpl_id,
_external=True, _external=True,
) )
ScolarNews.add( ScolarNews.add(
typ=ScolarNews.NEWS_NOTE, typ=ScolarNews.NEWS_NOTE,
obj=M["moduleimpl_id"], obj=evaluation.moduleimpl_id,
text='Chargement notes dans <a href="%(url)s">%(titre)s</a>' % mod, text=f"""Chargement notes dans <a href="{status_url}">{
url=mod["url"], module.titre or module.code}</a>""",
url=status_url,
max_frequency=30 * 60, # 30 minutes max_frequency=30 * 60, # 30 minutes
) )
msg = ( msg = f"""<p>{len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes, {
"<p>%d notes changées (%d sans notes, %d absents, %d note supprimées)</p>" len(absents)} absents, {nb_suppress} note supprimées)
% (nb_changed, len(withoutnotes), len(absents), nb_suppress) </p>"""
) if etudids_with_decisions:
if existing_decisions: msg += """<p class="warning">Important: il y avait déjà des décisions de jury
msg += """<p class="warning">Important: il y avait déjà des décisions de jury enregistrées, qui sont potentiellement à revoir suite à cette modification !</p>""" enregistrées, qui sont peut-être à revoir suite à cette modification !</p>
# msg += '<p>' + str(notes) # debug """
return 1, msg return 1, msg
except InvalidNoteValue: except InvalidNoteValue:
@ -310,14 +320,12 @@ def do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) -
if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl.id): if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl.id):
raise AccessDenied(f"Modification des notes impossible pour {current_user}") raise AccessDenied(f"Modification des notes impossible pour {current_user}")
# Convert and check value # Convert and check value
L, invalids, _, _, _ = _check_notes( L, invalids, _, _, _ = _check_notes([(etud.id, value)], evaluation)
[(etud.id, value)], evaluation.to_dict(), evaluation.moduleimpl.module.to_dict()
)
if len(invalids) == 0: if len(invalids) == 0:
nb_changed, _, _ = notes_add( etudids_changed, _, _ = notes_add(
current_user, evaluation.id, L, "Initialisation notes" current_user, evaluation.id, L, "Initialisation notes"
) )
if nb_changed == 1: if len(etudids_changed) == 1:
return True return True
return False # error return False # error
@ -352,9 +360,7 @@ def do_evaluation_set_missing(
if etudid not in notes_db: # pas de note if etudid not in notes_db: # pas de note
notes.append((etudid, value)) notes.append((etudid, value))
# Convert and check values # Convert and check values
L, invalids, _, _, _ = _check_notes( valid_notes, invalids, _, _, _ = _check_notes(notes, evaluation)
notes, evaluation.to_dict(), modimpl.module.to_dict()
)
dest_url = url_for( dest_url = url_for(
"notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id "notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id
) )
@ -372,13 +378,13 @@ def do_evaluation_set_missing(
""" """
# Confirm action # Confirm action
if not dialog_confirmed: if not dialog_confirmed:
plural = len(L) > 1 plural = len(valid_notes) > 1
return scu.confirm_dialog( return scu.confirm_dialog(
f"""<h2>Mettre toutes les notes manquantes de l'évaluation f"""<h2>Mettre toutes les notes manquantes de l'évaluation
à la valeur {value} ?</h2> à la valeur {value} ?</h2>
<p>Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC) <p>Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC)
n'a été rentrée seront affectés.</p> n'a été rentrée seront affectés.</p>
<p><b>{len(L)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""} <p><b>{len(valid_notes)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""}
par ce changement de note.</b> par ce changement de note.</b>
</p> </p>
""", """,
@ -392,7 +398,7 @@ def do_evaluation_set_missing(
) )
# ok # ok
comment = "Initialisation notes manquantes" comment = "Initialisation notes manquantes"
nb_changed, _, _ = notes_add(current_user, evaluation_id, L, comment) etudids_changed, _, _ = notes_add(current_user, evaluation_id, valid_notes, comment)
# news # news
url = url_for( url = url_for(
"notes.moduleimpl_status", "notes.moduleimpl_status",
@ -408,7 +414,7 @@ def do_evaluation_set_missing(
) )
return f""" return f"""
{ html_sco_header.sco_header() } { html_sco_header.sco_header() }
<h2>{nb_changed} notes changées</h2> <h2>{len(etudids_changed)} notes changées</h2>
<ul> <ul>
<li><a class="stdlink" href="{dest_url}"> <li><a class="stdlink" href="{dest_url}">
Revenir au formulaire de saisie des notes</a> Revenir au formulaire de saisie des notes</a>
@ -454,7 +460,7 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
) )
if not dialog_confirmed: if not dialog_confirmed:
nb_changed, nb_suppress, existing_decisions = notes_add( etudids_changed, nb_suppress, existing_decisions = notes_add(
current_user, evaluation_id, notes, do_it=False, check_inscription=False current_user, evaluation_id, notes, do_it=False, check_inscription=False
) )
msg = f"""<p>Confirmer la suppression des {nb_suppress} notes ? msg = f"""<p>Confirmer la suppression des {nb_suppress} notes ?
@ -475,14 +481,14 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
) )
# modif # modif
nb_changed, nb_suppress, existing_decisions = notes_add( etudids_changed, nb_suppress, existing_decisions = notes_add(
current_user, current_user,
evaluation_id, evaluation_id,
notes, notes,
comment="effacer tout", comment="effacer tout",
check_inscription=False, check_inscription=False,
) )
assert nb_changed == nb_suppress assert len(etudids_changed) == nb_suppress
H = [f"""<p>{nb_suppress} notes supprimées</p>"""] H = [f"""<p>{nb_suppress} notes supprimées</p>"""]
if existing_decisions: if existing_decisions:
H.append( H.append(
@ -516,7 +522,7 @@ def notes_add(
comment=None, comment=None,
do_it=True, do_it=True,
check_inscription=True, check_inscription=True,
) -> tuple: ) -> tuple[list[int], int, list[int]]:
""" """
Insert or update notes Insert or update notes
notes is a list of tuples (etudid,value) notes is a list of tuples (etudid,value)
@ -524,12 +530,12 @@ def notes_add(
WOULD be changed or suppressed. WOULD be changed or suppressed.
Nota: Nota:
- si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log) - si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log)
Return tuple (nb_changed, nb_suppress, existing_decisions)
Return: tuple (etudids_changed, nb_suppress, etudids_with_decision)
""" """
now = psycopg2.Timestamp( now = psycopg2.Timestamp(*time.localtime()[:6])
*time.localtime()[:6]
) # datetime.datetime.now().isoformat() # Vérifie inscription et valeur note
# Verifie inscription et valeur note
inscrits = { inscrits = {
x[0] x[0]
for x in sco_groups.do_evaluation_listeetuds_groups( for x in sco_groups.do_evaluation_listeetuds_groups(
@ -548,13 +554,13 @@ def notes_add(
# Met a jour la base # Met a jour la base
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
nb_changed = 0 etudids_changed = []
nb_suppress = 0 nb_suppress = 0
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] formsemestre: FormSemestre = evaluation.moduleimpl.formsemestre
existing_decisions = ( res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
[] # etudids pour lesquels il y a une decision de jury et que la note change:
) # etudids pour lesquels il y a une decision de jury et que la note change etudids_with_decision = []
try: try:
for etudid, value in notes: for etudid, value in notes:
changed = False changed = False
@ -562,7 +568,7 @@ def notes_add(
# nouvelle note # nouvelle note
if value != scu.NOTES_SUPPRESS: if value != scu.NOTES_SUPPRESS:
if do_it: if do_it:
aa = { args = {
"etudid": etudid, "etudid": etudid,
"evaluation_id": evaluation_id, "evaluation_id": evaluation_id,
"value": value, "value": value,
@ -570,13 +576,20 @@ def notes_add(
"uid": user.id, "uid": user.id,
"date": now, "date": now,
} }
ndb.quote_dict(aa) ndb.quote_dict(args)
# Note: le conflit ci-dessous peut arriver si un autre thread
# a modifié la base après qu'on ait lu notes_db
cursor.execute( cursor.execute(
"""INSERT INTO notes_notes """INSERT INTO notes_notes
(etudid, evaluation_id, value, comment, date, uid) (etudid, evaluation_id, value, comment, date, uid)
VALUES (%(etudid)s,%(evaluation_id)s,%(value)s,%(comment)s,%(date)s,%(uid)s) VALUES
(%(etudid)s,%(evaluation_id)s,%(value)s,
%(comment)s,%(date)s,%(uid)s)
ON CONFLICT ON CONSTRAINT notes_notes_etudid_evaluation_id_key
DO UPDATE SET etudid=%(etudid)s, evaluation_id=%(evaluation_id)s,
value=%(value)s, comment=%(comment)s, date=%(date)s, uid=%(uid)s
""", """,
aa, args,
) )
changed = True changed = True
else: else:
@ -584,7 +597,7 @@ def notes_add(
oldval = notes_db[etudid]["value"] oldval = notes_db[etudid]["value"]
if type(value) != type(oldval): if type(value) != type(oldval):
changed = True changed = True
elif type(value) == float and ( elif isinstance(value, float) and (
abs(value - oldval) > scu.NOTES_PRECISION abs(value - oldval) > scu.NOTES_PRECISION
): ):
changed = True changed = True
@ -603,7 +616,7 @@ def notes_add(
""", """,
{"etudid": etudid, "evaluation_id": evaluation_id}, {"etudid": etudid, "evaluation_id": evaluation_id},
) )
aa = { args = {
"etudid": etudid, "etudid": etudid,
"evaluation_id": evaluation_id, "evaluation_id": evaluation_id,
"value": value, "value": value,
@ -611,7 +624,7 @@ def notes_add(
"comment": comment, "comment": comment,
"uid": user.id, "uid": user.id,
} }
ndb.quote_dict(aa) ndb.quote_dict(args)
if value != scu.NOTES_SUPPRESS: if value != scu.NOTES_SUPPRESS:
if do_it: if do_it:
cursor.execute( cursor.execute(
@ -620,52 +633,49 @@ def notes_add(
WHERE etudid = %(etudid)s WHERE etudid = %(etudid)s
and evaluation_id = %(evaluation_id)s and evaluation_id = %(evaluation_id)s
""", """,
aa, args,
) )
else: # suppression ancienne note else: # suppression ancienne note
if do_it: if do_it:
log( log(
"notes_add, suppress, evaluation_id=%s, etudid=%s, oldval=%s" f"""notes_add, suppress, evaluation_id={evaluation_id}, etudid={
% (evaluation_id, etudid, oldval) etudid}, oldval={oldval}"""
) )
cursor.execute( cursor.execute(
"""DELETE FROM notes_notes """DELETE FROM notes_notes
WHERE etudid = %(etudid)s WHERE etudid = %(etudid)s
AND evaluation_id = %(evaluation_id)s AND evaluation_id = %(evaluation_id)s
""", """,
aa, args,
) )
# garde trace de la suppression dans l'historique: # garde trace de la suppression dans l'historique:
aa["value"] = scu.NOTES_SUPPRESS args["value"] = scu.NOTES_SUPPRESS
cursor.execute( cursor.execute(
"""INSERT INTO notes_notes_log (etudid,evaluation_id,value,comment,date,uid) """INSERT INTO notes_notes_log
VALUES (%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s) (etudid,evaluation_id,value,comment,date,uid)
VALUES
(%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s)
""", """,
aa, args,
) )
nb_suppress += 1 nb_suppress += 1
if changed: if changed:
nb_changed += 1 etudids_changed.append(etudid)
if has_existing_decision(M, E, etudid): if res.etud_has_decision(etudid):
existing_decisions.append(etudid) etudids_with_decision.append(etudid)
except Exception as exc: except Exception as exc:
log("*** exception in notes_add") log("*** exception in notes_add")
if do_it: if do_it:
cnx.rollback() # abort cnx.rollback() # abort
# inval cache # inval cache
sco_cache.invalidate_formsemestre( sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
formsemestre_id=M["formsemestre_id"]
) # > modif notes (exception)
sco_cache.EvaluationCache.delete(evaluation_id) sco_cache.EvaluationCache.delete(evaluation_id)
raise # XXX raise ScoException from exc
raise ScoGenError("Erreur enregistrement note: merci de ré-essayer") from exc
if do_it: if do_it:
cnx.commit() cnx.commit()
sco_cache.invalidate_formsemestre( sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
formsemestre_id=M["formsemestre_id"]
) # > modif notes
sco_cache.EvaluationCache.delete(evaluation_id) sco_cache.EvaluationCache.delete(evaluation_id)
return nb_changed, nb_suppress, existing_decisions return etudids_changed, nb_suppress, etudids_with_decision
def saisie_notes_tableur(evaluation_id, group_ids=()): def saisie_notes_tableur(evaluation_id, group_ids=()):
@ -868,44 +878,39 @@ def saisie_notes_tableur(evaluation_id, group_ids=()):
def feuille_saisie_notes(evaluation_id, group_ids=[]): def feuille_saisie_notes(evaluation_id, group_ids=[]):
"""Document Excel pour saisie notes dans l'évaluation et les groupes indiqués""" """Document Excel pour saisie notes dans l'évaluation et les groupes indiqués"""
evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
if not evals: if not evaluation:
raise ScoValueError("invalid evaluation_id") raise ScoValueError("invalid evaluation_id")
eval_dict = evals[0] modimpl = evaluation.moduleimpl
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=eval_dict["moduleimpl_id"])[0] formsemestre = modimpl.formsemestre
formsemestre_id = M["formsemestre_id"] mod_responsable = sco_users.user_info(modimpl.responsable_id)
Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] if evaluation.jour:
sem = sco_formsemestre.get_formsemestre(M["formsemestre_id"]) indication_date = evaluation.jour.isoformat()
mod_responsable = sco_users.user_info(M["responsable_id"])
if eval_dict["jour"]:
indication_date = ndb.DateDMYtoISO(eval_dict["jour"])
else: else:
indication_date = scu.sanitize_filename(eval_dict["description"])[:12] indication_date = scu.sanitize_filename(evaluation.description or "")[:12]
eval_name = "%s-%s" % (Mod["code"], indication_date) eval_name = f"{evaluation.moduleimpl.module.code}-{indication_date}"
if eval_dict["description"]: date_str = (
evaltitre = "%s du %s" % (eval_dict["description"], eval_dict["jour"]) f"""du {evaluation.jour.strftime("%d/%m/%Y")}"""
else: if evaluation.jour
evaltitre = "évaluation du %s" % eval_dict["jour"] else "(sans date)"
description = "%s en %s (%s) resp. %s" % (
evaltitre,
Mod["abbrev"] or "",
Mod["code"] or "",
mod_responsable["prenomnom"],
) )
eval_titre = f"""{evaluation.description if evaluation.description else "évaluation"} {date_str}"""
description = f"""{eval_titre} en {evaluation.moduleimpl.module.abbrev or ""} ({
evaluation.moduleimpl.module.code
}) resp. {mod_responsable["prenomnom"]}"""
groups_infos = sco_groups_view.DisplayedGroupsInfos( groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids=group_ids, group_ids=group_ids,
formsemestre_id=formsemestre_id, formsemestre_id=formsemestre.id,
select_all_when_unspecified=True, select_all_when_unspecified=True,
etat=None, etat=None,
) )
groups = sco_groups.listgroups(groups_infos.group_ids) groups = sco_groups.listgroups(groups_infos.group_ids)
gr_title_filename = sco_groups.listgroups_filename(groups) gr_title_filename = sco_groups.listgroups_filename(groups)
# gr_title = sco_groups.listgroups_abbrev(groups)
if None in [g["group_name"] for g in groups]: # tous les etudiants if None in [g["group_name"] for g in groups]: # tous les etudiants
getallstudents = True getallstudents = True
# gr_title = "tous"
gr_title_filename = "tous" gr_title_filename = "tous"
else: else:
getallstudents = False getallstudents = False
@ -917,17 +922,17 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]):
] ]
# une liste de liste de chaines: lignes de la feuille de calcul # une liste de liste de chaines: lignes de la feuille de calcul
L = [] rows = []
etuds = _get_sorted_etuds(eval_dict, etudids, formsemestre_id) etuds = _get_sorted_etuds(evaluation, etudids, formsemestre.id)
for e in etuds: for e in etuds:
etudid = e["etudid"] etudid = e["etudid"]
groups = sco_groups.get_etud_groups(etudid, formsemestre_id) groups = sco_groups.get_etud_groups(etudid, formsemestre.id)
grc = sco_groups.listgroups_abbrev(groups) grc = sco_groups.listgroups_abbrev(groups)
L.append( rows.append(
[ [
"%s" % etudid, str(etudid),
e["nom"].upper(), e["nom"].upper(),
e["prenom"].lower().capitalize(), e["prenom"].lower().capitalize(),
e["inscr"]["etat"], e["inscr"]["etat"],
@ -937,31 +942,11 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]):
] ]
) )
filename = "notes_%s_%s" % (eval_name, gr_title_filename) filename = f"notes_{eval_name}_{gr_title_filename}"
xls = sco_excel.excel_feuille_saisie( xls = sco_excel.excel_feuille_saisie(
eval_dict, sem["titreannee"], description, lines=L evaluation, formsemestre.titre_annee(), description, lines=rows
) )
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE) return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
# return sco_excel.send_excel_file(xls, filename)
def has_existing_decision(M, E, etudid):
"""Verifie s'il y a une validation pour cet etudiant dans ce semestre ou UE
Si oui, return True
"""
formsemestre_id = M["formsemestre_id"]
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if nt.get_etud_decision_sem(etudid):
return True
dec_ues = nt.get_etud_decisions_ue(etudid)
if dec_ues:
mod = sco_edit_module.module_list({"module_id": M["module_id"]})[0]
ue_id = mod["ue_id"]
if ue_id in dec_ues:
return True # decision pour l'UE a laquelle appartient cette evaluation
return False # pas de decision de jury affectee par cette note
# ----------------------------- # -----------------------------
@ -973,20 +958,18 @@ def saisie_notes(evaluation_id: int, group_ids: list = None):
if not isinstance(evaluation_id, int): if not isinstance(evaluation_id, int):
raise ScoInvalidParamError() raise ScoInvalidParamError()
group_ids = [int(group_id) for group_id in (group_ids or [])] group_ids = [int(group_id) for group_id in (group_ids or [])]
evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
if not evals: if evaluation is None:
raise ScoValueError("évaluation inexistante") raise ScoValueError("évaluation inexistante")
E = evals[0] modimpl = evaluation.moduleimpl
M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0]
formsemestre_id = M["formsemestre_id"]
moduleimpl_status_url = url_for( moduleimpl_status_url = url_for(
"notes.moduleimpl_status", "notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
moduleimpl_id=E["moduleimpl_id"], moduleimpl_id=evaluation.moduleimpl_id,
) )
# Check access # Check access
# (admin, respformation, and responsable_id) # (admin, respformation, and responsable_id)
if not sco_permissions_check.can_edit_notes(current_user, E["moduleimpl_id"]): if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id):
return f""" return f"""
{html_sco_header.sco_header()} {html_sco_header.sco_header()}
<h2>Modification des notes impossible pour {current_user.user_name}</h2> <h2>Modification des notes impossible pour {current_user.user_name}</h2>
@ -1001,16 +984,16 @@ def saisie_notes(evaluation_id: int, group_ids: list = None):
# Informations sur les groupes à afficher: # Informations sur les groupes à afficher:
groups_infos = sco_groups_view.DisplayedGroupsInfos( groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids=group_ids, group_ids=group_ids,
formsemestre_id=formsemestre_id, formsemestre_id=modimpl.formsemestre_id,
select_all_when_unspecified=True, select_all_when_unspecified=True,
etat=None, etat=None,
) )
if E["description"]: page_title = (
page_title = 'Saisie "%s"' % E["description"] f'Saisie "{evaluation.description}"'
else: if evaluation.description
page_title = "Saisie des notes" else "Saisie des notes"
)
# HTML page: # HTML page:
H = [ H = [
html_sco_header.sco_header( html_sco_header.sco_header(
@ -1036,19 +1019,19 @@ def saisie_notes(evaluation_id: int, group_ids: list = None):
"id": "menu_saisie_tableur", "id": "menu_saisie_tableur",
"endpoint": "notes.saisie_notes_tableur", "endpoint": "notes.saisie_notes_tableur",
"args": { "args": {
"evaluation_id": E["evaluation_id"], "evaluation_id": evaluation.id,
"group_ids": groups_infos.group_ids, "group_ids": groups_infos.group_ids,
}, },
}, },
{ {
"title": "Voir toutes les notes du module", "title": "Voir toutes les notes du module",
"endpoint": "notes.evaluation_listenotes", "endpoint": "notes.evaluation_listenotes",
"args": {"moduleimpl_id": E["moduleimpl_id"]}, "args": {"moduleimpl_id": evaluation.moduleimpl_id},
}, },
{ {
"title": "Effacer toutes les notes de cette évaluation", "title": "Effacer toutes les notes de cette évaluation",
"endpoint": "notes.evaluation_suppress_alln", "endpoint": "notes.evaluation_suppress_alln",
"args": {"evaluation_id": E["evaluation_id"]}, "args": {"evaluation_id": evaluation.id},
}, },
], ],
alone=True, alone=True,
@ -1077,7 +1060,9 @@ def saisie_notes(evaluation_id: int, group_ids: list = None):
) )
# Le formulaire de saisie des notes: # Le formulaire de saisie des notes:
form = _form_saisie_notes(E, M, groups_infos, destination=moduleimpl_status_url) form = _form_saisie_notes(
evaluation, modimpl, groups_infos, destination=moduleimpl_status_url
)
if form is None: if form is None:
return flask.redirect(moduleimpl_status_url) return flask.redirect(moduleimpl_status_url)
H.append(form) H.append(form)
@ -1101,10 +1086,9 @@ def saisie_notes(evaluation_id: int, group_ids: list = None):
return "\n".join(H) return "\n".join(H)
def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int): def _get_sorted_etuds(evaluation: Evaluation, etudids: list, formsemestre_id: int):
notes_db = sco_evaluation_db.do_evaluation_get_all_notes( # Notes existantes
eval_dict["evaluation_id"] notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)
) # Notes existantes
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
etuds = [] etuds = []
for etudid in etudids: for etudid in etudids:
@ -1123,17 +1107,17 @@ def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int):
e["groups"] = sco_groups.get_etud_groups(etudid, formsemestre_id) e["groups"] = sco_groups.get_etud_groups(etudid, formsemestre_id)
# Information sur absence (tenant compte de la demi-journée) # Information sur absence (tenant compte de la demi-journée)
jour_iso = ndb.DateDMYtoISO(eval_dict["jour"]) jour_iso = evaluation.jour.isoformat() if evaluation.jour else ""
warn_abs_lst = [] warn_abs_lst = []
if eval_dict["matin"]: if evaluation.is_matin():
nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=1) nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=True)
nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=1) nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=True)
if nbabs: if nbabs:
if nbabsjust: if nbabsjust:
warn_abs_lst.append("absent justifié le matin !") warn_abs_lst.append("absent justifié le matin !")
else: else:
warn_abs_lst.append("absent le matin !") warn_abs_lst.append("absent le matin !")
if eval_dict["apresmidi"]: if evaluation.is_apresmidi():
nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=0) nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=0)
nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=0) nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=0)
if nbabs: if nbabs:
@ -1169,35 +1153,38 @@ def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int):
return etuds return etuds
def _form_saisie_notes(E, M, groups_infos, destination=""): def _form_saisie_notes(
evaluation: Evaluation, modimpl: ModuleImpl, groups_infos, destination=""
):
"""Formulaire HTML saisie des notes dans l'évaluation E du moduleimpl M """Formulaire HTML saisie des notes dans l'évaluation E du moduleimpl M
pour les groupes indiqués. pour les groupes indiqués.
On charge tous les étudiants, ne seront montrés que ceux On charge tous les étudiants, ne seront montrés que ceux
des groupes sélectionnés grace a un filtre en javascript. des groupes sélectionnés grace a un filtre en javascript.
""" """
evaluation_id = E["evaluation_id"] formsemestre_id = modimpl.formsemestre_id
formsemestre_id = M["formsemestre_id"] formsemestre: FormSemestre = evaluation.moduleimpl.formsemestre
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
etudids = [ etudids = [
x[0] x[0]
for x in sco_groups.do_evaluation_listeetuds_groups( for x in sco_groups.do_evaluation_listeetuds_groups(
evaluation_id, getallstudents=True, include_demdef=True evaluation.id, getallstudents=True, include_demdef=True
) )
] ]
if not etudids: if not etudids:
return '<div class="ue_warning"><span>Aucun étudiant sélectionné !</span></div>' return '<div class="ue_warning"><span>Aucun étudiant sélectionné !</span></div>'
# Decisions de jury existantes ? # Décisions de jury existantes ?
decisions_jury = {etudid: has_existing_decision(M, E, etudid) for etudid in etudids} decisions_jury = {etudid: res.etud_has_decision(etudid) for etudid in etudids}
# Nb de decisions de jury (pour les inscrits à l'évaluation):
# Nb de décisions de jury (pour les inscrits à l'évaluation):
nb_decisions = sum(decisions_jury.values()) nb_decisions = sum(decisions_jury.values())
etuds = _get_sorted_etuds(E, etudids, formsemestre_id) etuds = _get_sorted_etuds(evaluation, etudids, formsemestre_id)
# Build form: # Build form:
descr = [ descr = [
("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}), ("evaluation_id", {"default": evaluation.id, "input_type": "hidden"}),
("formsemestre_id", {"default": formsemestre_id, "input_type": "hidden"}), ("formsemestre_id", {"default": formsemestre_id, "input_type": "hidden"}),
( (
"group_ids", "group_ids",
@ -1207,7 +1194,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
("comment", {"size": 44, "title": "Commentaire", "return_focus_next": True}), ("comment", {"size": 44, "title": "Commentaire", "return_focus_next": True}),
("changed", {"default": "0", "input_type": "hidden"}), # changed in JS ("changed", {"default": "0", "input_type": "hidden"}), # changed in JS
] ]
if M["module"]["module_type"] in ( if modimpl.module.module_type in (
ModuleType.STANDARD, ModuleType.STANDARD,
ModuleType.RESSOURCE, ModuleType.RESSOURCE,
ModuleType.SAE, ModuleType.SAE,
@ -1220,11 +1207,11 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
"title": "Notes ", "title": "Notes ",
"cssclass": "formnote_bareme", "cssclass": "formnote_bareme",
"readonly": True, "readonly": True,
"default": "&nbsp;/ %g" % E["note_max"], "default": "&nbsp;/ %g" % evaluation.note_max,
}, },
) )
) )
elif M["module"]["module_type"] == ModuleType.MALUS: elif modimpl.module.module_type == ModuleType.MALUS:
descr.append( descr.append(
( (
"s3", "s3",
@ -1238,7 +1225,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
) )
) )
else: else:
raise ValueError("invalid module type (%s)" % M["module"]["module_type"]) # bug raise ValueError(f"invalid module type ({modimpl.module.module_type})") # bug
initvalues = {} initvalues = {}
for e in etuds: for e in etuds:
@ -1248,7 +1235,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
if disabled: if disabled:
classdem = " etud_dem" classdem = " etud_dem"
etud_classes.append("etud_dem") etud_classes.append("etud_dem")
disabled_attr = 'disabled="%d"' % disabled disabled_attr = f'disabled="{disabled}"'
else: else:
classdem = "" classdem = ""
disabled_attr = "" disabled_attr = ""
@ -1265,18 +1252,17 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
) )
# Historique des saisies de notes: # Historique des saisies de notes:
if not disabled: explanation = (
explanation = ( ""
'<span id="hist_%s">' % etudid if disabled
+ get_note_history_menu(evaluation_id, etudid) else f"""<span id="hist_{etudid}">{
+ "</span>" get_note_history_menu(evaluation.id, etudid)
) }</span>"""
else: )
explanation = ""
explanation = e["absinfo"] + explanation explanation = e["absinfo"] + explanation
# Lien modif decision de jury: # Lien modif decision de jury:
explanation += '<span id="jurylink_%s" class="jurylink"></span>' % etudid explanation += f'<span id="jurylink_{etudid}" class="jurylink"></span>'
# Valeur actuelle du champ: # Valeur actuelle du champ:
initvalues["note_" + str(etudid)] = e["val"] initvalues["note_" + str(etudid)] = e["val"]
@ -1330,7 +1316,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
H.append(tf.getform()) # check and init H.append(tf.getform()) # check and init
H.append( H.append(
f"""<a href="{url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, f"""<a href="{url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept,
moduleimpl_id=M["moduleimpl_id"]) moduleimpl_id=modimpl.id)
}" class="btn btn-primary">Terminer</a> }" class="btn btn-primary">Terminer</a>
""" """
) )
@ -1345,7 +1331,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
Mettre les notes manquantes à Mettre les notes manquantes à
<input type="text" size="5" name="value"/> <input type="text" size="5" name="value"/>
<input type="submit" value="OK"/> <input type="submit" value="OK"/>
<input type="hidden" name="evaluation_id" value="{evaluation_id}"/> <input type="hidden" name="evaluation_id" value="{evaluation.id}"/>
<input class="group_ids_str" type="hidden" name="group_ids_str" value="{ <input class="group_ids_str" type="hidden" name="group_ids_str" value="{
",".join([str(x) for x in groups_infos.group_ids]) ",".join([str(x) for x in groups_infos.group_ids])
}"/> }"/>
@ -1362,50 +1348,56 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
return None return None
def save_note(etudid=None, evaluation_id=None, value=None, comment=""): def save_notes(
"""Enregistre une note (ajax)""" evaluation: Evaluation, notes: list[tuple[(int, float)]], comment: str = ""
authuser = current_user ) -> dict:
log( """Enregistre une liste de notes.
"save_note: evaluation_id=%s etudid=%s uid=%s value=%s" Vérifie que les étudiants sont bien inscrits à ce module, et que l'on a le droit.
% (evaluation_id, etudid, authuser, value) Result: dict avec
) """
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] log(f"save_note: evaluation_id={evaluation.id} uid={current_user} notes={notes}")
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] status_url = url_for(
Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
Mod["url"] = url_for(
"notes.moduleimpl_status", "notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
moduleimpl_id=M["moduleimpl_id"], moduleimpl_id=evaluation.moduleimpl_id,
_external=True, _external=True,
) )
result = {"nbchanged": 0} # JSON
# Check access: admin, respformation, or responsable_id # Check access: admin, respformation, or responsable_id
if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]): if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id):
result["status"] = "unauthorized" return json_error(403, "modification notes non autorisee pour cet utilisateur")
#
valid_notes, _, _, _, _ = _check_notes(notes, evaluation)
if valid_notes:
etudids_changed, _, etudids_with_decision = notes_add(
current_user, evaluation.id, valid_notes, comment=comment, do_it=True
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=evaluation.moduleimpl_id,
text=f"""Chargement notes dans <a href="{status_url}">{
evaluation.moduleimpl.module.titre or evaluation.moduleimpl.module.code}</a>""",
url=status_url,
max_frequency=30 * 60, # 30 minutes
)
result = {
"etudids_with_decision": etudids_with_decision,
"etudids_changed": etudids_changed,
"history_menu": {
etudid: get_note_history_menu(evaluation.id, etudid)
for etudid in etudids_changed
},
}
else: else:
L, _, _, _, _ = _check_notes([(etudid, value)], E, Mod) result = {
if L: "etudids_changed": [],
nbchanged, _, existing_decisions = notes_add( "etudids_with_decision": [],
authuser, evaluation_id, L, comment=comment, do_it=True "history_menu": [],
) }
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE, return result
obj=M["moduleimpl_id"],
text='Chargement notes dans <a href="%(url)s">%(titre)s</a>' % Mod,
url=Mod["url"],
max_frequency=30 * 60, # 30 minutes
)
result["nbchanged"] = nbchanged
result["existing_decisions"] = existing_decisions
if nbchanged > 0:
result["history_menu"] = get_note_history_menu(evaluation_id, etudid)
else:
result["history_menu"] = "" # no update needed
result["status"] = "ok"
return scu.sendJSON(result)
def get_note_history_menu(evaluation_id, etudid): def get_note_history_menu(evaluation_id: int, etudid: int) -> str:
"""Menu HTML historique de la note""" """Menu HTML historique de la note"""
history = sco_undo_notes.get_note_history(evaluation_id, etudid) history = sco_undo_notes.get_note_history(evaluation_id, etudid)
if not history: if not history:

View File

@ -42,6 +42,7 @@ sem_set_list()
import flask import flask
from flask import g, url_for from flask import g, url_for
from app import db, log
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre from app.models import FormSemestre
@ -52,7 +53,6 @@ from app.scodoc import sco_formsemestre_status
from app.scodoc import sco_portal_apogee from app.scodoc import sco_portal_apogee
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
from app import log
from app.scodoc.sco_etape_bilan import EtapeBilan from app.scodoc.sco_etape_bilan import EtapeBilan
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_vdi import ApoEtapeVDI
@ -127,7 +127,7 @@ class SemSet(dict):
self.sems = [] self.sems = []
self.formsemestres = [] self.formsemestres = []
for formsemestre_id in self.formsemestre_ids: for formsemestre_id in self.formsemestre_ids:
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
self.formsemestres.append(formsemestre) self.formsemestres.append(formsemestre)
sem = sco_formsemestre.get_formsemestre(formsemestre_id) sem = sco_formsemestre.get_formsemestre(formsemestre_id)
self.sems.append(sem) self.sems.append(sem)
@ -145,12 +145,7 @@ class SemSet(dict):
# Construction du ou des lien(s) vers le semestre # Construction du ou des lien(s) vers le semestre
self["semlinks"] = [ self["semlinks"] = [
f"""<a class="stdlink" href="{ formsemestre.html_link_status() for formsemestre in self.formsemestres
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id)
}">{formsemestre.titre_annee()}</a>
"""
for formsemestre in self.formsemestres
] ]
self["semtitles_str"] = "<br>".join(self["semlinks"]) self["semtitles_str"] = "<br>".join(self["semlinks"])
@ -383,7 +378,7 @@ class SemSet(dict):
def html_diagnostic(self): def html_diagnostic(self):
"""Affichage de la partie Effectifs et Liste des étudiants """Affichage de la partie Effectifs et Liste des étudiants
(actif seulement si un portail est configuré) (actif seulement si un portail est configuré) XXX pourquoi ??
""" """
if sco_portal_apogee.has_portal(): if sco_portal_apogee.has_portal():
return self.bilan.html_diagnostic() return self.bilan.html_diagnostic()

View File

@ -55,6 +55,7 @@ EKEY_APO = "nip"
EKEY_SCO = "code_nip" EKEY_SCO = "code_nip"
EKEY_NAME = "code NIP" EKEY_NAME = "code NIP"
# view: # view:
def formsemestre_synchro_etuds( def formsemestre_synchro_etuds(
formsemestre_id, formsemestre_id,
@ -270,11 +271,10 @@ def formsemestre_synchro_etuds(
if partitions: # il y a au moins une vraie partition if partitions: # il y a au moins une vraie partition
H.append( H.append(
f"""<li><a class="stdlink" href="{ f"""<li><a class="stdlink" href="{
url_for("scolar.affect_groups", url_for("scolar.partition_editor",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
partition_id=partitions[0]["partition_id"] }">Répartir les groupes de {partitions[0]["partition_name"]}</a></li>
)}">Répartir les groupes de {partitions[0]["partition_name"]}</a></li> """
"""
) )
H.append(footer) H.append(footer)
@ -407,6 +407,7 @@ def list_synch(sem, anneeapogee=None):
) )
# #
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
# Tri listes # Tri listes
def set_to_sorted_list(etudset, etud_apo=False, is_inscrit=False): def set_to_sorted_list(etudset, etud_apo=False, is_inscrit=False):
def key2etud(key, etud_apo=False): def key2etud(key, etud_apo=False):
@ -704,7 +705,6 @@ def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident):
typ=ScolarNews.NEWS_INSCR, typ=ScolarNews.NEWS_INSCR,
text=f"Import Apogée de {len(created_etudids)} étudiants en ", text=f"Import Apogée de {len(created_etudids)} étudiants en ",
obj=sem["formsemestre_id"], obj=sem["formsemestre_id"],
max_frequency=10 * 60, # 10'
) )

View File

@ -58,9 +58,8 @@ from flask import flash, g, request, url_for
from flask_login import current_user from flask_login import current_user
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu from app import db, log
from app import log
from app.models import UniteEns from app.models import UniteEns
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import codes_cursus from app.scodoc import codes_cursus
@ -74,6 +73,8 @@ from app.scodoc import sco_etud
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
def external_ue_create( def external_ue_create(
@ -114,7 +115,7 @@ def external_ue_create(
"is_external": True, "is_external": True,
}, },
) )
ue = UniteEns.query.get(ue_id) ue = db.session.get(UniteEns, ue_id)
flash(f"UE créée (code {ue.ue_code})") flash(f"UE créée (code {ue.ue_code})")
matiere_id = sco_edit_matiere.do_matiere_create( matiere_id = sco_edit_matiere.do_matiere_create(
{"ue_id": ue_id, "titre": titre or acronyme, "numero": 1} {"ue_id": ue_id, "titre": titre or acronyme, "numero": 1}

View File

@ -349,7 +349,7 @@ SCO_DEFAULT_SQL_USERS_CNX = "dbname=SCOUSERS port=%s" % SCO_DEFAULT_SQL_PORT
# Valeurs utilisées pour affichage seulement, pas de requetes ni de mails envoyés: # Valeurs utilisées pour affichage seulement, pas de requetes ni de mails envoyés:
SCO_WEBSITE = "https://scodoc.org" SCO_WEBSITE = "https://scodoc.org"
SCO_USER_MANUAL = "https://scodoc.org/GuideUtilisateur" SCO_USER_MANUAL = "https://scodoc.org/GuideUtilisateur"
SCO_ANNONCES_WEBSITE = "https://listes.univ-paris13.fr/mailman/listinfo/scodoc-annonces" SCO_ANNONCES_WEBSITE = "https://scodoc.org/Contact"
SCO_DEVEL_LIST = "scodoc-devel@listes.univ-paris13.fr" SCO_DEVEL_LIST = "scodoc-devel@listes.univ-paris13.fr"
SCO_USERS_LIST = "notes@listes.univ-paris13.fr" SCO_USERS_LIST = "notes@listes.univ-paris13.fr"
SCO_LISTS_URL = "https://scodoc.org/Contact" SCO_LISTS_URL = "https://scodoc.org/Contact"
@ -660,10 +660,10 @@ def bul_filename_old(sem: dict, etud: dict, format):
return filename return filename
def bul_filename(formsemestre, etud, format): def bul_filename(formsemestre, etud):
"""Build a filename for this bulletin""" """Build a filename for this bulletin (without suffix)"""
dt = time.strftime("%Y-%m-%d") dt = time.strftime("%Y-%m-%d")
filename = f"bul-{formsemestre.titre_num()}-{dt}-{etud.nom}.{format}" filename = f"bul-{formsemestre.titre_num()}-{dt}-{etud.nom}"
filename = make_filename(filename) filename = make_filename(filename)
return filename return filename

View File

@ -15,7 +15,6 @@
padding-bottom: 0px; padding-bottom: 0px;
padding-left: 16px; padding-left: 16px;
padding-right: 0px; padding-right: 0px;
background: #FFF; background: #FFF;
border: 1px solid #aaa; border: 1px solid #aaa;
border-radius: 8px; border-radius: 8px;
@ -40,3 +39,13 @@ div.code_rcue {
padding-bottom: 8px; padding-bottom: 8px;
position: relative; position: relative;
} }
div.no_niveau {
background-color: rgb(245, 237, 200);
}
div.code_jury {
padding-right: 4px;
padding-left: 4px;
width: 64px;
}

View File

@ -23,7 +23,7 @@
margin-top: 0px; margin-top: 0px;
} }
form#jury_but { .jury_but_box {
margin: 0px 16px 16px 16px; margin: 0px 16px 16px 16px;
background-color: rgb(253, 253, 231); background-color: rgb(253, 253, 231);
border: 2px solid rgb(4, 4, 118); border: 2px solid rgb(4, 4, 118);
@ -35,7 +35,9 @@ form#jury_but {
min-width: var(--sco-content-min-width); min-width: var(--sco-content-min-width);
max-width: var(--sco-content-max-width); max-width: var(--sco-content-max-width);
} }
div.jury_but_box_title {
margin-bottom: 10px;
}
.but_annee { .but_annee {
margin-left: 32px; margin-left: 32px;
display: inline-grid; display: inline-grid;
@ -168,6 +170,7 @@ div.but_niveau_ue.recorded_different,
div.but_niveau_rcue.recorded_different { div.but_niveau_rcue.recorded_different {
box-shadow: 0 0 0 3px red; box-shadow: 0 0 0 3px red;
outline: dashed 3px var(--color-recorded); outline: dashed 3px var(--color-recorded);
background-color: yellow;
} }
div.but_niveau_ue.annee_prec { div.but_niveau_ue.annee_prec {

View File

@ -0,0 +1,13 @@
div.jury_decisions_list div {
font-size: 120%;
font-weight: bold;
}
span.parcours {
color:blueviolet;
}
div.ue_list_etud_validations ul.liste_validations li {
margin-bottom: 8px;
}

View File

@ -144,6 +144,10 @@ div.ue.pair {
color: black; color: black;
} }
div.rcue {
grid-column: 1 / span 2;
}
/* ne fonctionne pas /* ne fonctionne pas
option.non_associe { option.non_associe {
background-color: yellow; background-color: yellow;
@ -154,3 +158,23 @@ option.non_associe {
margin-top: 16px; margin-top: 16px;
margin-bottom: 8px; margin-bottom: 8px;
} }
div.ue_validation_code {
display: inline-block;
}
div.ue_validation_code div.code {
margin-left: 12px;
}
select.validation_rcue {
color: black;
display: inline-block;
margin-left: 32px;
}
div.recap_ects, div.link_edit {
margin-left: 16px;
margin-right: 16px;
margin-bottom: 16px;
}
.link_edit a {
padding-right: 48px;
}

View File

@ -106,7 +106,7 @@ body:not(.editionActivated) .editing {
@keyframes boing { @keyframes boing {
100% { 100% {
transform: translateY(-20px) transform: translateY(-20px);
} }
} }
@ -152,6 +152,7 @@ body.editionActivated .filtres>div>div>div>div {
color: #000; color: #000;
border-radius: 4px; border-radius: 4px;
outline: 4px solid #FFF; outline: 4px solid #FFF;
padding: 2px;
} }
/* Suppression */ /* Suppression */
@ -354,6 +355,10 @@ body.editionActivated .filtres .nonEditable .move {
display: initial; display: initial;
} }
.groupe:has(.etudiants:empty) {
display: none;
}
/* .filtres .unselect { /* .filtres .unselect {
background: rgba(0, 153, 204, 0.5) !important; background: rgba(0, 153, 204, 0.5) !important;
} */ } */
@ -361,7 +366,29 @@ body.editionActivated .filtres .nonEditable .move {
/*****************************/ /*****************************/
/* Zone Etudiants */ /* Zone Etudiants */
/*****************************/ /*****************************/
#zoneChoix>.autoAffectation { #zoneChoix summary{
margin: 0 0 16px;
cursor: pointer;
}
#zoneChoix .autoAffectation>a {
text-decoration: underline;
}
#zoneChoix .dropZone {
background: #FFF;
border-radius: 8px;
border: 2px dashed #09C;
margin-bottom: 4px;
padding: 4px;
transition: 0.2s;
}
.fileOver {
transform: scale(0.9);
}
#zoneChoix .autoAffectation {
background: #c9c9c9; background: #c9c9c9;
color: #141414; color: #141414;
padding: 4px 8px; padding: 4px 8px;
@ -369,13 +396,13 @@ body.editionActivated .filtres .nonEditable .move {
border-radius: 4px; border-radius: 4px;
} }
#zoneChoix>.autoAffectation>select { #zoneChoix .autoAffectation>select {
border: none; border: none;
padding: 4px; padding: 4px;
border-radius: 4px; border-radius: 4px;
} }
#zoneChoix>.autoAffectation>.affectationGo { #zoneChoix .autoAffectation>.affectationGo {
display: inline-block; display: inline-block;
background: #0c9; background: #0c9;
padding: 8px 16px; padding: 8px 16px;

View File

@ -65,27 +65,34 @@ div#gtrcontent {
} }
div.flashes { div.flashes {
transition: opacity 0.5s ease;
margin-top: 8px;
left: 50%; left: 50%;
margin-top: 8px;
max-width: 800px;
position: fixed; position: fixed;
text-align: center;
top: 8px; top: 8px;
transform: translateX(-50%); transform: translateX(-50%);
width: auto; transition: opacity 0.5s ease;
z-index: 1000; z-index: 1000;
} }
div.alert { div.alert {
/* padding: 16px;
position: absolute; border-radius: 12px;
top: 10px; font-size: 200%;
right: 10px; */ opacity: 0.9;
} }
div.alert-info { div.alert-info {
color: #0019d7; color: #208d3b;
background-color: #68f36d; background-color: #fffd97;
border-color: #0a8d0c; border-color: #208d3b;
}
div.alert-warning {
color: #ef5c00;
background-color: #fbfb00d4;
border-color: #767676;
} }
div.alert-error { div.alert-error {
@ -94,6 +101,9 @@ div.alert-error {
border-color: #8d0a17; border-color: #8d0a17;
} }
form.inline-form {
display: inline-block;
}
div.tab-content { div.tab-content {
margin-top: 10px; margin-top: 10px;
@ -629,7 +639,7 @@ div.news {
border-radius: 8px; border-radius: 8px;
} }
div.news a { div.news a, div.news a.stdlink {
color: black; color: black;
text-decoration: none; text-decoration: none;
} }
@ -905,6 +915,17 @@ td.fichetitre2 .fl {
font-weight: normal; font-weight: normal;
} }
div.section_but {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-evenly;
}
div.section_but > div.link_validation_rcues {
align-self: center;
}
.ficheannotations { .ficheannotations {
background-color: #f7d892; background-color: #f7d892;
width: 910px; width: 910px;
@ -1112,9 +1133,11 @@ a.discretelink:hover {
text-align: center; text-align: center;
} }
.expl, .help {
max-width: var(--sco-content-max-width);
}
.help { .help {
font-style: italic; font-style: italic;
max-width: 800px;
} }
.help_important { .help_important {
@ -1122,13 +1145,29 @@ a.discretelink:hover {
color: red; color: red;
} }
div.sco_help { div.sco_box, div.sco_help {
margin-top: 12px; margin-top: 12px;
margin-bottom: 4px; margin-bottom: 4px;
margin-left: 0px;
padding: 8px; padding: 8px;
border-radius: 4px; border-radius: 4px;
border: 1px solid grey;
max-width: var(--sco-content-max-width);
}
div.sco_help {
font-style: italic; font-style: italic;
background-color: rgb(200, 200, 220); background-color: rgb(209, 255, 214);
}
div.sco_box_title {
font-size: 120%;
font-weight: bold;
margin-bottom: 8px;
}
.sco_green_bg {
background-color: rgb(155, 218, 155);
}
.sco_lightgreen_bg {
background-color: rgb(209, 255, 214);
} }
div.vertical_spacing_but { div.vertical_spacing_but {
@ -2503,13 +2542,7 @@ input.sco_tag_checkbox {
} }
div#ue_list_code { div#ue_list_code {
background-color: rgb(155, 218, 155);
padding: 10px;
border: 1px solid blue; border: 1px solid blue;
border-radius: 10px;
padding: 10px;
margin-top: 10px;
margin-right: 15px;
} }
ul.notes_module_list { ul.notes_module_list {
@ -2595,16 +2628,6 @@ div#ue_list_modules {
margin-right: 15px; margin-right: 15px;
} }
div#ue_list_etud_validations {
background-color: rgb(220, 250, 220);
padding-left: 4px;
padding-bottom: 1px;
margin: 3ex;
}
div#ue_list_etud_validations span {
font-weight: bold;
}
span.ue_share { span.ue_share {
font-weight: bold; font-weight: bold;
@ -2773,6 +2796,8 @@ table.notes_recapcomplet a:hover {
div.table_recap_caption { div.table_recap_caption {
width: fit-content; width: fit-content;
margin-top: 8px;
margin-bottom: 8px;
padding: 8px; padding: 8px;
border-radius: 8px; border-radius: 8px;
background-color: rgb(202, 255, 180); background-color: rgb(202, 255, 180);
@ -3173,6 +3198,19 @@ li.tf-msg {
/* EMO_WARNING, "&#9888;&#65039;" */ /* EMO_WARNING, "&#9888;&#65039;" */
} }
p.error {
font-weight: bold;
color: red;
}
p.error::before {
content: "\2049 \fe0f";
margin-right: 8px;
}
mark {
padding-right: 0px;
}
.infop { .infop {
font-weight: normal; font-weight: normal;

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