1
0
Fork 0

Compare commits

...

140 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -225,7 +225,7 @@ class User(UserMixin, db.Model):
return None
except (TypeError, KeyError):
return None
return User.query.get(user_id)
return db.session.get(User, user_id)
def to_dict(self, include_email=True):
"""l'utilisateur comme un dict, avec des champs supplémentaires"""
@ -376,7 +376,9 @@ class User(UserMixin, db.Model):
"""
if not isinstance(role, Role):
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):
"""Add roles to this user.

View File

@ -12,6 +12,7 @@ import datetime
import numpy as np
from flask import g, has_request_context, url_for
from app import db
from app.comp.res_but import ResultatsSemestreBUT
from app.models import Evaluation, FormSemestre, Identite
from app.models.groups import GroupDescr
@ -158,7 +159,7 @@ class BulletinBUT:
[etud.id]
].iterrows():
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
if ue.acronyme in d:
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 "-"
t = {
"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",
"_pdf_row_markup": ["b"],
"_pdf_style": [

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -31,9 +31,11 @@ from app.models import (
UniteEns,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
ScolarNews,
)
from app.models.config import ScoDocSiteConfig
from app.scodoc import html_sco_header
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
@ -91,35 +93,25 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
<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(
f"""<div class="but_niveau_titre">
<div title="{niveau.competence.titre_long}">{niveau.competence.titre}</div>
</div>"""
)
dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) # peut être None
ues = [
ue
for ue in deca.ues_impair
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
]
ue_impair = ues[0] if ues else None
ues = [
ue
for ue in deca.ues_pair
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
]
ue_pair = ues[0] if ues else None
ue_impair, ue_pair = rcue.ue_1, rcue.ue_2
# Les UEs à afficher,
# qui seront toujours en readonly sur le formsemestre de l'année précédente du redoublant
# qui
ues_ro = [
(
ue_impair,
(deca.a_cheval and deca.formsemestre_id != deca.formsemestre_impair.id),
rcue.ue_cur_impair is None,
),
(
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:
@ -153,17 +145,22 @@ def _gen_but_select(
code_valide: str,
disabled: bool = False,
klass: str = "",
data: dict = {},
data: dict = None,
code_valide_label: str = "",
) -> str:
"Le menu html select avec les codes"
# if disabled: # mauvaise idée car le disabled est traité en JS
# return f"""<div class="but_code {klass}">{code_valide}</div>"""
data = data or {}
options_htm = "\n".join(
[
f"""<option value="{code}"
{'selected' if code == code_valide else ''}
class="{'recorded' if code == code_valide else ''}"
>{code}</option>"""
>{code
if ((code != code_valide) or not code_valide_label)
else code_valide_label
}</option>"""
for code in codes
]
)
@ -202,20 +199,54 @@ def _gen_but_niveau_ue(
</div>
</div>
"""
elif dec_ue.formsemestre is None:
# Validation d'UE antérieure (semestre hors année scolaire courante)
if dec_ue.validation:
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.validation.moy_ue)}</span>"""
scoplement = f"""<div class="scoplement">
<div>
<b>UE {ue.acronyme} antérieure </b>
<span>validée {dec_ue.validation.code}
le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
</span>
</div>
<div>Non reprise dans l'année en cours</div>
</div>
"""
else:
moy_ue_str = """<span>-</span>"""
scoplement = """<div class="scoplement">
<div>
<b>Pas d'UE en cours ou validée dans cette compétence de ce côté.</b>
</div>
</div>
"""
else:
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
if dec_ue.code_valide:
scoplement = f"""<div class="scoplement">
<div>Code {dec_ue.code_valide} enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
date_str = (
f"""enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
à {dec_ue.validation.event_date.strftime("%Hh%M")}
"""
if dec_ue.validation and dec_ue.validation.event_date
else ""
)
scoplement = f"""<div class="scoplement">
<div>Code {dec_ue.code_valide} {date_str}
</div>
</div>
"""
else:
scoplement = ""
return f"""<div class="but_niveau_ue {
'recorded' if dec_ue.code_valide is not None else ''}
ue_class = "" # 'recorded' if dec_ue.code_valide is not None else ''
if dec_ue.code_valide is not None and dec_ue.codes:
if dec_ue.code_valide == dec_ue.codes[0]:
ue_class = "recorded"
else:
ue_class = "recorded_different"
return f"""<div class="but_niveau_ue {ue_class}
{'annee_prec' if annee_prec else ''}
">
<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:
if dec_rcue is None:
if dec_rcue is None or not dec_rcue.rcue.complete:
return """
<div class="but_niveau_rcue niveau_vide with_scoplement">
<div></div>
@ -244,13 +275,25 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
</div>
"""
scoplement = (
f"""<div class="scoplement">{
dec_rcue.validation.to_html()
}</div>"""
if dec_rcue.validation
else ""
)
code_propose_menu = dec_rcue.code_valide # le code enregistré
code_valide_label = code_propose_menu
if dec_rcue.validation:
if dec_rcue.code_valide == dec_rcue.codes[0]:
descr_validation = dec_rcue.validation.html()
else: # on une validation enregistrée différence de celle proposée
descr_validation = f"""Décision recommandée: <b>{dec_rcue.codes[0]}.</b>
Il y avait {dec_rcue.validation.html()}"""
if (
sco_codes.BUT_CODES_ORDER[dec_rcue.codes[0]]
> sco_codes.BUT_CODES_ORDER[dec_rcue.code_valide]
):
code_propose_menu = dec_rcue.codes[0]
code_valide_label = (
f"{dec_rcue.codes[0]} (actuel {dec_rcue.code_valide})"
)
scoplement = f"""<div class="scoplement">{descr_validation}</div>"""
else:
scoplement = "" # "pas de validation"
# Déjà enregistré ?
niveau_rcue_class = ""
@ -270,10 +313,11 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
<div class="but_code">
{_gen_but_select("code_rcue_"+str(niveau.id),
dec_rcue.codes,
dec_rcue.code_valide,
code_propose_menu,
disabled=True,
klass="manual code_rcue",
data = { "niveau_id" : str(niveau.id)}
data = { "niveau_id" : str(niveau.id)},
code_valide_label = code_valide_label,
)}
</div>
</div>
@ -351,6 +395,16 @@ def jury_but_semestriel(
flash(
f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée"
)
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=formsemestre.id,
text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""",
url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
)
return flask.redirect(
url_for(
"notes.formsemestre_validation_but",
@ -394,7 +448,7 @@ def jury_but_semestriel(
{warning}
</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 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.sco_utils import ModuleType
@ -194,7 +194,7 @@ class BonusSportAdditif(BonusSport):
# les points au dessus du seuil sont comptés (defaut: seuil_moy_gen):
seuil_comptage = None
proportion_point = 0.05 # multiplie les points au dessus du seuil
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
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
@ -435,8 +435,11 @@ class BonusAmiens(BonusSportAdditif):
class BonusBesanconVesoul(BonusSportAdditif):
"""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
sur toutes les moyennes d'UE.
<p>Le bonus est compris entre 0 et 0,2 points.
et est reporté sur les moyennes d'UE.
</p>
<p>La valeur saisie doit être entre 0 et 0,2: toute valeur
supérieure à 0,2 entraine un bonus de 0,2.
</p>
"""
@ -444,7 +447,7 @@ class BonusBesanconVesoul(BonusSportAdditif):
displayed_name = "IUT de Besançon - Vesoul"
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 0.0 # tous les points sont comptés
proportion_point = 1e10 # infini
proportion_point = 1
bonus_max = 0.2
@ -740,6 +743,7 @@ class BonusGrenobleIUT1(BonusSportMultiplicatif):
name = "bonus_iut1grenoble_2017"
displayed_name = "IUT de Grenoble 1"
# C'est un bonus "multiplicatif": on l'exprime en additif,
# sur chaque moyenne d'UE m_0
# Augmenter de 5% correspond à multiplier par a=1.05
@ -782,6 +786,7 @@ class BonusIUTRennes1(BonusSportAdditif):
seuil_moy_gen = 10.0
proportion_point = 1 / 20.0
classic_use_bonus_ues = False
# S'applique aussi en classic, sur la moy. gen.
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus"""
@ -822,16 +827,32 @@ class BonusStMalo(BonusIUTRennes1):
class BonusLaRocheSurYon(BonusSportAdditif):
"""Bonus IUT de La Roche-sur-Yon
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>
<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"
displayed_name = "IUT de La Roche-sur-Yon"
seuil_moy_gen = 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):
@ -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):
"""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
amplitude = 0.01 / 4 # 4pt => 1%
factor_max = 0.1 # 10% max
# Modifié 2022-11-29: calculer chaque bonus
# (de 1 à 3 modules) séparément.
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
@ -1533,6 +1585,63 @@ class BonusIUTV(BonusSportAdditif):
# c'est le bonus par défaut: aucune méthode à surcharger
# Finalement inutile: un bonus direct est mieux adapté à leurs besoins.
# # class BonusMastersUSPNIG(BonusSportAdditif):
# """Calcul bonus modules optionnels (sport, culture), règle Masters de l'Institut Galilée (USPN)
# Les étudiants peuvent suivre des enseignements optionnels
# de l'USPN (sports, musique, deuxième langue, culture, etc) dans une
# UE libre. Les points au-dessus de 10 sur 20 obtenus dans cette UE
# libre sont ajoutés au total des points obtenus pour les UE obligatoires
# du semestre concerné.
# """
# name = "bonus_masters__uspn_ig"
# displayed_name = "Masters de l'Institut Galilée (USPN)"
# proportion_point = 1.0
# seuil_moy_gen = 10.0
# def __init__(
# self,
# formsemestre: "FormSemestre",
# sem_modimpl_moys: np.array,
# ues: list,
# modimpl_inscr_df: pd.DataFrame,
# modimpl_coefs: np.array,
# etud_moy_gen,
# etud_moy_ue,
# ):
# # Pour ce bonus, il nous faut la somme des coefs des modules non bonus
# # du formsemestre (et non auxquels les étudiants sont inscrits !)
# self.sum_coefs = sum(
# [
# m.module.coefficient
# for m in formsemestre.modimpls_sorted
# if (m.module.module_type == ModuleType.STANDARD)
# and (m.module.ue.type == UE_STANDARD)
# ]
# )
# super().__init__(
# formsemestre,
# sem_modimpl_moys,
# ues,
# modimpl_inscr_df,
# modimpl_coefs,
# etud_moy_gen,
# etud_moy_ue,
# )
# # Bonus sur la moyenne générale seulement
# # On a dans bonus_moy_arr le bonus additif classique
# # Sa valeur sera appliquée comme moy_gen += bonus_moy_gen
# # or ici on veut
# # moy_gen = (somme des notes + bonus_moy_arr) / somme des coefs
# # moy_gen += bonus_moy_arr / somme des coefs
# self.bonus_moy_gen = (
# None if self.bonus_moy_gen is None else self.bonus_moy_gen / self.sum_coefs
# )
def get_bonus_class_dict(start=BonusSport, d=None):
"""Dictionnaire des classes de bonus
(liste les sous-classes de BonusSport ayant un nom)

View File

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

View File

@ -134,7 +134,7 @@ class ModuleImplResults:
manque des notes) ssi il y a des étudiants inscrits au semestre et au module
qui ont des notes ATT.
"""
moduleimpl = ModuleImpl.query.get(self.moduleimpl_id)
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
self.etudids = self._etudids()
# --- Calcul nombre d'inscrits pour déterminer les évaluations "completes":
@ -225,8 +225,8 @@ class ModuleImplResults:
"""
return [
inscr.etudid
for inscr in ModuleImpl.query.get(
self.moduleimpl_id
for inscr in db.session.get(
ModuleImpl, self.moduleimpl_id
).formsemestre.inscriptions
]
@ -319,10 +319,16 @@ class ModuleImplResultsAPC(ModuleImplResults):
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
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_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:
return pd.DataFrame(index=[], columns=evals_poids_df.columns)
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)
"""
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()
ues = modimpl.formsemestre.get_ues(with_sport=False)
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)
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
if nb_etuds == 0:
return pd.Series()

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@ import pandas as pd
from flask import g, url_for
from app import db
from app.comp import res_sem
from app.comp.res_cache import ResultatsCache
from app.comp.jury import ValidationsSemestre
@ -31,6 +32,7 @@ from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu
# Il faut bien distinguer
# - ce qui est caché de façon persistente (via redis):
# ce sont les attributs listés dans `_cached_attrs`
@ -137,7 +139,7 @@ class ResultatsSemestre(ResultatsCache):
def etud_ues(self, etudid: int) -> Generator[UniteEns]:
"""Liste des UE auxquelles l'étudiant est inscrit
(sans bonus, en BUT prend en compte le parcours de l'étudiant)."""
return (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:
"""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.
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()
if ue.type == UE_SPORT:
@ -381,7 +383,11 @@ class ResultatsSemestre(ResultatsCache):
was_capitalized = False
if etudid in self.validations.ue_capitalisees.index:
ue_cap = self._get_etud_ue_cap(etudid, ue)
if ue_cap and 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
if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_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 self.is_apc:
# 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
if coef_ue is None:
orig_sem = FormSemestre.get_formsemestre(ue_cap["formsemestre_id"])

View File

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

View File

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

View File

@ -164,7 +164,10 @@ class EntrepriseStageApprentissage(db.Model):
entreprise_id = db.Column(
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)
date_debut = 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")
)
annee = db.Column(db.Integer)
montant = db.Column(db.Integer)
montant = db.Column(db.Float)
notes = db.Column(db.Text)

View File

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

View File

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

View File

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

View File

@ -2,9 +2,6 @@
"""Décisions de jury (validations) des RCUE et années du BUT
"""
from typing import Union
from flask_sqlalchemy.query import Query
from app import db
from app.models import CODE_STR_LEN
@ -13,8 +10,6 @@ from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.scodoc import codes_cursus as sco_codes
from app.scodoc import sco_utils as scu
class ApcValidationRCUE(db.Model):
@ -22,7 +17,7 @@ class ApcValidationRCUE(db.Model):
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"
@ -41,7 +36,7 @@ class ApcValidationRCUE(db.Model):
formsemestre_id = db.Column(
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:
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)
@ -66,7 +61,7 @@ class ApcValidationRCUE(db.Model):
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
def to_html(self) -> str:
def html(self) -> str:
"description en HTML"
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
<b>{self.code}</b>
@ -87,6 +82,10 @@ class ApcValidationRCUE(db.Model):
"as a dict"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
d["etud"] = self.etud.to_dict_short()
d["ue1"] = self.ue1.to_dict()
d["ue2"] = self.ue2.to_dict()
return d
def to_dict_bul(self) -> dict:
@ -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):
"""Validation des années du BUT"""
__tablename__ = "apc_validation_annee"
# 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)
etudid = db.Column(
db.Integer,
@ -319,8 +128,11 @@ class ApcValidationAnnee(db.Model):
formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True
)
"le semestre IMPAIR (le 1er) de l'année"
annee_scolaire = db.Column(db.Integer, nullable=False) # 2021
"le semestre origine, normalement l'IMPAIR (le 1er) de l'année"
referentiel_competence_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
)
annee_scolaire = db.Column(db.Integer, nullable=False) # eg 2021
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
@ -338,25 +150,50 @@ class ApcValidationAnnee(db.Model):
"dict pour bulletins"
return {
"annee_scolaire": self.annee_scolaire,
"date": self.date.isoformat(),
"date": self.date.isoformat() if self.date else "",
"code": self.code,
"ordre": self.ordre,
}
def html(self) -> str:
"Affichage html"
date_str = (
f"""le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}"""
if self.date
else "(sans date)"
)
link = (
self.formsemestre.html_link_status(
label=f"{self.formsemestre.titre_formation(with_sem_idx=1)}",
title=self.formsemestre.titre_annee(),
)
if self.formsemestre
else "externe/antérieure"
)
return f"""Validation <b>année BUT{self.ordre}</b> émise par
{link}
: <b>{self.code}</b>
{date_str}
"""
def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
"""
Un dict avec les décisions de jury BUT enregistrées:
- decision_rcue : list[dict]
- decision_annee : dict
- decision_annee : dict (décision issue de ce semestre seulement (à confirmer ?))
Ne reprend pas les décisions d'UE, non spécifiques au BUT.
"""
decisions = {}
# --- RCUEs: seulement sur semestres pairs XXX à améliorer
if formsemestre.semestre_id % 2 == 0:
# validations émises depuis ce formsemestre:
validations_rcues = ApcValidationRCUE.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id
validations_rcues = (
ApcValidationRCUE.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id
)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
.order_by(UniteEns.numero, UniteEns.acronyme)
)
decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues]
titres_rcues = []
@ -378,16 +215,11 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
decisions["descr_decisions_rcue"] = ""
decisions["descr_decisions_niveaux"] = ""
# --- Année: prend la validation pour l'année scolaire de ce semestre
validation = (
ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
)
.join(ApcValidationAnnee.formsemestre)
.join(FormSemestre.formation)
.filter(Formation.formation_code == formsemestre.formation.formation_code)
.first()
)
validation = ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
).first()
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
else:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -295,8 +295,15 @@ class ApoCSVReadWrite:
filename=self.get_filename(),
)
cols = {} # { col_id : value }
for i, field in enumerate(fields):
cols[self.col_ids[i]] = field
try:
for i, field in enumerate(fields):
cols[self.col_ids[i]] = field
except IndexError as exc:
raise
raise ScoFormatError(
f"Fichier Apogee incorrect (colonnes excédentaires ? (<tt>{i}/{field}</tt>))",
filename=self.get_filename(),
) from exc
etud_tuples.append(
ApoEtudTuple(
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.models import Departement, FormSemestre
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 sco_bulletins_pdf
from app.scodoc import sco_groups
@ -125,6 +125,12 @@ class BaseArchiver(object):
if not os.path.isdir(obj_dir):
log(f"creating directory {obj_dir}")
os.mkdir(obj_dir)
except FileExistsError as exc:
raise ScoException(
f"""BaseArchiver error: obj_dir={obj_dir} exists={
os.path.exists(obj_dir)
} isdir={os.path.isdir(obj_dir)}"""
) from exc
finally:
scu.GSL.release()
return obj_dir
@ -338,7 +344,7 @@ def do_formsemestre_archive(
if data:
PVArchive.store(archive_id, "Tableau_moyennes" + scu.XLSX_SUFFIX, data)
# Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes)
table_html, _ = gen_formsemestre_recapcomplet_html_table(
table_html, _, _ = gen_formsemestre_recapcomplet_html_table(
formsemestre, res, include_evaluations=True
)
if table_html:

View File

@ -38,7 +38,7 @@ from flask import flash, render_template, url_for
from flask_json import json_response
from flask_login import current_user
from app import email
from app import db, email
from app import log
from app.scodoc.sco_utils import json_error
from app.but import bulletin_but
@ -354,7 +354,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
"modules_capitalized"
] = [] # modules de l'UE capitalisée (liste vide si pas capitalisée)
if ue_status["is_capitalized"] and ue_status["formsemestre_id"] is not None:
sem_origin = FormSemestre.query.get(ue_status["formsemestre_id"])
sem_origin = db.session.get(FormSemestre, ue_status["formsemestre_id"])
u[
"ue_descr_txt"
] = 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":
# 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(
formsemestre_cap
)
@ -749,7 +751,7 @@ def etud_descr_situation_semestre(
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
parcour_id = res.etuds_parcour_id[etudid]
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:
infos["parcours_titre"] = parcour.libelle or ""
@ -928,7 +930,7 @@ def formsemestre_bulletinetud(
"""
format = format or "html"
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
if not formsemestre:
raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
@ -943,7 +945,7 @@ def formsemestre_bulletinetud(
)[0]
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)
return scu.send_file(bulletin, filename, mime=mime, suffix=suffix)
elif format == "pdfmail":
@ -1238,7 +1240,7 @@ def make_menu_autres_operations(
"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",
"args": {
"formsemestre_id": formsemestre.id,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -98,10 +98,10 @@ def module_list(*args, **kw):
def do_module_create(args) -> int:
"Create a module. Returns id of new object."
formation = 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:
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:
raise ScoValueError("Formation incompatible: contacter le support ScoDoc")
# create
@ -114,7 +114,6 @@ def do_module_create(args) -> int:
typ=ScolarNews.NEWS_FORM,
obj=formation.id,
text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
)
formation.invalidate_cached_sems()
return module_id
@ -186,7 +185,6 @@ def do_module_delete(oid):
typ=ScolarNews.NEWS_FORM,
obj=mod["formation_id"],
text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
)
formation.invalidate_cached_sems()
@ -250,7 +248,7 @@ def do_module_edit(vals: dict) -> None:
# edit
cnx = ndb.GetDBConnexion()
_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):
@ -661,6 +659,7 @@ def module_edit(
"explanation": "numéro (1, 2, 3, 4, ...) pour ordre d'affichage",
"type": "int",
"default": default_num,
"allow_null": False,
},
),
]
@ -806,7 +805,7 @@ def module_edit(
if create:
if not matiere_id:
# 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:
raise ValueError("UE invalide")
matiere = ue.matieres.first()
@ -820,7 +819,7 @@ def module_edit(
tf[2]["semestre_id"] = ue.semestre_idx
module_id = do_module_create(tf[2])
module = Module.query.get(module_id)
module = db.session.get(Module, module_id)
else: # EDITION MODULE
# l'UE de rattachement peut changer
tf[2]["ue_id"], tf[2]["matiere_id"] = tf[2]["ue_matiere_id"].split("!")
@ -838,7 +837,7 @@ def module_edit(
)
# En APC, force le semestre égal à celui de l'UE
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:
raise ValueError("UE invalide")
tf[2]["semestre_id"] = selected_ue.semestre_idx
@ -854,13 +853,13 @@ def module_edit(
module.parcours = formation.referentiel_competence.parcours.all()
else:
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"]
]
# Modifie les AC
if "app_critiques" in tf[2]:
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"]
]
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_login import current_user
from app import db
from app import log
from app import db, log
from app.but import apc_edit_ue
from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN
from app.models import (
@ -137,15 +136,14 @@ def do_ue_create(args):
ue_id = _ueEditor.create(cnx, 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()
# news
formation = Formation.query.get(args["formation_id"])
formation = db.session.get(Formation, args["formation_id"])
ScolarNews.add(
typ=ScolarNews.NEWS_FORM,
obj=args["formation_id"],
text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
)
formation.invalidate_cached_sems()
return ue_id
@ -230,7 +228,6 @@ def do_ue_delete(ue: UniteEns, delete_validations=False, force=False):
typ=ScolarNews.NEWS_FORM,
obj=formation.id,
text=f"Modification de la formation {formation.acronyme}",
max_frequency=10 * 60,
)
#
if not force:
@ -286,7 +283,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
}
submitlabel = "Créer cette UE"
can_change_semestre_id = True
formation = Formation.query.get(formation_id)
formation = db.session.get(Formation, formation_id)
if not formation:
raise ScoValueError(f"Formation inexistante ! (id={formation_id})")
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",
"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",
},
),
@ -503,7 +501,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
else:
clone_form = ""
bonus_div = """<div id="bonus_description"></div>"""
ue_div = """<div id="ue_list_code"></div>"""
ue_div = """<div id="ue_list_code" class="sco_box sco_green_bg"></div>"""
return (
"\n".join(H)
+ 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"],
},
)
ue = UniteEns.query.get(ue_id)
ue = db.session.get(UniteEns, ue_id)
flash(f"UE créée (code {ue.ue_code})")
else:
if not tf[2]["numero"]:
tf[2]["numero"] = 0
do_ue_edit(tf[2])
flash("UE modifiée")
@ -596,7 +596,7 @@ def next_ue_numero(formation_id, semestre_id=None):
"""Numero d'une nouvelle UE dans cette formation.
Si le semestre est specifie, cherche les UE ayant des modules de ce semestre
"""
formation = Formation.query.get(formation_id)
formation = db.session.get(Formation, formation_id)
ues = ue_list(args={"formation_id": formation_id})
if not ues:
return 0
@ -660,7 +660,7 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
"""
from app.scodoc import sco_formsemestre_validation
formation: Formation = Formation.query.get(formation_id)
formation: Formation = db.session.get(Formation, formation_id)
if not formation:
raise ScoValueError("invalid formation_id")
parcours = formation.get_cursus()
@ -756,7 +756,7 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
],
page_title=f"Programme {formation.acronyme} v{formation.version}",
),
f"""<h2>{formation.to_html()} {lockicon}
f"""<h2>{formation.html()} {lockicon}
</h2>
""",
]
@ -1009,12 +1009,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
<p><ul>"""
)
for formsemestre in formsemestres:
H.append(
f"""<li><a class="stdlink" href="{
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id
)}">{formsemestre.titre_mois()}</a>"""
)
H.append(f"""<li>{formsemestre.html_link_status()}""")
if not formsemestre.etat:
H.append(" [verrouillé]")
else:
@ -1381,13 +1376,12 @@ def _ue_table_modules(
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
Either ue_code or ue_id may be specified.
hide_ue_id spécifie un id à retirer de la liste.
"""
ue_code = str(ue_code)
if ue_id:
if ue_id is not None:
ue = UniteEns.query.get_or_404(ue_id)
if not 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)
)
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)
ues = q_ues.all()
msg = " dans les formations du département "
if not ues:
if ue_id:
return (
f"""<span class="ue_share">Seule UE avec code {ue_code or '-'}</span>"""
)
if ue_id is not None:
return f"""<span class="ue_share">Seule UE avec code {
ue_code if ue_code is not None else '-'}{msg}</span>"""
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 = []
if ue_id:
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:
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>")
for ue in ues:
H.append(
f"""<li>{ue.acronyme} ({ue.titre}) dans <a class="stdlink"
href="{url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)}"
f"""<li>{ue.acronyme} ({ue.titre}) dans
<a class="stdlink" href="{
url_for("notes.ue_table",
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)}"
>{ue.formation.acronyme} ({ue.formation.titre})</a>, version {ue.formation.version}
</li>
"""
@ -1460,7 +1461,7 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
cnx = ndb.GetDBConnexion()
_ueEditor.edit(cnx, args)
formation = Formation.query.get(ue["formation_id"])
formation = db.session.get(Formation, ue["formation_id"])
if not dont_invalidate_cache:
# Invalide les semestres utilisant cette formation
# ainsi que les poids et coefs

View File

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

View File

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

View File

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

View File

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

View File

@ -433,7 +433,7 @@ def excel_simple_table(
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.
E: evaluation (dict)
lines: liste de tuples
@ -512,18 +512,20 @@ def excel_feuille_saisie(e, titreannee, description, lines):
# description evaluation
ws.append_single_cell_row(scu.unescape_html(description), style_titres)
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
ws.append_blank_row()
# code et titres colonnes
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("Prénom", 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),
]
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,16 +36,12 @@ import time
from flask import g, url_for
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app import log
from app import db, log
from app.models import ScolarNews, GroupDescr
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.sco_excel import COLORS
from app.scodoc.sco_exceptions import (
AccessDenied,
ScoFormatError,
@ -55,7 +51,6 @@ from app.scodoc.sco_exceptions import (
ScoLockedFormError,
ScoGenError,
)
from app.scodoc import html_sco_header
from app.scodoc import sco_cache
from app.scodoc import sco_etud
@ -63,6 +58,11 @@ from app.scodoc import sco_groups
from app.scodoc import sco_excel
from app.scodoc import sco_groups_view
from app.scodoc import sco_preferences
import app.scodoc.notesdb as ndb
from app.scodoc.sco_formsemestre_inscriptions import (
do_formsemestre_inscription_with_modules,
)
import app.scodoc.sco_utils as scu
# format description (in tools/)
FORMAT_FILE = "format_import_etudiants.txt"
@ -480,6 +480,7 @@ def scolars_import_excel_file(
text="Inscription de %d étudiants" # peuvent avoir ete inscrits a des semestres differents
% len(created_etudids),
obj=formsemestre_id,
max_frequency=0,
)
log("scolars_import_excel_file: completing transaction")
@ -638,10 +639,10 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
fields = adm_get_fields(titles, formsemestre_id)
idx_nom = None
idx_prenom = None
for idx in fields:
if fields[idx][0] == "nom":
for idx, field in fields.items():
if field[0] == "nom":
idx_nom = idx
if fields[idx][0] == "prenom":
if field[0] == "prenom":
idx_prenom = idx
if (idx_nom is None) or (idx_prenom is None):
log("fields indices=" + ", ".join([str(x) for x in fields]))
@ -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)
nom = adm_normalize_string(line[idx_nom])
prenom = adm_normalize_string(line[idx_prenom])
if not (nom, prenom) in etuds_by_nomprenom:
log(
"unable to find %s %s among members" % (line[idx_nom], line[idx_prenom])
)
if (nom, prenom) not in etuds_by_nomprenom:
msg = f"""Étudiant <b>{line[idx_nom]} {line[idx_prenom]} inexistant</b>"""
diag.append(msg)
else:
etud = etuds_by_nomprenom[(nom, prenom)]
cur_adm = sco_etud.admission_list(cnx, args={"etudid": etud["etudid"]})[0]
# peuple les champs presents dans le tableau
args = {}
for idx in fields:
field_name, convertor = fields[idx]
for idx, field in fields.items():
field_name, convertor = field
if field_name in modifiable_fields:
try:
val = convertor(line[idx])
except ValueError:
except ValueError as exc:
raise ScoFormatError(
'scolars_import_admission: valeur invalide, ligne %d colonne %s: "%s"'
% (nline, field_name, line[idx]),
@ -686,7 +686,7 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
),
)
) from exc
if val is not None: # note: ne peut jamais supprimer une valeur
args[field_name] = val
if args:
@ -719,10 +719,10 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
)
for group_id in group_ids:
group = GroupDescr.query.get(group_id)
group = db.session.get(GroupDescr, group_id)
if group.partition.groups_editable:
sco_groups.change_etud_group_in_partition(
args["etudid"], group_id
args["etudid"], group
)
else:
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.sco_utils as scu
from app import log
from app.models import Formation, FormSemestre
from app import db, log
from app.models import Formation, FormSemestre, GroupDescr
from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
from app.scodoc import sco_etud
from app.scodoc import sco_formations
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups
@ -177,7 +176,8 @@ def do_inscrit(sem, etudids, inscrit_groupes=False):
(la liste doit avoir été vérifiée au préalable)
En option: inscrit aux mêmes groupes que dans le semestre origine
"""
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()
log(f"do_inscrit (inscrit_groupes={inscrit_groupes}): {etudids}")
for etudid in etudids:
@ -220,11 +220,10 @@ def do_inscrit(sem, etudids, inscrit_groupes=False):
# Inscrit aux groupes
for partition_group in partition_groups:
sco_groups.change_etud_group_in_partition(
etudid,
partition_group["group_id"],
partition_group,
group: GroupDescr = db.session.get(
GroupDescr, partition_group["group_id"]
)
sco_groups.change_etud_group_in_partition(etudid, group)
def do_desinscrit(sem, etudids):
@ -416,10 +415,10 @@ def formsemestre_inscr_passage(
): # il y a au moins une vraie partition
H.append(
f"""<li><a class="stdlink" href="{
url_for("scolar.affect_groups",
scodoc_dept=g.scodoc_dept, partition_id=partition["partition_id"])
}">Répartir les groupes de {partition["partition_name"]}</a></li>
"""
url_for("scolar.partition_editor", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id)
}">Répartir les groupes de {partition["partition_name"]}</a></li>
"""
)
#
@ -436,7 +435,7 @@ def _build_page(
inscrit_groupes=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)
ignore_jury = int(ignore_jury)
if inscrit_groupes:

View File

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

View File

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

View File

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

View File

@ -57,6 +57,7 @@ from app.scodoc import sco_moduleimpl
from app.scodoc import sco_permissions_check
from app.tables import list_etuds
# menu evaluation dans moduleimpl
def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
"Menu avec actions sur une evaluation"
@ -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()
#
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]
H = [
html_sco_header.sco_header(
@ -528,7 +529,7 @@ def _ligne_evaluation(
) -> str:
"""Ligne <tr> décrivant une évaluation dans le tableau de bord moduleimpl."""
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(
evaluation.id,
partition_id=partition_id,
@ -732,7 +733,7 @@ def _ligne_evaluation(
)
if etat["moy"]:
H.append(
f"""<b>{etat["moy"]} / {evaluation.note_max:g}</b>
f"""<b>{etat["moy"]} / 20</b>
&nbsp; (<a class="stdlink" href="{
url_for('notes.evaluation_listenotes',
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>"""
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()
)
]

View File

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

View File

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

View File

@ -6,6 +6,7 @@
from flask import g
from flask_login import current_user
from app import db
from app.auth.models import User
from app.models import FormSemestre
import app.scodoc.notesdb as ndb
@ -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,
"<p>Responsable de ce semestre : <b>%s</b></p>"
% ", ".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,
]
@ -142,7 +146,9 @@ def check_access_diretud(formsemestre_id, required_permission=Permission.ScoImpl
def can_change_groups(formsemestre_id: int) -> bool:
"Vrai si l'utilisateur peut changer les groupes dans ce semestre"
"""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)
if not formsemestre.etat:
return False # semestre verrouillé

View File

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

View File

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

View File

@ -30,7 +30,7 @@
"""
from operator import itemgetter
from app import log
from app import db
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
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
Résultat:
{
'date' : date de la decision la plus recente,
'formsemestre' : sem,
'is_apc' : bool,
'formation' : { 'acronyme' :, 'titre': ... }
'decisions' : { [ { 'identite' : {'nom' :, 'prenom':, ...,},
'etat' : I ou D ou DEF
'decision_sem' : {'code':, 'code_prev': },
'decisions_ue' : { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' :,
'acronyme', 'numero': } },
'autorisations' : [ { 'semestre_id' : { ... } } ],
'validation_parcours' : True si parcours validé (diplome obtenu)
'prev_code' : code (calculé slt si with_prev),
'mention' : mention (en fct moy gen),
'sum_ects' : total ECTS acquis dans ce semestre (incluant les UE capitalisées)
'sum_ects_capitalises' : somme des ECTS des UE capitalisees
}
]
},
'decisions_dict' : { etudid : decision (comme ci-dessus) },
'date' : str = date de la decision la plus recente, format dd/mm/yyyy,
'formsemestre' : dict = formsemestre,
'is_apc' : bool,
'formation' : { 'acronyme' :, 'titre': ... }
'decisions' : [
{
'identite' : {'nom' :, 'prenom':, ...,},
'etat' : I ou D ou DEF
'decision_sem' : {'code':, 'code_prev': },
'decisions_ue' : {
ue_id : {
'code' : ADM|CMP|AJ,
'ects' : float,
'event_date' :str = "dd/mm/yyyy",
},
},
'autorisations' : [ { 'semestre_id' : { ... } } ],
'validation_parcours' : True si parcours validé (diplome obtenu)
'prev_code' : code (calculé slt si with_prev),
'mention' : mention (en fct moy gen),
'sum_ects' : total ECTS acquis dans ce semestre (incluant les UE capitalisées)
'sum_ects_capitalises' : somme des ECTS des UE capitalisees
},
...
],
'decisions_dict' : { etudid : decision (comme ci-dessus) },
}
"""
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 = {}
for ue_id in decisions_ue:
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"]
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.lib import styles
from app import db
from app.models import FormSemestre, Identite
import app.scodoc.sco_utils as scu
@ -70,7 +71,7 @@ def pdf_lettres_individuelles(
if not dpv:
return ""
#
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
prefs = sco_preferences.SemPreferences(formsemestre_id)
params = {
"date_jury": date_jury,

View File

@ -27,6 +27,7 @@
"""Tableau récapitulatif des notes d'un semestre
"""
import collections
import datetime
import time
from xml.etree import ElementTree
@ -109,7 +110,7 @@ def formsemestre_recapcomplet(
force_publishing=force_publishing,
)
table_html, table = _formsemestre_recapcomplet_to_html(
table_html, table, freq_codes_annuels = _formsemestre_recapcomplet_to_html(
formsemestre,
filename=filename,
mode_jury=mode_jury,
@ -142,7 +143,7 @@ def formsemestre_recapcomplet(
H.append(
'<select name="tabformat" onchange="document.f.submit()" class="noprint">'
)
for (fmt, label) in (
for fmt, label in (
("html", "Tableau"),
("evals", "Avec toutes les évaluations"),
("xlsx", "Excel (non formaté)"),
@ -186,7 +187,7 @@ def formsemestre_recapcomplet(
</li>
<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)
}">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>
"""
)
@ -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(
f"""
<div class="jury_stats">
<div>Nb d'étudiants avec décision annuelle:
{sum(table.freq_codes_annuels.values())} / {len(table)}
<div><b>Nb d'étudiants avec décision annuelle:</b>
{nb_etud_avec_decision_annuelle} / {freq_codes_annuels["total"]}
</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(
f"""<tr>
<td>{code}</td>
<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>"""
"""<div><b>Codes annuels octroyés:</b></div>
<table class="jury_stats_codes">
"""
)
H.append(
"""
</table>
</div>
"""
)
for code in sorted(freq_codes_annuels.keys()):
if code != "total":
H.append(
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
H.append(
"""
@ -251,6 +256,7 @@ def formsemestre_recapcomplet(
<div><tt>~</tt></div><div>valeur manquante</div>
<div><tt>=</tt></div><div>UE dispensée</div>
<div><tt>nan</tt></div><div>valeur non disponible</div>
<div>📍</div><div>code jury non enregistré</div>
</div>
</div>
"""
@ -271,12 +277,12 @@ def _formsemestre_recapcomplet_to_html(
filename: str = "",
mode_jury=False, # saisie décisions jury
selected_etudid=None,
) -> tuple[str, TableRecap]:
) -> tuple[str, TableRecap, collections.Counter]:
"""Le tableau recap en html"""
if tabformat not in ("html", "evals"):
raise ScoValueError("invalid table format")
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,
res,
include_evaluations=(tabformat == "evals"),
@ -284,7 +290,7 @@ def _formsemestre_recapcomplet_to_html(
filename=filename,
selected_etudid=selected_etudid,
)
return table_html, table
return table_html, table, freq_codes_annuels
def _formsemestre_recapcomplet_to_file(
@ -446,9 +452,9 @@ def gen_formsemestre_recapcomplet_html_table(
mode_jury=False,
filename="",
selected_etudid=None,
) -> tuple[str, TableRecap]:
) -> tuple[str, TableRecap, collections.Counter]:
"""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.
Si mode_jury, occultera colonnes modules (en js)
@ -460,6 +466,7 @@ def gen_formsemestre_recapcomplet_html_table(
"""
table = None
table_html = None
table_html_cached = None
cache_class = {
(True, True): sco_cache.TableJuryWithEvalsCache,
(True, False): sco_cache.TableJuryCache,
@ -467,8 +474,8 @@ def gen_formsemestre_recapcomplet_html_table(
(False, False): sco_cache.TableRecapCache,
}[(bool(mode_jury), bool(include_evaluations))]
if not selected_etudid:
table_html = cache_class.get(formsemestre.id)
if table_html is None:
table_html_cached = cache_class.get(formsemestre.id)
if table_html_cached is None:
table = _gen_formsemestre_recapcomplet_table(
res,
include_evaluations,
@ -477,9 +484,14 @@ def gen_formsemestre_recapcomplet_html_table(
selected_etudid=selected_etudid,
)
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(

View File

@ -33,11 +33,9 @@ from collections import defaultdict
from flask import request
from app import db
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.etudiants import Identite
from app.models.formsemestre import FormSemestreInscription
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
}
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 = (
f"S{formsemestre_impair.semestre_id}"
if len(formsemestre_id_precedents) == 1

View File

@ -36,41 +36,49 @@ import flask
from flask import g, url_for, request
from flask_login import current_user
from app import db, log
from app.auth.models import User
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Evaluation, FormSemestre
from app.models import ModuleImpl, ScolarNews
from app.models import (
Evaluation,
FormSemestre,
Module,
ModuleImpl,
NotesNotes,
ScolarNews,
)
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 (
AccessDenied,
InvalidNoteValue,
NoteProcessError,
ScoGenError,
ScoBugCatcher,
ScoException,
ScoInvalidParamError,
ScoValueError,
)
from app.scodoc.TrivialFormulator import TrivialFormulator, TF
from app.scodoc import html_sco_header, sco_users
from app.scodoc import htmlutils
from app.scodoc import sco_abs
from app.scodoc import sco_cache
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_evaluations
from app.scodoc import sco_excel
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_permissions_check
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(
@ -128,29 +136,30 @@ def _displayNote(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
"""notes is a list of tuples (etudid, value)
mod is the module (used to ckeck type, for malus)
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"]
if mod["module_type"] in (
note_max = evaluation.note_max or 0.0
module: Module = evaluation.moduleimpl.module
if module.module_type in (
scu.ModuleType.STANDARD,
scu.ModuleType.RESSOURCE,
scu.ModuleType.SAE,
):
note_min = scu.NOTES_MIN
elif mod["module_type"] == ModuleType.MALUS:
elif module.module_type == ModuleType.MALUS:
note_min = -20.0
else:
raise ValueError("Invalid module type") # bug
L = [] # liste (etudid, note) des notes ok (ou absent)
invalids = [] # etudid avec notes invalides
withoutnotes = [] # etudid sans notes (champs vides)
absents = [] # etudid absents
tosuppress = [] # etudids avec ancienne note à supprimer
valid_notes = [] # liste (etudid, note) des notes ok (ou absent)
etudids_invalids = [] # etudid avec notes invalides
etudids_without_notes = [] # etudid sans notes (champs vides)
etudids_absents = [] # etudid absents
etudid_to_suppress = [] # etudids avec ancienne note à supprimer
for etudid, note in notes:
note = str(note).strip().upper()
@ -166,31 +175,34 @@ def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict):
note_max,
note_min=note_min,
etudid=etudid,
absents=absents,
tosuppress=tosuppress,
invalids=invalids,
absents=etudids_absents,
tosuppress=etudid_to_suppress,
invalids=etudids_invalids,
)
if not invalid:
L.append((etudid, value))
valid_notes.append((etudid, value))
else:
withoutnotes.append(etudid)
return L, invalids, withoutnotes, absents, tosuppress
etudids_without_notes.append(etudid)
return (
valid_notes,
etudids_invalids,
etudids_without_notes,
etudids_absents,
etudid_to_suppress,
)
def do_evaluation_upload_xls():
"""
Soumission d'un fichier XLS (evaluation_id, notefile)
"""
authuser = current_user
vals = scu.get_request_args()
evaluation_id = int(vals["evaluation_id"])
comment = vals["comment"]
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0]
M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0]
# Check access
# (admin, respformation, and responsable_id)
if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]):
raise AccessDenied("Modification des notes impossible pour %s" % authuser)
evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
# Check access (admin, respformation, and responsable_id)
if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id):
raise AccessDenied(f"Modification des notes impossible pour {current_user}")
#
diag, lines = sco_excel.excel_file_to_list(vals["notefile"])
try:
@ -239,14 +251,16 @@ def do_evaluation_upload_xls():
if etudid:
notes.append((etudid, val))
ni += 1
except:
except Exception as exc:
diag.append(
f"""Erreur: Ligne invalide ! (erreur ligne {ni})<br>{lines[ni]}"""
)
raise InvalidNoteValue()
raise InvalidNoteValue() from exc
# -- check values
L, invalids, withoutnotes, absents, _ = _check_notes(notes, E, M["module"])
if len(invalids):
valid_notes, invalids, withoutnotes, absents, _ = _check_notes(
notes, evaluation
)
if invalids:
diag.append(
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))
raise InvalidNoteValue()
else:
nb_changed, nb_suppress, existing_decisions = notes_add(
authuser, evaluation_id, L, comment
etudids_changed, nb_suppress, etudids_with_decisions = notes_add(
current_user, evaluation_id, valid_notes, comment
)
# news
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[
0
]
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(
module: Module = evaluation.moduleimpl.module
status_url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=mod["moduleimpl_id"],
moduleimpl_id=evaluation.moduleimpl_id,
_external=True,
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=M["moduleimpl_id"],
text='Chargement notes dans <a href="%(url)s">%(titre)s</a>' % mod,
url=mod["url"],
obj=evaluation.moduleimpl_id,
text=f"""Chargement notes dans <a href="{status_url}">{
module.titre or module.code}</a>""",
url=status_url,
max_frequency=30 * 60, # 30 minutes
)
msg = (
"<p>%d notes changées (%d sans notes, %d absents, %d note supprimées)</p>"
% (nb_changed, len(withoutnotes), len(absents), nb_suppress)
)
if existing_decisions:
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>"""
# msg += '<p>' + str(notes) # debug
msg = f"""<p>{len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes, {
len(absents)} absents, {nb_suppress} note supprimées)
</p>"""
if etudids_with_decisions:
msg += """<p class="warning">Important: il y avait déjà des décisions de jury
enregistrées, qui sont peut-être à revoir suite à cette modification !</p>
"""
return 1, msg
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):
raise AccessDenied(f"Modification des notes impossible pour {current_user}")
# Convert and check value
L, invalids, _, _, _ = _check_notes(
[(etud.id, value)], evaluation.to_dict(), evaluation.moduleimpl.module.to_dict()
)
L, invalids, _, _, _ = _check_notes([(etud.id, value)], evaluation)
if len(invalids) == 0:
nb_changed, _, _ = notes_add(
etudids_changed, _, _ = notes_add(
current_user, evaluation.id, L, "Initialisation notes"
)
if nb_changed == 1:
if len(etudids_changed) == 1:
return True
return False # error
@ -352,9 +360,7 @@ def do_evaluation_set_missing(
if etudid not in notes_db: # pas de note
notes.append((etudid, value))
# Convert and check values
L, invalids, _, _, _ = _check_notes(
notes, evaluation.to_dict(), modimpl.module.to_dict()
)
valid_notes, invalids, _, _, _ = _check_notes(notes, evaluation)
dest_url = url_for(
"notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id
)
@ -372,13 +378,13 @@ def do_evaluation_set_missing(
"""
# Confirm action
if not dialog_confirmed:
plural = len(L) > 1
plural = len(valid_notes) > 1
return scu.confirm_dialog(
f"""<h2>Mettre toutes les notes manquantes de l'évaluation
à la valeur {value} ?</h2>
<p>Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC)
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>
</p>
""",
@ -392,7 +398,7 @@ def do_evaluation_set_missing(
)
# ok
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
url = url_for(
"notes.moduleimpl_status",
@ -408,7 +414,7 @@ def do_evaluation_set_missing(
)
return f"""
{ html_sco_header.sco_header() }
<h2>{nb_changed} notes changées</h2>
<h2>{len(etudids_changed)} notes changées</h2>
<ul>
<li><a class="stdlink" href="{dest_url}">
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:
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
)
msg = f"""<p>Confirmer la suppression des {nb_suppress} notes ?
@ -475,14 +481,14 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
)
# modif
nb_changed, nb_suppress, existing_decisions = notes_add(
etudids_changed, nb_suppress, existing_decisions = notes_add(
current_user,
evaluation_id,
notes,
comment="effacer tout",
check_inscription=False,
)
assert nb_changed == nb_suppress
assert len(etudids_changed) == nb_suppress
H = [f"""<p>{nb_suppress} notes supprimées</p>"""]
if existing_decisions:
H.append(
@ -516,7 +522,7 @@ def notes_add(
comment=None,
do_it=True,
check_inscription=True,
) -> tuple:
) -> tuple[list[int], int, list[int]]:
"""
Insert or update notes
notes is a list of tuples (etudid,value)
@ -524,12 +530,12 @@ def notes_add(
WOULD be changed or suppressed.
Nota:
- 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(
*time.localtime()[:6]
) # datetime.datetime.now().isoformat()
# Verifie inscription et valeur note
now = psycopg2.Timestamp(*time.localtime()[:6])
# Vérifie inscription et valeur note
inscrits = {
x[0]
for x in sco_groups.do_evaluation_listeetuds_groups(
@ -548,13 +554,13 @@ def notes_add(
# Met a jour la base
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
nb_changed = 0
etudids_changed = []
nb_suppress = 0
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0]
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
existing_decisions = (
[]
) # etudids pour lesquels il y a une decision de jury et que la note change
evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
formsemestre: FormSemestre = evaluation.moduleimpl.formsemestre
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
# etudids pour lesquels il y a une decision de jury et que la note change:
etudids_with_decision = []
try:
for etudid, value in notes:
changed = False
@ -562,7 +568,7 @@ def notes_add(
# nouvelle note
if value != scu.NOTES_SUPPRESS:
if do_it:
aa = {
args = {
"etudid": etudid,
"evaluation_id": evaluation_id,
"value": value,
@ -570,13 +576,20 @@ def notes_add(
"uid": user.id,
"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(
"""INSERT INTO notes_notes
(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
else:
@ -584,7 +597,7 @@ def notes_add(
oldval = notes_db[etudid]["value"]
if type(value) != type(oldval):
changed = True
elif type(value) == float and (
elif isinstance(value, float) and (
abs(value - oldval) > scu.NOTES_PRECISION
):
changed = True
@ -603,7 +616,7 @@ def notes_add(
""",
{"etudid": etudid, "evaluation_id": evaluation_id},
)
aa = {
args = {
"etudid": etudid,
"evaluation_id": evaluation_id,
"value": value,
@ -611,7 +624,7 @@ def notes_add(
"comment": comment,
"uid": user.id,
}
ndb.quote_dict(aa)
ndb.quote_dict(args)
if value != scu.NOTES_SUPPRESS:
if do_it:
cursor.execute(
@ -620,52 +633,49 @@ def notes_add(
WHERE etudid = %(etudid)s
and evaluation_id = %(evaluation_id)s
""",
aa,
args,
)
else: # suppression ancienne note
if do_it:
log(
"notes_add, suppress, evaluation_id=%s, etudid=%s, oldval=%s"
% (evaluation_id, etudid, oldval)
f"""notes_add, suppress, evaluation_id={evaluation_id}, etudid={
etudid}, oldval={oldval}"""
)
cursor.execute(
"""DELETE FROM notes_notes
WHERE etudid = %(etudid)s
AND evaluation_id = %(evaluation_id)s
""",
aa,
args,
)
# garde trace de la suppression dans l'historique:
aa["value"] = scu.NOTES_SUPPRESS
args["value"] = scu.NOTES_SUPPRESS
cursor.execute(
"""INSERT INTO notes_notes_log (etudid,evaluation_id,value,comment,date,uid)
VALUES (%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s)
"""INSERT INTO notes_notes_log
(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
if changed:
nb_changed += 1
if has_existing_decision(M, E, etudid):
existing_decisions.append(etudid)
etudids_changed.append(etudid)
if res.etud_has_decision(etudid):
etudids_with_decision.append(etudid)
except Exception as exc:
log("*** exception in notes_add")
if do_it:
cnx.rollback() # abort
# inval cache
sco_cache.invalidate_formsemestre(
formsemestre_id=M["formsemestre_id"]
) # > modif notes (exception)
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
sco_cache.EvaluationCache.delete(evaluation_id)
raise # XXX
raise ScoGenError("Erreur enregistrement note: merci de ré-essayer") from exc
raise ScoException from exc
if do_it:
cnx.commit()
sco_cache.invalidate_formsemestre(
formsemestre_id=M["formsemestre_id"]
) # > modif notes
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.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=()):
@ -868,44 +878,39 @@ def saisie_notes_tableur(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"""
evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})
if not evals:
evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
if not evaluation:
raise ScoValueError("invalid evaluation_id")
eval_dict = evals[0]
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=eval_dict["moduleimpl_id"])[0]
formsemestre_id = M["formsemestre_id"]
Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
sem = sco_formsemestre.get_formsemestre(M["formsemestre_id"])
mod_responsable = sco_users.user_info(M["responsable_id"])
if eval_dict["jour"]:
indication_date = ndb.DateDMYtoISO(eval_dict["jour"])
modimpl = evaluation.moduleimpl
formsemestre = modimpl.formsemestre
mod_responsable = sco_users.user_info(modimpl.responsable_id)
if evaluation.jour:
indication_date = evaluation.jour.isoformat()
else:
indication_date = scu.sanitize_filename(eval_dict["description"])[:12]
eval_name = "%s-%s" % (Mod["code"], indication_date)
indication_date = scu.sanitize_filename(evaluation.description or "")[:12]
eval_name = f"{evaluation.moduleimpl.module.code}-{indication_date}"
if eval_dict["description"]:
evaltitre = "%s du %s" % (eval_dict["description"], eval_dict["jour"])
else:
evaltitre = "évaluation du %s" % eval_dict["jour"]
description = "%s en %s (%s) resp. %s" % (
evaltitre,
Mod["abbrev"] or "",
Mod["code"] or "",
mod_responsable["prenomnom"],
date_str = (
f"""du {evaluation.jour.strftime("%d/%m/%Y")}"""
if evaluation.jour
else "(sans date)"
)
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(
group_ids=group_ids,
formsemestre_id=formsemestre_id,
formsemestre_id=formsemestre.id,
select_all_when_unspecified=True,
etat=None,
)
groups = sco_groups.listgroups(groups_infos.group_ids)
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
getallstudents = True
# gr_title = "tous"
gr_title_filename = "tous"
else:
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
L = []
rows = []
etuds = _get_sorted_etuds(eval_dict, etudids, formsemestre_id)
etuds = _get_sorted_etuds(evaluation, etudids, formsemestre.id)
for e in etuds:
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)
L.append(
rows.append(
[
"%s" % etudid,
str(etudid),
e["nom"].upper(),
e["prenom"].lower().capitalize(),
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(
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 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):
raise ScoInvalidParamError()
group_ids = [int(group_id) for group_id in (group_ids or [])]
evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})
if not evals:
evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
if evaluation is None:
raise ScoValueError("évaluation inexistante")
E = evals[0]
M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0]
formsemestre_id = M["formsemestre_id"]
modimpl = evaluation.moduleimpl
moduleimpl_status_url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=E["moduleimpl_id"],
moduleimpl_id=evaluation.moduleimpl_id,
)
# Check access
# (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"""
{html_sco_header.sco_header()}
<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:
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids=group_ids,
formsemestre_id=formsemestre_id,
formsemestre_id=modimpl.formsemestre_id,
select_all_when_unspecified=True,
etat=None,
)
if E["description"]:
page_title = 'Saisie "%s"' % E["description"]
else:
page_title = "Saisie des notes"
page_title = (
f'Saisie "{evaluation.description}"'
if evaluation.description
else "Saisie des notes"
)
# HTML page:
H = [
html_sco_header.sco_header(
@ -1036,19 +1019,19 @@ def saisie_notes(evaluation_id: int, group_ids: list = None):
"id": "menu_saisie_tableur",
"endpoint": "notes.saisie_notes_tableur",
"args": {
"evaluation_id": E["evaluation_id"],
"evaluation_id": evaluation.id,
"group_ids": groups_infos.group_ids,
},
},
{
"title": "Voir toutes les notes du module",
"endpoint": "notes.evaluation_listenotes",
"args": {"moduleimpl_id": E["moduleimpl_id"]},
"args": {"moduleimpl_id": evaluation.moduleimpl_id},
},
{
"title": "Effacer toutes les notes de cette évaluation",
"endpoint": "notes.evaluation_suppress_alln",
"args": {"evaluation_id": E["evaluation_id"]},
"args": {"evaluation_id": evaluation.id},
},
],
alone=True,
@ -1077,7 +1060,9 @@ def saisie_notes(evaluation_id: int, group_ids: list = None):
)
# 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:
return flask.redirect(moduleimpl_status_url)
H.append(form)
@ -1101,10 +1086,9 @@ def saisie_notes(evaluation_id: int, group_ids: list = None):
return "\n".join(H)
def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int):
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
eval_dict["evaluation_id"]
) # Notes existantes
def _get_sorted_etuds(evaluation: Evaluation, etudids: list, formsemestre_id: int):
# Notes existantes
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)
cnx = ndb.GetDBConnexion()
etuds = []
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)
# 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 = []
if eval_dict["matin"]:
nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=1)
nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=1)
if evaluation.is_matin():
nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=True)
nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=True)
if nbabs:
if nbabsjust:
warn_abs_lst.append("absent justifié le matin !")
else:
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)
nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=0)
if nbabs:
@ -1169,35 +1153,38 @@ def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int):
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
pour les groupes indiqués.
On charge tous les étudiants, ne seront montrés que ceux
des groupes sélectionnés grace a un filtre en javascript.
"""
evaluation_id = E["evaluation_id"]
formsemestre_id = M["formsemestre_id"]
formsemestre_id = modimpl.formsemestre_id
formsemestre: FormSemestre = evaluation.moduleimpl.formsemestre
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
etudids = [
x[0]
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:
return '<div class="ue_warning"><span>Aucun étudiant sélectionné !</span></div>'
# Decisions de jury existantes ?
decisions_jury = {etudid: has_existing_decision(M, E, etudid) for etudid in etudids}
# Nb de decisions de jury (pour les inscrits à l'évaluation):
# Décisions de jury existantes ?
decisions_jury = {etudid: res.etud_has_decision(etudid) for etudid in etudids}
# Nb de décisions de jury (pour les inscrits à l'évaluation):
nb_decisions = sum(decisions_jury.values())
etuds = _get_sorted_etuds(E, etudids, formsemestre_id)
etuds = _get_sorted_etuds(evaluation, etudids, formsemestre_id)
# Build form:
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"}),
(
"group_ids",
@ -1207,7 +1194,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
("comment", {"size": 44, "title": "Commentaire", "return_focus_next": True}),
("changed", {"default": "0", "input_type": "hidden"}), # changed in JS
]
if M["module"]["module_type"] in (
if modimpl.module.module_type in (
ModuleType.STANDARD,
ModuleType.RESSOURCE,
ModuleType.SAE,
@ -1220,11 +1207,11 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
"title": "Notes ",
"cssclass": "formnote_bareme",
"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(
(
"s3",
@ -1238,7 +1225,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
)
)
else:
raise ValueError("invalid module type (%s)" % M["module"]["module_type"]) # bug
raise ValueError(f"invalid module type ({modimpl.module.module_type})") # bug
initvalues = {}
for e in etuds:
@ -1248,7 +1235,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
if disabled:
classdem = " etud_dem"
etud_classes.append("etud_dem")
disabled_attr = 'disabled="%d"' % disabled
disabled_attr = f'disabled="{disabled}"'
else:
classdem = ""
disabled_attr = ""
@ -1265,18 +1252,17 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
)
# Historique des saisies de notes:
if not disabled:
explanation = (
'<span id="hist_%s">' % etudid
+ get_note_history_menu(evaluation_id, etudid)
+ "</span>"
)
else:
explanation = ""
explanation = (
""
if disabled
else f"""<span id="hist_{etudid}">{
get_note_history_menu(evaluation.id, etudid)
}</span>"""
)
explanation = e["absinfo"] + explanation
# 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:
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(
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>
"""
)
@ -1345,7 +1331,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""):
Mettre les notes manquantes à
<input type="text" size="5" name="value"/>
<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="{
",".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
def save_note(etudid=None, evaluation_id=None, value=None, comment=""):
"""Enregistre une note (ajax)"""
authuser = current_user
log(
"save_note: evaluation_id=%s etudid=%s uid=%s value=%s"
% (evaluation_id, etudid, authuser, value)
)
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0]
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["url"] = url_for(
def save_notes(
evaluation: Evaluation, notes: list[tuple[(int, float)]], comment: str = ""
) -> dict:
"""Enregistre une liste de notes.
Vérifie que les étudiants sont bien inscrits à ce module, et que l'on a le droit.
Result: dict avec
"""
log(f"save_note: evaluation_id={evaluation.id} uid={current_user} notes={notes}")
status_url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=M["moduleimpl_id"],
moduleimpl_id=evaluation.moduleimpl_id,
_external=True,
)
result = {"nbchanged": 0} # JSON
# Check access: admin, respformation, or responsable_id
if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]):
result["status"] = "unauthorized"
if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id):
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:
L, _, _, _, _ = _check_notes([(etudid, value)], E, Mod)
if L:
nbchanged, _, existing_decisions = notes_add(
authuser, evaluation_id, L, comment=comment, do_it=True
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
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)
result = {
"etudids_changed": [],
"etudids_with_decision": [],
"history_menu": [],
}
return 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"""
history = sco_undo_notes.get_note_history(evaluation_id, etudid)
if not history:

View File

@ -42,6 +42,7 @@ sem_set_list()
import flask
from flask import g, url_for
from app import db, log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
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_preferences
from app.scodoc.gen_tables import GenTable
from app import log
from app.scodoc.sco_etape_bilan import EtapeBilan
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_vdi import ApoEtapeVDI
@ -127,7 +127,7 @@ class SemSet(dict):
self.sems = []
self.formsemestres = []
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)
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
self.sems.append(sem)
@ -145,12 +145,7 @@ class SemSet(dict):
# Construction du ou des lien(s) vers le semestre
self["semlinks"] = [
f"""<a class="stdlink" href="{
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id)
}">{formsemestre.titre_annee()}</a>
"""
for formsemestre in self.formsemestres
formsemestre.html_link_status() for formsemestre in self.formsemestres
]
self["semtitles_str"] = "<br>".join(self["semlinks"])
@ -383,7 +378,7 @@ class SemSet(dict):
def html_diagnostic(self):
"""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():
return self.bilan.html_diagnostic()

View File

@ -55,6 +55,7 @@ EKEY_APO = "nip"
EKEY_SCO = "code_nip"
EKEY_NAME = "code NIP"
# view:
def formsemestre_synchro_etuds(
formsemestre_id,
@ -270,11 +271,10 @@ def formsemestre_synchro_etuds(
if partitions: # il y a au moins une vraie partition
H.append(
f"""<li><a class="stdlink" href="{
url_for("scolar.affect_groups",
scodoc_dept=g.scodoc_dept,
partition_id=partitions[0]["partition_id"]
)}">Répartir les groupes de {partitions[0]["partition_name"]}</a></li>
"""
url_for("scolar.partition_editor",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
}">Répartir les groupes de {partitions[0]["partition_name"]}</a></li>
"""
)
H.append(footer)
@ -407,6 +407,7 @@ def list_synch(sem, anneeapogee=None):
)
#
cnx = ndb.GetDBConnexion()
# Tri listes
def set_to_sorted_list(etudset, etud_apo=False, is_inscrit=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,
text=f"Import Apogée de {len(created_etudids)} étudiants en ",
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 app.models.formsemestre import FormSemestre
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
from app import log
from app import db, log
from app.models import UniteEns
from app.scodoc import html_sco_header
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_permissions import Permission
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(
@ -114,7 +115,7 @@ def external_ue_create(
"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})")
matiere_id = sco_edit_matiere.do_matiere_create(
{"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:
SCO_WEBSITE = "https://scodoc.org"
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_USERS_LIST = "notes@listes.univ-paris13.fr"
SCO_LISTS_URL = "https://scodoc.org/Contact"
@ -660,10 +660,10 @@ def bul_filename_old(sem: dict, etud: dict, format):
return filename
def bul_filename(formsemestre, etud, format):
"""Build a filename for this bulletin"""
def bul_filename(formsemestre, etud):
"""Build a filename for this bulletin (without suffix)"""
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)
return filename

View File

@ -15,7 +15,6 @@
padding-bottom: 0px;
padding-left: 16px;
padding-right: 0px;
background: #FFF;
border: 1px solid #aaa;
border-radius: 8px;
@ -39,4 +38,14 @@ div.code_rcue {
padding-top: 8px;
padding-bottom: 8px;
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;
}
form#jury_but {
.jury_but_box {
margin: 0px 16px 16px 16px;
background-color: rgb(253, 253, 231);
border: 2px solid rgb(4, 4, 118);
@ -35,7 +35,9 @@ form#jury_but {
min-width: var(--sco-content-min-width);
max-width: var(--sco-content-max-width);
}
div.jury_but_box_title {
margin-bottom: 10px;
}
.but_annee {
margin-left: 32px;
display: inline-grid;
@ -168,6 +170,7 @@ div.but_niveau_ue.recorded_different,
div.but_niveau_rcue.recorded_different {
box-shadow: 0 0 0 3px red;
outline: dashed 3px var(--color-recorded);
background-color: yellow;
}
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;
}
div.rcue {
grid-column: 1 / span 2;
}
/* ne fonctionne pas
option.non_associe {
background-color: yellow;
@ -153,4 +157,24 @@ option.non_associe {
.links {
margin-top: 16px;
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 {
100% {
transform: translateY(-20px)
transform: translateY(-20px);
}
}
@ -152,6 +152,7 @@ body.editionActivated .filtres>div>div>div>div {
color: #000;
border-radius: 4px;
outline: 4px solid #FFF;
padding: 2px;
}
/* Suppression */
@ -354,6 +355,10 @@ body.editionActivated .filtres .nonEditable .move {
display: initial;
}
.groupe:has(.etudiants:empty) {
display: none;
}
/* .filtres .unselect {
background: rgba(0, 153, 204, 0.5) !important;
} */
@ -361,7 +366,29 @@ body.editionActivated .filtres .nonEditable .move {
/*****************************/
/* 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;
color: #141414;
padding: 4px 8px;
@ -369,13 +396,13 @@ body.editionActivated .filtres .nonEditable .move {
border-radius: 4px;
}
#zoneChoix>.autoAffectation>select {
#zoneChoix .autoAffectation>select {
border: none;
padding: 4px;
border-radius: 4px;
}
#zoneChoix>.autoAffectation>.affectationGo {
#zoneChoix .autoAffectation>.affectationGo {
display: inline-block;
background: #0c9;
padding: 8px 16px;

View File

@ -65,27 +65,34 @@ div#gtrcontent {
}
div.flashes {
transition: opacity 0.5s ease;
margin-top: 8px;
left: 50%;
margin-top: 8px;
max-width: 800px;
position: fixed;
text-align: center;
top: 8px;
transform: translateX(-50%);
width: auto;
transition: opacity 0.5s ease;
z-index: 1000;
}
div.alert {
/*
position: absolute;
top: 10px;
right: 10px; */
padding: 16px;
border-radius: 12px;
font-size: 200%;
opacity: 0.9;
}
div.alert-info {
color: #0019d7;
background-color: #68f36d;
border-color: #0a8d0c;
color: #208d3b;
background-color: #fffd97;
border-color: #208d3b;
}
div.alert-warning {
color: #ef5c00;
background-color: #fbfb00d4;
border-color: #767676;
}
div.alert-error {
@ -94,6 +101,9 @@ div.alert-error {
border-color: #8d0a17;
}
form.inline-form {
display: inline-block;
}
div.tab-content {
margin-top: 10px;
@ -629,7 +639,7 @@ div.news {
border-radius: 8px;
}
div.news a {
div.news a, div.news a.stdlink {
color: black;
text-decoration: none;
}
@ -905,6 +915,17 @@ td.fichetitre2 .fl {
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 {
background-color: #f7d892;
width: 910px;
@ -1112,9 +1133,11 @@ a.discretelink:hover {
text-align: center;
}
.expl, .help {
max-width: var(--sco-content-max-width);
}
.help {
font-style: italic;
max-width: 800px;
}
.help_important {
@ -1122,13 +1145,29 @@ a.discretelink:hover {
color: red;
}
div.sco_help {
div.sco_box, div.sco_help {
margin-top: 12px;
margin-bottom: 4px;
margin-left: 0px;
padding: 8px;
border-radius: 4px;
border: 1px solid grey;
max-width: var(--sco-content-max-width);
}
div.sco_help {
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 {
@ -2503,13 +2542,7 @@ input.sco_tag_checkbox {
}
div#ue_list_code {
background-color: rgb(155, 218, 155);
padding: 10px;
border: 1px solid blue;
border-radius: 10px;
padding: 10px;
margin-top: 10px;
margin-right: 15px;
}
ul.notes_module_list {
@ -2595,16 +2628,6 @@ div#ue_list_modules {
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 {
font-weight: bold;
@ -2773,6 +2796,8 @@ table.notes_recapcomplet a:hover {
div.table_recap_caption {
width: fit-content;
margin-top: 8px;
margin-bottom: 8px;
padding: 8px;
border-radius: 8px;
background-color: rgb(202, 255, 180);
@ -3173,6 +3198,19 @@ li.tf-msg {
/* 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 {
font-weight: normal;

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