Compare commits

...

163 Commits

Author SHA1 Message Date
Iziram
3b2888cd5b APIDoc : génération fichier samples 2024-07-25 13:03:10 +02:00
Iziram
b542e7dab5 API Doc : meilleur tri + optimisations simples 2024-07-25 12:00:40 +02:00
Iziram
f7a8c1d2db API Doc : lien query OK 2024-07-25 11:45:26 +02:00
5cefe1a337 Doc API: typos 2024-07-25 11:08:40 +02:00
c0d2f66081 Doc API: template + fix _ vs - 2024-07-25 10:55:30 +02:00
188534819b Documentation API: correctifs + génération page complète 2024-07-25 10:42:49 +02:00
b3111769a1 Merge pull request 'Génération de la documentation API' (#969) from iziram/ScoDoc:master into master
Reviewed-on: ScoDoc/ScoDoc#969
2024-07-25 08:44:38 +02:00
Iziram
0b7be5d08a gen api doc : catégories 2024-07-24 17:50:16 +02:00
d37ce3f8d9 docstrings API 2024-07-24 17:34:30 +02:00
Iziram
c12bc778bb Maj Markdown docstring assiduites + justificatifs 2024-07-24 11:07:57 +02:00
Iziram
17233fb8c1 APIDoc utilisation template jinja + lien samples 2024-07-24 10:38:56 +02:00
71639606fa Génération doc API (WIP) 2024-07-23 16:49:11 +02:00
9ca86e7900 Génération tableau API 2024-07-23 07:07:29 +02:00
36547afb0b make DEBUG flag available in templates. 2024-07-21 07:49:58 +02:00
059678c3b3 Fix: affichage validation si UE sans ECTS 2024-07-20 14:39:03 +02:00
e91503a9b5 Optimisation affectation notes manquantes 2024-07-19 19:12:19 +02:00
929fe397ad etuds_sans_notes: template 2024-07-19 19:04:07 +02:00
b80d8fb454 Saisie excel multi-eval: améliore message log 2024-07-19 19:03:39 +02:00
a3394bd779 déplace css apo_compare_csv 2024-07-19 18:09:10 +02:00
8f2a6aa317 Remplace sco_header par template. 2024-07-19 18:04:14 +02:00
4de2d63861 Import notes toutes évaluations d'un module 2024-07-19 16:32:56 +02:00
10623e568b Remove unused sco_compute_moy.py 2024-07-19 10:19:30 +02:00
d5580c7f9f Titre par défaut formsemestre: utilise acronyme formation 2024-07-19 10:11:02 +02:00
24ccd8f9f7 Add get_instance method to all ScoDoc models 2024-07-19 09:42:44 +02:00
98127e7c1d Misc fix for unit tests 2024-07-18 16:54:48 +02:00
d76fdfefbe Bulletin court BUT: n'affiche pas la date de la décision année. 2024-07-18 16:27:48 +02:00
bf2e2bb600 API departement: noms fonctions pour doc 2024-07-17 15:45:35 +02:00
8a59121b80 API departement: noms fonctions pour doc 2024-07-17 15:36:24 +02:00
7620880b91 API logos: noms fonctions pour la doc 2024-07-17 15:04:51 +02:00
2b22115dd8 API: modifs qq noms fonctions pour doc 2024-07-17 14:58:49 +02:00
8a85aa5b16 Page jury: message mode lecture seule 2024-07-17 14:58:38 +02:00
7623ccef2b API: décorateur api_permission_required pour la documentation 2024-07-17 12:03:08 +02:00
650afd5c03 API map: corrige liens vers routes -query 2024-07-17 10:09:45 +02:00
41758b8c64 Fix: generate_excel_import_notes si aucune eval dans le semestre 2024-07-17 09:15:18 +02:00
576383da8d Indique semestre dans sujet mail bulletin. Implements #954. 2024-07-16 14:55:57 +02:00
a25407565c Fix: clonage semestre sans dates évaluations 2024-07-15 21:22:21 +02:00
068b6a5c6a Fix parcours DUT si étudiant désinscrit. Cosmetic/cleaning. 2024-07-15 19:08:15 +02:00
26b59ee547 Page détail validations accessible à tous, plus de détails. 2024-07-15 13:45:02 +02:00
137afb21b6 typo 2024-07-15 13:15:13 +02:00
830e2f4b01 Fix: listes validations UEs en BUT avec UE externes (donc ECTS fiche étud) 2024-07-15 13:08:02 +02:00
9b825c0fb1 Saisie notes multi-évaluations. Closes #942. 2024-07-14 22:20:37 +02:00
94a77abc92 Anonymisation étudiants: noms en majuscules 2024-07-14 15:27:29 +02:00
7c285c0894 Fix typo XML bul 2024-07-13 08:47:05 +02:00
7dada615e8 Fix fiche etud et bulletin BUT en cas de pb de config formation APC. Closes #965 2024-07-12 18:40:33 +02:00
2224500209 formsemestre_validation_but: message erreur si étudiant non inscrit 2024-07-12 17:23:06 +02:00
72fe741f62 Fix: export Apogée: traitement erreur étudiants sans NIP 2024-07-12 17:18:50 +02:00
810fa8e9f8 Merge pull request 'modification feuille appel closes #876' (#964) from iziram/ScoDoc:master into master
Reviewed-on: ScoDoc/ScoDoc#964
2024-07-12 16:49:40 +02:00
Iziram
57f0066556 Formulaire feuille d'appel 2024-07-12 16:40:46 +02:00
940dc2721a Fix: jury BUT sur étudiant sans deca 2024-07-12 16:21:27 +02:00
Iziram
75665497fa modification feuille appel closes #876 2024-07-12 10:22:03 +02:00
62e4481c77 WIP: Import de toutes les notes d'un semestre: génération de la feuille. Début de #942. 2024-07-11 19:45:52 +02:00
4214a53a4e version 9.6.991 2024-07-10 23:51:25 +02:00
63a44de4b8 Merge branch 'iziram-master' 2024-07-10 21:31:51 +02:00
cc2fe77850 cosmetic 2024-07-10 21:31:36 +02:00
f0f9158ac6 Améliore import/export formations BUT: identification des niveaux si libellés dupliqués. + Fix unicité apprentissages critiques-modules. 2024-07-10 18:27:13 +02:00
45ec03ec11 Améliore présentation bilan ECTS + fix bug but_ects_valides si pas d'ECTS 2024-07-10 09:47:40 +02:00
6ae433aa61 Fix: bul. BUT court sur etudiants DEM 2024-07-10 01:00:51 +02:00
abb6907a5d Page bilan ECTS etudiant toutes formations 2024-07-10 00:53:09 +02:00
Iziram
72c6696151 Assiduité : non affichage si préférence 2024-07-09 15:23:36 +02:00
Iziram
d4f206cc3e Assiduité : Désactivation du module (préférence semestre) closes #67 2024-07-09 14:31:28 +02:00
d4fd6527e5 Séparation vues notes/jurys et validations 2024-07-09 13:37:22 +02:00
9deae8cd6a Fix some logs 2024-07-09 09:09:25 +02:00
84cf99cda4 Fix: sorm jury BUT si codes vides 2024-07-09 09:09:03 +02:00
2720fe0b96 Efface aussi décisions DUT120 quand on efface toutes les décisions du semestre 2024-07-08 23:14:42 +02:00
34fe649d51 Log etud: code & check 2024-07-08 23:13:45 +02:00
e230118c59 APC: associations UE/Niveaux: empeche modif si RCUE enregistrés 2024-07-08 12:33:48 +02:00
08d6349672 Fix: formsemestre_validation_auto_but 2024-07-08 10:01:42 +02:00
12105ba056 Fix: ECTS are floats 2024-07-07 23:13:00 +02:00
b58ab93fee DUT après jury BUT: calcul automatique. Closes #577 2024-07-07 22:28:38 +02:00
e63cdba1f6 DUT après jury BUT: ajout sur bulletins et PVs. WIP #577 2024-07-07 22:07:27 +02:00
b14c3938b7 WIP: validation du DUT120 (#577). Manque PVs. 2024-07-06 23:28:20 +02:00
48e1207fd8 Fix: affichage diplomation sur bul BUT pdf 2024-07-05 17:11:21 +02:00
bf77b9112f Ajoute ECTS acquis sur TableJury et cursus_etud BUT 2024-07-05 14:12:00 +02:00
427672b396 Merge pull request 'Importation d'assiduités depuis un fichier Excel' (#955) from iziram/ScoDoc:master into master
Reviewed-on: ScoDoc/ScoDoc#955
2024-07-04 23:37:41 +02:00
6211680271 Fix: diplome BUT si derniere annéée en ADJ 2024-07-04 21:33:14 +02:00
Iziram
89c5307472 Assiduité : import excels closes #926 2024-07-04 16:46:18 +02:00
Iziram
abb460e659 Assiduité : WIP saisie excel 2024-07-04 16:46:18 +02:00
e6a165b18a Fix: création module avec parcours 2024-07-04 14:55:02 +02:00
a92e1ab853 Lettres individuelles: améliore présentation compétences validées 2024-07-04 14:51:46 +02:00
2570f811ed Fix: sélection de groupes lors d'envois en masse de bulletins 2024-07-04 13:20:16 +02:00
03fea2268a Fix: typo recherche étudiant 2024-07-04 09:53:31 +02:00
3c11998985 Fix: date de fin des formsemestre créés 2024-07-04 00:34:50 +02:00
417ecf79b7 élimine doublons dans la liste des décisions de jury sur l'année 2024-07-03 23:35:50 +02:00
053a332198 Fix: balises pdf descr_demission, date_demission, descr_defaillance, date_defaillance 2024-07-03 23:01:10 +02:00
7e85f7b192 API: module-edit, ue-edit 2024-07-03 22:42:38 +02:00
5ec598e693 Recherche étudiant: réparations et améliorations 2024-07-03 21:32:33 +02:00
03cea5daf7 Fix: tableau recap. avec évaluations: conversion notes 2024-07-02 00:19:30 +02:00
d556f152e9 cosmetic page saisie excel 2024-07-02 00:03:03 +02:00
c4b44a1022 Chargement notes excel: réorganisation du code 2024-06-30 23:00:42 +02:00
8f12c452df Tri date table opérations sur évaluation 2024-06-30 00:26:17 +02:00
9a289d5956 Saisie note excel: améliore feuille et reorganise le code. + affichage date eval sans heures 2024-06-28 19:03:46 +02:00
69af0b9778 Cache lien 'effacer des décisions de jury' quand on a pas le droit 2024-06-28 08:43:27 +02:00
90c71ebfa4 Calcul auto jury BUT: ne génère pas d'autorisations d'inscriptions en redoublement 2024-06-28 08:10:00 +02:00
7607e19e35 Fix: (non) prise en compte des évaluations bonus bloquées 2024-06-27 21:35:35 +02:00
055bbf9f7f Corrige 68f64ba383 pour #948 2024-06-27 21:22:19 +02:00
408c340525 Merge pull request 'Ajout du filtre de module dans la page de bilan étudiant + résolution bug d'affichage des tableaux' (#949) from iziram/ScoDoc:master into master
Reviewed-on: ScoDoc/ScoDoc#949
2024-06-27 20:44:34 +02:00
Iziram
68f64ba383 Assiduité : bilan_etud : filtre des assiduités par module closes #948 2024-06-27 19:14:05 +02:00
Iziram
51e36cdff0 Assiduité : fix bug affichage tableaux (colonnes visibles hors d'excel) 2024-06-27 18:44:39 +02:00
7666821fa6 view_module_abs: sépare cols civ/nom/prenom 2024-06-27 12:44:35 +02:00
6a342245bc Améliore affichage moyenne évaluation sur tableau bord module 2024-06-27 07:23:38 +02:00
668eeb8e3f PV de jury (et lettres): listes les validations d'UE antérieures. Closes #946 2024-06-26 22:24:10 +02:00
34aab0a46f Element de passage dans apogée. Close #937 2024-06-26 21:26:51 +02:00
c2a248633f Merge pull request 'Remise en place API Assiduites/evaluations et ajout Permission JustifValidate' (#945) from iziram/ScoDoc:permission_justif into master
Reviewed-on: ScoDoc/ScoDoc#945
2024-06-26 16:38:45 +02:00
Iziram
ac95ff9784 Assiduité : ajout permission "JustifValidate" par défaut (si AbsChange) 2024-06-26 16:27:47 +02:00
Iziram
b7b5d08de0 Remise en état api/assiduites/evaluations
prend en compte le fix du test api
git revert du revert
This reverts commit ddfd94b0ba.
2024-06-26 11:23:58 +02:00
Iziram
48376724f5 Assiduité : Permission JustifValidate #823 2024-06-26 10:32:15 +02:00
ddfd94b0ba Revert "Merge pull request 'Assiduité : routes api assiduites evaluations' (#941) from iziram/ScoDoc:assiduites_evals into master"
This reverts commit 3cfe3c5174, reversing
changes made to b40d33feaa.
2024-06-25 22:25:00 +02:00
3cfe3c5174 Merge pull request 'Assiduité : routes api assiduites evaluations' (#941) from iziram/ScoDoc:assiduites_evals into master
Reviewed-on: ScoDoc/ScoDoc#941
2024-06-25 22:19:49 +02:00
b40d33feaa Tableau bilan assiduite: cosmetic 2024-06-25 21:47:16 +02:00
ba37f74218 Tables recap: fige les 3 colonnes de gauche. Bug connu: defaut affichage si retaillage fenetre et modif de la hauteur des colonnes. 2024-06-25 19:22:35 +02:00
74b9816713 Fix bubble.js initialization 2024-06-25 19:08:48 +02:00
c88191b0ca Tables excel avec titres sur ligne: fix décalage ligne titre 2024-06-25 18:28:08 +02:00
ab65ae375f Fix affichage moyennes groupes sur moduleimpl_status si tous EXC 2024-06-25 18:10:01 +02:00
Iziram
4754edd973 Assiduité : test unit évaluations 2024-06-25 11:27:10 +02:00
Iziram
4aa85bac8d Assiduité : test api routes évaluations 2024-06-25 10:44:21 +02:00
9be3c9105f Evaluation bonus: ajout documentation + presentation UE bonus 2024-06-24 16:48:13 +02:00
Iziram
6955c36ff5 Assiduité : routes api assiduites evaluations closes #726 2024-06-24 15:35:43 +02:00
5867bdf534 Edition codes Apogée en ligne: utilise partout l'API. Ajout code pour passage (WIP #937) 2024-06-24 03:37:40 +02:00
a2f07cea64 Export Apogée: n'exporte rien sur les UEs dispensées. Closes #626 2024-06-24 02:07:34 +02:00
08dfaeb436 Evite de rediriger vers login si user CAS déjà reconnu et CAS forcé: fix #757 2024-06-24 01:15:40 +02:00
ee050889f0 Fix API unit test 2024-06-23 21:13:40 +02:00
84d0b4fb9d Documentation API (QUERY pour carte syntaxique) 2024-06-23 17:40:48 +02:00
d35940cc0d Feuille saisie note: affichage du nom usuel 2024-06-23 17:39:58 +02:00
bea7b2ed8a Merge pull request 'create_api_map : ajout keyword "DOC_ANCHOR"' (#940) from iziram/ScoDoc:hotfix into master
Reviewed-on: ScoDoc/ScoDoc#940
2024-06-23 17:25:59 +02:00
Iziram
87d04905fe create_api_map : ajout keyword "DOC_ANCHOR" 2024-06-23 12:28:30 +02:00
5cdf089a1b Merge pull request 'create_api_map : bug fix query avec 1 element + légères optimisations' (#939) from iziram/ScoDoc:hotfix into master
Reviewed-on: ScoDoc/ScoDoc#939
2024-06-22 17:33:13 +02:00
Iziram
476fb29065 create_api_map : bug fix query avec 1 element + légères optimisations 2024-06-22 17:30:59 +02:00
8cf11a2600 API departement: ameliore code et doc. 2024-06-22 17:02:29 +02:00
d92924701b etud_photo_orig_page: modernise code 2024-06-21 19:13:53 +02:00
569c09d66d Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc 2024-06-21 16:37:02 +02:00
8d50cd2a8e Ajout commande pour changer le login d'un utilisateur 2024-06-21 16:34:23 +02:00
1d137875d3 Merge pull request 'Commande flask pour générer la carte syntaxique de l'API' (#938) from iziram/ScoDoc:api_map into master
Reviewed-on: ScoDoc/ScoDoc#938
2024-06-21 15:53:18 +02:00
Iziram
9b89ca436e gen_api_map : commentaire + généralisation 2024-06-21 15:38:44 +02:00
Iziram
de47277e7c gen_api_map: compression espace vertical 2024-06-21 09:28:47 +02:00
39873183a8 Fix bug feuille prepa jury non BUT + ajoute test unitaire 2024-06-21 01:27:05 +02:00
a8703edfc5 Edition en ligne des codes Apgee UE/RCUE. Ajout API. 2024-06-21 00:53:52 +02:00
Iziram
06727f1b9b gen_api_map fini + annotation QUERY assiduites/justificatifs 2024-06-20 17:56:12 +02:00
Iziram
c37a92aa5c WIP : gen_api_map (no query) 2024-06-20 15:48:11 +02:00
c41307c637 Edition en ligne des codes Apogee des UEs de BUT 2024-06-19 19:14:12 +02:00
3163e00ff5 Diplôme BUT sur tableau recap. jury. Closes #929. 2024-06-19 18:51:02 +02:00
070c9ea36f Diplome BUT: PV et lettre indiv. #929 2024-06-19 18:28:23 +02:00
6224f37e4a Export table comptes croisés: fix #934 2024-06-19 16:34:45 +02:00
e877e04cc6 Export Apogée: corrige cas sans décisions de jury 2024-06-18 21:00:56 +02:00
af557f9c93 Import admission: mode avec etudid 2024-06-18 20:40:13 +02:00
8a49d99292 Clonage semestre: améliore code + qq modifs cosmétiques 2024-06-18 01:21:33 +02:00
07c2f00277 Import utilisateurs: strip champs 2024-06-14 20:15:20 +02:00
c94fea9f9e Fix typo 2024-06-14 07:16:04 +02:00
96e445cb49 Lien vers doc: closes #896 2024-06-13 22:31:20 +02:00
8f7320e044 dict pour mails notif abs: déjà trop riche. #932 2024-06-13 22:26:14 +02:00
d6eeb5116d Merge pull request 'Résolutions des derniers tickets du module Assiduité' (#932) from iziram/ScoDoc:assi_tickets into master
Reviewed-on: ScoDoc/ScoDoc#932
2024-06-13 22:12:32 +02:00
b57c7980e6 Merge branch 'master' into assi_tickets 2024-06-13 22:11:12 +02:00
8163c6814c Permission de modifier les inscrits à un module: dir. etud et resp. module. Closes #933 2024-06-13 22:03:50 +02:00
Iziram
c8a042cc09 Assiduité : balise semestre notification mail closes #896 2024-06-11 15:07:17 +02:00
Iziram
70605edad7 Assiduité : documentation code JS + cleanup code 2024-06-11 14:51:31 +02:00
Iziram
faa6f552d4 Assiduité : suppression signal_assiduites_diff 2024-06-11 14:51:15 +02:00
ba506d7f8e Migration : code apo RCUE dans les UEs. Avec 4ae484061e 2024-06-11 09:08:17 +02:00
1808e7f5a4 Merge pull request 'Assiduité : fix bug suppression fichier justif' (#930) from iziram/ScoDoc:hotfix into master
Reviewed-on: ScoDoc/ScoDoc#930
2024-06-10 13:31:28 +02:00
Iziram
379d636259 Assiduité : fix bug suppression fichier justif 2024-06-10 13:23:15 +02:00
4ae484061e Exports Apogée: modernise code. Exporte RCUE du BUT: implements #925 2024-06-09 15:18:03 +02:00
320cfbebc8 WIP: modernisation code jurys 2024-06-07 17:58:02 +02:00
e0208d0650 9.6.973 2024-06-06 23:43:20 +02:00
f18fbe284a Merge pull request 'Assiduité : ajout_assiduite_etud : fix bug moduleimpl' (#927) from iziram/ScoDoc:hotfix into master
Reviewed-on: ScoDoc/ScoDoc#927
2024-06-06 23:42:31 +02:00
Iziram
6acf72c0c9 Assiduité : ajout_assiduite_etud : fix bug moduleimpl 2024-06-06 18:48:18 +02:00
212 changed files with 11783 additions and 6233 deletions

View File

@ -1,10 +1,14 @@
"""api.__init__
"""
from functools import wraps
from flask_json import as_json
from flask import Blueprint
from flask import request, g
from flask import current_app, g, request
from flask_login import current_user
from app import db
from app.decorators import permission_required
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoException
from app.scodoc.sco_permissions import Permission
@ -16,6 +20,28 @@ api_web_bp = Blueprint("apiweb", __name__)
API_CLIENT_ERROR = 400 # erreur dans les paramètres fournis par le client
def api_permission_required(permission):
"""Ce décorateur fait la même chose que @permission_required
mais enregistre dans l'attribut .scodoc_permission
de la fonction la valeur de la permission.
Cette valeur n'est utilisée que pour la génération automatique de la documentation.
"""
def decorator(f):
f.scodoc_permission = permission
@wraps(f)
def decorated_function(*args, **kwargs):
scodoc_dept = getattr(g, "scodoc_dept", None)
if not current_user.has_permission(permission, scodoc_dept):
return current_app.login_manager.unauthorized()
return f(*args, **kwargs)
return decorated_function
return decorator
@api_bp.errorhandler(ScoException)
@api_web_bp.errorhandler(ScoException)
@api_bp.errorhandler(404)

View File

@ -12,15 +12,18 @@ from flask_json import as_json
from flask_login import current_user, login_required
from flask_sqlalchemy.query import Query
from sqlalchemy.orm.exc import ObjectDeletedError
from werkzeug.exceptions import HTTPException
from app import db, log, set_sco_dept
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app.api import api_bp as bp
from app.api import api_web_bp, get_model_api_object, tools
from app.decorators import permission_required, scodoc
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.models import (
Assiduite,
Evaluation,
FormSemestre,
Identite,
ModuleImpl,
@ -45,6 +48,8 @@ def assiduite(assiduite_id: int = None):
"""Retourne un objet assiduité à partir de son id
Exemple de résultat:
```json
{
"assiduite_id": 1,
"etudid": 2,
@ -53,11 +58,17 @@ def assiduite(assiduite_id: int = None):
"date_fin": "2022-10-31T10:00+01:00",
"etat": "retard",
"desc": "une description",
"user_id: 1 or null,
"user_name" : login scodoc or null
"user_nom_complet": "Marie Dupont"
"user_id": 1 or null,
"user_name" : login scodoc or null,
"user_nom_complet": "Marie Dupont",
"est_just": False or True,
}
```
SAMPLES
-------
/assiduite/1;
"""
return get_model_api_object(Assiduite, assiduite_id, Identite)
@ -75,15 +86,23 @@ def assiduite(assiduite_id: int = None):
@permission_required(Permission.ScoView)
@as_json
def assiduite_justificatifs(assiduite_id: int = None, long: bool = False):
"""Retourne la liste des justificatifs qui justifie cette assiduitée
"""Retourne la liste des justificatifs qui justifient cette assiduité.
Exemple de résultat:
```json
[
1,
2,
3,
...
]
```
SAMPLES
-------
/assiduite/1/justificatifs;
/assiduite/1/justificatifs/long;
"""
return get_assiduites_justif(assiduite_id, long)
@ -117,52 +136,42 @@ def assiduite_justificatifs(assiduite_id: int = None, long: bool = False):
@scodoc
@as_json
@permission_required(Permission.ScoView)
def count_assiduites(
def assiduites_count(
etudid: int = None, nip: str = None, ine: str = None, with_query: bool = False
):
"""
Retourne le nombre d'assiduités d'un étudiant
chemin : /assiduites/<int:etudid>/count
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/count/query?
Les différents filtres :
Type (type de comptage -> journee, demi, heure, nombre d'assiduite):
query?type=(journee, demi, heure) -> une seule valeur parmis les trois
ex: .../query?type=heure
Comportement par défaut : compte le nombre d'assiduité enregistrée
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemestre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
Retourne le nombre d'assiduités d'un étudiant.
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
formsemestre_id:<int:formsemestre_id>
metric:<array[string]:metric>
split:<bool:split>
PARAMS
-----
user_id:l'id de l'auteur de l'assiduité
est_just:si l'assiduité est justifiée (fait aussi filtre abs/retard)
moduleimpl_id:l'id du module concerné par l'assiduité
date_debut:date de début de l'assiduité (supérieur ou égal)
date_fin:date de fin de l'assiduité (inférieur ou égal)
etat:etat de l'étudiant &rightarrow; absent, present ou retard
formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité
metric: la/les métriques de comptage (journee, demi, heure, compte)
split: divise le comptage par état
SAMPLES
-------
/assiduites/1/count;
/assiduites/1/count/query?etat=retard;
/assiduites/1/count/query?split;
/assiduites/1/count/query?etat=present,retard&metric=compte,heure;
"""
@ -219,40 +228,35 @@ def count_assiduites(
def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /assiduites/<int:etudid>
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/query?
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
formsemestre_id:<int:formsemestre_id>
with_justifs:<bool:with_justifs>
Les différents filtres :
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemstre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
PARAMS
-----
user_id:l'id de l'auteur de l'assiduité
est_just:si l'assiduité est justifiée (fait aussi filtre abs/retard)
moduleimpl_id:l'id du module concerné par l'assiduité
date_debut:date de début de l'assiduité (supérieur ou égal)
date_fin:date de fin de l'assiduité (inférieur ou égal)
etat:etat de l'étudiant &rightarrow; absent, present ou retard
formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité
with_justif:ajoute les justificatifs liés à l'assiduité
SAMPLES
-------
/assiduites/1;
/assiduites/1/query?etat=retard;
/assiduites/1/query?moduleimpl_id=1;
/assiduites/1/query?with_justifs=;
"""
@ -264,6 +268,7 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False)
404,
message="étudiant inconnu",
)
# Récupération des assiduités de l'étudiant
assiduites_query: Query = etud.assiduites
@ -286,6 +291,108 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False)
return data_set
@bp.route("/assiduites/<int:etudid>/evaluations")
@api_web_bp.route("/assiduites/<int:etudid>/evaluations")
# etudid
@bp.route("/assiduites/etudid/<int:etudid>/evaluations")
@api_web_bp.route("/assiduites/etudid/<int:etudid>/evaluations")
# ine
@bp.route("/assiduites/ine/<ine>/evaluations")
@api_web_bp.route("/assiduites/ine/<ine>/evaluations")
# nip
@bp.route("/assiduites/nip/<nip>/evaluations")
@api_web_bp.route("/assiduites/nip/<nip>/evaluations")
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def assiduites_evaluations(etudid: int = None, nip=None, ine=None):
"""
Retourne la liste de toutes les évaluations de l'étudiant
Pour chaque évaluation, retourne la liste des objets assiduités
sur la plage de l'évaluation
Exemple de résultat:
```json
[
{
"evaluation_id": 1234,
"assiduites": [
{
"assiduite_id":1234,
...
},
]
}
]
SAMPLES
-------
/assiduites/1/evaluations;
```
"""
# Récupération de l'étudiant
etud: Identite = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
# Récupération des évaluations et des assidiutés
etud_evaluations_assiduites: list[dict] = scass.get_etud_evaluations_assiduites(
etud
)
return etud_evaluations_assiduites
@api_web_bp.route("/evaluation/<int:evaluation_id>/assiduites")
@bp.route("/evaluation/<int:evaluation_id>/assiduites")
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def evaluation_assiduites(evaluation_id):
"""
Retourne les objets assiduités de chaque étudiant sur la plage de l'évaluation
Exemple de résultat:
```json
{
"<etudid>" : [
{
"assiduite_id":1234,
...
},
]
}
```
CATEGORY
--------
Évaluations
"""
# Récupération de l'évaluation
try:
evaluation: Evaluation = Evaluation.get_evaluation(evaluation_id)
except HTTPException:
return json_error(404, "L'évaluation n'existe pas")
evaluation_assiduites_par_etudid: dict[int, list[Assiduite]] = {}
for assi in scass.get_evaluation_assiduites(evaluation):
etudid: str = str(assi.etudid)
etud_assiduites = evaluation_assiduites_par_etudid.get(etudid, [])
etud_assiduites.append(assi.to_dict(format_api=True))
evaluation_assiduites_par_etudid[etudid] = etud_assiduites
return evaluation_assiduites_par_etudid
@bp.route("/assiduites/group/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/group/query", defaults={"with_query": True})
@login_required
@ -297,38 +404,34 @@ def assiduites_group(with_query: bool = False):
Retourne toutes les assiduités d'un groupe d'étudiants
chemin : /assiduites/group/query?etudids=1,2,3
Un filtrage peut être donné avec une query
chemin : /assiduites/group/query?etudids=1,2,3
Les différents filtres :
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemstre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
etudids:<array[int]:etudids>
formsemestre_id:<int:formsemestre_id>
with_justif:<bool:with_justif>
PARAMS
-----
user_id:l'id de l'auteur de l'assiduité
est_just:si l'assiduité est justifiée (fait aussi filtre abs/retard)
moduleimpl_id:l'id du module concerné par l'assiduité
date_debut:date de début de l'assiduité (supérieur ou égal)
date_fin:date de fin de l'assiduité (inférieur ou égal)
etat:etat de l'étudiant &rightarrow; absent, present ou retard
etudids:liste des ids des étudiants concernés par la recherche
formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité
with_justifs:ajoute les justificatifs liés à l'assiduité
SAMPLES
-------
/assiduites/group/query?etudids=1,2,3;
"""
@ -388,7 +491,34 @@ def assiduites_group(with_query: bool = False):
@as_json
@permission_required(Permission.ScoView)
def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne toutes les assiduités du formsemestre"""
"""Retourne toutes les assiduités du formsemestre
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
PARAMS
-----
user_id:l'id de l'auteur de l'assiduité
est_just:si l'assiduité est justifiée (fait aussi filtre abs/retard)
moduleimpl_id:l'id du module concerné par l'assiduité
date_debut:date de début de l'assiduité (supérieur ou égal)
date_fin:date de fin de l'assiduité (inférieur ou égal)
etat:etat de l'étudiant &rightarrow; absent, present ou retard
formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité
SAMPLES
-------
/assiduites/formsemestre/1;
/assiduites/formsemestre/1/query?etat=retard;
/assiduites/formsemestre/1/query?moduleimpl_id=1;
"""
# Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None
@ -435,10 +565,42 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
@scodoc
@as_json
@permission_required(Permission.ScoView)
def count_assiduites_formsemestre(
def assiduites_formsemestre_count(
formsemestre_id: int = None, with_query: bool = False
):
"""Comptage des assiduités du formsemestre"""
"""Comptage des assiduités du formsemestre
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
formsemestre_id:<int:formsemestre_id>
metric:<array[string]:metric>
split:<bool:split>
PARAMS
-----
user_id:l'id de l'auteur de l'assiduité
est_just:si l'assiduité est justifiée (fait aussi filtre abs/retard)
moduleimpl_id:l'id du module concerné par l'assiduité
date_debut:date de début de l'assiduité (supérieur ou égal)
date_fin:date de fin de l'assiduité (inférieur ou égal)
etat:etat de l'étudiant &rightarrow; absent, present ou retard
formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité
metric: la/les métriques de comptage (journee, demi, heure, compte)
split: divise le comptage par état
SAMPLES
-------
/assiduites/formsemestre/1/count;
/assiduites/formsemestre/1/count/query?etat=retard;
/assiduites/formsemestre/1/count/query?etat=present,retard&metric=compte,heure;
"""
# Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None
@ -489,7 +651,10 @@ def count_assiduites_formsemestre(
def assiduite_create(etudid: int = None, nip=None, ine=None):
"""
Enregistrement d'assiduités pour un étudiant (etudid)
La requête doit avoir un content type "application/json":
DATA
----
```json
[
{
"date_debut": str,
@ -505,6 +670,12 @@ def assiduite_create(etudid: int = None, nip=None, ine=None):
}
...
]
```
SAMPLES
-------
/assiduite/1/create;[{""date_debut"": ""2023-10-27T08:00"",""date_fin"": ""2023-10-27T10:00"",""etat"": ""absent""}]
/assiduite/1/create;[{""date_debut"": ""2023-10-27T08:00"",""date_fin"": ""2023-10-27T10:00"",""etat"": ""absent""}]
"""
# Récupération de l'étudiant
@ -558,7 +729,10 @@ def assiduite_create(etudid: int = None, nip=None, ine=None):
def assiduites_create():
"""
Création d'une assiduité ou plusieurs assiduites
La requête doit avoir un content type "application/json":
DATA
----
```json
[
{
"date_debut": str,
@ -571,12 +745,17 @@ def assiduites_create():
"date_fin": str,
"etat": str,
"etudid":int,
"moduleimpl_id": int,
"desc":str,
}
...
]
```
SAMPLES
-------
/assiduites/create;[{""etudid"":1,""date_debut"": ""2023-10-26T08:00"",""date_fin"": ""2023-10-26T10:00"",""etat"": ""absent""}]
/assiduites/create;[{""etudid"":-1,""date_debut"": ""2023-10-26T08:00"",""date_fin"": ""2023-10-26T10:00"",""etat"": ""absent""}]
"""
@ -747,13 +926,18 @@ def assiduite_delete():
"""
Suppression d'une assiduité à partir de son id
Forme des données envoyées :
DATA
----
```json
[
<assiduite_id:int>,
...
]
```
SAMPLES
-------
/assiduite/delete;[2,2,3]
"""
# Récupération des ids envoyés dans la liste
@ -828,13 +1012,24 @@ def _delete_one(assiduite_id: int) -> tuple[int, str]:
def assiduite_edit(assiduite_id: int):
"""
Edition d'une assiduité à partir de son id
La requête doit avoir un content type "application/json":
DATA
----
```json
{
"etat"?: str,
"moduleimpl_id"?: int
"desc"?: str
"est_just"?: bool
}
```
SAMPLES
-------
/assiduite/1/edit;{""etat"":""absent""}
/assiduite/1/edit;{""moduleimpl_id"":2}
/assiduite/1/edit;{""etat"": ""retard"",""moduleimpl_id"":3}
"""
# Récupération de l'assiduité à modifier
@ -876,7 +1071,10 @@ def assiduite_edit(assiduite_id: int):
def assiduites_edit():
"""
Edition de plusieurs assiduités
La requête doit avoir un content type "application/json":
DATA
----
```json
[
{
"assiduite_id" : int,
@ -886,6 +1084,13 @@ def assiduites_edit():
"est_just"?: bool
}
]
```
SAMPLES
-------
/assiduites/edit;[{""etat"":""absent"",""assiduite_id"":1}]
/assiduites/edit;[{""moduleimpl_id"":2,""assiduite_id"":1}]
/assiduites/edit;[{""etat"": ""retard"",""moduleimpl_id"":3,""assiduite_id"":1}]
"""
edit_list: list[object] = request.get_json(force=True)

View File

@ -6,6 +6,11 @@
"""
API : billets d'absences
CATEGORY
--------
Billets d'absence
"""
from flask import g, request
@ -29,7 +34,7 @@ from app.scodoc.sco_permissions import Permission
@permission_required(Permission.ScoView)
@as_json
def billets_absence_etudiant(etudid: int):
"""Liste des billets d'absence pour cet étudiant"""
"""Liste des billets d'absence pour cet étudiant."""
billets = sco_abs_billets.query_billets_etud(etudid)
return [billet.to_dict() for billet in billets]
@ -41,7 +46,20 @@ def billets_absence_etudiant(etudid: int):
@permission_required(Permission.AbsAddBillet)
@as_json
def billets_absence_create():
"""Ajout d'un billet d'absence"""
"""Ajout d'un billet d'absence. Renvoie le billet créé en json.
DATA
----
```json
{
"etudid" : int,
"abs_begin" : date_iso,
"abs_end" : date_iso,
"description" : string,
"justified" : bool
}
```
"""
data = request.get_json(force=True) # may raise 400 Bad Request
etudid = data.get("etudid")
abs_begin = data.get("abs_begin")

View File

@ -9,6 +9,11 @@
Note: les routes /departement[s] sont publiées sur l'API (/ScoDoc/api/),
mais évidemment pas sur l'API web (/ScoDoc/<dept>/api).
CATEGORY
--------
Département
"""
from datetime import datetime
@ -16,26 +21,15 @@ from flask import request
from flask_json import as_json
from flask_login import login_required
import app
from app import db
from app import db, log
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
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.models import Departement, FormSemestre
from app.models import departements
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
def get_departement(dept_ident: str) -> Departement:
"Le departement, par id ou acronyme. Erreur 404 si pas trouvé."
try:
dept_id = int(dept_ident)
except ValueError:
dept_id = None
if dept_id is None:
return Departement.query.filter_by(acronym=dept_ident).first_or_404()
return Departement.query.get_or_404(dept_id)
from app.scodoc.sco_utils import json_error
@bp.route("/departements")
@ -44,7 +38,7 @@ def get_departement(dept_ident: str) -> Departement:
@permission_required(Permission.ScoView)
@as_json
def departements_list():
"""Liste les départements"""
"""Liste tous les départements."""
return [dept.to_dict(with_dept_name=True) for dept in Departement.query]
@ -54,7 +48,7 @@ def departements_list():
@permission_required(Permission.ScoView)
@as_json
def departements_ids():
"""Liste des ids de départements"""
"""Liste des ids de tous les départements."""
return [dept.id for dept in Departement.query]
@ -63,11 +57,12 @@ def departements_ids():
@scodoc
@permission_required(Permission.ScoView)
@as_json
def departement(acronym: str):
def departement_by_acronym(acronym: str):
"""
Info sur un département. Accès par acronyme.
Exemple de résultat :
```json
{
"id": 1,
"acronym": "TAPI",
@ -76,6 +71,7 @@ def departement(acronym: str):
"visible": true,
"date_creation": "Fri, 15 Apr 2022 12:19:28 GMT"
}
```
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return dept.to_dict(with_dept_name=True)
@ -102,11 +98,15 @@ def departement_by_id(dept_id: int):
def departement_create():
"""
Création d'un département.
The request content type should be "application/json":
Le content type doit être `application/json`.
DATA
----
```json
{
"acronym": str,
"visible":bool,
"visible": bool,
}
```
"""
data = request.get_json(force=True) # may raise 400 Bad Request
acronym = str(data.get("acronym", ""))
@ -117,6 +117,9 @@ def departement_create():
dept = departements.create_dept(acronym, visible=visible)
except ScoValueError as exc:
return json_error(500, exc.args[0] if exc.args else "")
log(f"departement_create {dept.acronym}")
return dept.to_dict()
@ -127,10 +130,12 @@ def departement_create():
@as_json
def departement_edit(acronym):
"""
Edition d'un département: seul visible peut être modifié
The request content type should be "application/json":
Édition d'un département: seul le champ `visible` peut être modifié.
DATA
----
{
"visible":bool,
"visible": bool,
}
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
@ -142,6 +147,7 @@ def departement_edit(acronym):
dept.visible = visible
db.session.add(dept)
db.session.commit()
log(f"departement_edit {dept.acronym}")
return dept.to_dict()
@ -151,11 +157,13 @@ def departement_edit(acronym):
@permission_required(Permission.ScoSuperAdmin)
def departement_delete(acronym):
"""
Suppression d'un département.
Suppression d'un département identifié par son acronyme.
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
acronym = dept.acronym
db.session.delete(dept)
db.session.commit()
log(f"departement_delete {acronym}")
return {"OK": True}
@ -164,13 +172,16 @@ def departement_delete(acronym):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_etudiants(acronym: str):
def departement_etudiants(acronym: str):
"""
Retourne la liste des étudiants d'un département
Retourne la liste des étudiants d'un département.
acronym: l'acronyme d'un département
PARAMS
------
acronym : l'acronyme d'un département
Exemple de résultat :
```json
[
{
"civilite": "M",
@ -185,6 +196,7 @@ def dept_etudiants(acronym: str):
},
...
]
```
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return [etud.to_dict_short() for etud in dept.etudiants]
@ -195,7 +207,7 @@ def dept_etudiants(acronym: str):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_etudiants_by_id(dept_id: int):
def departement_etudiants_by_id(dept_id: int):
"""
Retourne la liste des étudiants d'un département d'id donné.
"""
@ -208,8 +220,8 @@ def dept_etudiants_by_id(dept_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_formsemestres_ids(acronym: str):
"""liste des ids formsemestre du département"""
def departement_formsemestres_ids(acronym: str):
"""Liste des ids de tous les formsemestres du département."""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return [formsemestre.id for formsemestre in dept.formsemestres]
@ -219,57 +231,34 @@ def dept_formsemestres_ids(acronym: str):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_formsemestres_ids_by_id(dept_id: int):
"""liste des ids formsemestre du département"""
def departement_formsemestres_ids_by_id(dept_id: int):
"""Liste des ids de tous les formsemestres du département."""
dept = Departement.query.get_or_404(dept_id)
return [formsemestre.id for formsemestre in dept.formsemestres]
@bp.route("/departement/<string:acronym>/formsemestres_courants")
@bp.route("/departement/id/<int:dept_id>/formsemestres_courants")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_formsemestres_courants(acronym: str):
def departement_formsemestres_courants(acronym: str = "", dept_id: int | None = None):
"""
Liste des semestres actifs d'un département d'acronyme donné
Liste les formsemestres du département indiqué (par son acronyme ou son id)
contenant la date courante, ou à défaut celle indiquée en argument
(au format ISO).
QUERY
-----
date_courante:<string:date_courante>
Exemple de résultat :
[
{
"titre": "master machine info",
"gestion_semestrielle": false,
"scodoc7_id": null,
"date_debut": "01/09/2021",
"bul_bgcolor": null,
"date_fin": "15/12/2022",
"resp_can_edit": false,
"dept_id": 1,
"etat": true,
"resp_can_change_ens": false,
"id": 1,
"modalite": "FI",
"ens_can_edit_eval": false,
"formation_id": 1,
"gestion_compensation": false,
"elt_sem_apo": null,
"semestre_id": 1,
"bul_hide_xml": false,
"elt_annee_apo": null,
"block_moyennes": false,
"formsemestre_id": 1,
"titre_num": "master machine info semestre 1",
"date_debut_iso": "2021-09-01",
"date_fin_iso": "2022-12-15",
"responsables": [
3,
2
]
},
...
]
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
dept = (
Departement.query.filter_by(acronym=acronym).first_or_404()
if acronym
else Departement.query.get_or_404(dept_id)
)
date_courante = request.args.get("date_courante")
date_courante = datetime.fromisoformat(date_courante) if date_courante else None
return [
@ -278,29 +267,3 @@ def dept_formsemestres_courants(acronym: str):
dept, date_courante
)
]
@bp.route("/departement/id/<int:dept_id>/formsemestres_courants")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_formsemestres_courants_by_id(dept_id: int):
"""
Liste des semestres actifs d'un département d'id donné
"""
# Le département, spécifié par un id ou un acronyme
dept = Departement.query.get_or_404(dept_id)
date_courante = request.args.get("date_courante")
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = db.func.current_date()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,
FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= test_date,
)
return [d.to_dict_api() for d in formsemestres]

View File

@ -6,6 +6,10 @@
"""
API : accès aux étudiants
CATEGORY
--------
Étudiants
"""
from datetime import datetime
from operator import attrgetter
@ -21,8 +25,9 @@ import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.api import tools
from app.api import api_permission_required as permission_required
from app.but import bulletin_but_court
from app.decorators import scodoc, permission_required
from app.decorators import scodoc
from app.models import (
Admission,
Departement,
@ -37,9 +42,8 @@ from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc import sco_etud
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_photos
from app.scodoc.sco_utils import json_error, suppress_accents
import app.scodoc.sco_photos as sco_photos
import app.scodoc.sco_utils as scu
# Un exemple:
@ -89,14 +93,20 @@ def _get_etud_by_code(
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants_courants(long=False):
def etudiants_courants(long: bool = False):
"""
La liste des étudiants des semestres "courants" (tous départements)
(date du jour comprise dans la période couverte par le sem.)
dans lesquels l'utilisateur a la permission ScoView
(donc tous si le dept du rôle est None).
La liste des étudiants des semestres "courants".
Considère tous les départements dans lesquels l'utilisateur a la
permission `ScoView` (donc tous si le dépt. du rôle est `None`),
et les formsemestres contenant la date courante,
ou à défaut celle indiquée en argument (au format ISO).
QUERY
-----
date_courante:<string:date_courante>
Exemple de résultat :
```json
[
{
"id": 1234,
@ -109,6 +119,7 @@ def etudiants_courants(long=False):
}
...
]
```
En format "long": voir documentation.
@ -154,10 +165,13 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
"""
Retourne les informations de l'étudiant correspondant, ou 404 si non trouvé.
PARAMS
------
etudid : l'etudid de l'étudiant
nip : le code nip de l'étudiant
ine : le code ine de l'étudiant
`etudid` est unique dans la base (tous départements).
Les codes INE et NIP sont uniques au sein d'un département.
Si plusieurs objets ont le même code, on ramène le plus récemment inscrit.
"""
@ -181,11 +195,18 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
@login_required
@scodoc
@permission_required(Permission.ScoView)
def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
def etudiant_get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
"""
Retourne la photo de l'étudiant
correspondant ou un placeholder si non existant.
Retourne la photo de l'étudiant ou un placeholder si non existant.
Le paramètre `size` peut prendre la valeur `small` (taille réduite, hauteur
environ 90 pixels) ou `orig` (défaut, image de la taille originale).
QUERY
-----
size:<string:size>
PARAMS
------
etudid : l'etudid de l'étudiant
nip : le code nip de l'étudiant
ine : le code ine de l'étudiant
@ -215,7 +236,7 @@ def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
@scodoc
@permission_required(Permission.EtudChangeAdr)
@as_json
def set_photo_image(etudid: int = None):
def etudiant_set_photo_image(etudid: int = None):
"""Enregistre la photo de l'étudiant."""
allowed_depts = current_user.get_depts_with_permission(Permission.EtudChangeAdr)
query = Identite.query.filter_by(id=etudid)
@ -258,9 +279,12 @@ def set_photo_image(etudid: int = None):
@as_json
def etudiants(etudid: int = None, nip: str = None, ine: str = None):
"""
Info sur le ou les étudiants correspondant. Comme /etudiant mais renvoie
toujours une liste.
Info sur le ou les étudiants correspondants.
Comme `/etudiant` mais renvoie toujours une liste.
Si non trouvé, liste vide, pas d'erreur.
Dans 99% des cas, la liste contient un seul étudiant, mais si l'étudiant a
été inscrit dans plusieurs départements, on a plusieurs objets (1 par dept.).
"""
@ -293,8 +317,9 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
@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.
"""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:
@ -329,13 +354,13 @@ def etudiants_by_name(start: str = "", min_len=3, limit=32):
@as_json
def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None):
"""
Liste des semestres qu'un étudiant a suivi, triés par ordre chronologique.
Liste des formsemestres qu'un étudiant a suivi, triés par ordre chronologique.
Accès par etudid, nip ou ine.
Attention, si accès via NIP ou INE, les semestres peuvent être de départements
Attention, si accès via NIP ou INE, les formsemestres peuvent être de départements
différents (si l'étudiant a changé de département). L'id du département est `dept_id`.
Si accès par département, ne retourne que les formsemestre suivis dans le département.
Si accès par département, ne retourne que les formsemestres suivis dans le département.
"""
if etudid is not None:
q_etud = Identite.query.filter_by(id=etudid)
@ -403,13 +428,14 @@ def bulletin(
"""
Retourne le bulletin d'un étudiant dans un formsemestre.
PARAMS
------
formsemestre_id : l'id d'un formsemestre
code_type : "etudid", "nip" ou "ine"
code : valeur du code INE, NIP ou etudid, selon code_type.
version : type de bulletin (par défaut, "selectedevals"): short, long, selectedevals, butcourt
pdf : si spécifié, bulletin au format PDF (et non JSON).
Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin
"""
if version == "pdf":
version = "long"
@ -463,10 +489,13 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
"""
Retourne la liste des groupes auxquels appartient l'étudiant dans le formsemestre indiqué
PARAMS
------
formsemestre_id : l'id d'un formsemestre
etudid : l'etudid d'un étudiant
Exemple de résultat :
```json
[
{
"partition_id": 1,
@ -491,6 +520,7 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
"group_name": "A"
}
]
```
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
@ -518,9 +548,12 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
@permission_required(Permission.EtudInscrit)
@as_json
def etudiant_create(force=False):
"""Création d'un nouvel étudiant
"""Création d'un nouvel étudiant.
Si force, crée même si homonymie détectée.
L'étudiant créé n'est pas inscrit à un semestre.
Champs requis: nom, prenom (sauf si config sans prénom), dept (string:acronyme)
"""
args = request.get_json(force=True) # may raise 400 Bad Request
@ -588,7 +621,13 @@ def etudiant_edit(
code_type: str = "etudid",
code: str = None,
):
"""Edition des données étudiant (identité, admission, adresses)"""
"""Édition des données étudiant (identité, admission, adresses).
PARAMS
------
`code_type`: le type du code, `etudid`, `ine` ou `nip`.
`code`: la valeur du code
"""
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
@ -627,7 +666,23 @@ def etudiant_annotation(
code_type: str = "etudid",
code: str = None,
):
"""Ajout d'une annotation sur un étudiant"""
"""Ajout d'une annotation sur un étudiant.
Renvoie l'annotation créée.
PARAMS
------
`code_type`: le type du code, `etudid`, `ine` ou `nip`.
`code`: la valeur du code
DATA
----
```json
{
"comment" : string
}
```
"""
if not current_user.has_permission(Permission.ViewEtudData):
return json_error(403, "non autorisé (manque ViewEtudData)")
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
@ -664,7 +719,13 @@ def etudiant_annotation_delete(
code_type: str = "etudid", code: str = None, annotation_id: int = None
):
"""
Suppression d'une annotation
Suppression d'une annotation. On spécifie l'étudiant et l'id de l'annotation.
PARAMS
------
`code_type`: le type du code, `etudid`, `ine` ou `nip`.
`code`: la valeur du code
`annotation_id` : id de l'annotation
"""
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:

View File

@ -6,6 +6,10 @@
"""
ScoDoc 9 API : accès aux évaluations
CATEGORY
--------
Évaluations
"""
from flask import g, request
from flask_json import as_json
@ -14,7 +18,8 @@ from flask_login import current_user, login_required
import app
from app import log, db
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.models import Evaluation, ModuleImpl, FormSemestre
from app.scodoc import sco_evaluation_db, sco_saisie_notes
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
@ -31,24 +36,28 @@ import app.scodoc.sco_utils as scu
def get_evaluation(evaluation_id: int):
"""Description d'une évaluation.
DATA
----
```json
{
'coefficient': 1.0,
'date_debut': '2016-01-04T08:30:00',
'date_fin': '2016-01-04T12:30:00',
'description': 'TP NI9219 Température',
'evaluation_type': 0,
'id': 15797,
'moduleimpl_id': 1234,
'note_max': 20.0,
'numero': 3,
'poids': {
'UE1.1': 1.0,
'UE1.2': 1.0,
'UE1.3': 1.0
},
'publish_incomplete': False,
'visibulletin': True
}
'coefficient': 1.0,
'date_debut': '2016-01-04T08:30:00',
'date_fin': '2016-01-04T12:30:00',
'description': 'TP Température',
'evaluation_type': 0,
'id': 15797,
'moduleimpl_id': 1234,
'note_max': 20.0,
'numero': 3,
'poids': {
'UE1.1': 1.0,
'UE1.2': 1.0,
'UE1.3': 1.0
},
'publish_incomplete': False,
'visibulletin': True
}
```
"""
query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept:
@ -69,11 +78,13 @@ def get_evaluation(evaluation_id: int):
@as_json
def moduleimpl_evaluations(moduleimpl_id: int):
"""
Retourne la liste des évaluations d'un moduleimpl
Retourne la liste des évaluations d'un moduleimpl.
PARAMS
------
moduleimpl_id : l'id d'un moduleimpl
Exemple de résultat : voir /evaluation
Exemple de résultat : voir `/evaluation`.
"""
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
return [evaluation.to_dict_api() for evaluation in modimpl.evaluations]
@ -87,30 +98,36 @@ def moduleimpl_evaluations(moduleimpl_id: int):
@as_json
def evaluation_notes(evaluation_id: int):
"""
Retourne la liste des notes de l'évaluation
Retourne la liste des notes de l'évaluation.
PARAMS
------
evaluation_id : l'id de l'évaluation
Exemple de résultat :
{
"11": {
```json
{
"11": {
"etudid": 11,
"evaluation_id": 1,
"value": 15.0,
"note_max" : 20.0,
"comment": "",
"date": "Wed, 20 Apr 2022 06:49:05 GMT",
"date": "2024-07-19T19:08:44+02:00",
"uid": 2
},
"12": {
},
"12": {
"etudid": 12,
"evaluation_id": 1,
"value": 12.0,
"value": "ABS",
"note_max" : 20.0,
"comment": "",
"date": "Wed, 20 Apr 2022 06:49:06 GMT",
"date": "2024-07-19T19:08:44+02:00",
"uid": 2
},
...
}
},
...
}
```
"""
query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept:
@ -144,13 +161,18 @@ def evaluation_notes(evaluation_id: int):
@as_json
def evaluation_set_notes(evaluation_id: int): # evaluation-notes-set
"""Écriture de notes dans une évaluation.
The request content type should be "application/json",
and contains:
DATA
----
```json
{
'notes' : [ [etudid, value], ... ],
'comment' : optional string
}
Result:
```
Résultat:
- 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é
@ -185,8 +207,9 @@ def evaluation_set_notes(evaluation_id: int): # evaluation-notes-set
@as_json
def evaluation_create(moduleimpl_id: int):
"""Création d'une évaluation.
The request content type should be "application/json",
and contains:
DATA
----
{
"description" : str,
"evaluation_type" : int, // {0,1,2} default 0 (normale)
@ -199,7 +222,8 @@ def evaluation_create(moduleimpl_id: int):
"coefficient" : float, // si non spécifié, 1.0
"poids" : { ue_id : poids } // optionnel
}
Result: l'évaluation créée.
Résultat: l'évaluation créée.
"""
moduleimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id)
if not moduleimpl.can_edit_evaluation(current_user):
@ -249,7 +273,7 @@ def evaluation_create(moduleimpl_id: int):
@as_json
def evaluation_delete(evaluation_id: int):
"""Suppression d'une évaluation.
Efface aussi toutes ses notes
Efface aussi toutes ses notes.
"""
query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept:

View File

@ -6,6 +6,10 @@
"""
ScoDoc 9 API : accès aux formations
CATEGORY
--------
Formations
"""
from flask import flash, g, request
@ -15,12 +19,15 @@ from flask_login import login_required
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.models import APO_CODE_STR_LEN
from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required
from app.models import (
ApcNiveau,
ApcParcours,
Formation,
Module,
UniteEns,
)
from app.scodoc import sco_formations
@ -35,7 +42,8 @@ from app.scodoc.sco_permissions import Permission
@as_json
def formations():
"""
Retourne la liste de toutes les formations (tous départements)
Retourne la liste de toutes les formations (tous départements,
sauf si route départementale).
"""
query = Formation.query
if g.scodoc_dept:
@ -55,7 +63,7 @@ def formations_ids():
Retourne la liste de toutes les id de formations
(tous départements, ou du département indiqué dans la route)
Exemple de résultat : [ 17, 99, 32 ]
Exemple de résultat : `[ 17, 99, 32 ]`.
"""
query = Formation.query
if g.scodoc_dept:
@ -71,24 +79,26 @@ def formations_ids():
@as_json
def formation_by_id(formation_id: int):
"""
La formation d'id donné
La formation d'id donné.
formation_id : l'id d'une formation
Exemple de résultat :
{
"id": 1,
"acronyme": "BUT R&amp;T",
"titre_officiel": "Bachelor technologique réseaux et télécommunications",
"formation_code": "V1RET",
"code_specialite": null,
"dept_id": 1,
"titre": "BUT R&amp;T",
"version": 1,
"type_parcours": 700,
"referentiel_competence_id": null,
"formation_id": 1
}
```json
{
"id": 1,
"acronyme": "BUT R&amp;T",
"titre_officiel": "Bachelor technologique réseaux et télécommunications",
"formation_code": "V1RET",
"code_specialite": null,
"dept_id": 1,
"titre": "BUT R&amp;T",
"version": 1,
"type_parcours": 700,
"referentiel_competence_id": null,
"formation_id": 1
}
```
"""
query = Formation.query.filter_by(id=formation_id)
if g.scodoc_dept:
@ -120,97 +130,102 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
"""
Retourne la formation, avec UE, matières, modules
PARAMS
------
formation_id : l'id d'une formation
export_ids : True ou False, si l'on veut ou non exporter les ids
export_with_ids : si présent, exporte aussi les ids des objets ScoDoc de la formation.
Exemple de résultat :
```json
{
"id": 1,
"acronyme": "BUT R&amp;T",
"titre_officiel": "Bachelor technologique r\u00e9seaux et t\u00e9l\u00e9communications",
"formation_code": "V1RET",
"code_specialite": null,
"dept_id": 1,
"titre": "BUT R&amp;T",
"version": 1,
"type_parcours": 700,
"referentiel_competence_id": null,
"formation_id": 1,
"ue": [
{
"id": 1,
"acronyme": "BUT R&amp;T",
"titre_officiel": "Bachelor technologique r\u00e9seaux et t\u00e9l\u00e9communications",
"formation_code": "V1RET",
"code_specialite": null,
"dept_id": 1,
"titre": "BUT R&amp;T",
"version": 1,
"type_parcours": 700,
"referentiel_competence_id": null,
"formation_id": 1,
"ue": [
"acronyme": "RT1.1",
"numero": 1,
"titre": "Administrer les r\u00e9seaux et l\u2019Internet",
"type": 0,
"ue_code": "UCOD11",
"ects": 12.0,
"is_external": false,
"code_apogee": "",
"coefficient": 0.0,
"semestre_idx": 1,
"color": "#B80004",
"reference": 1,
"matiere": [
{
"acronyme": "RT1.1",
"numero": 1,
"titre": "Administrer les r\u00e9seaux et l\u2019Internet",
"type": 0,
"ue_code": "UCOD11",
"ects": 12.0,
"is_external": false,
"code_apogee": "",
"coefficient": 0.0,
"semestre_idx": 1,
"color": "#B80004",
"reference": 1,
"matiere": [
"titre": "Administrer les r\u00e9seaux et l\u2019Internet",
"numero": 1,
"module": [
{
"titre": "Administrer les r\u00e9seaux et l\u2019Internet",
"numero": 1,
"module": [
"titre": "Initiation aux r\u00e9seaux informatiques",
"abbrev": "Init aux r\u00e9seaux informatiques",
"code": "R101",
"heures_cours": 0.0,
"heures_td": 0.0,
"heures_tp": 0.0,
"coefficient": 1.0,
"ects": "",
"semestre_id": 1,
"numero": 10,
"code_apogee": "",
"module_type": 2,
"coefficients": [
{
"titre": "Initiation aux r\u00e9seaux informatiques",
"abbrev": "Init aux r\u00e9seaux informatiques",
"code": "R101",
"heures_cours": 0.0,
"heures_td": 0.0,
"heures_tp": 0.0,
"coefficient": 1.0,
"ects": "",
"semestre_id": 1,
"numero": 10,
"code_apogee": "",
"module_type": 2,
"coefficients": [
{
"ue_reference": "1",
"coef": "12.0"
},
{
"ue_reference": "2",
"coef": "4.0"
},
{
"ue_reference": "3",
"coef": "4.0"
}
]
"ue_reference": "1",
"coef": "12.0"
},
{
"titre": "Se sensibiliser \u00e0 l&apos;hygi\u00e8ne informatique...",
"abbrev": "Hygi\u00e8ne informatique",
"code": "SAE11",
"heures_cours": 0.0,
"heures_td": 0.0,
"heures_tp": 0.0,
"coefficient": 1.0,
"ects": "",
"semestre_id": 1,
"numero": 10,
"code_apogee": "",
"module_type": 3,
"coefficients": [
{
"ue_reference": "1",
"coef": "16.0"
}
]
"ue_reference": "2",
"coef": "4.0"
},
...
]
{
"ue_reference": "3",
"coef": "4.0"
}
]
},
{
"titre": "Se sensibiliser \u00e0 l&apos;hygi\u00e8ne informatique...",
"abbrev": "Hygi\u00e8ne informatique",
"code": "SAE11",
"heures_cours": 0.0,
"heures_td": 0.0,
"heures_tp": 0.0,
"coefficient": 1.0,
"ects": "",
"semestre_id": 1,
"numero": 10,
"code_apogee": "",
"module_type": 3,
"coefficients": [
{
"ue_reference": "1",
"coef": "16.0"
}
]
},
...
]
]
},
]
}
...
]
},
]
}
```
"""
query = Formation.query.filter_by(id=formation_id)
if g.scodoc_dept:
@ -233,11 +248,8 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
@as_json
def referentiel_competences(formation_id: int):
"""
Retourne le référentiel de compétences
formation_id : l'id d'une formation
return null si pas de référentiel associé.
Retourne le référentiel de compétences de la formation
ou null si pas de référentiel associé.
"""
query = Formation.query.filter_by(id=formation_id)
if g.scodoc_dept:
@ -248,16 +260,22 @@ def referentiel_competences(formation_id: int):
return formation.referentiel_competence.to_dict()
@bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
@api_web_bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
@bp.route("/formation/ue/<int:ue_id>/set_parcours", methods=["POST"])
@api_web_bp.route("/formation/ue/<int:ue_id>/set_parcours", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.EditFormation)
@as_json
def set_ue_parcours(ue_id: int):
def ue_set_parcours(ue_id: int):
"""Associe UE et parcours BUT.
La liste des ids de parcours est passée en argument JSON.
JSON arg: [parcour_id1, parcour_id2, ...]
DATA
----
```json
[ parcour_id1, parcour_id2, ... ]
```
"""
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
@ -270,7 +288,7 @@ def set_ue_parcours(ue_id: int):
parcours = [
ApcParcours.query.get_or_404(int(parcour_id)) for parcour_id in parcours_ids
]
log(f"set_ue_parcours: ue_id={ue.id} parcours_ids={parcours_ids}")
log(f"ue_set_parcours: ue_id={ue.id} parcours_ids={parcours_ids}")
ok, error_message = ue.set_parcours(parcours)
if not ok:
return json_error(404, error_message)
@ -278,19 +296,19 @@ def set_ue_parcours(ue_id: int):
@bp.route(
"/assoc_ue_niveau/<int:ue_id>/<int:niveau_id>",
"/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>",
methods=["POST"],
)
@api_web_bp.route(
"/assoc_ue_niveau/<int:ue_id>/<int:niveau_id>",
"/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
@as_json
def assoc_ue_niveau(ue_id: int, niveau_id: int):
"""Associe l'UE au niveau de compétence"""
def ue_assoc_niveau(ue_id: int, niveau_id: int):
"""Associe l'UE au niveau de compétence."""
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
@ -307,32 +325,278 @@ def assoc_ue_niveau(ue_id: int, niveau_id: int):
@bp.route(
"/desassoc_ue_niveau/<int:ue_id>",
"/formation/ue/<int:ue_id>/desassoc_niveau",
methods=["POST"],
)
@api_web_bp.route(
"/desassoc_ue_niveau/<int:ue_id>",
"/formation/ue/<int:ue_id>/desassoc_niveau",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
@as_json
def desassoc_ue_niveau(ue_id: int):
def ue_desassoc_niveau(ue_id: int):
"""Désassocie cette UE de son niveau de compétence
(si elle n'est pas associée, ne fait rien)
(si elle n'est pas associée, ne fait rien).
"""
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
ue.niveau_competence = None
db.session.add(ue)
db.session.commit()
# Invalidation du cache
ue.formation.invalidate_cached_sems()
log(f"desassoc_ue_niveau: {ue}")
if g.scodoc_dept:
# "usage web"
ok, error_message = ue.set_niveau_competence(None)
if not ok:
if g.scodoc_dept: # "usage web"
flash(error_message, "error")
return json_error(404, error_message)
if g.scodoc_dept: # "usage web"
flash(f"UE {ue.acronyme} dé-associée")
return {"status": 0}
@bp.route("/formation/ue/<int:ue_id>", methods=["GET"])
@api_web_bp.route("/formation/ue/<int:ue_id>", methods=["GET"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
def get_ue(ue_id: int):
"""Renvoie l'UE."""
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
return ue.to_dict(convert_objects=True)
@bp.route("/formation/module/<int:module_id>", methods=["GET"])
@api_web_bp.route("/formation/module/<int:module_id>", methods=["GET"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
def formation_module_get(module_id: int):
"""Renvoie le module."""
query = Module.query.filter_by(id=module_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
module: Module = query.first_or_404()
return module.to_dict(convert_objects=True)
@bp.route("/formation/ue/set_code_apogee", methods=["POST"])
@api_web_bp.route("/formation/ue/set_code_apogee", methods=["POST"])
@bp.route(
"/formation/ue/<int:ue_id>/set_code_apogee/<string:code_apogee>", methods=["POST"]
)
@api_web_bp.route(
"/formation/ue/<int:ue_id>/set_code_apogee/<string:code_apogee>", methods=["POST"]
)
@bp.route(
"/formation/ue/<int:ue_id>/set_code_apogee",
defaults={"code_apogee": ""},
methods=["POST"],
)
@api_web_bp.route(
"/formation/ue/<int:ue_id>/set_code_apogee",
defaults={"code_apogee": ""},
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
def ue_set_code_apogee(ue_id: int | None = None, code_apogee: str = ""):
"""Change le code Apogée de l'UE.
Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
par des virgules.
Ce changement peut être fait sur formation verrouillée.
Si `ue_id` n'est pas spécifié, utilise l'argument oid du POST.
Si `code_apogee` n'est pas spécifié ou vide,
utilise l'argument value du POST.
Le retour est une chaîne (le code enregistré), pas du json.
"""
if ue_id is None:
ue_id = request.form.get("oid")
if ue_id is None:
return json_error(404, "argument oid manquant")
if not code_apogee:
code_apogee = request.form.get("value", "")
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
code_apogee = code_apogee.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque
log(f"API ue_set_code_apogee: ue_id={ue.id} code_apogee={code_apogee}")
ue.code_apogee = code_apogee
db.session.add(ue)
db.session.commit()
return code_apogee or ""
@bp.route(
"/formation/ue/<int:ue_id>/set_code_apogee_rcue/<string:code_apogee>",
methods=["POST"],
)
@api_web_bp.route(
"/formation/ue/<int:ue_id>/set_code_apogee_rcue/<string:code_apogee>",
methods=["POST"],
)
@bp.route(
"/formation/ue/<int:ue_id>/set_code_apogee_rcue",
defaults={"code_apogee": ""},
methods=["POST"],
)
@api_web_bp.route(
"/formation/ue/<int:ue_id>/set_code_apogee_rcue",
defaults={"code_apogee": ""},
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
def ue_set_code_apogee_rcue(ue_id: int, code_apogee: str = ""):
"""Change le code Apogée du RCUE de l'UE.
Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
par des virgules.
Ce changement peut être fait sur formation verrouillée.
Si code_apogee n'est pas spécifié ou vide,
utilise l'argument value du POST (utilisé par `jinplace.js`)
Le retour est une chaîne (le code enregistré), pas du json.
"""
if not code_apogee:
code_apogee = request.form.get("value", "")
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
code_apogee = code_apogee.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque
log(f"API ue_set_code_apogee_rcue: ue_id={ue.id} code_apogee={code_apogee}")
ue.code_apogee_rcue = code_apogee
db.session.add(ue)
db.session.commit()
return code_apogee or ""
@bp.route("/formation/module/set_code_apogee", methods=["POST"])
@api_web_bp.route("/formation/module/set_code_apogee", methods=["POST"])
@bp.route(
"/formation/module/<int:module_id>/set_code_apogee/<string:code_apogee>",
methods=["POST"],
)
@api_web_bp.route(
"/formation/module/<int:module_id>/set_code_apogee/<string:code_apogee>",
methods=["POST"],
)
@bp.route(
"/formation/module/<int:module_id>/set_code_apogee",
defaults={"code_apogee": ""},
methods=["POST"],
)
@api_web_bp.route(
"/formation/module/<int:module_id>/set_code_apogee",
defaults={"code_apogee": ""},
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
def formation_module_set_code_apogee(
module_id: int | None = None, code_apogee: str = ""
):
"""Change le code Apogée du module.
Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
par des virgules.
Ce changement peut être fait sur formation verrouillée.
Si `module_id` n'est pas spécifié, utilise l'argument `oid` du POST.
Si `code_apogee` n'est pas spécifié ou vide,
utilise l'argument value du POST (utilisé par jinplace.js)
Le retour est une chaîne (le code enregistré), pas du json.
"""
if module_id is None:
module_id = request.form.get("oid")
if module_id is None:
return json_error(404, "argument oid manquant")
if not code_apogee:
code_apogee = request.form.get("value", "")
query = Module.query.filter_by(id=module_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
module: Module = query.first_or_404()
code_apogee = code_apogee.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque
log(
f"API formation_module_set_code_apogee: module_id={module.id} code_apogee={code_apogee}"
)
module.code_apogee = code_apogee
db.session.add(module)
db.session.commit()
return code_apogee or ""
@bp.route(
"/formation/module/<int:module_id>/edit",
methods=["POST"],
)
@api_web_bp.route(
"/formation/module/<int:module_id>/edit",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
@as_json
def formation_module_edit(module_id: int):
"""Édition d'un module. Renvoie le module en json."""
query = Module.query.filter_by(id=module_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
module: Module = query.first_or_404()
args = request.get_json(force=True) # may raise 400 Bad Request
module.from_dict(args)
db.session.commit()
db.session.refresh(module)
log(f"API module_edit: module_id={module.id} args={args}")
r = module.to_dict(convert_objects=True, with_parcours_ids=True)
return r
@bp.route(
"/formation/ue/<int:ue_id>/edit",
methods=["POST"],
)
@api_web_bp.route(
"/formation/ue/<int:ue_id>/edit",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
@as_json
def ue_edit(ue_id: int):
"""Édition d'une UE. Renvoie l'UE en json."""
ue = UniteEns.get_ue(ue_id)
args = request.get_json(force=True) # may raise 400 Bad Request
ue.from_dict(args)
db.session.commit()
db.session.refresh(ue)
log(f"API ue_edit: ue_id={ue.id} args={args}")
r = ue.to_dict(convert_objects=True)
return r

View File

@ -6,6 +6,12 @@
"""
ScoDoc 9 API : accès aux formsemestres
CATEGORY
--------
FormSemestre
"""
from operator import attrgetter, itemgetter
@ -14,9 +20,10 @@ from flask_json import as_json
from flask_login import current_user, login_required
import sqlalchemy as sa
import app
from app import db
from app import db, log
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.decorators import scodoc, permission_required
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.scodoc.sco_utils import json_error
from app.comp import res_sem
from app.comp.moy_mod import ModuleImplResults
@ -54,35 +61,37 @@ def formsemestre_infos(formsemestre_id: int):
formsemestre_id : l'id du formsemestre
Exemple de résultat :
{
"block_moyennes": false,
"bul_bgcolor": "white",
"bul_hide_xml": false,
"date_debut_iso": "2021-09-01",
"date_debut": "01/09/2021",
"date_fin_iso": "2022-08-31",
"date_fin": "31/08/2022",
"dept_id": 1,
"elt_annee_apo": null,
"elt_sem_apo": null,
"ens_can_edit_eval": false,
"etat": true,
"formation_id": 1,
"formsemestre_id": 1,
"gestion_compensation": false,
"gestion_semestrielle": false,
"id": 1,
"modalite": "FI",
"resp_can_change_ens": true,
"resp_can_edit": false,
"responsables": [1, 99], // uids
"scodoc7_id": null,
"semestre_id": 1,
"titre_formation" : "BUT GEA",
"titre_num": "BUT GEA semestre 1",
"titre": "BUT GEA",
}
```json
{
"block_moyennes": false,
"bul_bgcolor": "white",
"bul_hide_xml": false,
"date_debut_iso": "2021-09-01",
"date_debut": "01/09/2021",
"date_fin_iso": "2022-08-31",
"date_fin": "31/08/2022",
"dept_id": 1,
"elt_annee_apo": null,
"elt_passage_apo" : null,
"elt_sem_apo": null,
"ens_can_edit_eval": false,
"etat": true,
"formation_id": 1,
"formsemestre_id": 1,
"gestion_compensation": false,
"gestion_semestrielle": false,
"id": 1,
"modalite": "FI",
"resp_can_change_ens": true,
"resp_can_edit": false,
"responsables": [1, 99], // uids
"scodoc7_id": null,
"semestre_id": 1,
"titre_formation" : "BUT GEA",
"titre_num": "BUT GEA semestre 1",
"titre": "BUT GEA",
}
```
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
@ -99,15 +108,28 @@ def formsemestre_infos(formsemestre_id: int):
@as_json
def formsemestres_query():
"""
Retourne les formsemestres filtrés par
étape Apogée ou année scolaire ou département (acronyme ou id) ou état ou code étudiant
Retourne les formsemestres filtrés par étape Apogée ou année scolaire
ou département (acronyme ou id) ou état ou code étudiant.
PARAMS
------
etape_apo : un code étape apogée
annee_scolaire : année de début de l'année scolaire
dept_acronym : acronyme du département (eg "RT")
dept_id : id du département
ine ou nip: code d'un étudiant: ramène alors tous les semestres auxquels il est inscrit.
etat: 0 si verrouillé, 1 sinon
QUERY
-----
etape_apo:<string:etape_apo>
annee_scolaire:<string:annee_scolaire>
dept_acronym:<string:dept_acronym>
dept_id:<int:dept_id>
etat:<int:etat>
nip:<string:nip>
ine:<string:ine>
"""
etape_apo = request.args.get("etape_apo")
annee_scolaire = request.args.get("annee_scolaire")
@ -177,7 +199,36 @@ def formsemestres_query():
@permission_required(Permission.EditFormSemestre)
@as_json
def formsemestre_edit(formsemestre_id: int):
"""Modifie les champs d'un formsemestre."""
"""Modifie les champs d'un formsemestre.
On peut spécifier un ou plusieurs champs.
DATA
---
```json
{
"semestre_id" : string,
"titre" : string,
"date_debut" : date iso,
"date_fin" : date iso,
"edt_id" : string,
"etat" : string,
"modalite" : string,
"gestion_compensation" : bool,
"bul_hide_xml" : bool,
"block_moyennes" : bool,
"block_moyenne_generale" : bool,
"mode_calcul_moyennes" : string,
"gestion_semestrielle" : string,
"bul_bgcolor" : string,
"resp_can_edit" : bool,
"resp_can_change_ens" : bool,
"ens_can_edit_eval" : bool,
"elt_sem_apo" : string,
"elt_annee_apo : string,
}
```
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
args = request.get_json(force=True) # may raise 400 Bad Request
editable_keys = {
@ -209,6 +260,154 @@ def formsemestre_edit(formsemestre_id: int):
return formsemestre.to_dict_api()
@bp.route("/formsemestre/apo/set_etapes", methods=["POST"])
@api_web_bp.route("/formsemestre/apo/set_etapes", methods=["POST"])
@scodoc
@permission_required(Permission.EditApogee)
def formsemestre_set_apo_etapes():
"""Change les codes étapes du semestre indiqué.
Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
par des virgules.
Ce changement peut être fait sur un semestre verrouillé
DATA
----
```json
{
oid : int, le formsemestre_id
value : string, eg "V1RT, V1RT2", codes séparés par des virgules
}
"""
formsemestre_id = int(request.form.get("oid"))
etapes_apo_str = request.form.get("value")
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
current_etapes = {e.etape_apo for e in formsemestre.etapes}
new_etapes = {s.strip() for s in etapes_apo_str.split(",")}
if new_etapes != current_etapes:
formsemestre.etapes = []
for etape_apo in new_etapes:
etape = FormSemestreEtape(
formsemestre_id=formsemestre_id, etape_apo=etape_apo
)
formsemestre.etapes.append(etape)
db.session.add(formsemestre)
db.session.commit()
log(
f"""API formsemestre_set_apo_etapes: formsemestre_id={
formsemestre.id} code_apogee={etapes_apo_str}"""
)
return ("", 204)
@bp.route("/formsemestre/apo/set_elt_sem", methods=["POST"])
@api_web_bp.route("/formsemestre/apo/set_elt_sem", methods=["POST"])
@scodoc
@permission_required(Permission.EditApogee)
def formsemestre_set_elt_sem_apo():
"""Change les codes étapes du semestre indiqué.
Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
par des virgules.
Ce changement peut être fait sur un semestre verrouillé.
DATA
----
```json
{
oid : int, le formsemestre_id
value : string, eg "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules
}
```
"""
oid = int(request.form.get("oid"))
value = (request.form.get("value") or "").strip()
formsemestre = FormSemestre.get_formsemestre(oid)
if value != formsemestre.elt_sem_apo:
formsemestre.elt_sem_apo = value
db.session.add(formsemestre)
db.session.commit()
log(
f"""API formsemestre_set_elt_sem_apo: formsemestre_id={
formsemestre.id} code_apogee={value}"""
)
return ("", 204)
@bp.route("/formsemestre/apo/set_elt_annee", methods=["POST"])
@api_web_bp.route("/formsemestre/apo/set_elt_annee", methods=["POST"])
@scodoc
@permission_required(Permission.EditApogee)
def formsemestre_set_elt_annee_apo():
"""Change les codes étapes du semestre indiqué (par le champ oid).
Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
par des virgules.
Ce changement peut être fait sur un semestre verrouillé.
DATA
----
```json
{
oid : int, le formsemestre_id
value : string, eg "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules
}
```
"""
oid = int(request.form.get("oid"))
value = (request.form.get("value") or "").strip()
formsemestre = FormSemestre.get_formsemestre(oid)
if value != formsemestre.elt_annee_apo:
formsemestre.elt_annee_apo = value
db.session.add(formsemestre)
db.session.commit()
log(
f"""API formsemestre_set_elt_annee_apo: formsemestre_id={
formsemestre.id} code_apogee={value}"""
)
return ("", 204)
@bp.route("/formsemestre/apo/set_elt_passage", methods=["POST"])
@api_web_bp.route("/formsemestre/apo/set_elt_passage", methods=["POST"])
@scodoc
@permission_required(Permission.EditApogee)
def formsemestre_set_elt_passage_apo():
"""Change les codes apogée de passage du semestre indiqué (par le champ oid).
Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
par des virgules.
Ce changement peut être fait sur un semestre verrouillé.
DATA
----
```json
{
oid : int, le formsemestre_id
value : string, eg "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules
}
```
"""
oid = int(request.form.get("oid"))
value = (request.form.get("value") or "").strip()
formsemestre = FormSemestre.get_formsemestre(oid)
if value != formsemestre.elt_annee_apo:
formsemestre.elt_passage_apo = value
db.session.add(formsemestre)
db.session.commit()
log(
f"""API formsemestre_set_elt_passage_apo: formsemestre_id={
formsemestre.id} code_apogee={value}"""
)
return ("", 204)
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
@ -219,9 +418,12 @@ def formsemestre_edit(formsemestre_id: int):
@as_json
def bulletins(formsemestre_id: int, version: str = "long"):
"""
Retourne les bulletins d'un formsemestre donné
Retourne les bulletins d'un formsemestre.
formsemestre_id : l'id d'un formesemestre
PARAMS
------
formsemestre_id : int
version : string ("long", "short", "selectedevals")
Exemple de résultat : liste, voir https://scodoc.org/ScoDoc9API/#bulletin
"""
@ -253,66 +455,67 @@ def formsemestre_programme(formsemestre_id: int):
"""
Retourne la liste des UEs, ressources et SAEs d'un semestre
formsemestre_id : l'id d'un formsemestre
Exemple de résultat :
```json
{
"ues": [
{
"ues": [
{
"type": 0,
"formation_id": 1,
"ue_code": "UCOD11",
"id": 1,
"ects": 12.0,
"acronyme": "RT1.1",
"is_external": false,
"numero": 1,
"code_apogee": "",
"titre": "Administrer les r\u00e9seaux et l\u2019Internet",
"coefficient": 0.0,
"semestre_idx": 1,
"color": "#B80004",
"ue_id": 1
},
...
],
"ressources": [
{
"ens": [ 10, 18 ],
"formsemestre_id": 1,
"type": 0,
"formation_id": 1,
"ue_code": "UCOD11",
"id": 1,
"ects": 12.0,
"acronyme": "RT1.1",
"is_external": false,
"numero": 1,
"code_apogee": "",
"titre": "Administrer les r\u00e9seaux et l\u2019Internet",
"coefficient": 0.0,
"semestre_idx": 1,
"color": "#B80004",
"ue_id": 1
},
...
],
"ressources": [
{
"ens": [ 10, 18 ],
"formsemestre_id": 1,
"id": 15,
"module": {
"abbrev": "Programmer",
"code": "SAE15",
"code_apogee": "V7GOP",
"coefficient": 1.0,
"formation_id": 1,
"heures_cours": 0.0,
"heures_td": 0.0,
"heures_tp": 0.0,
"id": 15,
"module": {
"abbrev": "Programmer",
"code": "SAE15",
"code_apogee": "V7GOP",
"coefficient": 1.0,
"formation_id": 1,
"heures_cours": 0.0,
"heures_td": 0.0,
"heures_tp": 0.0,
"id": 15,
"matiere_id": 3,
"module_id": 15,
"module_type": 3,
"numero": 50,
"semestre_id": 1,
"titre": "Programmer en Python",
"ue_id": 3
},
"matiere_id": 3,
"module_id": 15,
"moduleimpl_id": 15,
"responsable_id": 2
},
"module_type": 3,
"numero": 50,
"semestre_id": 1,
"titre": "Programmer en Python",
"ue_id": 3
},
"module_id": 15,
"moduleimpl_id": 15,
"responsable_id": 2
},
...
],
"saes": [
{
...
],
"saes": [
{
...
},
...
],
"modules" : [ ... les modules qui ne sont ni des SAEs ni des ressources ... ]
}
},
...
],
"modules" : [ ... les modules qui ne sont ni des SAEs ni des ressources ... ]
}
```
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
@ -376,7 +579,16 @@ def formsemestre_programme(formsemestre_id: int):
def formsemestre_etudiants(
formsemestre_id: int, with_query: bool = False, long: bool = False
):
"""Étudiants d'un formsemestre."""
"""Étudiants d'un formsemestre.
Si l'état est spécifié, ne renvoie que les inscrits (`I`), les
démissionnaires (`D`) ou les défaillants (`DEF`)
QUERY
-----
etat:<string:etat>
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
@ -418,13 +630,13 @@ def formsemestre_etudiants(
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etat_evals(formsemestre_id: int):
def formsemestre_etat_evaluations(formsemestre_id: int):
"""
Informations sur l'état des évaluations d'un formsemestre.
formsemestre_id : l'id d'un semestre
Exemple de résultat :
```json
[
{
"id": 1, // moduleimpl_id
@ -452,11 +664,9 @@ def etat_evals(formsemestre_id: int):
]
},
]
```
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
app.set_sco_dept(formsemestre.departement.acronym)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
@ -529,8 +739,16 @@ def etat_evals(formsemestre_id: int):
@permission_required(Permission.ScoView)
@as_json
def formsemestre_resultat(formsemestre_id: int):
"""Tableau récapitulatif des résultats
"""Tableau récapitulatif des résultats.
Pour chaque étudiant, son état, ses groupes, ses moyennes d'UE et de modules.
Si `format=raw`, ne converti pas les valeurs.
QUERY
-----
format:<string:format>
"""
format_spec = request.args.get("format", None)
if format_spec is not None and format_spec != "raw":
@ -570,14 +788,14 @@ def formsemestre_resultat(formsemestre_id: int):
return rows
@bp.route("/formsemestre/<int:formsemestre_id>/get_groups_auto_assignment")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/get_groups_auto_assignment")
@bp.route("/formsemestre/<int:formsemestre_id>/groups_get_auto_assignment")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/groups_get_auto_assignment")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def get_groups_auto_assignment(formsemestre_id: int):
"""rend les données"""
def groups_get_auto_assignment(formsemestre_id: int):
"""Rend les données stockées par `groups_save_auto_assignment`."""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
@ -588,22 +806,27 @@ def get_groups_auto_assignment(formsemestre_id: int):
@bp.route(
"/formsemestre/<int:formsemestre_id>/save_groups_auto_assignment", methods=["POST"]
"/formsemestre/<int:formsemestre_id>/groups_save_auto_assignment", methods=["POST"]
)
@api_web_bp.route(
"/formsemestre/<int:formsemestre_id>/save_groups_auto_assignment", methods=["POST"]
"/formsemestre/<int:formsemestre_id>/groups_save_auto_assignment", methods=["POST"]
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def save_groups_auto_assignment(formsemestre_id: int):
"""enregistre les données"""
def groups_save_auto_assignment(formsemestre_id: int):
"""Enregistre les données, associées à ce formsemestre.
Usage réservé aux fonctions de gestion des groupes, ne pas utiliser ailleurs.
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
if not formsemestre.can_change_groups():
return json_error(403, "non autorisé (can_change_groups)")
if len(request.data) > GROUPS_AUTO_ASSIGNMENT_DATA_MAX:
return json_error(413, "data too large")
formsemestre.groups_auto_assignment_data = request.data
@ -618,11 +841,16 @@ def save_groups_auto_assignment(formsemestre_id: int):
@permission_required(Permission.ScoView)
@as_json
def formsemestre_edt(formsemestre_id: int):
"""l'emploi du temps du semestre.
"""L'emploi du temps du semestre.
Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur.
group_ids permet de filtrer sur les groupes ScoDoc.
show_modules_titles affiche le titre complet du module (défaut), sinon juste le code.
Expérimental, ne pas utiliser hors ScoDoc.
QUERY
-----
group_ids : string (optionnel) filtre sur les groupes ScoDoc.
show_modules_titles: show_modules_titles affiche le titre complet du module (défaut), sinon juste le code.
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:

View File

@ -5,7 +5,12 @@
##############################################################################
"""
ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions
ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions.
CATEGORY
--------
Jury
"""
import datetime
@ -17,7 +22,8 @@ from flask_login import current_user, login_required
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR, tools
from app.decorators import scodoc, permission_required
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.scodoc.sco_exceptions import ScoException
from app.but import jury_but_results
from app.models import (
@ -32,6 +38,7 @@ from app.models import (
ScolarNews,
Scolog,
UniteEns,
ValidationDUT120,
)
from app.scodoc import codes_cursus
from app.scodoc import sco_cache
@ -62,7 +69,7 @@ def decisions_jury(formsemestre_id: int):
raise ScoException("non implemente")
def _news_delete_jury_etud(etud: Identite):
def _news_delete_jury_etud(etud: Identite, detail: str = ""):
"génère news sur effacement décision"
# n'utilise pas g.scodoc_dept, pas toujours dispo en mode API
url = url_for(
@ -71,7 +78,7 @@ def _news_delete_jury_etud(etud: Identite):
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=etud.id,
text=f"""Suppression décision jury pour <a href="{url}">{etud.nomprenom}</a>""",
text=f"""Suppression décision jury {detail} pour <a href="{url}">{etud.nomprenom}</a>""",
url=url,
)
@ -89,7 +96,7 @@ def _news_delete_jury_etud(etud: Identite):
@permission_required(Permission.ScoView)
@as_json
def validation_ue_delete(etudid: int, validation_id: int):
"Efface cette validation"
"Efface cette validation d'UE."
return _validation_ue_delete(etudid, validation_id)
@ -106,7 +113,7 @@ def validation_ue_delete(etudid: int, validation_id: int):
@permission_required(Permission.ScoView)
@as_json
def validation_formsemestre_delete(etudid: int, validation_id: int):
"Efface cette validation"
"Efface cette validation de semestre."
# c'est la même chose (formations classiques)
return _validation_ue_delete(etudid, validation_id)
@ -158,7 +165,7 @@ def _validation_ue_delete(etudid: int, validation_id: int):
@permission_required(Permission.EtudInscrit)
@as_json
def autorisation_inscription_delete(etudid: int, validation_id: int):
"Efface cette validation"
"Efface cette autorisation d'inscription."
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
@ -187,8 +194,12 @@ def autorisation_inscription_delete(etudid: int, validation_id: int):
@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":
DATA
----
```json
{
"code" : str,
"ue1_id" : int,
@ -198,6 +209,7 @@ def validation_rcue_record(etudid: int):
"date" : date_iso, // si non spécifié, now()
"parcours_id" :int,
}
```
"""
etud = tools.get_etud(etudid)
if etud is None:
@ -289,13 +301,12 @@ def validation_rcue_record(etudid: int):
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,
)
db.session.commit()
log(f"{operation} {validation}")
return validation.to_dict()
@ -313,18 +324,18 @@ def validation_rcue_record(etudid: int):
@permission_required(Permission.EtudInscrit)
@as_json
def validation_rcue_delete(etudid: int, validation_id: int):
"Efface cette validation"
"Efface cette validation de RCUE."
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}")
log(f"delete validation_ue_delete: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud)
_news_delete_jury_etud(etud, detail="UE")
return "ok"
@ -341,16 +352,45 @@ def validation_rcue_delete(etudid: int, validation_id: int):
@permission_required(Permission.EtudInscrit)
@as_json
def validation_annee_but_delete(etudid: int, validation_id: int):
"Efface cette validation"
"Efface cette validation d'année BUT."
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}")
ordre = validation.ordre
log(f"delete validation_annee_but: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud)
_news_delete_jury_etud(etud, detail=f"année BUT{ordre}")
return "ok"
@bp.route(
"/etudiant/<int:etudid>/jury/validation_dut120/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_dut120/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def validation_dut120_delete(etudid: int, validation_id: int):
"Efface cette validation de DUT120."
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ValidationDUT120.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
log(f"delete validation_dut120: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud, detail="diplôme DUT120")
return "ok"

View File

@ -3,8 +3,8 @@
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Justificatifs
"""
"""ScoDoc 9 API : Justificatifs"""
from datetime import datetime
from flask_json import as_json
@ -19,7 +19,8 @@ from app import db, set_sco_dept
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object, tools
from app.decorators import permission_required, scodoc
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.models import Identite, Justificatif, Departement, FormSemestre, Scolog
from app.models.assiduites import (
get_formsemestre_from_data,
@ -37,9 +38,11 @@ from app.scodoc.sco_groups import get_group_members
@scodoc
@permission_required(Permission.ScoView)
def justificatif(justif_id: int = None):
"""Retourne un objet justificatif à partir de son id
"""Retourne un objet justificatif à partir de son id.
Exemple de résultat:
```json
{
"justif_id": 1,
"etudid": 2,
@ -51,6 +54,11 @@ def justificatif(justif_id: int = None):
"entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null,
}
```
SAMPLES
-------
/justificatif/1;
"""
@ -91,28 +99,32 @@ def justificatif(justif_id: int = None):
def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /justificatifs/<int:etudid>
Un filtrage peut être donné avec une query
chemin : /justificatifs/<int:etudid>/query?
QUERY
-----
user_id:<int:user_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
order:<bool:order>
courant:<bool:courant>
group_id:<int:group_id>
PARAMS
-----
user_id:l'id de l'auteur du justificatif
date_debut:date de début du justificatif (supérieur ou égal)
date_fin:date de fin du justificatif (inférieur ou égal)
etat:etat du justificatif &rightarrow; valide, non_valide, attente, modifie
order:retourne les justificatifs dans l'ordre décroissant (non vide = True)
courant:retourne les justificatifs de l'année courante (bool : v/t ou f)
group_id:<int:group_id>
SAMPLES
-------
/justificatifs/1;
/justificatifs/1/query?etat=attente;
Les différents filtres :
Etat (etat du justificatif -> validé, non validé, modifé, en attente):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=validé,modifié
Date debut
(date de début du justificatif, sont affichés les justificatifs
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin du justificatif, sont affichés les justificatifs
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
user_id (l'id de l'auteur du justificatif)
query?user_id=[int]
ex query?user_id=3
"""
# Récupération de l'étudiant
etud: Identite = tools.get_etud(etudid, nip, ine)
@ -154,6 +166,32 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False):
"""
Renvoie tous les justificatifs d'un département
(en ajoutant un champ "formsemestre" si possible)
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
order:<bool:order>
courant:<bool:courant>
group_id:<int:group_id>
PARAMS
-----
user_id:l'id de l'auteur du justificatif
date_debut:date de début du justificatif (supérieur ou égal)
date_fin:date de fin du justificatif (inférieur ou égal)
etat:etat du justificatif &rightarrow; valide, non_valide, attente, modifie
order:retourne les justificatifs dans l'ordre décroissant (non vide = True)
courant:retourne les justificatifs de l'année courante (bool : v/t ou f)
group_id:<int:group_id>
SAMPLES
-------
/justificatifs/dept/1;
"""
# Récupération du département et des étudiants du département
@ -225,7 +263,34 @@ def _set_sems(justi: Justificatif, restrict: bool) -> dict:
@as_json
@permission_required(Permission.ScoView)
def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne tous les justificatifs du formsemestre"""
"""Retourne tous les justificatifs du formsemestre
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
order:<bool:order>
courant:<bool:courant>
group_id:<int:group_id>
PARAMS
-----
user_id:l'id de l'auteur du justificatif
date_debut:date de début du justificatif (supérieur ou égal)
date_fin:date de fin du justificatif (inférieur ou égal)
etat:etat du justificatif &rightarrow; valide, non_valide, attente, modifie
order:retourne les justificatifs dans l'ordre décroissant (non vide = True)
courant:retourne les justificatifs de l'année courante (bool : v/t ou f)
group_id:<int:group_id>
SAMPLES
-------
/justificatifs/formsemestre/1;
"""
# Récupération du formsemestre
formsemestre: FormSemestre = None
@ -273,7 +338,10 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
def justif_create(etudid: int = None, nip=None, ine=None):
"""
Création d'un justificatif pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
DATA
----
```json
[
{
"date_debut": str,
@ -288,6 +356,10 @@ def justif_create(etudid: int = None, nip=None, ine=None):
}
...
]
```
SAMPLES
-------
/justificatif/1/create;[{""date_debut"": ""2023-10-27T08:00"",""date_fin"": ""2023-10-27T10:00"",""etat"": ""attente""}]
"""
@ -341,6 +413,10 @@ def _create_one(
errors.append("param 'etat': invalide")
etat: scu.EtatJustificatif = scu.EtatJustificatif.get(etat)
if etat != scu.EtatJustificatif.ATTENTE and not current_user.has_permission(
Permission.JustifValidate
):
errors.append("param 'etat': non autorisé (Permission.JustifValidate)")
# cas 2 : date_debut
date_debut: str = data.get("date_debut", None)
@ -414,14 +490,23 @@ def _create_one(
def justif_edit(justif_id: int):
"""
Edition d'un justificatif à partir de son id
La requête doit avoir un content type "application/json":
DATA
----
```json
{
"etat"?: str,
"raison"?: str
"date_debut"?: str
"date_fin"?: str
}
```
SAMPLES
-------
/justificatif/1/edit;{""etat"":""valide""}
/justificatif/1/edit;{""raison"":""MEDIC""}
"""
# Récupération du justificatif à modifier
@ -440,7 +525,10 @@ def justif_edit(justif_id: int):
if etat is None:
errors.append("param 'etat': invalide")
else:
justificatif_unique.etat = etat
if current_user.has_permission(Permission.JustifValidate):
justificatif_unique.etat = etat
else:
errors.append("param 'etat': non autorisé (Permission.JustifValidate)")
# Cas 2 : raison
raison: str = data.get("raison", False)
@ -493,13 +581,12 @@ def justif_edit(justif_id: int):
# Mise à jour du justificatif
justificatif_unique.dejustifier_assiduites()
db.session.add(justificatif_unique)
db.session.commit()
Scolog.logdb(
method="edit_justificatif",
etudid=justificatif_unique.etudiant.id,
msg=f"justificatif modif: {justificatif_unique}",
)
db.session.commit()
# Génération du dictionnaire de retour
# La couverture correspond
@ -526,13 +613,18 @@ def justif_delete():
"""
Suppression d'un justificatif à partir de son id
Forme des données envoyées :
DATA
----
```json
[
<justif_id:int>,
...
]
```
SAMPLES
-------
/justificatif/delete;[2, 2, 3]
"""
@ -587,6 +679,11 @@ def _delete_one(justif_id: int) -> tuple[int, str]:
scass.simple_invalidate_cache(justificatif_unique.to_dict())
# On actualise les assiduités justifiées de l'étudiant concerné
justificatif_unique.dejustifier_assiduites()
Scolog.logdb(
method="justificatif/delete",
etudid=justificatif_unique.etudiant.id,
msg="suppression justificatif",
)
# On supprime le justificatif
db.session.delete(justificatif_unique)
@ -603,6 +700,8 @@ def _delete_one(justif_id: int) -> tuple[int, str]:
def justif_import(justif_id: int = None):
"""
Importation d'un fichier (création d'archive)
> Procédure d'importation de fichier : [importer un justificatif](FichiersJustificatifs.md#importer-un-fichier)
"""
# On vérifie qu'un fichier a bien été envoyé
@ -654,6 +753,8 @@ def justif_export(justif_id: int | None = None, filename: str | None = None):
"""
Retourne un fichier d'une archive d'un justificatif.
La permission est ScoView + (AbsJustifView ou être l'auteur du justifcatif)
> Procédure de téléchargement de fichier : [télécharger un justificatif](FichiersJustificatifs.md#télécharger-un-fichier)
"""
# On récupère le justificatif concerné
justificatif_unique = Justificatif.get_justificatif(justif_id)
@ -691,14 +792,20 @@ def justif_export(justif_id: int | None = None, filename: str | None = None):
def justif_remove(justif_id: int = None):
"""
Supression d'un fichier ou d'une archive
{
"remove": <"all"/"list">
> Procédure de suppression de fichier : [supprimer un justificatif](FichiersJustificatifs.md#supprimer-un-fichier)
DATA
----
```json
{
"remove": <"all"/"list">,
"filenames"?: [
<filename:str>,
...
]
}
```
"""
# On récupère le dictionnaire
@ -764,6 +871,11 @@ def justif_remove(justif_id: int = None):
def justif_list(justif_id: int = None):
"""
Liste les fichiers du justificatif
SAMPLES
-------
/justificatif/1/list;
"""
# Récupération du justificatif concerné
@ -806,6 +918,11 @@ def justif_list(justif_id: int = None):
def justif_justifies(justif_id: int = None):
"""
Liste assiduite_id justifiées par le justificatif
SAMPLES
-------
/justificatif/1/justifies;
"""
# On récupère le justificatif concerné

View File

@ -34,11 +34,13 @@ from flask import Response, send_file
from flask_json import as_json
from app.api import api_bp as bp
from app.scodoc.sco_utils import json_error
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.models import Departement
from app.scodoc.sco_logos import list_logos, find_logo
from app.decorators import scodoc, permission_required
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
# Note: l'API logos n'est accessible qu'en mode global (avec jeton, sans dept)
@ -47,8 +49,8 @@ from app.scodoc.sco_permissions import Permission
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def api_get_glob_logos():
"""Liste tous les logos"""
def logo_list_globals():
"""Liste des noms des logos définis pour le site ScoDoc."""
logos = list_logos()[None]
return list(logos.keys())
@ -56,7 +58,12 @@ def api_get_glob_logos():
@bp.route("/logo/<string:logoname>")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
def api_get_glob_logo(logoname):
def logo_get_global(logoname):
"""Renvoie le logo global de nom donné.
L'image est au format png ou jpg; le format retourné dépend du format sous lequel
l'image a été initialement enregistrée.
"""
logo = find_logo(logoname=logoname)
if logo is None:
return json_error(404, message="logo not found")
@ -77,7 +84,10 @@ def _core_get_logos(dept_id) -> list:
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def api_get_local_logos_by_acronym(departement):
def logo_get_local_by_acronym(departement):
"""Liste des noms des logos définis pour le département
désigné par son acronyme.
"""
dept_id = Departement.from_acronym(departement).id
return _core_get_logos(dept_id)
@ -86,7 +96,10 @@ def api_get_local_logos_by_acronym(departement):
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def api_get_local_logos_by_id(dept_id):
def logo_get_local_by_id(dept_id):
"""Liste des noms des logos définis pour le département
désigné par son id.
"""
return _core_get_logos(dept_id)
@ -105,7 +118,13 @@ def _core_get_logo(dept_id, logoname) -> Response:
@bp.route("/departement/<string:departement>/logo/<string:logoname>")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
def api_get_local_logo_dept_by_acronym(departement, logoname):
def logo_get_local_dept_by_acronym(departement, logoname):
"""Le logo: image (format png ou jpg).
**Exemple d'utilisation:**
* `/ScoDoc/api/departement/MMI/logo/header`
"""
dept_id = Departement.from_acronym(departement).id
return _core_get_logo(dept_id, logoname)
@ -113,5 +132,11 @@ def api_get_local_logo_dept_by_acronym(departement, logoname):
@bp.route("/departement/id/<int:dept_id>/logo/<string:logoname>")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
def api_get_local_logo_dept_by_id(dept_id, logoname):
def logo_get_local_dept_by_id(dept_id, logoname):
"""Le logo: image (format png ou jpg).
**Exemple d'utilisation:**
* `/ScoDoc/api/departement/id/3/logo/header`
"""
return _core_get_logo(dept_id, logoname)

View File

@ -6,6 +6,10 @@
"""
ScoDoc 9 API : accès aux moduleimpl
CATEGORY
--------
ModuleImpl
"""
from flask_json import as_json
@ -13,7 +17,8 @@ from flask_login import login_required
import app
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.models import ModuleImpl
from app.scodoc import sco_liste_notes
from app.scodoc.sco_permissions import Permission
@ -27,38 +32,43 @@ from app.scodoc.sco_permissions import Permission
@as_json
def moduleimpl(moduleimpl_id: int):
"""
Retourne un moduleimpl en fonction de son id
Retourne le moduleimpl.
PARAMS
------
moduleimpl_id : l'id d'un moduleimpl
Exemple de résultat :
{
```json
{
"id": 1,
"formsemestre_id": 1,
"module_id": 1,
"responsable_id": 2,
"moduleimpl_id": 1,
"ens": [],
"module": {
"heures_tp": 0,
"code_apogee": "",
"titre": "Initiation aux réseaux informatiques",
"coefficient": 1,
"module_type": 2,
"id": 1,
"formsemestre_id": 1,
"module_id": 1,
"responsable_id": 2,
"moduleimpl_id": 1,
"ens": [],
"module": {
"heures_tp": 0,
"code_apogee": "",
"titre": "Initiation aux réseaux informatiques",
"coefficient": 1,
"module_type": 2,
"id": 1,
"ects": null,
"abbrev": "Init aux réseaux informatiques",
"ue_id": 1,
"code": "R101",
"formation_id": 1,
"heures_cours": 0,
"matiere_id": 1,
"heures_td": 0,
"semestre_id": 1,
"numero": 10,
"module_id": 1
}
"ects": null,
"abbrev": "Init aux réseaux informatiques",
"ue_id": 1,
"code": "R101",
"formation_id": 1,
"heures_cours": 0,
"matiere_id": 1,
"heures_td": 0,
"semestre_id": 1,
"numero": 10,
"module_id": 1
}
}
```
"""
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
return modimpl.to_dict(convert_objects=True)
@ -71,16 +81,20 @@ def moduleimpl(moduleimpl_id: int):
@permission_required(Permission.ScoView)
@as_json
def moduleimpl_inscriptions(moduleimpl_id: int):
"""Liste des inscriptions à ce moduleimpl
"""Liste des inscriptions à ce moduleimpl.
Exemple de résultat :
[
{
"id": 1,
"etudid": 666,
"moduleimpl_id": 1234,
},
...
]
```json
[
{
"id": 1,
"etudid": 666,
"moduleimpl_id": 1234,
},
...
]
```
"""
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
return [i.to_dict() for i in modimpl.inscriptions]
@ -92,22 +106,26 @@ def moduleimpl_inscriptions(moduleimpl_id: int):
@scodoc
@permission_required(Permission.ScoView)
def moduleimpl_notes(moduleimpl_id: int):
"""Liste des notes dans ce moduleimpl
"""Liste des notes dans ce moduleimpl.
Exemple de résultat :
[
{
"etudid": 17776, // code de l'étudiant
"nom": "DUPONT",
"prenom": "Luz",
"38411": 16.0, // Note dans l'évaluation d'id 38411
"38410": 15.0,
"moymod": 15.5, // Moyenne INDICATIVE module
"moy_ue_2875": 15.5, // Moyenne vers l'UE 2875
"moy_ue_2876": 15.5, // Moyenne vers l'UE 2876
"moy_ue_2877": 15.5 // Moyenne vers l'UE 2877
},
...
]
```json
[
{
"etudid": 17776, // code de l'étudiant
"nom": "DUPONT",
"prenom": "Luz",
"38411": 16.0, // Note dans l'évaluation d'id 38411
"38410": 15.0,
"moymod": 15.5, // Moyenne INDICATIVE module
"moy_ue_2875": 15.5, // Moyenne vers l'UE 2875
"moy_ue_2876": 15.5, // Moyenne vers l'UE 2876
"moy_ue_2877": 15.5 // Moyenne vers l'UE 2877
},
...
]
```
"""
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
app.set_sco_dept(modimpl.formsemestre.departement.acronym)

View File

@ -6,6 +6,11 @@
"""
ScoDoc 9 API : partitions
CATEGORY
--------
Groupes et Partitions
"""
from operator import attrgetter
@ -18,7 +23,8 @@ from sqlalchemy.exc import IntegrityError
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.decorators import scodoc, permission_required
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.scodoc.sco_utils import json_error
from app.models import FormSemestre, FormSemestreInscription, Identite
from app.models import GroupDescr, Partition, Scolog
@ -40,7 +46,8 @@ def partition_info(partition_id: int):
"""Info sur une partition.
Exemple de résultat :
```
```json
{
'bul_show_rank': False,
'formsemestre_id': 39,
@ -70,10 +77,11 @@ def partition_info(partition_id: int):
@permission_required(Permission.ScoView)
@as_json
def formsemestre_partitions(formsemestre_id: int):
"""Liste de toutes les partitions d'un formsemestre
"""Liste de toutes les partitions d'un formsemestre.
formsemestre_id : l'id d'un formsemestre
Exemple de résultat :
```json
{
partition_id : {
"bul_show_rank": False,
@ -87,7 +95,7 @@ def formsemestre_partitions(formsemestre_id: int):
},
...
}
```
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
@ -107,13 +115,18 @@ def formsemestre_partitions(formsemestre_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etud_in_group(group_id: int):
def group_etudiants(group_id: int):
"""
Retourne la liste des étudiants dans un groupe
(inscrits au groupe et inscrits au semestre).
PARAMS
------
group_id : l'id d'un groupe
Exemple de résultat :
```json
[
{
'civilite': 'M',
@ -126,6 +139,7 @@ def etud_in_group(group_id: int):
},
...
]
```
"""
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
@ -150,8 +164,14 @@ def etud_in_group(group_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etud_in_group_query(group_id: int):
"""Étudiants du groupe, filtrés par état"""
def group_etudiants_query(group_id: int):
"""Étudiants du groupe, filtrés par état (aucun, `I`, `D`, `DEF`)
QUERY
-----
etat : string
"""
etat = request.args.get("etat")
if etat not in {None, scu.INSCRIT, scu.DEMISSION, scu.DEF}:
return json_error(API_CLIENT_ERROR, "etat: valeur invalide")
@ -178,8 +198,8 @@ def etud_in_group_query(group_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def set_etud_group(etudid: int, group_id: int):
"""Affecte l'étudiant au groupe indiqué"""
def group_set_etudiant(group_id: int, etudid: int):
"""Affecte l'étudiant au groupe indiqué."""
etud = Identite.query.get_or_404(etudid)
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
@ -241,7 +261,8 @@ def group_remove_etud(group_id: int, etudid: int):
@permission_required(Permission.ScoView)
@as_json
def partition_remove_etud(partition_id: int, etudid: int):
"""Enlève l'étudiant de tous les groupes de cette partition
"""Enlève l'étudiant de tous les groupes de cette partition.
(NB: en principe, un étudiant ne doit être que dans 0 ou 1 groupe d'une partition)
"""
etud = Identite.query.get_or_404(etudid)
@ -286,12 +307,15 @@ def partition_remove_etud(partition_id: int, etudid: int):
@permission_required(Permission.ScoView)
@as_json
def group_create(partition_id: int): # partition-group-create
"""Création d'un groupe dans une partition
"""Création d'un groupe dans une partition.
The request content type should be "application/json":
DATA
----
```json
{
"group_name" : nom_du_groupe,
}
```
"""
query = Partition.query.filter_by(id=partition_id)
if g.scodoc_dept:
@ -338,7 +362,7 @@ def group_create(partition_id: int): # partition-group-create
@permission_required(Permission.ScoView)
@as_json
def group_delete(group_id: int):
"""Suppression d'un groupe"""
"""Suppression d'un groupe."""
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
query = (
@ -367,7 +391,7 @@ def group_delete(group_id: int):
@permission_required(Permission.ScoView)
@as_json
def group_edit(group_id: int):
"""Edit a group"""
"""Édition d'un groupe."""
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
query = (
@ -408,9 +432,10 @@ def group_edit(group_id: int):
@permission_required(Permission.ScoView)
@as_json
def group_set_edt_id(group_id: int, edt_id: str):
"""Set edt_id for this group.
Contrairement à /edit, peut-être changé pour toute partition
ou formsemestre non verrouillé.
"""Set edt_id du groupe.
Contrairement à `/edit`, peut-être changé pour toute partition
d'un formsemestre non verrouillé.
"""
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
@ -436,16 +461,19 @@ def group_set_edt_id(group_id: int, edt_id: str):
@permission_required(Permission.ScoView)
@as_json
def partition_create(formsemestre_id: int):
"""Création d'une partition dans un semestre
"""Création d'une partition dans un semestre.
The request content type should be "application/json":
DATA
----
```json
{
"partition_name": str,
"numero":int,
"bul_show_rank":bool,
"show_in_lists":bool,
"groups_editable":bool
"numero": int,
"bul_show_rank": bool,
"show_in_lists": bool,
"groups_editable": bool
}
```
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
@ -500,9 +528,14 @@ def partition_create(formsemestre_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_order_partitions(formsemestre_id: int):
"""Modifie l'ordre des partitions du formsemestre
JSON args: [partition_id1, partition_id2, ...]
def formsemestre_set_partitions_order(formsemestre_id: int):
"""Modifie l'ordre des partitions du formsemestre.
DATA
----
```json
[ partition_id1, partition_id2, ... ]
```
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
@ -513,7 +546,7 @@ def formsemestre_order_partitions(formsemestre_id: int):
if not formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
partition_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(partition_ids, int) and not all(
if not isinstance(partition_ids, list) and not all(
isinstance(x, int) for x in partition_ids
):
return json_error(
@ -527,7 +560,7 @@ def formsemestre_order_partitions(formsemestre_id: int):
db.session.commit()
app.set_sco_dept(formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(formsemestre_id)
log(f"formsemestre_order_partitions({partition_ids})")
log(f"formsemestre_set_partitions_order({partition_ids})")
return [
partition.to_dict()
for partition in formsemestre.partitions.order_by(Partition.numero)
@ -542,8 +575,13 @@ def formsemestre_order_partitions(formsemestre_id: int):
@permission_required(Permission.ScoView)
@as_json
def partition_order_groups(partition_id: int):
"""Modifie l'ordre des groupes de la partition
JSON args: [group_id1, group_id2, ...]
"""Modifie l'ordre des groupes de la partition.
DATA
----
```json
[ group_id1, group_id2, ... ]
```
"""
query = Partition.query.filter_by(id=partition_id)
if g.scodoc_dept:
@ -554,7 +592,7 @@ def partition_order_groups(partition_id: int):
if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
group_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(group_ids, int) and not all(
if not isinstance(group_ids, list) and not all(
isinstance(x, int) for x in group_ids
):
return json_error(
@ -579,10 +617,13 @@ def partition_order_groups(partition_id: int):
@permission_required(Permission.ScoView)
@as_json
def partition_edit(partition_id: int):
"""Modification d'une partition dans un semestre
"""Modification d'une partition dans un semestre.
The request content type should be "application/json"
All fields are optional:
Tous les champs sont optionnels.
DATA
----
```json
{
"partition_name": str,
"numero":int,
@ -590,6 +631,7 @@ def partition_edit(partition_id: int):
"show_in_lists":bool,
"groups_editable":bool
}
```
"""
query = Partition.query.filter_by(id=partition_id)
if g.scodoc_dept:
@ -653,9 +695,9 @@ def partition_edit(partition_id: int):
def partition_delete(partition_id: int):
"""Suppression d'une partition (et de tous ses groupes).
Note 1: La partition par défaut (tous les étudiants du sem.) ne peut
* Note 1: La partition par défaut (tous les étudiants du sem.) ne peut
pas être supprimée.
Note 2: Si la partition de parcours est supprimée, les étudiants
* Note 2: Si la partition de parcours est supprimée, les étudiants
sont désinscrits des parcours.
"""
query = Partition.query.filter_by(id=partition_id)

View File

@ -3,12 +3,18 @@ from app import db, log
from app.api import api_bp as bp
from app.auth.logic import basic_auth, token_auth
"""
CATEGORY
--------
Authentification API
"""
@bp.route("/tokens", methods=["POST"])
@basic_auth.login_required
@as_json
def get_token():
"renvoie un jeton jwt pour l'utilisateur courant"
def token_get():
"Renvoie un jeton jwt pour l'utilisateur courant."
token = basic_auth.current_user().get_token()
log(f"API: giving token to {basic_auth.current_user()}")
db.session.commit()
@ -17,8 +23,8 @@ def get_token():
@bp.route("/tokens", methods=["DELETE"])
@token_auth.login_required
def revoke_token():
"révoque le jeton de l'utilisateur courant"
def token_revoke():
"Révoque le jeton de l'utilisateur courant."
user = token_auth.current_user()
user.revoke_token()
db.session.commit()

View File

@ -6,6 +6,10 @@
"""
ScoDoc 9 API : accès aux utilisateurs
CATEGORY
--------
Utilisateurs
"""
from flask import g, request
@ -14,15 +18,14 @@ from flask_login import current_user, login_required
from app import db, log
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.api import api_permission_required as permission_required
from app.auth.models import User, Role, UserRole
from app.auth.models import is_valid_password
from app.decorators import scodoc, permission_required
from app.models import Departement, ScoDocSiteConfig
from app.scodoc import sco_edt_cal
from app.decorators import scodoc
from app.models import Departement
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
from app.scodoc import sco_utils as scu
@bp.route("/user/<int:uid>")
@ -33,7 +36,7 @@ from app.scodoc import sco_utils as scu
@as_json
def user_info(uid: int):
"""
Info sur un compte utilisateur scodoc
Info sur un compte utilisateur ScoDoc.
"""
user: User = db.session.get(User, uid)
if user is None:
@ -54,11 +57,22 @@ def user_info(uid: int):
@as_json
def users_info_query():
"""Utilisateurs, filtrés par dept, active ou début nom
Exemple:
```
/users/query?departement=dept_acronym&active=1&starts_with=<string:nom>
```
Seuls les utilisateurs "accessibles" (selon les permissions) sont retournés.
Si accès via API web, le département de l'URL est ignoré, seules
les permissions de l'utilisateur sont prises en compte.
QUERY
-----
active: bool
departement: string
starts_with: string
"""
query = User.query
active = request.args.get("active")
@ -107,7 +121,10 @@ def _is_allowed_user_edit(args: dict) -> tuple[bool, str]:
@as_json
def user_create():
"""Création d'un utilisateur
The request content type should be "application/json":
DATA
----
```json
{
"active":bool (default True),
"dept": str or null,
@ -116,6 +133,7 @@ def user_create():
"user_name": str,
...
}
```
"""
args = request.get_json(force=True) # may raise 400 Bad Request
user_name = args.get("user_name")
@ -152,8 +170,10 @@ def user_create():
@permission_required(Permission.UsersAdmin)
@as_json
def user_edit(uid: int):
"""Modification d'un utilisateur
"""Modification d'un utilisateur.
Champs modifiables:
```json
{
"dept": str or null,
"nom": str,
@ -161,6 +181,7 @@ def user_edit(uid: int):
"active":bool
...
}
```
"""
args = request.get_json(force=True) # may raise 400 Bad Request
user: User = User.query.get_or_404(uid)
@ -199,11 +220,15 @@ def user_edit(uid: int):
@permission_required(Permission.UsersAdmin)
@as_json
def user_password(uid: int):
"""Modification du mot de passe d'un utilisateur
"""Modification du mot de passe d'un utilisateur.
Champs modifiables:
```json
{
"password": str
}
```.
Si le mot de passe ne convient pas, erreur 400.
"""
data = request.get_json(force=True) # may raise 400 Bad Request
@ -237,7 +262,7 @@ def user_password(uid: int):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def user_role_add(uid: int, role_name: str, dept: str = None):
"""Add a role in the given dept to the user"""
"""Ajoute un rôle à l'utilisateur dans le département donné."""
user: User = User.query.get_or_404(uid)
role: Role = Role.query.filter_by(name=role_name).first_or_404()
if dept is not None: # check
@ -266,7 +291,7 @@ def user_role_add(uid: int, role_name: str, dept: str = None):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def user_role_remove(uid: int, role_name: str, dept: str = None):
"""Remove the role (in the given dept) from the user"""
"""Retire le rôle (dans le département donné) à cet utilisateur."""
user: User = User.query.get_or_404(uid)
role: Role = Role.query.filter_by(name=role_name).first_or_404()
if dept is not None: # check
@ -292,8 +317,8 @@ def user_role_remove(uid: int, role_name: str, dept: str = None):
@scodoc
@permission_required(Permission.UsersView)
@as_json
def list_permissions():
"""Liste des noms de permissions définies"""
def permissions_list():
"""Liste des noms de permissions définies."""
return list(Permission.permission_by_name.keys())
@ -303,7 +328,7 @@ def list_permissions():
@scodoc
@permission_required(Permission.UsersView)
@as_json
def list_role(role_name: str):
def role_get(role_name: str):
"""Un rôle"""
return Role.query.filter_by(name=role_name).first_or_404().to_dict()
@ -314,8 +339,8 @@ def list_role(role_name: str):
@scodoc
@permission_required(Permission.UsersView)
@as_json
def list_roles():
"""Tous les rôles définis"""
def roles_list():
"""Tous les rôles définis."""
return [role.to_dict() for role in Role.query]
@ -332,7 +357,7 @@ def list_roles():
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_permission_add(role_name: str, perm_name: str):
"""Add permission to role"""
"""Ajoute une permission à un rôle."""
role: Role = Role.query.filter_by(name=role_name).first_or_404()
permission = Permission.get_by_name(perm_name)
if permission is None:
@ -357,7 +382,7 @@ def role_permission_add(role_name: str, perm_name: str):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_permission_remove(role_name: str, perm_name: str):
"""Remove permission from role"""
"""Retire une permission d'un rôle."""
role: Role = Role.query.filter_by(name=role_name).first_or_404()
permission = Permission.get_by_name(perm_name)
if permission is None:
@ -376,10 +401,15 @@ def role_permission_remove(role_name: str, perm_name: str):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_create(role_name: str):
"""Create a new role with permissions.
"""Création d'un nouveau rôle avec les permissions données.
DATA
----
```json
{
"permissions" : [ 'ScoView', ... ]
}
```
"""
role: Role = Role.query.filter_by(name=role_name).first()
if role:
@ -404,11 +434,16 @@ def role_create(role_name: str):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_edit(role_name: str):
"""Edit a role. On peut spécifier un nom et/ou des permissions.
"""Édition d'un rôle. On peut spécifier un nom et/ou des permissions.
DATA
----
```json
{
"name" : name
"permissions" : [ 'ScoView', ... ]
}
```
"""
role: Role = Role.query.filter_by(name=role_name).first_or_404()
data = request.get_json(force=True) # may raise 400 Bad Request
@ -436,7 +471,7 @@ def role_edit(role_name: str):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_delete(role_name: str):
"""Delete a role"""
"""Suprression d'un rôle."""
role: Role = Role.query.filter_by(name=role_name).first_or_404()
db.session.delete(role)
db.session.commit()

View File

@ -35,9 +35,9 @@ def after_cas_login():
if user.cas_allow_login:
current_app.logger.info(f"CAS: login {user.user_name}")
if login_user(user):
flask.session[
"scodoc_cas_login_date"
] = datetime.datetime.now().isoformat()
flask.session["scodoc_cas_login_date"] = (
datetime.datetime.now().isoformat()
)
user.cas_last_login = datetime.datetime.utcnow()
if flask.session.get("CAS_EDT_ID"):
# essaie de récupérer l'edt_id s'il est présent
@ -45,8 +45,10 @@ def after_cas_login():
# via l'expression `cas_edt_id_from_xml_regexp`
# voir flask_cas.routing
edt_id = flask.session.get("CAS_EDT_ID")
current_app.logger.info(f"""after_cas_login: storing edt_id for {
user.user_name}: '{edt_id}'""")
current_app.logger.info(
f"""after_cas_login: storing edt_id for {
user.user_name}: '{edt_id}'"""
)
user.edt_id = edt_id
db.session.add(user)
db.session.commit()
@ -55,12 +57,17 @@ def after_cas_login():
current_app.logger.info(
f"CAS login denied for {user.user_name} (not allowed to use CAS)"
)
else:
else: # pas d'utilisateur ScoDoc ou bien compte inactif
current_app.logger.info(
f"""CAS login denied for {
user.user_name if user else ""
} cas_id={cas_id} (unknown or inactive)"""
)
if ScoDocSiteConfig.is_cas_forced():
# Dans ce cas, pas de redirect vers la page de login pour éviter de boucler
raise ScoValueError(
"compte ScoDoc inexistant ou inactif pour cet utilisateur CAS"
)
else:
current_app.logger.info(
f"""CAS attribute '{ScoDocSiteConfig.get("cas_attribute_id")}' not found !

View File

@ -14,6 +14,15 @@ import cracklib # pylint: disable=import-error
from flask import current_app, g
from flask_login import UserMixin, AnonymousUserMixin
from sqlalchemy.exc import (
IntegrityError,
DataError,
DatabaseError,
OperationalError,
ProgrammingError,
StatementError,
InterfaceError,
)
from werkzeug.security import generate_password_hash, check_password_hash
@ -48,13 +57,13 @@ def is_valid_password(cleartxt) -> bool:
return False
def invalid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is invalid"
def is_valid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is valid"
return (
not user_name
or (len(user_name) < 2)
or (len(user_name) >= USERNAME_STR_LEN)
or not VALID_LOGIN_EXP.match(user_name)
user_name
and (len(user_name) >= 2)
and (len(user_name) < USERNAME_STR_LEN)
and VALID_LOGIN_EXP.match(user_name)
)
@ -123,7 +132,7 @@ class User(UserMixin, ScoDocModel):
# check login:
if not "user_name" in kwargs:
raise ValueError("missing user_name argument")
if invalid_user_name(kwargs["user_name"]):
if not is_valid_user_name(kwargs["user_name"]):
raise ValueError(f"invalid user_name: {kwargs['user_name']}")
kwargs["nom"] = kwargs.get("nom", "") or ""
kwargs["prenom"] = kwargs.get("prenom", "") or ""
@ -329,7 +338,8 @@ class User(UserMixin, ScoDocModel):
if new_user:
if "user_name" in data:
# never change name of existing users
if invalid_user_name(data["user_name"]):
# (see change_user_name method to do that)
if not is_valid_user_name(data["user_name"]):
raise ValueError(f"invalid user_name: {data['user_name']}")
self.user_name = data["user_name"]
if "password" in data:
@ -522,6 +532,64 @@ class User(UserMixin, ScoDocModel):
# nomnoacc était le nom en minuscules sans accents (inutile)
def change_user_name(self, new_user_name: str):
"""Modify user name, update all relevant tables.
commit session.
"""
# Safety check
new_user_name = new_user_name.strip()
if (
not is_valid_user_name(new_user_name)
or User.query.filter_by(user_name=new_user_name).count() > 0
):
raise ValueError("invalid user_name")
# Le user_name est utilisé dans d'autres tables (sans être une clé)
# BulAppreciations.author
# EntrepriseHistorique.authenticated_user
# EtudAnnotation.author
# ScolarNews.authenticated_user
# Scolog.authenticated_user
from app.models import (
BulAppreciations,
EtudAnnotation,
ScolarNews,
Scolog,
)
from app.entreprises.models import EntrepriseHistorique
try:
# Update all instances of EtudAnnotation
db.session.query(BulAppreciations).filter(
BulAppreciations.author == self.user_name
).update({BulAppreciations.author: new_user_name})
db.session.query(EntrepriseHistorique).filter(
EntrepriseHistorique.authenticated_user == self.user_name
).update({EntrepriseHistorique.authenticated_user: new_user_name})
db.session.query(EtudAnnotation).filter(
EtudAnnotation.author == self.user_name
).update({EtudAnnotation.author: new_user_name})
db.session.query(ScolarNews).filter(
ScolarNews.authenticated_user == self.user_name
).update({ScolarNews.authenticated_user: new_user_name})
db.session.query(Scolog).filter(
Scolog.authenticated_user == self.user_name
).update({Scolog.authenticated_user: new_user_name})
# And update ourself:
self.user_name = new_user_name
db.session.add(self)
db.session.commit()
except (
IntegrityError,
DataError,
DatabaseError,
OperationalError,
ProgrammingError,
StatementError,
InterfaceError,
) as exc:
db.session.rollback()
raise exc
class AnonymousUser(AnonymousUserMixin):
"Notre utilisateur anonyme"

View File

@ -18,7 +18,7 @@ from app.auth.forms import (
ResetPasswordRequestForm,
UserCreationForm,
)
from app.auth.models import Role, User, invalid_user_name
from app.auth.models import Role, User, is_valid_user_name
from app.auth.email import send_password_reset_email
from app.decorators import admin_required
from app.forms.generic import SimpleConfirmationForm
@ -35,10 +35,12 @@ def _login_form():
form = LoginForm()
if form.validate_on_submit():
# note: ceci est la première requête SQL déclenchée par un utilisateur arrivant
if invalid_user_name(form.user_name.data):
user = None
else:
user = User.query.filter_by(user_name=form.user_name.data).first()
user = (
User.query.filter_by(user_name=form.user_name.data).first()
if is_valid_user_name(form.user_name.data)
else None
)
if user is None or not user.check_password(form.password.data):
current_app.logger.info("login: invalid (%s)", form.user_name.data)
flash(_("Nom ou mot de passe invalide"))

View File

@ -60,7 +60,7 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
f"""<label><input type="checkbox" name="{parcour.id}" value="{parcour.id}"
{'checked' if parcour.id in ue_pids else ""}
onclick="set_ue_parcour(this);"
data-setter="{url_for("apiweb.set_ue_parcours",
data-setter="{url_for("apiweb.ue_set_parcours",
scodoc_dept=g.scodoc_dept, ue_id=ue.id)}"
>{parcour.code}{ects_parcour_txt}</label>"""
)

View File

@ -576,7 +576,6 @@ class BulletinBUT:
show_uevalid=self.prefs["bul_show_uevalid"],
show_mention=self.prefs["bul_show_mention"],
)
d.update(infos)
# --- Rangs
d["rang_nt"] = (

View File

@ -39,6 +39,7 @@ from app.scodoc.codes_cursus import UE_STANDARD
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
from app.scodoc.sco_logos import find_logo
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_pv_lettres_inviduelles import add_dut120_infos
import app.scodoc.sco_utils as scu
from app.views import notes_bp as bp
from app.views import ScoData
@ -67,7 +68,6 @@ def bulletin_but(formsemestre_id: int, etudid: int = None, fmt="html"):
raise ScoValueError("formation non BUT")
args = _build_bulletin_but_infos(etud, formsemestre, fmt=fmt)
if fmt == "pdf":
filename = scu.bul_filename(formsemestre, etud, prefix="bul-but")
bul_pdf = bulletin_but_court_pdf.make_bulletin_but_court_pdf(args)
@ -153,4 +153,5 @@ def _build_bulletin_but_infos(
if ue.type == UE_STANDARD and ue.acronyme in ue_acronyms
],
}
add_dut120_infos(formsemestre, etud.id, args)
return args

View File

@ -97,6 +97,8 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
tuple[int, str], ScolarFormSemestreValidation
] = None,
ues_acronyms: list[str] = None,
diplome_dut120: bool = False,
diplome_dut120_descr: str = "",
):
super().__init__(bul, authuser=current_user, filigranne=filigranne)
self.bul = bul
@ -110,7 +112,8 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
self.title = title
self.ue_validation_by_niveau = ue_validation_by_niveau
self.ues_acronyms = ues_acronyms # sans UEs sport
self.diplome_dut120 = diplome_dut120
self.diplome_dut120_descr = diplome_dut120_descr
self.nb_ues = len(self.ues_acronyms)
# Styles PDF
self.style_base = styles.ParagraphStyle("style_base")
@ -243,13 +246,17 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
)
table_abs_ues.hAlign = "RIGHT"
# Ligne (en bas) avec table cursus et boite jury
# table_content = [self.table_cursus_but()]
# if self.prefs["bul_show_decision"]:
# table_content.append([Spacer(1, 8 * mm), self.boite_decisions_jury()])
table_content = [self.table_cursus_but()]
table_content.append(
[Spacer(1, 8 * mm), self.boite_decisions_jury()]
if self.prefs["bul_show_decision"]
else []
)
table_cursus_jury = Table(
[
[
self.table_cursus_but(),
[Spacer(1, 8 * mm), self.boite_decisions_jury()],
]
],
[table_content],
colWidths=(self.width_page_avail - 84 * mm, 84 * mm),
style=style_table_2cols,
)
@ -523,14 +530,17 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
def boite_decisions_jury(self):
"""La boite en bas à droite avec jury"""
txt = f"""ECTS acquis en BUT : <b>{self.ects_total:g}</b><br/>"""
if self.bul["semestre"].get("decision_annee", None):
txt += f"""
Décision saisie le {
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime(scu.DATE_FMT)
}, année BUT{self.bul["semestre"]["decision_annee"]["ordre"]}
Décision année BUT{self.bul["semestre"]["decision_annee"]["ordre"]}
<b>{self.bul["semestre"]["decision_annee"]["code"]}</b>.
<br/>
{self.bul.get("diplomation", "")}
"""
if self.diplome_dut120_descr:
txt += f"""<br/>{self.diplome_dut120_descr}."""
if self.bul["semestre"].get("autorisation_inscription", None):
txt += (
"<br/>Autorisé à s'inscrire en <b>"

View File

@ -203,7 +203,7 @@ def bulletin_but_xml_compat(
e.date_debut.isoformat() if e.date_debut else ""
),
date_fin=(
e.date_fin.isoformat() if e.date_debut else ""
e.date_fin.isoformat() if e.date_fin else ""
),
coefficient=str(e.coefficient),
# pas les poids en XML compat

View File

@ -14,9 +14,11 @@ Classe raccordant avec ScoDoc 7:
"""
import collections
from collections.abc import Iterable
from operator import attrgetter
from flask import g, url_for
from flask_sqlalchemy.query import Query
from app import db, log
from app.comp.res_but import ResultatsSemestreBUT
@ -29,7 +31,7 @@ from app.models.but_refcomp import (
ApcReferentielCompetences,
)
from app.models.ues import UEParcours
from app.models.but_validations import ApcValidationRCUE
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
@ -42,9 +44,9 @@ from app.scodoc import sco_cursus_dut
class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
"""Pour compat ScoDoc 7: à revoir pour le BUT"""
"""Pour compat ScoDoc 7"""
def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT):
def __init__(self, etud: Identite, formsemestre_id: int, res: ResultatsSemestreBUT):
super().__init__(etud, formsemestre_id, res)
# Ajustements pour le BUT
self.can_compensate_with_prev = False # jamais de compensation à la mode DUT
@ -54,8 +56,22 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
return False
def parcours_validated(self):
"True si le parcours est validé"
return False # XXX TODO
"True si le parcours (ici diplôme BUT) est validé"
return but_parcours_validated(
self.etud.id, self.cur_sem.formation.referentiel_competence_id
)
def but_parcours_validated(etudid: int, referentiel_competence_id: int) -> bool:
"""Détermine si le parcours BUT est validé:
ne regarde que si une validation BUT3 est enregistrée
"""
return any(
sco_codes.code_annee_validant(v.code)
for v in ApcValidationAnnee.query.filter_by(
etudid=etudid, ordre=3, referentiel_competence_id=referentiel_competence_id
)
)
class EtudCursusBUT:
@ -193,6 +209,10 @@ class EtudCursusBUT:
# slow, utile pour affichage fiche
return annee in [n.annee for n in self.competences[competence_id].niveaux]
def get_ects_acquis(self) -> int:
"Nombre d'ECTS validés par etud dans le BUT de ce référentiel"
return but_ects_valides(self.etud, self.formation.referentiel_competence.id)
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 ] }
@ -287,104 +307,136 @@ class FormSemestreCursusBUT:
)
return niveaux_by_annee
def get_etud_validation_par_competence_et_annee(self, etud: Identite):
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
validation_par_competence_et_annee = {}
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# On s'assurer qu'elle concerne notre cursus !
ue = validation_rcue.ue2
if ue.id not in self.ue_ids:
if (
ue.formation.referentiel_competences_id
== self.referentiel_competences_id
):
self.ue_ids = ue.id
else:
continue # skip this validation
niveau = validation_rcue.niveau()
if not niveau.competence.id in validation_par_competence_et_annee:
validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = validation_par_competence_et_annee.get(
niveau.competence.id
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
] = validation_rcue
return validation_par_competence_et_annee
# def get_etud_validation_par_competence_et_annee(self, etud: Identite):
# """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
# validation_par_competence_et_annee = {}
# for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# # On s'assurer qu'elle concerne notre cursus !
# ue = validation_rcue.ue2
# if ue.id not in self.ue_ids:
# if (
# ue.formation.referentiel_competences_id
# == self.referentiel_competences_id
# ):
# self.ue_ids = ue.id
# else:
# continue # skip this validation
# niveau = validation_rcue.niveau()
# if not niveau.competence.id in validation_par_competence_et_annee:
# validation_par_competence_et_annee[niveau.competence.id] = {}
# previous_validation = validation_par_competence_et_annee.get(
# niveau.competence.id
# ).get(validation_rcue.annee())
# # prend la "meilleure" validation
# if (not previous_validation) or (
# sco_codes.BUT_CODES_ORDER[validation_rcue.code]
# > sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
# ):
# self.validation_par_competence_et_annee[niveau.competence.id][
# niveau.annee
# ] = validation_rcue
# return validation_par_competence_et_annee
def list_etud_inscriptions(self, etud: Identite):
"Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
self.niveaux_by_annee = {}
"{ annee : liste des niveaux à valider }"
self.niveaux: dict[int, ApcNiveau] = {}
"cache les niveaux"
for annee in (1, 2, 3):
niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
annee, [self.parcour] if self.parcour else None # XXX WIP
)[1]
# groupe les niveaux de tronc commun et ceux spécifiques au parcour
self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
niveaux_d[self.parcour.id] if self.parcour else []
)
self.niveaux.update(
{niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
)
# def list_etud_inscriptions(self, etud: Identite):
# "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
# self.niveaux_by_annee = {}
# "{ annee : liste des niveaux à valider }"
# self.niveaux: dict[int, ApcNiveau] = {}
# "cache les niveaux"
# for annee in (1, 2, 3):
# niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
# annee, [self.parcour] if self.parcour else None # XXX WIP
# )[1]
# # groupe les niveaux de tronc commun et ceux spécifiques au parcour
# self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
# niveaux_d[self.parcour.id] if self.parcour else []
# )
# self.niveaux.update(
# {niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
# )
self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau()
if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get(
niveau.competence.id
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
] = validation_rcue
# self.validation_par_competence_et_annee = {}
# """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
# for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# niveau = validation_rcue.niveau()
# if not niveau.competence.id in self.validation_par_competence_et_annee:
# self.validation_par_competence_et_annee[niveau.competence.id] = {}
# previous_validation = self.validation_par_competence_et_annee.get(
# niveau.competence.id
# ).get(validation_rcue.annee())
# # prend la "meilleure" validation
# if (not previous_validation) or (
# sco_codes.BUT_CODES_ORDER[validation_rcue.code]
# > sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
# ):
# self.validation_par_competence_et_annee[niveau.competence.id][
# niveau.annee
# ] = validation_rcue
self.competences = {
competence.id: competence
for competence in (
self.parcour.query_competences()
if self.parcour
else self.formation.referentiel_competence.get_competences_tronc_commun()
)
}
"cache { competence_id : competence }"
# self.competences = {
# competence.id: competence
# for competence in (
# self.parcour.query_competences()
# if self.parcour
# else self.formation.referentiel_competence.get_competences_tronc_commun()
# )
# }
# "cache { competence_id : competence }"
def but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float:
def but_ects_valides(
etud: Identite,
referentiel_competence_id: int,
annees_but: None | Iterable[str] = None,
) -> int:
"""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.
Si annees_but est spécifié, un iterable "BUT1, "BUT2" par exemple, ne prend que ces années.
"""
validations = but_validations_ues(etud, referentiel_competence_id, annees_but)
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 or 0.0
return int(sum(ects_dict.values())) if ects_dict else 0
def but_validations_ues(
etud: Identite,
referentiel_competence_id: int,
annees_but: None | Iterable[str] = None,
) -> list[ScolarFormSemestreValidation]:
"""Query les validations d'UEs pour cet étudiant
dans des UEs appartenant à ce référentiel de compétence
et en option pour les années BUT indiquées.
annees_but : None (tout) ou liste [ "BUT1", ... ]
"""
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)
)
# restreint à certaines années (utile pour les ECTS du DUT120)
if annees_but:
validations = validations.filter(ApcNiveau.annee.in_(annees_but))
# restreint au référentiel de compétence
validations = validations.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
# Tri (nb: fait en python pour gérer les validations externes qui n'ont pas de formsemestre)
return sorted(
validations,
key=lambda v: (
(v.formsemestre.semestre_id, v.ue.numero, v.ue.acronyme)
if v.formsemestre
else (v.ue.semestre_idx or -2, v.ue.numero, v.ue.acronyme)
),
)
def etud_ues_de_but1_non_validees(

View File

@ -64,6 +64,7 @@ import re
import numpy as np
from flask import flash, g, url_for
import sqlalchemy as sa
from app import db
from app import log
@ -81,8 +82,10 @@ from app.models import Evaluation, ModuleImpl, Scolog, ScolarAutorisationInscrip
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
ValidationDUT120,
)
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.models.validations import ScolarFormSemestreValidation
@ -90,6 +93,7 @@ from app.scodoc import sco_cache
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.codes_cursus import (
code_rcue_validant,
code_ue_validant,
BUT_CODES_ORDER,
CODES_RCUE_VALIDES,
CODES_UE_VALIDES,
@ -421,7 +425,11 @@ class DecisionsProposeesAnnee(DecisionsProposees):
+ '</div><div class="warning warning-info">'.join(messages)
+ "</div>"
)
self.codes = [self.codes[0]] + sorted((c or "") for c in self.codes[1:])
# Présente les codes unifiés, avec le code proposé en tête et les autres triés
codes_set = set(self.codes)
codes_set.remove(self.codes[0])
self.codes = [self.codes[0]] + sorted(x or "" for x in codes_set)
def passage_de_droit_en_but3(self) -> tuple[bool, str]:
"""Vérifie si les conditions supplémentaires de passage BUT2 vers BUT3 sont satisfaites"""
@ -753,13 +761,13 @@ class DecisionsProposeesAnnee(DecisionsProposees):
self.validation.date = datetime.now()
db.session.add(self.validation)
db.session.commit()
log(f"Recording {self}: {code}")
Scolog.logdb(
method="jury_but",
etudid=self.etud.id,
msg=f"Validation année BUT{self.annee_but}: {code}",
)
db.session.commit()
if mark_recorded:
self.recorded = True
self.invalidate_formsemestre_cache()
@ -885,7 +893,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
not only_validantes
) or code in sco_codes.CODES_ANNEE_BUT_VALIDES_DE_DROIT:
modif |= self.record(code)
self.record_autorisation_inscription(code)
self.record_autorisation_inscription(code)
return modif
def erase(self, only_one_sem=False):
@ -896,6 +904,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
Si only_one_sem, n'efface que les décisions UE et les
autorisations de passage du semestre d'origine du deca.
Efface les validations de DUT120 issues du semestre d'origine du deca.
Dans tous les cas, efface les validations de l'année en cours.
(commite la session.)
"""
@ -945,6 +955,17 @@ class DecisionsProposeesAnnee(DecisionsProposees):
msg=f"Validation année BUT{self.annee_but}: effacée",
)
# Efface les validations de DUT120 issues du semestre d'origine du deca.
for validation in ValidationDUT120.query.filter_by(
etudid=self.etud.id, formsemestre_id=self.formsemestre.id
):
db.session.delete(validation)
Scolog.logdb(
"jury_but",
etudid=self.etud.id,
msg="Validation DUT120 effacée",
)
# Efface éventuelles validations de semestre
# (en principe inutilisées en BUT)
# et autres UEs (en cas de changement d'architecture de formation depuis le jury ?)
@ -986,6 +1007,36 @@ class DecisionsProposeesAnnee(DecisionsProposees):
pour PV jurys
"""
validations = []
# Validations antérieures émises par ce formsemestre
for res in (self.res_impair, self.res_pair):
if res:
validations_anterieures = (
ScolarFormSemestreValidation.query.filter_by(
etudid=self.etud.id, formsemestre_id=res.formsemestre.id
)
.filter(
ScolarFormSemestreValidation.semestre_id
!= res.formsemestre.semestre_id
)
.join(UniteEns)
.join(Formation)
.filter_by(formation_code=res.formsemestre.formation.formation_code)
.order_by(
sa.desc(UniteEns.semestre_idx),
UniteEns.acronyme,
sa.desc(ScolarFormSemestreValidation.event_date),
)
.all()
)
if validations_anterieures:
validations.append(
", ".join(
v.ue.acronyme
for v in validations_anterieures
if v and v.ue and code_ue_validant(v.code)
)
)
# Validations des UEs des deux semestres de l'année
for res in (self.res_impair, self.res_pair):
if res:
dec_ues = [
@ -994,7 +1045,10 @@ class DecisionsProposeesAnnee(DecisionsProposees):
if ue.type == UE_STANDARD and ue.id in self.decisions_ues
]
valids = [dec_ue.descr_validation() for dec_ue in dec_ues]
validations.append(", ".join(v for v in valids if v))
# présentation de la liste des UEs:
if valids:
validations.append(", ".join(v for v in valids if v))
return line_sep.join(validations)
def descr_pb_coherence(self) -> list[str]:
@ -1034,8 +1088,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
return messages
def valide_diplome(self) -> bool:
"Vrai si l'étudiant à validé son diplôme"
return False # TODO XXX
"Vrai si l'étudiant a validé son diplôme (décision enregistrée)"
return self.annee_but == 3 and sco_codes.code_annee_validant(self.code_valide)
def list_ue_parcour_etud(
@ -1174,13 +1228,12 @@ class DecisionsProposeesRCUE(DecisionsProposees):
code=code,
)
db.session.add(self.validation)
db.session.commit()
Scolog.logdb(
method="jury_but",
etudid=self.etud.id,
msg=f"Validation {self.rcue}: {code}",
commit=True,
)
db.session.commit()
log(f"rcue.record {self}: {code}")
# Modifie au besoin les codes d'UE
@ -1593,13 +1646,12 @@ class DecisionsProposeesUE(DecisionsProposees):
moy_ue=self.moy_ue,
)
db.session.add(self.validation)
db.session.commit()
Scolog.logdb(
method="jury_but",
etudid=self.etud.id,
msg=f"Validation UE {self.ue.id} {self.ue.acronyme}({self.moy_ue}): {code}",
commit=True,
)
db.session.commit()
log(f"DecisionsProposeesUE: recording {self.validation}")
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id)

View File

@ -8,11 +8,12 @@
"""
from flask import g, request, url_for
from openpyxl.styles import Font, Border, Side, Alignment, PatternFill
from openpyxl.styles import Alignment
from app import log
from app.but import jury_but
from app.but.cursus_but import but_ects_valides
from app.models.but_validations import ValidationDUT120
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.scodoc.gen_tables import GenTable
@ -155,6 +156,14 @@ def pvjury_table_but(
deca = None
ects_but_valides = but_ects_valides(etud, referentiel_competence_id)
has_diplome = deca.valide_diplome() if deca else False
diplome_lst = ["ADM"] if has_diplome else []
validation_dut120 = ValidationDUT120.query.filter_by(
etudid=etudid, formsemestre_id=formsemestre.id
).first()
if validation_dut120:
diplome_lst.append("Diplôme de DUT validé.")
diplome_str = ". ".join(diplome_lst)
row = {
"nom_pv": (
etud.code_ine or etud.code_nip or etud.id
@ -172,8 +181,12 @@ def pvjury_table_but(
etudid=etud.id,
),
"cursus": _descr_cursus_but(etud),
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {ects_but_valides:g}""",
"_ects_xls": deca.ects_annee(),
"ects": (
f"""{deca.ects_annee():g}<br><br>Tot. {ects_but_valides:g}"""
if deca
else ""
),
"_ects_xls": deca.ects_annee() if deca else "",
"ects_but": ects_but_valides,
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": (
@ -181,10 +194,15 @@ def pvjury_table_but(
),
"decision_but": deca.code_valide if deca else "",
"devenir": (
", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else ""
"Diplôme obtenu"
if has_diplome
else (
", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else ""
)
),
"diplome": diplome_str,
# pour exports excel seulement:
"civilite": etud.civilite_etat_civil_str,
"nom": etud.nom,
@ -193,7 +211,7 @@ def pvjury_table_but(
"code_nip": etud.code_nip,
"code_ine": etud.code_ine,
}
if deca.valide_diplome() or not only_diplome:
if (deca and deca.valide_diplome()) or not only_diplome:
rows.append(row)
rows.sort(key=lambda x: x["_nom_pv_order"])

View File

@ -9,14 +9,14 @@
from flask import g, url_for
from app import db
from app.but import jury_but
from app.models import Identite, FormSemestre, ScolarNews
from app.but import jury_but, jury_dut120
from app.models import Identite, FormSemestre, ScolarNews, ValidationDUT120
from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but(
formsemestre: FormSemestre, only_adm: bool = True, dry_run=False
formsemestre: FormSemestre, only_adm: bool = True, dry_run=False, with_dut120=True
) -> tuple[int, list[jury_but.DecisionsProposeesAnnee]]:
"""Calcul automatique des décisions de jury sur une "année" BUT.
@ -27,6 +27,8 @@ def formsemestre_validation_auto_but(
En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc
(mode à n'utiliser que pour les tests unitaires vérifiant la saisie des jurys)
Enregistre aussi le DUT120.
Returns:
- En mode normal, (nombre d'étudiants pour lesquels on a enregistré au moins un code, []])
- En mode dry_run, (0, list[DecisionsProposeesAnnee])
@ -40,7 +42,10 @@ def formsemestre_validation_auto_but(
etud = Identite.get_etud(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if not dry_run:
nb_etud_modif += deca.record_all(only_validantes=only_adm)
modified = deca.record_all(only_validantes=only_adm)
modified |= validation_dut120_auto(etud, formsemestre)
if modified:
nb_etud_modif += 1
else:
decas.append(deca)
@ -56,3 +61,28 @@ def formsemestre_validation_auto_but(
),
)
return nb_etud_modif, decas
def validation_dut120_auto(etud: Identite, formsemestre: FormSemestre) -> bool:
"""Si l'étudiant n'a pas déjà validé son DUT120 dans cette spécialité
et qu'il satisfait les confitions, l'enregistre.
Returns True si nouvelle décision enregistrée.
"""
refcomp = formsemestre.formation.referentiel_competence
if not refcomp:
raise ScoValueError("formation non associée à un référentiel de compétences")
validation = ValidationDUT120.query.filter_by(
etudid=etud.id, referentiel_competence_id=refcomp.id
).first()
if validation:
return False # déjà enregistré
if jury_dut120.etud_valide_dut120(etud, refcomp.id):
new_validation = ValidationDUT120(
etudid=etud.id,
referentiel_competence_id=refcomp.id,
formsemestre_id=formsemestre.id, # Replace with appropriate value
)
db.session.add(new_validation)
db.session.commit()
return True
return False # ne peut pas valider

112
app/but/jury_dut120.py Normal file
View File

@ -0,0 +1,112 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury DUT120: gestion et vues
Ce diplôme est attribué sur demande aux étudiants de BUT ayant acquis les 120 ECTS
de BUT 1 et BUT 2.
"""
import time
from flask import flash, g, redirect, render_template, request, url_for
from flask_wtf import FlaskForm
from wtforms import SubmitField
from app import db, log
from app.but import cursus_but
from app.decorators import scodoc, permission_required
from app.models import FormSemestre, Identite, Scolog, ValidationDUT120
from app.scodoc.sco_exceptions import ScoPermissionDenied, ScoValueError
from app.scodoc.sco_permissions import Permission
from app.views import notes_bp as bp
from app.views import ScoData
def etud_valide_dut120(etud: Identite, referentiel_competence_id: int) -> bool:
"""Vrai si l'étudiant satisfait les conditions pour valider le DUT120"""
ects_but1_but2 = cursus_but.but_ects_valides(
etud, referentiel_competence_id, annees_but=("BUT1", "BUT2")
)
return ects_but1_but2 >= 120
class ValidationDUT120Form(FlaskForm):
"Formulaire validation DUT120"
submit = SubmitField("Enregistrer le diplôme DUT 120")
@bp.route(
"/validate_dut120/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>",
methods=["GET", "POST"],
)
@scodoc
@permission_required(Permission.ScoView)
def validate_dut120_etud(etudid: int, formsemestre_id: int):
"""Formulaire validation individuelle du DUT120"""
# Check arguments
etud = Identite.get_etud(etudid)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
refcomp = formsemestre.formation.referentiel_competence
if not refcomp:
raise ScoValueError("formation non associée à un référentiel de compétences")
# Permission
if not formsemestre.can_edit_jury():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
ects_but1_but2 = cursus_but.but_ects_valides(
etud, refcomp.id, annees_but=("BUT1", "BUT2")
)
form = ValidationDUT120Form()
# Check if ValidationDUT120 instance already exists
existing_validation = ValidationDUT120.query.filter_by(
etudid=etud.id, referentiel_competence_id=refcomp.id
).first()
if existing_validation:
flash("DUT120 déjà validé", "info")
etud_can_validate_dut = False
# Check if the student meets the criteria
elif ects_but1_but2 < 120:
flash("L'étudiant ne remplit pas les conditions", "warning")
etud_can_validate_dut = False # here existing_validation is None
else:
etud_can_validate_dut = True
if etud_can_validate_dut and request.method == "POST" and form.validate_on_submit():
new_validation = ValidationDUT120(
etudid=etud.id,
referentiel_competence_id=refcomp.id,
formsemestre_id=formsemestre.id, # Replace with appropriate value
)
db.session.add(new_validation)
Scolog.logdb(
"jury_but",
etudid=etud.id,
msg=f"Validation DUT120 enregistrée depuis S{formsemestre.semestre_id}",
)
db.session.commit()
log(f"ValidationDUT120 enregistrée pour {etud} depuis {formsemestre}")
flash("Validation DUT120 enregistrée", "success")
return redirect(etud.url_fiche())
return render_template(
"but/validate_dut120.j2",
ects_but1_but2=ects_but1_but2,
etud=etud,
etud_can_validate_dut=etud_can_validate_dut,
form=form,
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre, etud=etud),
time=time,
title="Délivrance du DUT",
validation=existing_validation,
)

View File

@ -10,23 +10,25 @@ Non spécifique au BUT.
"""
from flask import render_template
from flask_login import current_user
import sqlalchemy as sa
from app.models import (
ApcValidationAnnee,
ApcValidationRCUE,
Identite,
UniteEns,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
UniteEns,
ValidationDUT120,
)
from app.scodoc.sco_permissions import Permission
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.
"""Vue présentant *toutes* les décisions de jury concernant cet étudiant
et permettant (si permission) de les supprimer une à une.
"""
sem_vals = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, ue_id=None
@ -60,8 +62,12 @@ def jury_delete_manual(etud: Identite):
sem_vals=sem_vals,
ue_vals=ue_vals,
autorisations=autorisations,
dut120_vals=ValidationDUT120.query.filter_by(etudid=etud.id).order_by(
ValidationDUT120.date
),
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()}",
read_only=not current_user.has_permission(Permission.EtudInscrit),
)

View File

@ -4,13 +4,10 @@
# See LICENSE
##############################################################################
"""Jury édition manuelle des décisions (correction d'erreurs, parcours hors normes)
Non spécifique au BUT.
"""Jury édition manuelle des décisions RCUE antérieures
"""
from flask import render_template
import sqlalchemy as sa
from app import log
from app.but import cursus_but

View File

@ -340,19 +340,19 @@ class ModuleImplResults:
]
def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations bonus de ce module, ou liste vide s'il n'en a pas."""
"""Les évaluations bonus non bloquées de ce module, ou liste vide s'il n'en a pas."""
return [
e
for e in modimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_BONUS
if e.evaluation_type == Evaluation.EVALUATION_BONUS and not e.is_blocked()
]
def get_evaluations_bonus_idx(self, modimpl: ModuleImpl) -> list[int]:
"""Les indices des évaluations bonus"""
"""Les indices des évaluations bonus non bloquées"""
return [
i
for (i, e) in enumerate(modimpl.evaluations)
if e.evaluation_type == Evaluation.EVALUATION_BONUS
if e.evaluation_type == Evaluation.EVALUATION_BONUS and not e.is_blocked()
]

View File

@ -150,7 +150,7 @@ class ResultatsSemestre(ResultatsCache):
def etud_ects_tot_sem(self, etudid: int) -> float:
"""Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)"""
etud_ues = self.etud_ues(etudid)
return sum([ue.ects or 0 for ue in etud_ues]) if etud_ues else 0.0
return sum([ue.ects or 0.0 for ue in etud_ues]) if etud_ues else 0.0
def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray:
"""Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue.

View File

@ -322,7 +322,7 @@ class NotesTableCompat(ResultatsSemestre):
validations = self.get_formsemestre_validations()
return validations.decisions_jury_ues.get(etudid, None)
def get_etud_ects_valides(self, etudid: int, decisions_ues: dict = False) -> 0:
def get_etud_ects_valides(self, etudid: int, decisions_ues: dict = False) -> float:
"""Le total des ECTS validés (et enregistrés) par l'étudiant dans ce semestre.
NB: avant jury, rien d'enregistré, donc zéro ECTS.
Optimisation: si decisions_ues est passé, l'utilise, sinon appelle get_etud_decisions_ue()
@ -331,7 +331,7 @@ class NotesTableCompat(ResultatsSemestre):
decisions_ues = self.get_etud_decisions_ue(etudid)
if not decisions_ues:
return 0.0
return sum([d.get("ects", 0.0) for d in decisions_ues.values()])
return float(sum(d.get("ects", 0) for d in decisions_ues.values()))
def get_etud_decision_sem(self, etudid: int) -> dict:
"""Decision du jury semestre prise pour cet etudiant, ou None s'il n'y en pas eu.

View File

@ -84,6 +84,9 @@ def scodoc(func):
def permission_required(permission):
"""Vérifie les permissions"""
# Attention: l'API utilise api_permission_required
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):

View File

@ -151,7 +151,7 @@ class EntrepriseHistorique(db.Model):
__tablename__ = "are_historique"
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
authenticated_user = db.Column(db.Text)
authenticated_user = db.Column(db.Text) # user_name login sans contrainte
entreprise_id = db.Column(db.Integer)
object = db.Column(db.Text)
object_id = db.Column(db.Integer)

View File

@ -170,13 +170,7 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
)
etat = SelectField(
"État du justificatif",
choices=[
("", "Choisir..."), # Placeholder
(scu.EtatJustificatif.ATTENTE.value, "En attente de validation"),
(scu.EtatJustificatif.NON_VALIDE.value, "Non valide"),
(scu.EtatJustificatif.MODIFIE.value, "Modifié"),
(scu.EtatJustificatif.VALIDE.value, "Valide"),
],
choices=[], # sera rempli dynamiquement
validators=[DataRequired(message="This field is required.")],
)
fichiers = MultipleFileField(label="Ajouter des fichiers")

View File

@ -140,8 +140,8 @@ class ConfigAssiduitesForm(FlaskForm):
)
edt_ics_title_regexp = StringField(
label="Extraction du titre",
description=r"""expression régulière python dont le premier groupe doit
sera le titre de l'évènement affcihé dans le calendrier ScoDoc.
description=r"""expression régulière python dont le premier groupe
sera le titre de l'évènement affiché dans le calendrier ScoDoc.
Exemple: <tt>Matière : \w+ - ([\w\.\s']+)</tt>
""",
validators=[Optional(), check_ics_regexp],

View File

@ -0,0 +1,56 @@
"""
Formulaire FlaskWTF pour les groupes
"""
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, validators
class FeuilleAppelPreForm(FlaskForm):
"""
Formulaire utiliser dans le téléchargement des feuilles d'émargement
"""
def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs)
self.ok = True
self.error_messages: list[str] = []
def set_error(self, err_msg, field=None):
"Set error message both in form and field"
self.ok = False
self.error_messages.append(err_msg)
if field:
field.errors.append(err_msg)
discipline = StringField(
"Discipline",
)
ens = StringField(
"Enseignant",
)
date = StringField(
"Date de la séance",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "date",
},
)
heure = StringField(
"Heure de début de la séance",
default="",
validators=[validators.Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "heure",
},
)
submit = SubmitField("Télécharger la liste d'émargement")

View File

@ -3,7 +3,9 @@
"""Modèles base de données ScoDoc
"""
from flask import abort, g
import sqlalchemy
import app
from app import db
CODE_STR_LEN = 16 # chaine pour les codes
@ -116,6 +118,42 @@ class ScoDocModel(db.Model):
args = {field.name: field.data for field in form}
return self.from_dict(args)
@classmethod
def get_instance(cls, oid: int, accept_none=False):
"""Instance du modèle ou ou 404 (ou None si accept_none),
cherche uniquement dans le département courant.
Ne fonctionne que si le modèle a un attribut dept_id.
Si accept_none, return None si l'id est invalide ou ne correspond
pas à une validation.
"""
if not isinstance(oid, int):
try:
oid = int(oid)
except (TypeError, ValueError):
if accept_none:
return None
abort(404, "oid invalide")
if g.scodoc_dept:
if hasattr(cls, "_sco_dept_relations"):
# Quand dept_id n'est pas dans le modèle courant,
# cet attribut indique la liste des tables à joindre pour
# obtenir le departement.
query = cls.query.filter_by(id=oid)
for relation_name in cls._sco_dept_relations:
query = query.join(getattr(app.models, relation_name))
query = query.filter_by(dept_id=g.scodoc_dept_id)
else:
# département accessible dans le modèle courant
query = cls.query.filter_by(id=oid, dept_id=g.scodoc_dept_id)
else:
# Pas de département courant (API non départementale)
query = cls.query.filter_by(id=oid)
if accept_none:
return query.first()
return query.first_or_404()
from app.models.absences import Absence, AbsenceNotification, BilletAbsence
from app.models.departements import Departement
@ -173,7 +211,11 @@ from app.models.but_refcomp import (
ApcReferentielCompetences,
ApcSituationPro,
)
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
ValidationDUT120,
)
from app.models.config import ScoDocSiteConfig

View File

@ -7,7 +7,10 @@ from app import db
class Absence(db.Model):
"""une absence (sur une demi-journée)"""
"""LEGACY
Ce modèle n'est PLUS UTILISE depuis ScoDoc 9.6 et remplacé par assiduité.
une absence (sur une demi-journée)
"""
__tablename__ = "absences"
id = db.Column(db.Integer, primary_key=True)

View File

@ -21,6 +21,7 @@ from app.scodoc import sco_abs_notification
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
from app.scodoc.sco_utils import (
EtatAssiduite,
@ -188,6 +189,12 @@ class Assiduite(ScoDocModel):
):
raise ScoValueError("La date de fin n'est pas un jour travaillé")
# Vérification de l'activation du module
if (err_msg := has_assiduites_disable_pref(formsemestre_date_debut)) or (
err_msg := has_assiduites_disable_pref(formsemestre_date_fin)
):
raise ScoValueError(err_msg)
# Vérification de non duplication des périodes
assiduites: Query = etud.assiduites
if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite):
@ -341,7 +348,7 @@ class Assiduite(ScoDocModel):
"""
Retourne le module associé à l'assiduité
Si traduire est vrai, retourne le titre du module précédé du code
Sinon rentourne l'objet Module ou None
Sinon retourne l'objet Module ou None
"""
if self.moduleimpl_id is not None:
@ -351,7 +358,7 @@ class Assiduite(ScoDocModel):
return f"{mod.code} {mod.titre}"
return mod
elif self.external_data is not None and "module" in self.external_data:
if self.external_data is not None and "module" in self.external_data:
return (
"Autre module (pas dans la liste)"
if self.external_data["module"] == "Autre"
@ -558,13 +565,13 @@ class Justificatif(ScoDocModel):
Raises ScoValueError si paramètres incorrects.
"""
nouv_justificatif = cls.create_from_dict(locals())
db.session.commit()
log(f"create_justificatif: etudid={etudiant.id} {nouv_justificatif}")
Scolog.logdb(
method="create_justificatif",
etudid=etudiant.id,
msg=f"justificatif: {nouv_justificatif}",
)
db.session.commit()
return nouv_justificatif
def supprime(self):
@ -817,3 +824,29 @@ def get_formsemestre_from_data(data: dict[str, datetime | int]) -> FormSemestre:
)
.first()
)
def has_assiduites_disable_pref(formsemestre: FormSemestre) -> str | bool:
"""
Vérifie si le semestre possède la préférence "assiduites_disable"
et renvoie le message d'erreur associé.
La préférence est un text field. Il est considéré comme vide si :
- la chaine de caractère est vide
- si elle n'est composée que de caractères d'espacement (espace, tabulation, retour à la ligne)
Si la chaine est vide, la fonction renvoie False
"""
# Si pas de formsemestre, on ne peut pas vérifier la préférence
# On considère que la préférence n'est pas activée
if formsemestre is None:
return False
pref: str = (
sco_preferences.get_preference("assiduites_disable", formsemestre.id) or ""
)
pref = pref.strip()
return pref if pref else False

View File

@ -604,6 +604,7 @@ app_critiques_modules = db.Table(
db.ForeignKey("apc_app_critique.id", ondelete="CASCADE"),
primary_key=True,
),
db.UniqueConstraint("module_id", "app_crit_id", name="uix_module_id_app_crit_id"),
)

View File

@ -2,9 +2,12 @@
"""Décisions de jury (validations) des RCUE et années du BUT
"""
from collections import defaultdict
import sqlalchemy as sa
from app import db
from app.models import CODE_STR_LEN
from app.models import CODE_STR_LEN, ScoDocModel
from app.models.but_refcomp import ApcNiveau
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
@ -13,7 +16,7 @@ from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
class ApcValidationRCUE(db.Model):
class ApcValidationRCUE(ScoDocModel):
"""Validation des niveaux de compétences
aka "regroupements cohérents d'UE" dans le jargon BUT.
@ -58,6 +61,8 @@ class ApcValidationRCUE(db.Model):
ue2 = db.relationship("UniteEns", foreign_keys=ue2_id)
parcour = db.relationship("ApcParcours")
_sco_dept_relations = ("Identite",) # pour accéder au département
def __repr__(self):
return f"""<{self.__class__.__name__} {self.id} {self.etud} {
self.ue1}/{self.ue2}:{self.code!r}>"""
@ -113,8 +118,14 @@ class ApcValidationRCUE(db.Model):
"formsemestre_id": self.formsemestre_id,
}
def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée associés à cette validation RCUE.
Prend les codes des deux UEs
"""
return self.ue1.get_codes_apogee_rcue() | self.ue2.get_codes_apogee_rcue()
class ApcValidationAnnee(db.Model):
class ApcValidationAnnee(ScoDocModel):
"""Validation des années du BUT"""
__tablename__ = "apc_validation_annee"
@ -145,6 +156,8 @@ class ApcValidationAnnee(db.Model):
etud = db.relationship("Identite", backref="apc_validations_annees")
formsemestre = db.relationship("FormSemestre", backref="apc_validations_annees")
_sco_dept_relations = ("Identite",) # pour accéder au département
def __repr__(self):
return f"""<{self.__class__.__name__} {self.id} {self.etud
} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>"""
@ -202,17 +215,9 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
.order_by(UniteEns.numero, UniteEns.acronyme)
)
decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues]
titres_rcues = []
for dec_rcue in decisions["decision_rcue"]:
niveau = dec_rcue["niveau"]
if niveau is None:
titres_rcues.append(f"""pas de compétence: code {dec_rcue["code"]}""")
else:
titres_rcues.append(
f"""{niveau["competence"]["titre"]}&nbsp;{niveau["ordre"]}:&nbsp;{
dec_rcue["code"]}"""
)
titres_rcues = _build_decisions_rcue_list(decisions["decision_rcue"])
decisions["descr_decisions_rcue"] = ", ".join(titres_rcues)
decisions["descr_decisions_rcue_list"] = titres_rcues
decisions["descr_decisions_niveaux"] = (
"Niveaux de compétences: " + decisions["descr_decisions_rcue"]
)
@ -236,3 +241,112 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
else:
decisions["decision_annee"] = None
return decisions
def _build_decisions_rcue_list(decisions_rcue: dict) -> list[str]:
"""Formatte liste des décisions niveaux de compétences / RCUE pour
lettres individuelles.
Le résulat est trié par compétence et donne pour chaque niveau avec validation:
[ 'Administrer: niveau 1 ADM, niveau 2 ADJ', ... ]
"""
# Construit { id_competence : validation }
# où validation est {'code': 'CMP', 'niveau': {'annee': 'BUT3', 'competence': {}, ... }
validation_by_competence = defaultdict(list)
for validation in decisions_rcue:
if validation:
# Attention, certaines validations de RCUE peuvent ne plus être associées
# à un niveau de compétence si l'UE a été déassociée (ce qui ne devrait pas être fait)
competence_id = (
(validation.get("niveau") or {}).get("competence") or {}
).get("id_orebut")
validation_by_competence[competence_id].append(validation)
# Tri des listes de validation par numéro de compétence
validations_niveaux = sorted(
validation_by_competence.values(),
key=lambda v: (
((v[0].get("niveau") or {}).get("competence") or {}).get("numero", 0)
if v
else -1
),
)
titres_rcues = []
empty = {} # pour syntaxe f-string
for validations in validations_niveaux:
if validations:
v = validations[0]
titre_competence = ((v.get("niveau") or {}).get("competence", {})).get(
"titre", "sans titre ! A vérifier !"
)
titres_rcues.append(
f"""{titre_competence} : """
+ ", ".join(
[
f"niveau {((v.get('niveau') or empty).get('ordre') or '?')} {v.get('code', '?')}"
for v in validations
]
)
)
return titres_rcues
class ValidationDUT120(ScoDocModel):
"""Validations du DUT 120
Ce diplôme est attribué sur demande aux étudiants de BUT ayant acquis les 120 ECTS
de BUT 1 et BUT 2.
"""
id = db.Column(db.Integer, primary_key=True)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id", ondelete="CASCADE"),
nullable=False,
)
"""le semestre origine, dans la plupart des cas le S4 (le diplôme DUT120
apparaîtra sur les PV de ce formsemestre)"""
referentiel_competence_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
) # pas de cascade, on ne doit pas supprimer un référentiel utilisé
"""Identifie la spécialité de DUT décernée"""
date = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
"""Date de délivrance"""
etud = db.relationship("Identite", backref="validations_dut120")
formsemestre = db.relationship("FormSemestre", backref="validations_dut120")
_sco_dept_relations = ("Identite",) # pour accéder au département
def __repr__(self):
return f"""<ValidationDUT120 {self.etud}>"""
def html(self) -> str:
"Affichage html"
date_str = (
f"""le {self.date.strftime(scu.DATEATIME_FMT)}"""
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"
)
specialite = (
self.formsemestre.formation.referentiel_competence.get_title()
if self.formsemestre.formation.referentiel_competence
else "(désassociée!)"
)
return f"""Diplôme de <b>DUT en 120 ECTS du {specialite}</b> émis par
{link}
{date_str}
"""

View File

@ -261,7 +261,7 @@ class ScoDocSiteConfig(db.Model):
@classmethod
def is_bul_pdf_disabled(cls) -> bool:
"""True si on interdit les exports PDF des bulltins"""
"""True si on interdit les exports PDF des bulletins"""
cfg = ScoDocSiteConfig.query.filter_by(name="disable_bul_pdf").first()
return cfg is not None and cfg.value

View File

@ -52,6 +52,17 @@ class Departement(db.Model):
def __repr__(self):
return f"<{self.__class__.__name__}(id={self.id}, acronym='{self.acronym}')>"
@classmethod
def get_departement(cls, dept_ident: str | int) -> "Departement":
"Le département, par id ou acronyme. Erreur 404 si pas trouvé."
try:
dept_id = int(dept_ident)
except ValueError:
dept_id = None
if dept_id is None:
return cls.query.filter_by(acronym=dept_ident).first_or_404()
return cls.query.get_or_404(dept_id)
def to_dict(self, with_dept_name=True, with_dept_preferences=False):
data = {
"id": self.id,

View File

@ -179,7 +179,9 @@ class Identite(models.ScoDocModel):
def html_link_fiche(self) -> str:
"lien vers la fiche"
return f"""<a class="etudlink" href="{self.url_fiche()}">{self.nomprenom}</a>"""
return (
f"""<a class="etudlink" href="{self.url_fiche()}">{self.nom_prenom()}</a>"""
)
def url_fiche(self) -> str:
"url de la fiche étudiant"
@ -197,18 +199,28 @@ class Identite(models.ScoDocModel):
return cls.query.filter_by(**args).first_or_404()
@classmethod
def get_etud(cls, etudid: int) -> "Identite":
"""Etudiant ou 404, cherche uniquement dans le département courant"""
def get_etud(cls, etudid: int, accept_none=False) -> "Identite":
"""Etudiant ou 404 (ou None si accept_none),
cherche uniquement dans le département courant.
Si accept_none, return None si l'id est invalide ou ne correspond
pas à un étudiant.
"""
if not isinstance(etudid, int):
try:
etudid = int(etudid)
except (TypeError, ValueError):
if accept_none:
return None
abort(404, "etudid invalide")
if g.scodoc_dept:
return cls.query.filter_by(
id=etudid, dept_id=g.scodoc_dept_id
).first_or_404()
return cls.query.filter_by(id=etudid).first_or_404()
query = (
cls.query.filter_by(id=etudid, dept_id=g.scodoc_dept_id)
if g.scodoc_dept
else cls.query.filter_by(id=etudid)
)
if accept_none:
return query.first()
return query.first_or_404()
@classmethod
def create_etud(cls, **args) -> "Identite":
@ -304,7 +316,7 @@ class Identite(models.ScoDocModel):
@property
def nomprenom(self, reverse=False) -> str:
"""DEPRECATED
"""DEPRECATED: préférer nom_prenom()
Civilité/prénom/nom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
Prend l'identité courante et non celle de l'état civil si elles diffèrent.
@ -359,14 +371,15 @@ class Identite(models.ScoDocModel):
"Le mail associé à la première adresse de l'étudiant, ou None"
return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None
def get_formsemestres(self) -> list:
def get_formsemestres(self, recent_first=True) -> list:
"""Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit,
triée par date_debut
triée par date_debut, le plus récent d'abord (comme "sems" de scodoc7)
(si recent_first=False, le plus ancien en tête)
"""
return sorted(
[ins.formsemestre for ins in self.formsemestre_inscriptions],
key=attrgetter("date_debut"),
reverse=True,
reverse=recent_first,
)
def get_modimpls_by_formsemestre(
@ -817,7 +830,7 @@ def check_etud_duplicate_code(args, code_name, edit=True):
dest_endpoint = "notes.index_html"
parameters = {}
err_page = f"""<h3><h3>Code étudiant ({code_name}) dupliqué !</h3>
err_page = f"""<h3>Code étudiant ({code_name}) dupliqué !</h3>
<p class="help">Le {code_name} {args[code_name]} est déjà utilisé: un seul étudiant peut avoir
ce code. Vérifier votre valeur ou supprimer l'autre étudiant avec cette valeur.
</p>
@ -832,7 +845,7 @@ def check_etud_duplicate_code(args, code_name, edit=True):
log(f"*** error: code {code_name} duplique: {args[code_name]}")
raise ScoGenError(err_page)
raise ScoGenError(err_page, safe=True)
def make_etud_args(
@ -1067,8 +1080,9 @@ class Admission(models.ScoDocModel):
return args_dict
# Suivi scolarité / débouchés
class ItemSuivi(db.Model):
class ItemSuivi(models.ScoDocModel):
"""Suivi scolarité / débouchés"""
__tablename__ = "itemsuivi"
id = db.Column(db.Integer, primary_key=True)
@ -1080,6 +1094,8 @@ class ItemSuivi(db.Model):
item_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
situation = db.Column(db.Text)
_sco_dept_relations = ("Identite",) # accès au dept_id
class ItemSuiviTag(db.Model):
__tablename__ = "itemsuivi_tags"
@ -1101,7 +1117,7 @@ itemsuivi_tags_assoc = db.Table(
)
class EtudAnnotation(db.Model):
class EtudAnnotation(models.ScoDocModel):
"""Annotation sur un étudiant"""
__tablename__ = "etud_annotations"
@ -1112,6 +1128,8 @@ class EtudAnnotation(db.Model):
author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user
comment = db.Column(db.Text)
_sco_dept_relations = ("Identite",) # accès au dept_id
def to_dict(self):
"""Représentation dictionnaire."""
e = dict(self.__dict__)

View File

@ -60,6 +60,8 @@ class Evaluation(models.ScoDocModel):
numero = db.Column(db.Integer, nullable=False, default=0)
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
_sco_dept_relations = ("ModuleImpl", "FormSemestre") # accès au dept_id
EVALUATION_NORMALE = 0 # valeurs stockées en base, ne pas changer !
EVALUATION_RATTRAPAGE = 1
EVALUATION_SESSION2 = 2
@ -267,10 +269,12 @@ class Evaluation(models.ScoDocModel):
@classmethod
def get_evaluation(
cls, evaluation_id: int | str, dept_id: int = None
cls, evaluation_id: int | str, dept_id: int = None, accept_none=False
) -> "Evaluation":
"""Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant."""
from app.models import FormSemestre, ModuleImpl
"""Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant.
Si accept_none, return None si l'id est invalide ou n'existe pas.
"""
from app.models import FormSemestre
if not isinstance(evaluation_id, int):
try:
@ -282,6 +286,8 @@ class Evaluation(models.ScoDocModel):
query = cls.query.filter_by(id=evaluation_id)
if dept_id is not None:
query = query.join(ModuleImpl).join(FormSemestre).filter_by(dept_id=dept_id)
if accept_none:
return query.first()
return query.first_or_404()
@classmethod
@ -363,6 +369,8 @@ class Evaluation(models.ScoDocModel):
return f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}"
if self.date_debut.date() == self.date_fin.date(): # même jour
if self.date_debut.time() == self.date_fin.time():
if self.date_fin.time() == datetime.time(0, 0):
return f"le {self.date_debut.strftime('%d/%m/%Y')}" # sans heure
return (
f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}"
)

View File

@ -12,12 +12,12 @@ from app import db
from app import email
from app import log
from app.auth.models import User
from app.models import SHORT_STR_LEN
from app.models import ScoDocModel, SHORT_STR_LEN
import app.scodoc.sco_utils as scu
from app.scodoc import sco_preferences
class Scolog(db.Model):
class Scolog(ScoDocModel):
"""Log des actions (journal modif etudiants)"""
__tablename__ = "scolog"
@ -27,14 +27,15 @@ class Scolog(db.Model):
method = db.Column(db.Text)
msg = db.Column(db.Text)
etudid = db.Column(db.Integer) # sans contrainte pour garder logs après suppression
authenticated_user = db.Column(db.Text) # login, sans contrainte
authenticated_user = db.Column(db.Text) # user_name login, sans contrainte
# zope_remote_addr suppressed
@classmethod
def logdb(
cls, method: str = None, etudid: int = None, msg: str = None, commit=False
):
"""Add entry in student's log (replacement for old scolog.logdb)"""
"""Add entry in student's log (replacement for old scolog.logdb).
Par défaut ne commite pas."""
entry = Scolog(
method=method,
msg=msg,
@ -45,6 +46,21 @@ class Scolog(db.Model):
if commit:
db.session.commit()
def to_dict(self, convert_date=False) -> dict:
"convert to dict"
return {
"etudid": self.etudid,
"date": (
(self.date.strftime(scu.DATETIME_FMT) if convert_date else self.date)
if self.date
else ""
),
"_date_order": self.date.isoformat() if self.date else "",
"authenticated_user": self.authenticated_user or "",
"msg": self.msg or "",
"method": self.method or "",
}
class ScolarNews(db.Model):
"""Nouvelles pour page d'accueil"""
@ -76,7 +92,9 @@ class ScolarNews(db.Model):
date = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), index=True
)
authenticated_user = db.Column(db.Text, index=True) # login, sans contrainte
authenticated_user = db.Column(
db.Text, index=True
) # user_name login, sans contrainte
# type in 'INSCR', 'NOTES', 'FORM', 'SEM', 'MISC'
type = db.Column(db.String(SHORT_STR_LEN), index=True)
object = db.Column(

View File

@ -7,7 +7,7 @@ from flask_sqlalchemy.query import Query
import app
from app import db
from app.comp import df_cache
from app.models import SHORT_STR_LEN
from app.models import ScoDocModel, SHORT_STR_LEN
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
@ -23,7 +23,7 @@ from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_STANDARD
class Formation(db.Model):
class Formation(ScoDocModel):
"""Programme pédagogique d'une formation"""
__tablename__ = "notes_formations"
@ -297,7 +297,7 @@ class Formation(db.Model):
db.session.commit()
class Matiere(db.Model):
class Matiere(ScoDocModel):
"""Matières: regroupe les modules d'une UE
La matière a peu d'utilité en dehors de la présentation des modules
d'une UE.
@ -313,6 +313,7 @@ class Matiere(db.Model):
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
modules = db.relationship("Module", lazy="dynamic", backref="matiere")
_sco_dept_relations = ("UniteEns", "Formation") # accès au dept_id
def __repr__(self):
return f"""<{self.__class__.__name__}(id={self.id}, ue_id={

View File

@ -36,6 +36,7 @@ from app.models.config import ScoDocSiteConfig
from app.models.departements import Departement
from app.models.etudiants import Identite
from app.models.evaluations import Evaluation
from app.models.events import ScolarNews
from app.models.formations import Formation
from app.models.groups import GroupDescr, Partition
from app.models.moduleimpls import (
@ -122,9 +123,11 @@ class FormSemestre(models.ScoDocModel):
)
"autorise les enseignants à créer des évals dans leurs modimpls"
elt_sem_apo = db.Column(db.Text()) # peut être fort long !
"code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
"code element semestre Apogée, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
elt_annee_apo = db.Column(db.Text())
"code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
elt_passage_apo = db.Column(db.Text())
"code element passage Apogée"
# Data pour groups_auto_assignment
# (ce champ est utilisé uniquement via l'API par le front js)
@ -207,6 +210,70 @@ class FormSemestre(models.ScoDocModel):
).first_or_404()
return cls.query.filter_by(id=formsemestre_id).first_or_404()
@classmethod
def create_formsemestre(cls, args: dict, silent=False) -> "FormSemestre":
"""Création d'un formsemestre, avec toutes les valeurs par défaut
et notification (sauf si silent).
Crée la partition par défaut.
"""
# was sco_formsemestre.do_formsemestre_create
if "dept_id" not in args:
args["dept_id"] = g.scodoc_dept_id
formsemestre: "FormSemestre" = cls.create_from_dict(args)
db.session.flush()
for etape in args["etapes"]:
formsemestre.add_etape(etape)
db.session.commit()
for u in args["responsables"]:
formsemestre.responsables.append(u)
# create default partition
partition = Partition(
formsemestre=formsemestre, partition_name=None, numero=1000000
)
db.session.add(partition)
partition.create_group(default=True)
db.session.commit()
if not silent:
url = url_for(
"notes.formsemestre_status",
scodoc_dept=formsemestre.departement.acronym,
formsemestre_id=formsemestre.id,
)
ScolarNews.add(
typ=ScolarNews.NEWS_SEM,
text=f"""Création du semestre <a href="{url}">{formsemestre.titre}</a>""",
url=url,
max_frequency=0,
)
return formsemestre
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict.
args: dict with args in application.
returns: dict to store in model's db.
"""
if "date_debut" in args:
args["date_debut"] = scu.convert_fr_date(args["date_debut"])
if "date_fin" in args:
args["date_fin"] = scu.convert_fr_date(args["date_fin"])
if "etat" in args:
args["etat"] = bool(args["etat"])
if "bul_bgcolor" in args:
args["bul_bgcolor"] = args.get("bul_bgcolor") or "white"
if "titre" in args:
args["titre"] = args.get("titre") or "sans titre"
return args
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
Add 'etapes' to excluded."""
# on ne peut pas affecter directement etapes
return super().filter_model_attributes(data, (excluded or set()) | {"etapes"})
def sort_key(self) -> tuple:
"""clé pour tris par ordre de date_debut, le plus ancien en tête
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
@ -610,6 +677,41 @@ class FormSemestre(models.ScoDocModel):
)
)
@classmethod
def est_in_semestre_scolaire(
cls,
date_debut: datetime.date,
year=False,
periode=None,
mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
) -> bool:
"""Vrai si la date_debut est dans la période indiquée (1,2,0)
du semestre `periode` de l'année scolaire indiquée
(ou, à défaut, de celle en cours).
La période utilise les même conventions que semset["sem_id"];
* 1 : première période
* 2 : deuxième période
* 0 ou période non précisée: annualisé (donc inclut toutes les périodes)
)
"""
if not year:
year = scu.annee_scolaire()
# n'utilise pas le jour pivot
jour_pivot_annee = jour_pivot_periode = 1
# calcule l'année universitaire et la période
sem_annee, sem_periode = cls.comp_periode(
date_debut,
mois_pivot_annee,
mois_pivot_periode,
jour_pivot_annee,
jour_pivot_periode,
)
if periode is None or periode == 0:
return sem_annee == year
return sem_annee == year and sem_periode == periode
def est_terminal(self) -> bool:
"Vrai si dernier semestre de son cursus (ou formation mono-semestre)"
return (self.semestre_id < 0) or (
@ -694,7 +796,7 @@ class FormSemestre(models.ScoDocModel):
FormSemestre.titre,
)
def etapes_apo_vdi(self) -> list[ApoEtapeVDI]:
def etapes_apo_vdi(self) -> list["ApoEtapeVDI"]:
"Liste des vdis"
# was read_formsemestre_etapes
return [e.as_apovdi() for e in self.etapes if e.etape_apo]
@ -707,9 +809,9 @@ class FormSemestre(models.ScoDocModel):
return ""
return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
def add_etape(self, etape_apo: str):
def add_etape(self, etape_apo: str | ApoEtapeVDI):
"Ajoute une étape"
etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=etape_apo)
etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=str(etape_apo))
db.session.add(etape)
def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
@ -743,11 +845,11 @@ class FormSemestre(models.ScoDocModel):
else:
return ", ".join([u.get_nomcomplet() for u in self.responsables])
def est_responsable(self, user: User):
def est_responsable(self, user: User) -> bool:
"True si l'user est l'un des responsables du semestre"
return user.id in [u.id for u in self.responsables]
def est_chef_or_diretud(self, user: User = None):
def est_chef_or_diretud(self, user: User | None = None) -> bool:
"Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre"
user = user or current_user
return user.has_permission(Permission.EditFormSemestre) or self.est_responsable(
@ -765,7 +867,7 @@ class FormSemestre(models.ScoDocModel):
return True # typiquement admin, chef dept
return self.est_responsable(user)
def can_edit_jury(self, user: User = None):
def can_edit_jury(self, user: User | None = None):
"""Vrai si utilisateur (par def. current) peut saisir decision de jury
dans ce semestre: vérifie permission et verrouillage.
"""
@ -893,7 +995,12 @@ class FormSemestre(models.ScoDocModel):
def get_codes_apogee(self, category=None) -> set[str]:
"""Les codes Apogée (codés en base comme "VRT1,VRT2")
category: None: tous, "etapes": étapes associées, "sem: code semestre", "annee": code annuel
category:
None: tous,
"etapes": étapes associées,
"sem: code semestre"
"annee": code annuel
"passage": code passage
"""
codes = set()
if category is None or category == "etapes":
@ -902,6 +1009,8 @@ class FormSemestre(models.ScoDocModel):
codes |= {x.strip() for x in self.elt_sem_apo.split(",") if x}
if (category is None or category == "annee") and self.elt_annee_apo:
codes |= {x.strip() for x in self.elt_annee_apo.split(",") if x}
if (category is None or category == "passage") and self.elt_passage_apo:
codes |= {x.strip() for x in self.elt_passage_apo.split(",") if x}
return codes
def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]:
@ -938,7 +1047,7 @@ class FormSemestre(models.ScoDocModel):
def etudids_actifs(self) -> tuple[list[int], set[int]]:
"""Liste les etudids inscrits (incluant DEM et DEF),
qui ser al'index des dataframes de notes
qui sera l'index des dataframes de notes
et donne l'ensemble des inscrits non DEM ni DEF.
"""
return [inscr.etudid for inscr in self.inscriptions], {
@ -1225,10 +1334,18 @@ class FormSemestreEtape(db.Model):
"Etape False if code empty"
return self.etape_apo is not None and (len(self.etape_apo) > 0)
def __eq__(self, other):
if isinstance(other, ApoEtapeVDI):
return self.as_apovdi() == other
return str(self) == str(other)
def __repr__(self):
return f"<Etape {self.id} apo={self.etape_apo!r}>"
def as_apovdi(self) -> ApoEtapeVDI:
def __str__(self):
return self.etape_apo or ""
def as_apovdi(self) -> "ApoEtapeVDI":
return ApoEtapeVDI(self.etape_apo)
@ -1381,8 +1498,9 @@ class FormSemestreInscription(db.Model):
def __repr__(self):
return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={
self.formsemestre_id} etat={self.etat} {
('parcours='+str(self.parcour)) if self.parcour else ''}>"""
self.formsemestre_id} (S{self.formsemestre.semestre_id}) etat={self.etat} {
('parcours="'+str(self.parcour.code)+'"') if self.parcour else ''
} {('etape="'+self.etape+'"') if self.etape else ''}>"""
class NotesSemSet(db.Model):

View File

@ -54,6 +54,7 @@ class Partition(ScoDocModel):
cascade="all, delete-orphan",
order_by="GroupDescr.numero, GroupDescr.group_name",
)
_sco_dept_relations = ("FormSemestre",)
def __init__(self, **kwargs):
super(Partition, self).__init__(**kwargs)
@ -93,6 +94,10 @@ class Partition(ScoDocModel):
):
group.remove_etud(etud)
def is_default(self) -> bool:
"vrai si partition par défault (tous les étudiants)"
return not self.partition_name
def is_parcours(self) -> bool:
"Vrai s'il s'agit de la partition de parcours"
return self.partition_name == scu.PARTITION_PARCOURS
@ -221,6 +226,11 @@ class GroupDescr(ScoDocModel):
numero = db.Column(db.Integer, nullable=False, default=0)
"Numero = ordre de presentation"
_sco_dept_relations = (
"Partition",
"FormSemestre",
)
etuds = db.relationship(
"Identite",
secondary="group_membership",
@ -332,13 +342,12 @@ class GroupDescr(ScoDocModel):
"Enlève l'étudiant de ce groupe s'il en fait partie (ne fait rien sinon)"
if etud in self.etuds:
self.etuds.remove(etud)
db.session.commit()
Scolog.logdb(
method="group_remove_etud",
etudid=etud.id,
msg=f"Retrait du groupe {self.group_name} de {self.partition.partition_name}",
commit=True,
)
db.session.commit()
# Update parcours
if self.partition.partition_name == scu.PARTITION_PARCOURS:
self.partition.formsemestre.update_inscriptions_parcours_from_groups(

View File

@ -60,6 +60,8 @@ class ModuleImpl(ScoDocModel):
)
"enseignants du module (sans le responsable)"
_sco_dept_relations = ("FormSemestre",) # accès au dept_id
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} module={repr(self.module)}>"
@ -253,21 +255,60 @@ class ModuleImpl(ScoDocModel):
return False
return True
def est_inscrit(self, etud: Identite) -> bool:
def can_change_inscriptions(self, user: User | None = None, raise_exc=True) -> bool:
"""check si user peut inscrire/désinsincrire des étudiants à ce module.
Autorise ScoEtudInscrit ou responsables semestre.
"""
user = current_user if user is None else user
if not self.formsemestre.etat:
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")
return False
# -- check access
# resp. module ou ou perm. EtudInscrit ou resp. semestre
if (
user.id != self.responsable_id
and not user.has_permission(Permission.EtudInscrit)
and user.id not in (u.id for u in self.formsemestre.responsables)
):
if raise_exc:
raise AccessDenied(f"Modification impossible pour {user}")
return False
return True
def est_inscrit(self, etud: Identite):
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre).
(lent, pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df).
Retourne Vrai si inscrit au module, faux sinon.
Retourne ModuleImplInscription si inscrit au module, False sinon.
"""
# vérifie inscrit au moduleimpl ET au formsemestre
from app.models.formsemestre import FormSemestre, FormSemestreInscription
is_module: int = (
ModuleImplInscription.query.filter_by(
etudid=etud.id, moduleimpl_id=self.id
).count()
> 0
inscription = (
ModuleImplInscription.query.filter_by(etudid=etud.id, moduleimpl_id=self.id)
.join(ModuleImpl)
.join(FormSemestre)
.join(FormSemestreInscription)
.filter_by(etudid=etud.id)
.first()
)
return is_module
return inscription or False
def query_inscriptions(self) -> Query:
"""Query ModuleImplInscription: inscrits au moduleimpl et au formsemestre
(pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df).
"""
from app.models.formsemestre import FormSemestre, FormSemestreInscription
return (
ModuleImplInscription.query.filter_by(moduleimpl_id=self.id)
.join(ModuleImpl)
.join(FormSemestre)
.join(FormSemestreInscription)
.filter_by(etudid=ModuleImplInscription.etudid)
)
# Enseignants (chargés de TD ou TP) d'un moduleimpl

View File

@ -75,6 +75,8 @@ class Module(models.ScoDocModel):
backref=db.backref("modules", lazy=True),
)
_sco_dept_relations = "Formation" # accès au dept_id
def __init__(self, **kwargs):
self.ue_coefs = []
super(Module, self).__init__(**kwargs)
@ -106,31 +108,76 @@ class Module(models.ScoDocModel):
return args_dict
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
def filter_model_attributes(cls, args: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
Add 'id' to excluded."""
# on ne peut pas affecter directement parcours
return super().filter_model_attributes(data, (excluded or set()) | {"parcours"})
return super().filter_model_attributes(args, (excluded or set()) | {"parcours"})
def from_dict(self, args: dict, excluded: set[str] | None = None) -> bool:
"""Update object's fields given in dict. Add to session but don't commit.
True if modification.
- can't change ue nor formation
- can change matiere_id, iff new matiere in same ue
- can change parcours: parcours list of ApcParcour id or instances.
"""
# Vérifie les changements de matiere
new_matiere_id = args.get("matiere_id", self.matiere_id)
if new_matiere_id != self.matiere_id:
# exists ?
from app.models import Matiere
matiere = db.session.get(Matiere, new_matiere_id)
if matiere is None or matiere.ue_id != self.ue_id:
raise ScoValueError("invalid matiere")
modified = super().from_dict(
args, excluded=(excluded or set()) | {"formation_id", "ue_id"}
)
existing_parcours = {p.id for p in self.parcours}
new_parcours = args.get("parcours", []) or []
if existing_parcours != set(new_parcours):
self._set_parcours_from_list(new_parcours)
return True
return modified
@classmethod
def create_from_dict(cls, data: dict) -> "Module":
"""Create from given dict, add parcours"""
mod = super().create_from_dict(data)
for p in data.get("parcours", []) or []:
"""Create from given dict, add parcours.
Flush session."""
module = super().create_from_dict(data)
db.session.flush()
module._set_parcours_from_list(data.get("parcours", []) or [])
return module
def _set_parcours_from_list(self, parcours: list[ApcParcours | int]):
"""Ajoute ces parcours à la liste des parcours du module.
Chaque élément est soit un objet parcours soit un id.
S'assure que chaque parcours est dans le référentiel de compétence
associé à la formation du module.
"""
for p in parcours:
if isinstance(p, ApcParcours):
parcour: ApcParcours = p
if p.referentiel_id != self.formation.referentiel_competence.id:
raise ScoValueError("Parcours hors référentiel du module")
else:
pid = int(p)
query = ApcParcours.query.filter_by(id=pid)
try:
pid = int(p)
except ValueError as exc:
raise ScoValueError("id de parcours invalide") from exc
query = (
ApcParcours.query.filter_by(id=pid)
.join(ApcReferentielCompetences)
.filter_by(id=self.formation.referentiel_competence.id)
)
if g.scodoc_dept:
query = query.join(ApcReferentielCompetences).filter_by(
dept_id=g.scodoc_dept_id
)
query = query.filter_by(dept_id=g.scodoc_dept_id)
parcour: ApcParcours = query.first()
if parcour is None:
raise ScoValueError("Parcours invalide")
mod.parcours.append(parcour)
return mod
self.parcours.append(parcour)
def clone(self):
"""Create a new copy of this module."""
@ -163,16 +210,29 @@ class Module(models.ScoDocModel):
mod.app_critiques.append(app_critique)
return mod
def to_dict(self, convert_objects=False, with_matiere=False, with_ue=False) -> dict:
def to_dict(
self,
convert_objects=False,
with_matiere=False,
with_ue=False,
with_parcours_ids=False,
) -> dict:
"""If convert_objects, convert all attributes to native types
(suitable jor json encoding).
If convert_objects and with_parcours_ids, give parcours as a list of id (API)
"""
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
d.pop("formation", None)
if convert_objects:
d["parcours"] = [p.to_dict() for p in self.parcours]
if with_parcours_ids:
d["parcours"] = [p.id for p in self.parcours]
else:
d["parcours"] = [p.to_dict() for p in self.parcours]
d["ue_coefs"] = [
c.to_dict(convert_objects=convert_objects) for c in self.ue_coefs
c.to_dict(convert_objects=False)
for c in self.ue_coefs
# note: don't convert_objects: we do wan't the details of the UEs here
]
d["app_critiques"] = {x.code: x.to_dict() for x in self.app_critiques}
if not with_matiere:

View File

@ -5,11 +5,12 @@
import sqlalchemy as sa
from app import db
from app import models
from app.scodoc import safehtml
import app.scodoc.sco_utils as scu
class BulAppreciations(db.Model):
class BulAppreciations(models.ScoDocModel):
"""Appréciations sur bulletins"""
__tablename__ = "notes_appreciations"
@ -27,6 +28,8 @@ class BulAppreciations(db.Model):
author = db.Column(db.Text) # le pseudo (user_name), sans contrainte
comment = db.Column(db.Text) # texte libre
_sco_dept_relations = ("Identite",) # accès au dept_id
@classmethod
def get_appreciations_list(
cls, formsemestre_id: int, etudid: int

View File

@ -3,10 +3,10 @@
"""Model : preferences
"""
from app import db
from app import db, models
class ScoPreference(db.Model):
class ScoPreference(models.ScoDocModel):
"""ScoDoc preferences (par département)"""
__tablename__ = "sco_prefs"
@ -19,5 +19,8 @@ class ScoPreference(db.Model):
value = db.Column(db.Text())
formsemestre_id = db.Column(db.Integer, db.ForeignKey("notes_formsemestre.id"))
_sco_dept_relations = ("FormSemestre",) # accès au dept_id
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} {self.departement.acronym} {self.name}={self.value}>"
return f"""<{self.__class__.__name__} {self.id} {self.departement.acronym
} {self.name}={self.value}>"""

View File

@ -1,7 +1,7 @@
"""ScoDoc 9 models : Unités d'Enseignement (UE)
"""
from flask import g
from flask import abort, g
import pandas as pd
from app import db, log
@ -46,6 +46,8 @@ class UniteEns(models.ScoDocModel):
# coef UE, utilise seulement si l'option use_ue_coefs est activée:
coefficient = db.Column(db.Float)
# id de l'élément Apogée du RCUE (utilisé pour les UEs de sem. pair du BUT)
code_apogee_rcue = db.Column(db.String(APO_CODE_STR_LEN))
# coef. pour le calcul de moyennes de RCUE. Par défaut, 1.
coef_rcue = db.Column(db.Float, nullable=False, default=1.0, server_default="1.0")
@ -121,6 +123,16 @@ class UniteEns(models.ScoDocModel):
return args
def from_dict(self, args: dict, excluded: set[str] | None = None) -> bool:
"""Update object's fields given in dict. Add to session but don't commit.
True if modification.
- can't change formation nor niveau_competence
"""
return super().from_dict(
args,
excluded=(excluded or set()) | {"formation_id", "niveau_competence_id"},
)
def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
"""as a dict, with the same conversions as in ScoDoc7.
If convert_objects, convert all attributes to native types
@ -253,6 +265,30 @@ class UniteEns(models.ScoDocModel):
log(f"ue.set_ects( ue_id={self.id}, acronyme={self.acronyme}, ects={ects} )")
db.session.add(self)
@classmethod
def get_ue(cls, ue_id: int, accept_none=False) -> "UniteEns":
"""UE ou 404 (ou None si accept_none),
cherche uniquement dans le département courant.
Si accept_none, return None si l'id est invalide ou inexistant.
"""
if not isinstance(ue_id, int):
try:
ue_id = int(ue_id)
except (TypeError, ValueError):
if accept_none:
return None
abort(404, "ue_id invalide")
query = cls.query.filter_by(id=ue_id)
if g.scodoc_dept:
from app.models import Formation
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
if accept_none:
return query.first()
return query.first_or_404()
def get_ressources(self):
"Liste des modules ressources rattachés à cette UE"
return self.modules.filter_by(module_type=scu.ModuleType.RESSOURCE).all()
@ -274,6 +310,12 @@ class UniteEns(models.ScoDocModel):
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
def get_codes_apogee_rcue(self) -> set[str]:
"""Les codes Apogée RCUE (codés en base comme "VRT1,VRT2")"""
if self.code_apogee_rcue:
return {x.strip() for x in self.code_apogee_rcue.split(",") if x}
return set()
def _parcours_niveaux_ids(self, parcours=list[ApcParcours]) -> set[int]:
"""set des ids de niveaux communs à tous les parcours listés"""
return set.intersection(
@ -354,7 +396,21 @@ class UniteEns(models.ScoDocModel):
return True, ""
def set_niveau_competence(self, niveau: ApcNiveau) -> tuple[bool, str]:
def is_used_in_validation_rcue(self) -> bool:
"""Vrai si cette UE est utilisée dans une validation enregistrée d'RCUE."""
from app.models.but_validations import ApcValidationRCUE
return (
ApcValidationRCUE.query.filter(
db.or_(
ApcValidationRCUE.ue1_id == self.id,
ApcValidationRCUE.ue2_id == self.id,
)
).count()
> 0
)
def set_niveau_competence(self, niveau: ApcNiveau | None) -> tuple[bool, str]:
"""Associe cette UE au niveau de compétence indiqué.
Le niveau doit être dans l'un des parcours de l'UE (si elle n'est pas
de tronc commun).
@ -362,7 +418,12 @@ class UniteEns(models.ScoDocModel):
Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie.
Returns True if (de)association done, False on error.
Si l'UE est utilisée dans un validation de RCUE, on ne peut plus la changer de niveau.
Returns
- True if (de)association done, False on error.
- Error message (string)
"""
# Sanity checks
if not self.formation.referentiel_competence:
@ -370,6 +431,12 @@ class UniteEns(models.ScoDocModel):
False,
"La formation n'est pas associée à un référentiel de compétences",
)
# UE utilisée dans des validations RCUE ?
if self.is_used_in_validation_rcue():
return (
False,
"UE utilisée dans un RCUE validé: son niveau ne peut plus être modifié",
)
if niveau is not None:
if self.niveau_competence_id is not None:
return (

View File

@ -2,12 +2,15 @@
"""Notes, décisions de jury
"""
from flask_sqlalchemy.query import Query
from app import db
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.models.formations import Formation
from app.models.ues import UniteEns
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import CODES_UE_VALIDES
@ -113,6 +116,7 @@ class ScolarFormSemestreValidation(db.Model):
if self.ue.parcours else ""}
{("émise par " + link)}
: <b>{self.code}</b>{moyenne}
<b>{(self.ue.ects or 0):g} ECTS</b>
le {self.event_date.strftime(scu.DATEATIME_FMT)}
"""
else:
@ -131,6 +135,27 @@ class ScolarFormSemestreValidation(db.Model):
else 0.0
)
@classmethod
def validations_ues(
cls, etud: "Identite", formation_code: str | None = None
) -> Query:
"""Query les validations d'UE pour cet étudiant dans des UEs de formations
du code indiqué, ou toutes si le formation_code est None.
"""
from app.models.formsemestre import FormSemestre
query = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.filter(ScolarFormSemestreValidation.ue_id != None)
.join(UniteEns)
.join(FormSemestre, ScolarFormSemestreValidation.formsemestre)
)
if formation_code is not None:
query = query.join(Formation).filter_by(formation_code=formation_code)
return query.order_by(
FormSemestre.semestre_id, UniteEns.numero, UniteEns.acronyme
)
class ScolarAutorisationInscription(db.Model):
"""Autorisation d'inscription dans un semestre"""
@ -186,7 +211,7 @@ class ScolarAutorisationInscription(db.Model):
origin_formsemestre_id: int,
semestre_id: int,
):
"""Ajoute une autorisation"""
"""Ajoute une autorisation (don't commit)"""
autorisation = cls(
etudid=etudid,
formation_code=formation_code,

View File

@ -200,7 +200,7 @@ CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ, ADSUP}
# Pour le BUT:
CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD} # PASD pour enregistrement auto
CODES_ANNEE_BUT_VALIDES = {ADM, ADSUP}
CODES_ANNEE_BUT_VALIDES = {ADJ, 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

View File

@ -55,6 +55,8 @@ from reportlab.lib.colors import Color
from reportlab.lib import styles
from reportlab.lib.units import cm
from flask import render_template
from app.scodoc import html_sco_header
from app.scodoc import sco_utils as scu
from app.scodoc import sco_excel
@ -315,7 +317,10 @@ class GenTable:
def get_titles_list(self):
"list of titles"
return [self.titles.get(cid, "") for cid in self.columns_ids]
titles = [self.titles.get(cid, "") for cid in self.columns_ids]
if "row_title" in self.titles and "row_title" not in self.columns_ids:
titles.insert(0, self.titles["row_title"])
return titles
def gen(self, fmt="html", columns_ids=None):
"""Build representation of the table in the specified format.
@ -678,16 +683,15 @@ class GenTable:
fmt="html",
page_title="",
filename=None,
cssstyles=[],
javascripts=[],
javascripts=(),
with_html_headers=True,
publish=True,
init_qtip=False,
):
"""
Build page at given format
This is a simple page with only a title and the table.
If not publish, does not set response header
If not publish, do not set response header for non HTML formats.
If with_html_headers, render a full page using ScoDoc template.
"""
if not filename:
filename = self.filename
@ -695,21 +699,16 @@ class GenTable:
html_title = self.html_title or title
if fmt == "html":
H = []
if with_html_headers:
H.append(
self.html_header
or html_sco_header.sco_header(
cssstyles=cssstyles,
page_title=page_title,
javascripts=javascripts,
init_qtip=init_qtip,
)
)
if html_title:
H.append(html_title)
H.append(self.html())
if with_html_headers:
H.append(html_sco_header.sco_footer())
return render_template(
"sco_page.j2",
content="\n".join(H),
title=page_title,
javascripts=javascripts,
)
return "\n".join(H)
elif fmt == "pdf":
pdf_objs = self.pdf()

View File

@ -97,10 +97,18 @@ _HTML_BEGIN = f"""<!DOCTYPE html>
<link href="{scu.STATIC_DIR}/css/scodoc.css" rel="stylesheet" type="text/css" />
<link href="{scu.STATIC_DIR}/css/menu.css" rel="stylesheet" type="text/css" />
<link rel="stylesheet" type="text/css" href="{scu.STATIC_DIR}/DataTables/datatables.min.css" />
<script src="{scu.STATIC_DIR}/libjs/menu.js"></script>
<script src="{scu.STATIC_DIR}/libjs/bubble.js"></script>
<script>
window.onload=function(){{enableTooltips("gtrcontent"); enableTooltips("sidebar");}};
window.onload=function(){{
if (document.getElementById('gtrcontent')) {{
enableTooltips("gtrcontent");
}}
if (document.getElementById('sidebar')) {{
enableTooltips("sidebar");
}}
}};
</script>
<script src="{scu.STATIC_DIR}/jQuery/jquery.js"></script>
@ -108,6 +116,7 @@ _HTML_BEGIN = f"""<!DOCTYPE html>
<script src="{scu.STATIC_DIR}/libjs/jquery.field.min.js"></script>
<script src="{scu.STATIC_DIR}/libjs/jquery-ui-1.10.4.custom/js/jquery-ui-1.10.4.custom.min.js"></script>
<script src="{scu.STATIC_DIR}/DataTables/datatables.min.js"></script>
<script src="{scu.STATIC_DIR}/libjs/qtip/jquery.qtip-3.0.3.min.js"></script>
<link type="text/css" rel="stylesheet" href="{scu.STATIC_DIR}/libjs/qtip/jquery.qtip-3.0.3.min.css" />
@ -217,8 +226,14 @@ def sco_header(
<script src="{scu.STATIC_DIR}/libjs/menu.js"></script>
<script src="{scu.STATIC_DIR}/libjs/bubble.js"></script>
<script>
window.onload=function(){{enableTooltips("gtrcontent"); enableTooltips("sidebar");}};
window.onload=function(){{
if (document.getElementById('gtrcontent')) {{
enableTooltips("gtrcontent");
}}
if (document.getElementById('sidebar')) {{
enableTooltips("sidebar");
}}
}};
const SCO_URL="{url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)}";
const SCO_TIMEZONE="{scu.TIME_ZONE}";
</script>"""

View File

@ -31,6 +31,7 @@
Il suffit d'appeler abs_notify() après chaque ajout d'absence.
"""
import datetime
from typing import Optional

View File

@ -43,52 +43,15 @@ Pour chaque étudiant commun:
comparer les résultats
"""
from flask import g, url_for
from flask import g, render_template, url_for
from app import log
from app.scodoc import sco_apogee_csv, sco_apogee_reader
from app.scodoc.sco_apogee_csv import ApoData
from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import html_sco_header
from app.scodoc import sco_preferences
_HELP_TXT = """
<div class="help">
<p>Outil de comparaison de fichiers (maquettes CSV) Apogée.
</p>
<p>Cet outil compare deux fichiers fournis. Aucune donnée stockée dans ScoDoc n'est utilisée.
</p>
</div>
"""
def apo_compare_csv_form():
"""Form: submit 2 CSV files to compare them."""
H = [
html_sco_header.sco_header(page_title="Comparaison de fichiers Apogée"),
"""<h2>Comparaison de fichiers Apogée</h2>
<form id="apo_csv_add" action="apo_compare_csv" method="post" enctype="multipart/form-data">
""",
_HELP_TXT,
"""
<div class="apo_compare_csv_form_but">
Fichier Apogée A:
<input type="file" size="30" name="file_a"/>
</div>
<div class="apo_compare_csv_form_but">
Fichier Apogée B:
<input type="file" size="30" name="file_b"/>
</div>
<input type="checkbox" name="autodetect" checked/>autodétecter encodage</input>
<div class="apo_compare_csv_form_submit">
<input type="submit" value="Comparer ces fichiers"/>
</div>
</form>""",
html_sco_header.sco_footer(),
]
return "\n".join(H)
def apo_compare_csv(file_a, file_b, autodetect=True):
"""Page comparing 2 Apogee CSV files"""
@ -114,17 +77,12 @@ def apo_compare_csv(file_a, file_b, autodetect=True):
""",
dest_url=dest_url,
) from exc
H = [
html_sco_header.sco_header(page_title="Comparaison de fichiers Apogée"),
"<h2>Comparaison de fichiers Apogée</h2>",
_HELP_TXT,
'<div class="apo_compare_csv">',
_apo_compare_csv(apo_data_a, apo_data_b),
"</div>",
"""<p><a href="apo_compare_csv_form" class="stdlink">Autre comparaison</a></p>""",
html_sco_header.sco_footer(),
]
return "\n".join(H)
return render_template(
"apogee/apo_compare_csv.j2",
title="Comparaison de fichiers Apogée",
content=_apo_compare_csv(apo_data_a, apo_data_b),
)
def _load_apo_data(csvfile, autodetect=True):

View File

@ -43,14 +43,13 @@ import re
import time
from zipfile import ZipFile
from flask import send_file
from flask import g, send_file
import numpy as np
from app import log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.comp.res_but import ResultatsSemestreBUT
from app.models import (
ApcValidationAnnee,
ApcValidationRCUE,
@ -79,7 +78,6 @@ from app.scodoc.codes_cursus import (
)
from app.scodoc import sco_cursus
from app.scodoc import sco_formsemestre
from app.scodoc import sco_etud
def _apo_fmt_note(note, fmt="%3.2f"):
@ -99,7 +97,7 @@ class EtuCol:
"""Valeurs colonnes d'un element pour un etudiant"""
def __init__(self, nip, apo_elt, init_vals):
pass # XXX
pass
ETUD_OK = "ok"
@ -132,7 +130,7 @@ class ApoEtud(dict):
"Vrai si BUT"
self.col_elts = {}
"{'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}}"
self.etud: Identite = None
self.etud: Identite | None = None
"etudiant ScoDoc associé"
self.etat = None # ETUD_OK, ...
self.is_nar = False
@ -150,9 +148,9 @@ class ApoEtud(dict):
_apo_fmt_note, fmt=ScoDocSiteConfig.get_code_apo("NOTES_FMT") or "3.2f"
)
# Initialisés par associate_sco:
self.autre_sem: dict = None
self.autre_formsemestre: FormSemestre = None
self.autre_res: NotesTableCompat = None
self.cur_sem: dict = None
self.cur_formsemestre: FormSemestre = None
self.cur_res: NotesTableCompat = None
self.new_cols = {}
"{ col_id : value to record in csv }"
@ -171,24 +169,18 @@ class ApoEtud(dict):
Sinon, cherche le semestre, et met l'état à ETUD_OK ou ETUD_NON_INSCRIT.
"""
# futur: #WIP
# etud: Identite = Identite.query.filter_by(code_nip=self["nip"], dept_id=g.scodoc_dept_id).first()
# self.etud = etud
etuds = sco_etud.get_etud_info(code_nip=self["nip"], filled=True)
if not etuds:
self.etud = Identite.query.filter_by(
code_nip=self["nip"], dept_id=g.scodoc_dept_id
).first()
if not self.etud:
# pas dans ScoDoc
self.etud = None
self.log.append("non inscrit dans ScoDoc")
self.etat = ETUD_ORPHELIN
else:
# futur: #WIP
# formsemestre_ids = {
# ins.formsemestre_id for ins in etud.formsemestre_inscriptions
# }
# in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids)
self.etud = etuds[0]
# cherche le semestre ScoDoc correspondant à l'un de ceux de l'etape:
formsemestre_ids = {s["formsemestre_id"] for s in self.etud["sems"]}
formsemestre_ids = {
ins.formsemestre_id for ins in self.etud.formsemestre_inscriptions
}
in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids)
if not in_formsemestre_ids:
self.log.append(
@ -228,7 +220,9 @@ class ApoEtud(dict):
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 ?"""
f"""Fichier Apogee invalide : ligne mal formatée ? <br>colonne <tt>{
col_id}</tt> non déclarée ?""",
safe=True,
) from exc
else:
try:
@ -254,7 +248,7 @@ class ApoEtud(dict):
# codes = set([apo_data.apo_csv.cols[col_id].code for col_id in apo_data.apo_csv.col_ids])
# return codes - set(sco_elts)
def search_elt_in_sem(self, code, sem) -> dict:
def search_elt_in_sem(self, code: str, sem: dict) -> dict:
"""
VET code jury etape (en BUT, le code annuel)
ELP élément pédagogique: UE, module
@ -267,13 +261,17 @@ class ApoEtud(dict):
Args:
code (str): code apo de l'element cherché
sem (dict): semestre dans lequel on cherche l'élément
cur_sem (dict): semestre "courant" pour résultats annuels (VET)
autre_sem (dict): autre semestre utilisé pour calculer les résultats annuels (VET)
Utilise notamment:
cur_formsemestre : semestre "courant" pour résultats annuels (VET)
autre_formsemestre : autre formsemestre utilisé pour les résultats annuels (VET)
Returns:
dict: with N, B, J, R keys, ou None si elt non trouvé
"""
etudid = self.etud["etudid"]
if not self.etud:
return None
etudid = self.etud.id
if not self.cur_res:
log("search_elt_in_sem: no cur_res !")
return None
@ -316,10 +314,10 @@ class ApoEtud(dict):
code in {x.strip() for x in sem["elt_annee_apo"].split(",")}
):
export_res_etape = self.export_res_etape
if (not export_res_etape) and self.cur_sem:
if (not export_res_etape) and self.cur_formsemestre:
# exporte toujours le résultat de l'étape si l'étudiant est diplômé
Se = sco_cursus.get_situation_etud_cursus(
self.etud, self.cur_sem["formsemestre_id"]
self.etud, self.cur_formsemestre.id
)
export_res_etape = Se.all_other_validated()
@ -329,35 +327,15 @@ class ApoEtud(dict):
self.log.append("export étape désactivé")
return VOID_APO_RES
# Element passage
res_passage = self.search_elt_passage(code, res)
if res_passage:
return res_passage
# Elements UE
decisions_ue = res.get_etud_decisions_ue(etudid)
for ue in res.get_ues_stat_dict():
if ue["code_apogee"] and code in {
x.strip() for x in ue["code_apogee"].split(",")
}:
if self.export_res_ues:
if (
decisions_ue and ue["ue_id"] in decisions_ue
) or self.export_res_sdj:
ue_status = res.get_etud_ue_status(etudid, ue["ue_id"])
if decisions_ue and ue["ue_id"] in decisions_ue:
code_decision_ue = decisions_ue[ue["ue_id"]]["code"]
code_decision_ue_apo = ScoDocSiteConfig.get_code_apo(
code_decision_ue
)
else:
code_decision_ue_apo = ""
return dict(
N=self.fmt_note(ue_status["moy"] if ue_status else ""),
B=20,
J="",
R=code_decision_ue_apo,
M="",
)
else:
return VOID_APO_RES
else:
return VOID_APO_RES
res_ue = self.search_elt_ue(code, res)
if res_ue:
return res_ue
# Elements Modules
modimpls = res.get_modimpls_dict()
@ -377,9 +355,79 @@ class ApoEtud(dict):
if module_code_found:
return VOID_APO_RES
# RCUE du BUT (validations enregistrées seulement, pas avant jury)
if res.is_apc:
for val_rcue in ApcValidationRCUE.query.filter_by(
etudid=etudid, formsemestre_id=sem["formsemestre_id"]
):
if code in val_rcue.get_codes_apogee():
return dict(
N="", # n'exporte pas de moyenne RCUE
B=20,
J="",
R=ScoDocSiteConfig.get_code_apo(val_rcue.code),
M="",
)
#
return None # element Apogee non trouvé dans ce semestre
def search_elt_ue(self, code: str, res: NotesTableCompat) -> dict:
"""Cherche un résultat d'UE pour ce code Apogée.
dict vide si pas de résultat trouvé pour ce code.
"""
decisions_ue = res.get_etud_decisions_ue(self.etud.id)
for ue in res.get_ues_stat_dict():
if ue["code_apogee"] and code in {
x.strip() for x in ue["code_apogee"].split(",")
}:
if self.export_res_ues:
if (
decisions_ue and ue["ue_id"] in decisions_ue
) or self.export_res_sdj:
# Si dispensé de cette UE, n'exporte rien
if (self.etud.id, ue["ue_id"]) in res.dispense_ues:
return VOID_APO_RES
ue_status = res.get_etud_ue_status(self.etud.id, ue["ue_id"])
if decisions_ue and ue["ue_id"] in decisions_ue:
code_decision_ue = decisions_ue[ue["ue_id"]]["code"]
code_decision_ue_apo = ScoDocSiteConfig.get_code_apo(
code_decision_ue
)
else:
code_decision_ue_apo = ""
return dict(
N=self.fmt_note(ue_status["moy"] if ue_status else ""),
B=20,
J="",
R=code_decision_ue_apo,
M="",
)
else:
return VOID_APO_RES
else:
return VOID_APO_RES
return {} # no UE result found for this code
def search_elt_passage(self, code: str, res: NotesTableCompat) -> dict:
"""Cherche un résultat de type "passage" pour ce code Apogée.
dict vide si pas de résultat trouvé pour ce code.
L'élement est rempli si:
- code est dans les codes passage du formsemestre (sem)
- autorisation d'inscription enregistre de sem vers sem d'indice suivant
"""
if res.formsemestre.semestre_id < 1:
return {}
next_semestre_id = res.formsemestre.semestre_id + 1
if code in res.formsemestre.get_codes_apogee(category="passage"):
if next_semestre_id in res.get_autorisations_inscription().get(
self.etud.id, set()
):
return dict(
N="", B=20, J="", R=ScoDocSiteConfig.get_code_apo("ADM"), M=""
)
return {}
def comp_elt_semestre(self, nt: NotesTableCompat, decision: dict, etudid: int):
"""Calcul résultat apo semestre.
Toujours vide pour en BUT/APC.
@ -418,11 +466,10 @@ class ApoEtud(dict):
#
# XXX cette règle est discutable, à valider
# log('comp_elt_annuel cur_sem=%s autre_sem=%s' % (cur_sem['formsemestre_id'], autre_sem['formsemestre_id']))
if not self.cur_sem:
if not self.cur_formsemestre:
# l'étudiant n'a pas de semestre courant ?!
self.log.append("pas de semestre courant")
log(f"comp_elt_annuel: etudid {etudid} has no cur_sem")
log(f"comp_elt_annuel: etudid {etudid} has no cur_formsemestre")
return VOID_APO_RES
if self.is_apc:
@ -438,7 +485,7 @@ class ApoEtud(dict):
# ne touche pas aux RATs
return VOID_APO_RES
if not self.autre_sem:
if not self.autre_formsemestre:
# formations monosemestre, ou code VET semestriel,
# ou jury intermediaire et etudiant non redoublant...
return self.comp_elt_semestre(self.cur_res, cur_decision, etudid)
@ -518,7 +565,7 @@ class ApoEtud(dict):
self.validation_annee_but: ApcValidationAnnee = (
ApcValidationAnnee.query.filter_by(
formsemestre_id=formsemestre.id,
etudid=self.etud["etudid"],
etudid=self.etud.id,
referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id,
).first()
)
@ -527,7 +574,7 @@ class ApoEtud(dict):
)
def etud_set_semestres_de_etape(self, apo_data: "ApoData"):
"""Set .cur_sem and .autre_sem et charge les résultats.
"""Set .cur_formsemestre and .autre_formsemestre et charge les résultats.
Lorsqu'on a une formation semestrialisée mais avec un code étape annuel,
il faut considérer les deux semestres ((S1,S2) ou (S3,S4)) pour calculer
le code annuel (VET ou VRT1A (voir elt_annee_apo)).
@ -535,52 +582,48 @@ class ApoEtud(dict):
Pour les jurys intermediaires (janvier, S1 ou S3): (S2 ou S4) de la même
étape lors d'une année précédente ?
Set cur_sem: le semestre "courant" et autre_sem, ou None s'il n'y en a pas.
Set cur_formsemestre: le formsemestre "courant"
et autre_formsemestre, ou None s'il n'y en a pas.
"""
# Cherche le semestre "courant":
cur_sems = [
sem
for sem in self.etud["sems"]
# Cherche le formsemestre "courant":
cur_formsemestres = [
formsemestre
for formsemestre in self.etud.get_formsemestres()
if (
(sem["semestre_id"] == apo_data.cur_semestre_id)
and (apo_data.etape in sem["etapes"])
(formsemestre.semestre_id == apo_data.cur_semestre_id)
and (apo_data.etape in formsemestre.etapes)
and (
sco_formsemestre.sem_in_semestre_scolaire(
sem,
FormSemestre.est_in_semestre_scolaire(
formsemestre.date_debut,
apo_data.annee_scolaire,
0, # annee complete
)
)
)
]
if not cur_sems:
cur_sem = None
else:
# prend le plus recent avec decision
cur_sem = None
for sem in cur_sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
cur_formsemestre = None
if cur_formsemestres:
# prend le plus récent avec décision
for formsemestre in cur_formsemestres:
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
has_decision = res.etud_has_decision(self.etud["etudid"])
if has_decision:
cur_sem = sem
if apo_data.export_res_sdj or res.etud_has_decision(self.etud.id):
cur_formsemestre = formsemestre
self.cur_res = res
break
if cur_sem is None:
cur_sem = cur_sems[0] # aucun avec décision, prend le plus recent
if res.formsemestre.id == cur_sem["formsemestre_id"]:
if cur_formsemestres is None:
cur_formsemestre = cur_formsemestres[
0
] # aucun avec décision, prend le plus recent
if res.formsemestre.id == cur_formsemestre.id:
self.cur_res = res
else:
formsemestre = FormSemestre.query.get_or_404(
cur_sem["formsemestre_id"]
)
self.cur_res = res_sem.load_formsemestre_results(formsemestre)
self.cur_res = res_sem.load_formsemestre_results(cur_formsemestre)
self.cur_sem = cur_sem
self.cur_formsemestre = cur_formsemestre
if apo_data.cur_semestre_id <= 0:
# "autre_sem" non pertinent pour sessions sans semestres:
self.autre_sem = None
# autre_formsemestre non pertinent pour sessions sans semestres:
self.autre_formsemestre = None
self.autre_res = None
return
@ -601,52 +644,49 @@ class ApoEtud(dict):
courant_mois_debut = 1 # ou 2 (fev-jul)
else:
raise ValueError("invalid periode value !") # bug ?
courant_date_debut = "%d-%02d-01" % (
courant_annee_debut,
courant_mois_debut,
courant_date_debut = datetime.date(
day=1, month=courant_mois_debut, year=courant_annee_debut
)
else:
courant_date_debut = "9999-99-99"
courant_date_debut = datetime.date(day=31, month=12, year=9999)
# etud['sems'] est la liste des semestres de l'étudiant, triés par date,
# le plus récemment effectué en tête.
# Cherche les semestres (antérieurs) de l'indice autre de la même étape apogée
# s'il y en a plusieurs, choisit le plus récent ayant une décision
autres_sems = []
for sem in self.etud["sems"]:
for formsemestre in self.etud.get_formsemestres():
if (
sem["semestre_id"] == autre_semestre_id
and apo_data.etape_apogee in sem["etapes"]
formsemestre.semestre_id == autre_semestre_id
and apo_data.etape_apogee in formsemestre.etapes
):
if (
sem["date_debut_iso"] < courant_date_debut
formsemestre.date_debut < courant_date_debut
): # on demande juste qu'il ait démarré avant
autres_sems.append(sem)
autres_sems.append(formsemestre)
if not autres_sems:
autre_sem = None
autre_formsemestre = None
elif len(autres_sems) == 1:
autre_sem = autres_sems[0]
autre_formsemestre = autres_sems[0]
else:
autre_sem = None
for sem in autres_sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
autre_formsemestre = None
for formsemestre in autres_sems:
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if res.is_apc:
has_decision = res.etud_has_decision(self.etud["etudid"])
has_decision = res.etud_has_decision(self.etud.id)
else:
has_decision = res.get_etud_decision_sem(self.etud["etudid"])
if has_decision:
autre_sem = sem
has_decision = res.get_etud_decision_sem(self.etud.id)
if has_decision or apo_data.export_res_sdj:
autre_formsemestre = formsemestre
break
if autre_sem is None:
autre_sem = autres_sems[0] # aucun avec decision, prend le plus recent
if autre_formsemestre is None:
autre_formsemestre = autres_sems[
0
] # aucun avec decision, prend le plus recent
self.autre_sem = autre_sem
self.autre_formsemestre = autre_formsemestre
# Charge les résultats:
if autre_sem:
formsemestre = FormSemestre.query.get_or_404(autre_sem["formsemestre_id"])
self.autre_res = res_sem.load_formsemestre_results(formsemestre)
if autre_formsemestre:
self.autre_res = res_sem.load_formsemestre_results(self.autre_formsemestre)
else:
self.autre_res = None
@ -688,7 +728,8 @@ class ApoData:
filename = self.orig_filename or e.filename
raise ScoFormatError(
f"""<h3>Erreur lecture du fichier Apogée <tt>{filename}</tt></h3>
<p>{e.args[0]}</p>"""
<p>{e.args[0]}</p>""",
safe=True,
) from e
self.etape_apogee = self.get_etape_apogee() # 'V1RT'
self.vdi_apogee = self.get_vdi_apogee() # '111'
@ -780,7 +821,9 @@ class ApoData:
self.sems_periode = None
def get_etape_apogee(self) -> str:
"""Le code etape: 'V1RT', donné par le code de l'élément VET"""
"""Le code etape: 'V1RT', donné par le code de l'élément VET.
Le VET doit être parmi les colonnes de la section XX-APO_COLONNES-XX
"""
for elt in self.apo_csv.apo_elts.values():
if elt.type_objet == "VET":
return elt.code
@ -845,7 +888,8 @@ class ApoData:
log(f"Colonnes presentes: {present}")
raise ScoFormatError(
f"""Fichier Apogee invalide<br>Colonnes declarees: <tt>{declared}</tt>
<br>Colonnes presentes: <tt>{present}</tt>"""
<br>Colonnes presentes: <tt>{present}</tt>""",
safe=True,
) from exc
# l'ensemble de tous les codes des elements apo des semestres:
sem_elems = reduce(set.union, list(self.get_codes_by_sem().values()), set())
@ -873,6 +917,16 @@ class ApoData:
codes_ues = set().union(
*[ue.get_codes_apogee() for ue in formsemestre.get_ues(with_sport=True)]
)
codes_rcues = (
set().union(
*[
ue.get_codes_apogee_rcue()
for ue in formsemestre.get_ues(with_sport=True)
]
)
if self.is_apc
else set()
)
s = set()
codes_by_sem[sem["formsemestre_id"]] = s
for col_id in self.apo_csv.col_ids[4:]:
@ -885,13 +939,18 @@ class ApoData:
if code in codes_ues:
s.add(code)
continue
# associé à un RCUE BUT
if code in codes_rcues:
s.add(code)
continue
# associé à un module:
if code in codes_modules:
s.add(code)
# log('codes_by_sem=%s' % pprint.pformat(codes_by_sem))
return codes_by_sem
def build_cr_table(self):
def build_cr_table(self) -> GenTable:
"""Table compte rendu des décisions"""
rows = [] # tableau compte rendu des decisions
for apo_etud in self.etuds:
@ -913,14 +972,14 @@ class ApoData:
columns_ids = ["NIP", "nom", "prenom"]
columns_ids.extend(("etape", "etape_note", "est_NAR", "commentaire"))
T = GenTable(
table = GenTable(
columns_ids=columns_ids,
titles=dict(zip(columns_ids, columns_ids)),
rows=rows,
table_id="build_cr_table",
xls_sheet_name="Decisions ScoDoc",
)
return T
return table
def build_adsup_table(self):
"""Construit une table listant les ADSUP émis depuis les formsemestres

View File

@ -299,11 +299,14 @@ class ApoCSVReadWrite:
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(),
safe=True,
) from exc
# Ajoute colonnes vides manquantes, pratique si on a édité le fichier Apo à la main...
for i in range(len(fields), len(self.col_ids)):
cols[self.col_ids[i]] = ""
etud_tuples.append(
ApoEtudTuple(
nip=fields[0], # id etudiant
@ -337,6 +340,8 @@ class ApoCSVReadWrite:
fields = line.split(APO_SEP)
if len(fields) == 2:
k, v = fields
elif len(fields) == 1:
k, v = fields[0], ""
else:
log(f"Error read CSV: \nline={line}\nfields={fields}")
log(dir(f))

View File

@ -139,7 +139,7 @@ class BaseArchiver:
dirs = glob.glob(base + "*")
return [os.path.split(x)[1] for x in dirs]
def list_obj_archives(self, oid: int, dept_id: int = None):
def list_obj_archives(self, oid: int, dept_id: int = None) -> list[str]:
"""Returns
:return: list of archive identifiers for this object (paths to non empty dirs)
"""

View File

@ -3,13 +3,15 @@ Ecrit par Matthias Hartmann.
"""
from datetime import date, datetime, time, timedelta
from functools import wraps
from pytz import UTC
from flask import g
from flask import g, request
from flask_sqlalchemy.query import Query
from app import log, db, set_sco_dept
from app.models import (
Evaluation,
Identite,
FormSemestre,
FormSemestreInscription,
@ -17,12 +19,13 @@ from app.models import (
ModuleImplInscription,
ScoDocSiteConfig,
)
from app.models.assiduites import Assiduite, Justificatif
from app.models.assiduites import Assiduite, Justificatif, has_assiduites_disable_pref
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_preferences
from app.scodoc import sco_cache
from app.scodoc import sco_etud
import app.scodoc.sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError
class CountCalculator:
@ -731,6 +734,125 @@ def create_absence_billet(
return calculator.to_dict()["demi"]
def get_evaluation_assiduites(evaluation: Evaluation) -> Query:
"""
Renvoie une query d'assiduité en fonction des étudiants inscrits à l'évaluation
et de la date de l'évaluation.
Attention : Si l'évaluation n'a pas de date, renvoie une liste vide
"""
# Evaluation sans date
if evaluation.date_debut is None:
return []
# Récupération des étudiants inscrits à l'évaluation
etuds: Query = Identite.query.join(
ModuleImplInscription, Identite.id == ModuleImplInscription.etudid
).filter(ModuleImplInscription.moduleimpl_id == evaluation.moduleimpl_id)
etudids: list[int] = [etud.id for etud in etuds]
# Récupération des assiduités des étudiants inscrits à l'évaluation
date_debut: datetime = evaluation.date_debut
date_fin: datetime
if evaluation.date_fin is not None:
date_fin = evaluation.date_fin
else:
# On met à la fin de la journée de date_debut
date_fin = datetime.combine(date_debut.date(), time.max)
# Filtrage par rapport à la plage de l'évaluation
assiduites: Query = Assiduite.query.filter(
Assiduite.date_debut >= date_debut,
Assiduite.date_fin <= date_fin,
Assiduite.etudid.in_(etudids),
)
return assiduites
def get_etud_evaluations_assiduites(etud: Identite) -> list[dict]:
"""
Retourne la liste des évaluations d'un étudiant. Pour chaque évaluation,
retourne la liste des assiduités concernant la plage de l'évaluation.
"""
etud_evaluations_assiduites: list[dict] = []
# On récupère les moduleimpls puis les évaluations liés aux moduleimpls
modsimpl_ids: list[int] = [
modimp_inscr.moduleimpl_id
for modimp_inscr in ModuleImplInscription.query.filter_by(etudid=etud.id)
]
evaluations: Query = Evaluation.query.filter(
Evaluation.moduleimpl_id.in_(modsimpl_ids)
)
# Pour chaque évaluation, on récupère l'assiduité de l'étudiant sur la plage
# de l'évaluation
for evaluation in evaluations:
eval_assis: dict = {"evaluation_id": evaluation.id, "assiduites": []}
# Pas d'assiduités si pas de date
if evaluation.date_debut is not None:
date_debut: datetime = evaluation.date_debut
date_fin: datetime
if evaluation.date_fin is not None:
date_fin = evaluation.date_fin
else:
# On met à la fin de la journée de date_debut
date_fin = datetime.combine(date_debut.date(), time.max)
# Filtrage par rapport à la plage de l'évaluation
assiduites: Query = etud.assiduites.filter(
Assiduite.date_debut >= date_debut,
Assiduite.date_fin <= date_fin,
)
# On récupère les assiduités et on met à jour le dictionnaire
eval_assis["assiduites"] = [
assi.to_dict(format_api=True) for assi in assiduites
]
# On ajoute le dictionnaire à la liste des évaluations
etud_evaluations_assiduites.append(eval_assis)
return etud_evaluations_assiduites
# --- Décorateur ---
def check_disabled(func):
"""
Vérifie sur le module a été désactivé dans les préférences du semestre.
Récupère le formsemestre depuis l'url (formsemestre_id)
Si le formsemestre est trouvé :
- Vérifie si le module a été désactivé dans les préférences du semestre
- Si le module a été désactivé, une ScoValueError est levée
Sinon :
Il ne se passe rien
"""
@wraps(func)
def decorated_function(*args, **kwargs):
# Récupération du formsemestre depuis l'url
formsemestre_id = request.args.get("formsemestre_id")
# Si on a un formsemestre_id
if formsemestre_id:
# Récupération du formsemestre
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# Vériication si le module a été désactivé (avec la préférence)
pref: str | bool = has_assiduites_disable_pref(formsemestre)
# Le module est désactivé si on récupère un message d'erreur (str)
if pref:
raise ScoValueError(pref, dest_url=request.referrer)
return func(*args, **kwargs)
return decorated_function
# Gestion du cache
def get_assiduites_count(etudid: int, sem: dict) -> tuple[int, int, int]:
"""Les comptes d'absences de cet étudiant dans ce semestre:
@ -751,7 +873,7 @@ def formsemestre_get_assiduites_count(
) -> tuple[int, int, int]:
"""Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
Utilise un cache.
Utilise un cache (si moduleimpl_id n'est pas spécifié).
"""
metrique = sco_preferences.get_preference("assi_metrique", formsemestre.id)
return get_assiduites_count_in_interval(
@ -779,7 +901,7 @@ def get_assiduites_count_in_interval(
"""Les comptes d'absences de cet étudiant entre ces deux dates, incluses:
tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
On peut spécifier les dates comme datetime ou iso.
Utilise un cache.
Utilise un cache (si moduleimpl_id n'est pas spécifié).
"""
date_debut_iso = date_debut_iso or date_debut.strftime("%Y-%m-%d")
date_fin_iso = date_fin_iso or date_fin.strftime("%Y-%m-%d")

View File

@ -69,6 +69,7 @@ from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups
from app.scodoc import sco_preferences
from app.scodoc import sco_pv_dict
from app.scodoc import sco_pv_lettres_inviduelles
from app.scodoc import sco_users
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType, fmt_note
@ -709,7 +710,11 @@ def etud_descr_situation_semestre(
decisions_ue : noms (acronymes) des UE validées, séparées par des virgules.
descr_decisions_ue : ' UE acquises: UE1, UE2', ou vide si pas de dec. ou si pas show_uevalid
descr_mention : 'Mention Bien', ou vide si pas de mention ou si pas show_mention
diplomation : "Diplôme obtenu." ou ""
parcours_titre, parcours_code, refcomp_specialite, refcomp_specialite_long
diplome_dut120_descr: phrase explicative si DUT enregistré (en BUT)
diplome_dut120: booléen, vrai si DUT enregistré (en BUT)
"""
# Fonction utilisée par tous les bulletins (APC ou classiques)
infos = collections.defaultdict(str)
@ -760,17 +765,19 @@ def etud_descr_situation_semestre(
infos["descr_decisions_niveaux"] = infos["descr_decisions_rcue"] = ""
infos["descr_decision_annee"] = ""
infos["descr_demission"] = f"Démission le {date_dem}." if date_dem else ""
infos["date_demission"] = date_dem if date_dem else ""
if date_dem:
infos["descr_demission"] = f"Démission le {date_dem}."
infos["date_demission"] = date_dem
infos["decision_jury"] = infos["descr_decision_jury"] = "Démission"
infos["situation"] = ". ".join(
[x for x in [infos["descr_inscription"], infos["descr_demission"]] if x]
)
return infos, None # ne donne pas les dec. de jury pour les demissionnaires
infos["descr_defaillance"] = f"Défaillant{ne}" if date_def else ""
infos["date_defaillance"] = date_def or ""
if date_def:
infos["descr_defaillance"] = f"Défaillant{ne}"
infos["date_defaillance"] = date_def
infos["descr_decision_jury"] = f"Défaillant{ne}"
dpv = sco_pv_dict.dict_pvjury(formsemestre.id, etudids=[etudid])
@ -825,12 +832,18 @@ def etud_descr_situation_semestre(
)
else:
descr_dec += " Diplôme obtenu."
infos["diplomation"] = "Diplôme obtenu." if pv["validation_parcours"] else ""
# Ajoute diplome_dut120_descr et diplome_dut120
sco_pv_lettres_inviduelles.add_dut120_infos(formsemestre, etudid, infos)
_format_situation_fields(
infos,
[
"descr_inscription",
"descr_defaillance",
"descr_decisions_ue",
"diplome_dut120_descr",
"descr_decision_annee",
],
[descr_dec, descr_mention, descr_autorisations],
@ -885,7 +898,9 @@ def _dates_insc_dem_def(etudid, formsemestre_id) -> tuple:
def _format_situation_fields(
infos, field_names: list[str], extra_values: list[str]
) -> None:
"""Réuni les champs pour former le paragraphe "situation", et ajoute la pontuation aux champs."""
"""Réuni les champs pour former le paragraphe "situation", et ajoute la pontuation
aux champs.
"""
infos["situation"] = ". ".join(
x
for x in [infos.get(field_name, "") for field_name in field_names]
@ -1087,7 +1102,9 @@ def do_formsemestre_bulletinetud(
flash(f"{etud.nomprenom} n'a pas d'adresse e-mail !")
return False, bul_dict["filigranne"]
else:
mail_bulletin(formsemestre.id, bul_dict, pdfdata, filename, recipient_addr)
mail_bulletin(
formsemestre, etud, bul_dict, pdfdata, filename, recipient_addr
)
flash(f"mail envoyé à {recipient_addr}")
return True, bul_dict["filigranne"]
@ -1095,22 +1112,28 @@ def do_formsemestre_bulletinetud(
raise ValueError(f"do_formsemestre_bulletinetud: invalid format ({fmt})")
def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
def mail_bulletin(
formsemestre: FormSemestre,
etud: Identite,
infos: dict,
pdfdata,
filename,
recipient_addr,
):
"""Send bulletin by email to etud
If bul_mail_list_abs pref is true, put list of absences in mail body (text).
"""
etud = infos["etud"]
webmaster = sco_preferences.get_preference("bul_mail_contact_addr", formsemestre_id)
webmaster = sco_preferences.get_preference("bul_mail_contact_addr", formsemestre.id)
dept = scu.unescape_html(
sco_preferences.get_preference("DeptName", formsemestre_id)
sco_preferences.get_preference("DeptName", formsemestre.id)
)
copy_addr = sco_preferences.get_preference("email_copy_bulletins", formsemestre_id)
intro_mail = sco_preferences.get_preference("bul_intro_mail", formsemestre_id)
copy_addr = sco_preferences.get_preference("email_copy_bulletins", formsemestre.id)
intro_mail = sco_preferences.get_preference("bul_intro_mail", formsemestre.id)
if intro_mail:
try:
hea = intro_mail % {
"nomprenom": etud["nomprenom"],
"nomprenom": etud.nom_prenom(),
"dept": dept,
"webmaster": webmaster,
}
@ -1124,12 +1147,12 @@ def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
if sco_preferences.get_preference("bul_mail_list_abs"):
from app.views.assiduites import generate_bul_list
etud_identite: Identite = Identite.get_etud(etud["etudid"])
form_semestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
hea += "\n\n"
hea += generate_bul_list(etud_identite, form_semestre)
hea += generate_bul_list(etud, formsemestre)
subject = f"""Relevé de notes de {etud["nomprenom"]}"""
subject = f"""Relevé de notes du semestre {
formsemestre.semestre_id if formsemestre.semestre_id >= 0 else ''
} de {etud.nom_prenom()}"""
recipients = [recipient_addr]
sender = email.get_from_addr()
if copy_addr:

View File

@ -445,7 +445,10 @@ def dict_decision_jury(
...
],
'situation': 'Inscrit le 25/06/2021. Décision jury: Validé. UE acquises: '
'UE31, UE32. Diplôme obtenu.'}
'UE31, UE32. Diplôme obtenu.',
'diplomation' : 'Diplôme obtenu.' # (ou vide)
}
"""
from app.scodoc import sco_bulletins
@ -458,7 +461,10 @@ def dict_decision_jury(
formsemestre,
show_uevalid=prefs["bul_show_uevalid"],
)
d["diplomation"] = infos["diplomation"]
d["situation"] = infos["situation"]
d["diplome_dut120"] = infos["diplome_dut120"]
d["diplome_dut120_descr"] = infos["diplome_dut120_descr"]
if dpv:
decision = dpv["decisions"][0]
etat = decision["etat"]

View File

@ -1,102 +0,0 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""Calcul des moyennes de module (restes de fonctions ScoDoc 7)
"""
from app.models import ModuleImpl
import app.scodoc.notesdb as ndb
def moduleimpl_has_expression(modimpl: ModuleImpl):
"""True if we should use a user-defined expression
En ScoDoc 9, utilisé pour afficher un avertissement, l'expression elle même
n'est plus supportée.
"""
return (
modimpl.computation_expr
and modimpl.computation_expr.strip()
and modimpl.computation_expr.strip()[0] != "#"
)
def formsemestre_expressions_use_abscounts(formsemestre_id):
"""True si les notes de ce semestre dépendent des compteurs d'absences.
Cela n'est normalement pas le cas, sauf si des formules utilisateur
utilisent ces compteurs.
"""
# check presence of 'nbabs' in expressions
ab = "nb_abs" # chaine recherchée
cnx = ndb.GetDBConnexion()
# 1- moyennes d'UE:
elist = formsemestre_ue_computation_expr_list(
cnx, {"formsemestre_id": formsemestre_id}
)
for e in elist:
expr = e["computation_expr"].strip()
if expr and expr[0] != "#" and ab in expr:
return True
# 2- moyennes de modules
# #sco9 il n'y a plus d'expressions
# for mod in sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id):
# if moduleimpl_has_expression(mod) and ab in mod["computation_expr"]:
# return True
return False
_formsemestre_ue_computation_exprEditor = ndb.EditableTable(
"notes_formsemestre_ue_computation_expr",
"notes_formsemestre_ue_computation_expr_id",
(
"notes_formsemestre_ue_computation_expr_id",
"formsemestre_id",
"ue_id",
"computation_expr",
),
html_quote=False, # does nt automatically quote
)
formsemestre_ue_computation_expr_create = _formsemestre_ue_computation_exprEditor.create
formsemestre_ue_computation_expr_delete = _formsemestre_ue_computation_exprEditor.delete
formsemestre_ue_computation_expr_list = _formsemestre_ue_computation_exprEditor.list
formsemestre_ue_computation_expr_edit = _formsemestre_ue_computation_exprEditor.edit
def get_ue_expression(formsemestre_id, ue_id, html_quote=False):
"""Returns UE expression (formula), or None if no expression has been defined"""
cnx = ndb.GetDBConnexion()
el = formsemestre_ue_computation_expr_list(
cnx, {"formsemestre_id": formsemestre_id, "ue_id": ue_id}
)
if not el:
return None
else:
expr = el[0]["computation_expr"].strip()
if expr and expr[0] != "#":
if html_quote:
expr = ndb.quote_html(expr)
return expr
else:
return None

View File

@ -34,13 +34,13 @@ from app.scodoc import sco_cursus_dut
from app.comp.res_compat import NotesTableCompat
from app.comp import res_sem
from app.models import FormSemestre
from app.models import FormSemestre, Identite
import app.scodoc.notesdb as ndb
# SituationEtudParcours -> get_situation_etud_cursus
def get_situation_etud_cursus(
etud: dict, formsemestre_id: int
etud: Identite, formsemestre_id: int
) -> sco_cursus_dut.SituationEtudCursus:
"""renvoie une instance de SituationEtudCursus (ou sous-classe spécialisée)"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)

View File

@ -31,13 +31,18 @@
from app import db
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, UniteEns, ScolarAutorisationInscription
from app.models import (
FormSemestre,
Identite,
ScolarAutorisationInscription,
Scolog,
UniteEns,
)
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app import log
from app.scodoc.scolog import logdb
from app.scodoc import sco_cache, sco_etud
from app.scodoc import sco_cache
from app.scodoc import sco_formsemestre
from app.scodoc.codes_cursus import (
CMP,
@ -72,7 +77,7 @@ class DecisionSem(object):
def __init__(
self,
code_etat=None,
code_etat_ues={}, # { ue_id : code }
code_etat_ues: dict = None, # { ue_id : code }
new_code_prev="",
explication="", # aide pour le jury
formsemestre_id_utilise_pour_compenser=None, # None si code != ADC
@ -81,7 +86,7 @@ class DecisionSem(object):
rule_id=None, # id regle correspondante
):
self.code_etat = code_etat
self.code_etat_ues = code_etat_ues
self.code_etat_ues = code_etat_ues or {}
self.new_code_prev = new_code_prev
self.explication = explication
self.formsemestre_id_utilise_pour_compenser = (
@ -109,20 +114,27 @@ class DecisionSem(object):
class SituationEtudCursus:
"Semestre dans un cursus"
pass
class SituationEtudCursusClassic(SituationEtudCursus):
"Semestre dans un parcours"
def __init__(self, etud: dict, formsemestre_id: int, nt: NotesTableCompat):
def __init__(self, etud: Identite, formsemestre_id: int, nt: NotesTableCompat):
"""
etud: dict filled by fill_etuds_info()
"""
assert formsemestre_id == nt.formsemestre.id
self.etud = etud
self.etudid = etud["etudid"]
self.etudid = etud.id
self.formsemestre_id = formsemestre_id
self.sem = sco_formsemestre.get_formsemestre(formsemestre_id)
self.formsemestres: list[FormSemestre] = []
"les semestres parcourus, le plus ancien en tête"
self.sem = sco_formsemestre.get_formsemestre(
formsemestre_id
) # TODO utiliser formsemestres
self.cur_sem: FormSemestre = nt.formsemestre
self.can_compensate: set[int] = set()
"les formsemestre_id qui peuvent compenser le courant"
self.nt: NotesTableCompat = nt
self.formation = self.nt.formsemestre.formation
self.parcours = self.nt.parcours
@ -130,18 +142,20 @@ class SituationEtudCursusClassic(SituationEtudCursus):
# pour le DUT, le dernier est toujours S4.
# Ici: terminal si semestre == NB_SEM ou bien semestre_id==-1
# (licences et autres formations en 1 seule session))
self.semestre_non_terminal = self.sem["semestre_id"] != self.parcours.NB_SEM
if self.sem["semestre_id"] == NO_SEMESTRE_ID:
self.semestre_non_terminal = self.cur_sem.semestre_id != self.parcours.NB_SEM
if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
self.semestre_non_terminal = False
# Liste des semestres du parcours de cet étudiant:
self._comp_semestres()
# Determine le semestre "precedent"
self.prev_formsemestre_id = self._search_prev()
self._search_prev()
# Verifie barres
self._comp_barres()
# Verifie compensation
if self.prev and self.sem["gestion_compensation"]:
self.can_compensate_with_prev = self.prev["can_compensate"]
if self.prev_formsemestre and self.cur_sem.gestion_compensation:
self.can_compensate_with_prev = (
self.prev_formsemestre.id in self.can_compensate
)
else:
self.can_compensate_with_prev = False
@ -170,20 +184,20 @@ class SituationEtudCursusClassic(SituationEtudCursus):
if rule.conclusion[0] in self.parcours.UNUSED_CODES:
continue
# Saute regles REDOSEM si pas de semestres decales:
if (not self.sem["gestion_semestrielle"]) and rule.conclusion[
if (not self.cur_sem.gestion_semestrielle) and rule.conclusion[
3
] == "REDOSEM":
continue
if rule.match(state):
if rule.conclusion[0] == ADC:
# dans les regles on ne peut compenser qu'avec le PRECEDENT:
fiduc = self.prev_formsemestre_id
fiduc = self.prev_formsemestre.id
assert fiduc
else:
fiduc = None
# Detection d'incoherences (regles BUG)
if rule.conclusion[5] == BUG:
log("get_possible_choices: inconsistency: state=%s" % str(state))
log(f"get_possible_choices: inconsistency: state={state}")
#
# valid_semestre = code_semestre_validant(rule.conclusion[0])
choices.append(
@ -203,15 +217,15 @@ class SituationEtudCursusClassic(SituationEtudCursus):
"Phrase d'explication pour le code devenir"
if not devenir:
return ""
s = self.sem["semestre_id"] # numero semestre courant
if s < 0: # formation sans semestres (eg licence)
s_idx = self.cur_sem.semestre_id # numero semestre courant
if s_idx < 0: # formation sans semestres (eg licence)
next_s = 1
else:
next_s = self._get_next_semestre_id()
# log('s=%s next=%s' % (s, next_s))
SA = self.parcours.SESSION_ABBRV # 'S' ou 'A'
sess_abrv = self.parcours.SESSION_ABBRV # 'S' ou 'A'
if self.semestre_non_terminal and not self.all_other_validated():
passage = "Passe en %s%s" % (SA, next_s)
passage = f"Passe en {sess_abrv}{next_s}"
else:
passage = "Formation terminée"
if devenir == NEXT:
@ -219,29 +233,23 @@ class SituationEtudCursusClassic(SituationEtudCursus):
elif devenir == REO:
return "Réorienté"
elif devenir == REDOANNEE:
return "Redouble année (recommence %s%s)" % (SA, (s - 1))
return f"Redouble année (recommence {sess_abrv}{s_idx - 1})"
elif devenir == REDOSEM:
return "Redouble semestre (recommence en %s%s)" % (SA, s)
return f"Redouble semestre (recommence en {sess_abrv}{s_idx})"
elif devenir == RA_OR_NEXT:
return passage + ", ou redouble année (en %s%s)" % (SA, (s - 1))
return passage + ", ou redouble année (en {sess_abrv}{s_idx - 1})"
elif devenir == RA_OR_RS:
return "Redouble semestre %s%s, ou redouble année (en %s%s)" % (
SA,
s,
SA,
s - 1,
)
return f"""Redouble semestre {sess_abrv}{s_idx}, ou redouble année (en {
sess_abrv}{s_idx - 1})"""
elif devenir == RS_OR_NEXT:
return passage + ", ou semestre %s%s" % (SA, s)
return f"{passage}, ou semestre {sess_abrv}{s_idx}"
elif devenir == NEXT_OR_NEXT2:
return passage + ", ou en semestre %s%s" % (
SA,
s + 2,
) # coherent avec get_next_semestre_ids
# coherent avec get_next_semestre_ids
return f"{passage}, ou en semestre {sess_abrv}{s_idx + 2}"
elif devenir == NEXT2:
return "Passe en %s%s" % (SA, s + 2)
return f"Passe en {sess_abrv}{s_idx + 2}"
else:
log("explique_devenir: code devenir inconnu: %s" % devenir)
log(f"explique_devenir: code devenir inconnu: {devenir}")
return "Code devenir inconnu !"
def all_other_validated(self):
@ -258,7 +266,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
def _sems_validated(self, exclude_current=False):
"True si semestres du parcours validés"
if self.sem["semestre_id"] == NO_SEMESTRE_ID:
if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
# mono-semestre: juste celui ci
decision = self.nt.get_etud_decision_sem(self.etudid)
return decision and code_semestre_validant(decision["code"])
@ -266,8 +274,8 @@ class SituationEtudCursusClassic(SituationEtudCursus):
to_validate = set(
range(1, self.parcours.NB_SEM + 1)
) # ensemble des indices à valider
if exclude_current and self.sem["semestre_id"] in to_validate:
to_validate.remove(self.sem["semestre_id"])
if exclude_current and self.cur_sem.semestre_id in to_validate:
to_validate.remove(self.cur_sem.semestre_id)
return self._sem_list_validated(to_validate)
def can_jump_to_next2(self):
@ -275,20 +283,20 @@ class SituationEtudCursusClassic(SituationEtudCursus):
Il faut donc que tous les semestres 1...n-1 soient validés et que n+1 soit en attente.
(et que le sem courant n soit validé, ce qui n'est pas testé ici)
"""
n = self.sem["semestre_id"]
if not self.sem["gestion_semestrielle"]:
s_idx = self.cur_sem.semestre_id
if not self.cur_sem.gestion_semestrielle:
return False # pas de semestre décalés
if n == NO_SEMESTRE_ID or n > self.parcours.NB_SEM - 2:
if s_idx == NO_SEMESTRE_ID or s_idx > self.parcours.NB_SEM - 2:
return False # n+2 en dehors du parcours
if self._sem_list_validated(set(range(1, n))):
# antérieurs validé, teste suivant
n1 = n + 1
for sem in self.get_semestres():
if self._sem_list_validated(set(range(1, s_idx))):
# antérieurs validés, teste suivant
n1 = s_idx + 1
for formsemestre in self.formsemestres:
if (
sem["semestre_id"] == n1
and sem["formation_code"] == self.formation.formation_code
formsemestre.semestre_id == n1
and formsemestre.formation.formation_code
== self.formation.formation_code
):
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre
)
@ -315,19 +323,17 @@ class SituationEtudCursusClassic(SituationEtudCursus):
return not sem_idx_set
def _comp_semestres(self):
# etud['sems'] est trie par date decroissante (voir fill_etuds_info)
if not "sems" in self.etud:
self.etud["sems"] = sco_etud.etud_inscriptions_infos(
self.etud["etudid"], self.etud["ne"]
)["sems"]
sems = self.etud["sems"][:] # copy
sems.reverse()
# plus ancien en tête:
self.formsemestres = self.etud.get_formsemestres(recent_first=False)
# Nb max d'UE et acronymes
ue_acros = {} # acronyme ue : 1
nb_max_ue = 0
for sem in sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
sems = []
for formsemestre in self.formsemestres: # plus ancien en tête
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
sem = formsemestre.to_dict()
sems.append(sem)
ues = nt.get_ues_stat_dict(filter_sport=True)
for ue in ues:
ue_acros[ue["acronyme"]] = 1
@ -338,41 +344,48 @@ class SituationEtudCursusClassic(SituationEtudCursus):
sem["formation_code"] = formsemestre.formation.formation_code
# si sem peut servir à compenser le semestre courant, positionne
# can_compensate
sem["can_compensate"] = self.check_compensation_dut(sem, nt)
if self.check_compensation_dut(sem, nt):
self.can_compensate.add(formsemestre.id)
self.ue_acros = list(ue_acros.keys())
self.ue_acros.sort()
self.nb_max_ue = nb_max_ue
self.sems = sems
def get_semestres(self):
def get_semestres(self) -> list[dict]:
"""Liste des semestres dans lesquels a été inscrit
l'étudiant (quelle que soit la formation), le plus ancien en tête"""
return self.sems
def get_cursus_descr(self, filter_futur=False, filter_formation_code=False):
def get_cursus_descr(self, filter_futur=False, filter_formation_code=False) -> str:
"""Description brève du parcours: "S1, S2, ..."
Si filter_futur, ne mentionne pas les semestres qui sont après le semestre courant.
Si filter_formation_code, restreint aux semestres de même code formation que le courant.
"""
cur_begin_date = self.sem["dateord"]
cur_formation_code = self.sem["formation_code"]
cur_begin_date = self.cur_sem.date_debut
cur_formation_code = self.cur_sem.formation.formation_code
p = []
for s in self.sems:
if s["ins"]["etat"] == scu.DEMISSION:
for formsemestre in self.formsemestres:
inscription = formsemestre.etuds_inscriptions.get(self.etud.id)
if inscription is None:
return "non inscrit" # !!!
if inscription.etat == scu.DEMISSION:
dem = " (dem.)"
else:
dem = ""
if filter_futur and s["dateord"] > cur_begin_date:
if filter_futur and formsemestre.date_debut > cur_begin_date:
continue # skip semestres demarrant apres le courant
if filter_formation_code and s["formation_code"] != cur_formation_code:
if (
filter_formation_code
and formsemestre.formation.formation_code != cur_formation_code
):
continue # restreint aux semestres de la formation courante (pour les PV)
session_abbrv = self.parcours.SESSION_ABBRV # 'S' ou 'A'
if s["semestre_id"] < 0:
if formsemestre.semestre_id < 0:
session_abbrv = "A" # force, cas des DUT annuels par exemple
p.append("%s%d%s" % (session_abbrv, -s["semestre_id"], dem))
p.append("%s%d%s" % (session_abbrv, -formsemestre.semestre_id, dem))
else:
p.append("%s%d%s" % (session_abbrv, s["semestre_id"], dem))
p.append("%s%d%s" % (session_abbrv, formsemestre.semestre_id, dem))
return ", ".join(p)
def get_parcours_decisions(self):
@ -381,7 +394,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
Returns: { semestre_id : code }
"""
r = {}
if self.sem["semestre_id"] == NO_SEMESTRE_ID:
if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
indices = [NO_SEMESTRE_ID]
else:
indices = list(range(1, self.parcours.NB_SEM + 1))
@ -424,83 +437,83 @@ class SituationEtudCursusClassic(SituationEtudCursus):
"true si ce semestre pourrait etre compensé par un autre (e.g. barres UE > 8)"
return self.barres_ue_ok
def _search_prev(self):
def _search_prev(self) -> FormSemestre | None:
"""Recherche semestre 'precedent'.
return prev_formsemestre_id
positionne .prev_decision
"""
self.prev = None
self.prev_formsemestre = None
self.prev_decision = None
if len(self.sems) < 2:
if len(self.formsemestres) < 2:
return None
# Cherche sem courant dans la liste triee par date_debut
cur = None
icur = -1
for cur in self.sems:
for cur in self.formsemestres:
icur += 1
if cur["formsemestre_id"] == self.formsemestre_id:
if cur.id == self.formsemestre_id:
break
if not cur or cur["formsemestre_id"] != self.formsemestre_id:
if not cur or cur.id != self.formsemestre_id:
log(
f"*** SituationEtudCursus: search_prev: cur not found (formsemestre_id={self.formsemestre_id}, etudid={self.etudid})"
f"""*** SituationEtudCursus: search_prev: cur not found (formsemestre_id={
self.formsemestre_id}, etudid={self.etudid})"""
)
return None # pas de semestre courant !!!
# Cherche semestre antérieur de même formation (code) et semestre_id precedent
#
# i = icur - 1 # part du courant, remonte vers le passé
i = len(self.sems) - 1 # par du dernier, remonte vers le passé
prev = None
i = len(self.formsemestres) - 1 # par du dernier, remonte vers le passé
prev_formsemestre = None
while i >= 0:
if (
self.sems[i]["formation_code"] == self.formation.formation_code
and self.sems[i]["semestre_id"] == cur["semestre_id"] - 1
self.formsemestres[i].formation.formation_code
== self.formation.formation_code
and self.formsemestres[i].semestre_id == cur.semestre_id - 1
):
prev = self.sems[i]
prev_formsemestre = self.formsemestres[i]
break
i -= 1
if not prev:
if not prev_formsemestre:
return None # pas de precedent trouvé
self.prev = prev
self.prev_formsemestre = prev_formsemestre
# Verifications basiques:
# ?
# Code etat du semestre precedent:
formsemestre = FormSemestre.query.get_or_404(prev["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_formsemestre)
self.prev_decision = nt.get_etud_decision_sem(self.etudid)
self.prev_moy_gen = nt.get_etud_moy_gen(self.etudid)
self.prev_barres_ue_ok = nt.etud_check_conditions_ues(self.etudid)[0]
return self.prev["formsemestre_id"]
def get_next_semestre_ids(self, devenir):
def get_next_semestre_ids(self, devenir: str) -> list[int]:
"""Liste des numeros de semestres autorises avec ce devenir
Ne vérifie pas que le devenir est possible (doit être fait avant),
juste que le rang du semestre est dans le parcours [1..NB_SEM]
"""
s = self.sem["semestre_id"]
s_idx = self.cur_sem.semestre_id
if devenir == NEXT:
ids = [self._get_next_semestre_id()]
elif devenir == REDOANNEE:
ids = [s - 1]
ids = [s_idx - 1]
elif devenir == REDOSEM:
ids = [s]
ids = [s_idx]
elif devenir == RA_OR_NEXT:
ids = [s - 1, self._get_next_semestre_id()]
ids = [s_idx - 1, self._get_next_semestre_id()]
elif devenir == RA_OR_RS:
ids = [s - 1, s]
ids = [s_idx - 1, s_idx]
elif devenir == RS_OR_NEXT:
ids = [s, self._get_next_semestre_id()]
ids = [s_idx, self._get_next_semestre_id()]
elif devenir == NEXT_OR_NEXT2:
ids = [
self._get_next_semestre_id(),
s + 2,
s_idx + 2,
] # cohérent avec explique_devenir()
elif devenir == NEXT2:
ids = [s + 2]
ids = [s_idx + 2]
else:
ids = [] # reoriente ou autre: pas de next !
# clip [1..NB_SEM]
r = []
for idx in ids:
if idx > 0 and idx <= self.parcours.NB_SEM:
if 0 < idx <= self.parcours.NB_SEM:
r.append(idx)
return r
@ -508,27 +521,27 @@ class SituationEtudCursusClassic(SituationEtudCursus):
"""Indice du semestre suivant non validé.
S'il n'y en a pas, ramène NB_SEM+1
"""
s = self.sem["semestre_id"]
if s >= self.parcours.NB_SEM:
s_idx = self.cur_sem.semestre_id
if s_idx >= self.parcours.NB_SEM:
return self.parcours.NB_SEM + 1
validated = True
while validated and (s < self.parcours.NB_SEM):
s = s + 1
while validated and (s_idx < self.parcours.NB_SEM):
s_idx = s_idx + 1
# semestre s validé ?
validated = False
for sem in self.sems:
for formsemestre in self.formsemestres:
if (
sem["formation_code"] == self.formation.formation_code
and sem["semestre_id"] == s
formsemestre.formation.formation_code
== self.formation.formation_code
and formsemestre.semestre_id == s_idx
):
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre
)
decision = nt.get_etud_decision_sem(self.etudid)
if decision and code_semestre_validant(decision["code"]):
validated = True
return s
return s_idx
def valide_decision(self, decision):
"""Enregistre la decision (instance de DecisionSem)
@ -543,8 +556,11 @@ class SituationEtudCursusClassic(SituationEtudCursus):
fsid = decision.formsemestre_id_utilise_pour_compenser
if fsid:
ok = False
for sem in self.sems:
if sem["formsemestre_id"] == fsid and sem["can_compensate"]:
for formsemestre in self.formsemestres:
if (
formsemestre.id == fsid
and formsemestre.id in self.can_compensate
):
ok = True
break
if not ok:
@ -569,13 +585,11 @@ class SituationEtudCursusClassic(SituationEtudCursus):
decision.assiduite,
decision.formsemestre_id_utilise_pour_compenser,
)
logdb(
cnx,
Scolog.logdb(
method="validate_sem",
etudid=self.etudid,
commit=False,
msg="formsemestre_id=%s code=%s"
% (self.formsemestre_id, decision.code_etat),
msg=f"formsemestre_id={self.formsemestre_id} code={decision.code_etat}",
)
# -- decisions UEs
formsemestre_validate_ues(
@ -585,7 +599,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
decision.assiduite,
)
# -- modification du code du semestre precedent
if self.prev and decision.new_code_prev:
if self.prev_formsemestre and decision.new_code_prev:
if decision.new_code_prev == ADC:
# ne compense le prec. qu'avec le sem. courant
fsid = self.formsemestre_id
@ -593,30 +607,29 @@ class SituationEtudCursusClassic(SituationEtudCursus):
fsid = None
to_invalidate += formsemestre_update_validation_sem(
cnx,
self.prev["formsemestre_id"],
self.prev_formsemestre.id,
self.etudid,
decision.new_code_prev,
assidu=True,
formsemestre_id_utilise_pour_compenser=fsid,
)
logdb(
cnx,
Scolog.logdb(
method="validate_sem",
etudid=self.etudid,
commit=False,
msg="formsemestre_id=%s code=%s"
% (self.prev["formsemestre_id"], decision.new_code_prev),
msg=f"formsemestre_id={self.prev_formsemestre.id} code={decision.new_code_prev}",
)
# modifs des codes d'UE (pourraient passer de ADM a CMP, meme sans modif des notes)
formsemestre_validate_ues(
self.prev["formsemestre_id"],
self.prev_formsemestre.id,
self.etudid,
decision.new_code_prev,
decision.assiduite, # attention: en toute rigueur il faudrait utiliser une indication de l'assiduite au sem. precedent, que nous n'avons pas...
decision.assiduite, # attention: en toute rigueur il faudrait utiliser
# une indication de l'assiduite au sem. precedent, que nous n'avons pas...
)
sco_cache.invalidate_formsemestre(
formsemestre_id=self.prev["formsemestre_id"]
formsemestre_id=self.prev_formsemestre.id
) # > modif decisions jury (sem, UE)
try:
@ -698,7 +711,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
class SituationEtudCursusECTS(SituationEtudCursusClassic):
"""Gestion parcours basés sur ECTS"""
def __init__(self, etud, formsemestre_id, nt):
def __init__(self, etud: Identite, formsemestre_id: int, nt):
SituationEtudCursusClassic.__init__(self, etud, formsemestre_id, nt)
def could_be_compensated(self):
@ -742,14 +755,6 @@ class SituationEtudCursusECTS(SituationEtudCursusClassic):
# -------------------------------------------------------------------------------------------
def int_or_null(s):
if s == "":
return None
else:
return int(s)
_scolar_formsemestre_validation_editor = ndb.EditableTable(
"scolar_formsemestre_validation",
"formsemestre_validation_id",
@ -920,13 +925,13 @@ def formsemestre_validate_ues(formsemestre_id, etudid, code_etat_sem, assiduite)
cnx, nt, formsemestre_id, etudid, ue_id, code_ue
)
logdb(
cnx,
Scolog.logdb(
method="validate_ue",
etudid=etudid,
msg="ue_id=%s code=%s" % (ue_id, code_ue),
msg=f"ue_id={ue_id} code={code_ue}",
commit=False,
)
db.session.commit()
cnx.commit()

View File

@ -34,11 +34,10 @@ from flask import url_for, g, request
from app import log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
from app.models import FormSemestre, Scolog
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc.scolog import logdb
from app.scodoc.gen_tables import GenTable
from app.scodoc import safehtml
from app.scodoc import html_sco_header
@ -77,7 +76,6 @@ def report_debouche_date(start_year=None, fmt="html"):
tab.base_url = f"{request.base_url}?start_year={start_year}"
return tab.make_page(
title="""<h2 class="formsemestre">Débouchés étudiants </h2>""",
init_qtip=True,
javascripts=["js/etud_info.js"],
fmt=fmt,
with_html_headers=True,
@ -291,8 +289,8 @@ def itemsuivi_suppress(itemsuivi_id):
item = itemsuivi_get(cnx, itemsuivi_id, ignore_errors=True)
if item:
_itemsuivi_delete(cnx, itemsuivi_id)
logdb(cnx, method="itemsuivi_suppress", etudid=item["etudid"])
log("suppressed itemsuivi %s" % (itemsuivi_id,))
Scolog.logdb(method="itemsuivi_suppress", etudid=item["etudid"], commit=True)
log(f"suppressed itemsuivi {itemsuivi_id}")
return ("", 204)
@ -304,7 +302,7 @@ def itemsuivi_create(etudid, item_date=None, situation="", fmt=None):
itemsuivi_id = _itemsuivi_create(
cnx, args={"etudid": etudid, "item_date": item_date, "situation": situation}
)
logdb(cnx, method="itemsuivi_create", etudid=etudid)
Scolog.logdb(method="itemsuivi_create", etudid=etudid, commit=True)
log("created itemsuivi %s for %s" % (itemsuivi_id, etudid))
item = itemsuivi_get(cnx, itemsuivi_id)
if fmt == "json":

View File

@ -137,6 +137,7 @@ def _convert_formsemestres_to_dicts(
"bul_hide_xml": formsemestre.bul_hide_xml,
"dateord": formsemestre.date_debut,
"elt_annee_apo": formsemestre.elt_annee_apo,
"elt_passage_apo": formsemestre.elt_passage_apo,
"elt_sem_apo": formsemestre.elt_sem_apo,
"etapes_apo_str": formsemestre.etapes_apo_str(),
"formation": f"{formation.acronyme} v{formation.version}",
@ -189,6 +190,7 @@ def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable
"formation",
"etapes_apo_str",
"elt_annee_apo",
"elt_passage_apo",
"elt_sem_apo",
]
if showcodes:
@ -203,9 +205,18 @@ def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable
html_class=html_class,
html_sortable=True,
html_table_attrs=f"""
data-apo_save_url="{url_for('notes.formsemestre_set_apo_etapes', scodoc_dept=g.scodoc_dept)}"
data-elt_annee_apo_save_url="{url_for('notes.formsemestre_set_elt_annee_apo', scodoc_dept=g.scodoc_dept)}"
data-elt_sem_apo_save_url="{url_for('notes.formsemestre_set_elt_sem_apo', scodoc_dept=g.scodoc_dept)}"
data-apo_save_url="{
url_for('apiweb.formsemestre_set_apo_etapes', scodoc_dept=g.scodoc_dept)
}"
data-elt_annee_apo_save_url="{
url_for('apiweb.formsemestre_set_elt_annee_apo', scodoc_dept=g.scodoc_dept)
}"
data-elt_sem_apo_save_url="{
url_for('apiweb.formsemestre_set_elt_sem_apo', scodoc_dept=g.scodoc_dept)
}"
data-elt_passage_apo_save_url="{
url_for('apiweb.formsemestre_set_elt_passage_apo', scodoc_dept=g.scodoc_dept)
}"
""",
html_with_td_classes=True,
preferences=sco_preferences.SemPreferences(),
@ -221,6 +232,7 @@ def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable
"etapes_apo_str": "Étape Apo.",
"elt_annee_apo": "Elt. année Apo.",
"elt_sem_apo": "Elt. sem. Apo.",
"elt_passage_apo": "Elt. pass. Apo.",
"formation": "Formation",
},
table_id="semlist",
@ -282,6 +294,9 @@ def _style_sems(sems: list[dict], fmt="html") -> list[dict]:
sem["_elt_sem_apo_td_attrs"] = (
f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['elt_sem_apo']}" """
)
sem["_elt_passage_apo_td_attrs"] = (
f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['elt_passage_apo']}" """
)
return sems

View File

@ -47,7 +47,6 @@ from app.scodoc import html_sco_header
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_ue
from app.scodoc import sco_formsemestre
def formation_delete(formation_id=None, dialog_confirmed=False):
@ -187,7 +186,7 @@ def formation_edit(formation_id=None, create=False):
"acronyme",
{
"size": 12,
"explanation": "identifiant de la formation (par ex. DUT R&T)",
"explanation": "identifiant de la formation (par ex. BUT R&T)",
"allow_null": False,
},
),
@ -195,7 +194,7 @@ def formation_edit(formation_id=None, create=False):
"titre",
{
"size": 80,
"explanation": "nom complet de la formation (ex: DUT Réseaux et Télécommunications",
"explanation": "nom de la formation (ex: BUT Réseaux et Télécommunications)",
"allow_null": False,
},
),

View File

@ -691,9 +691,13 @@ def module_edit(
str(parcour.id) for parcour in ref_comp.parcours
]
+ ["-1"],
"explanation": """Parcours dans lesquels est utilisé ce module.<br>
Attention: si le module ne doit pas avoir les mêmes coefficients suivant le parcours,
il faut en créer plusieurs versions, car dans ScoDoc chaque module a ses coefficients.""",
"explanation": """Parcours dans lesquels est utilisé ce module (inutile
hors BUT, pour les modules standards et dans les UEs de bonus).
<br>
Attention: si le module ne doit pas avoir les mêmes coefficients suivant
le parcours, il faut en créer plusieurs versions, car dans ScoDoc chaque
module a ses coefficients.
""",
},
)
]
@ -890,23 +894,6 @@ def module_edit(
)
# Edition en ligne du code Apogee
def edit_module_set_code_apogee(id=None, value=None):
"Set UE code apogee"
module_id = id
value = str(value).strip("-_ \t")
log("edit_module_set_code_apogee: module_id=%s code_apogee=%s" % (module_id, value))
modules = module_list(args={"module_id": module_id})
if not modules:
return "module invalide" # should not occur
do_module_edit({"module_id": module_id, "code_apogee": value})
if not value:
value = scu.APO_MISSING_CODE_STR
return value
def module_table(formation_id):
"""Liste des modules de la formation
(XXX inutile ou a revoir)

View File

@ -84,6 +84,7 @@ _ueEditor = ndb.EditableTable(
"ects",
"is_external",
"code_apogee",
"code_apogee_rcue",
"coefficient",
"coef_rcue",
"color",
@ -425,6 +426,20 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"max_length": APO_CODE_STR_LEN,
},
),
]
if is_apc:
form_descr += [
(
"code_apogee_rcue",
{
"title": "Code Apogée du RCUE",
"size": 25,
"explanation": "(optionnel) code(s) élément pédagogique Apogée du RCUE",
"max_length": APO_CODE_STR_LEN,
},
),
]
form_descr += [
(
"is_external",
{
@ -513,7 +528,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
{ue_parcours_div}
{modules_div}
<div id="bonus_description"></div>
<div id="bonus_description" class="scobox"></div>
<div id="ue_list_code" class="sco_box sco_green_bg"></div>
{html_sco_header.sco_footer()}
@ -1041,10 +1056,10 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
if current_user.has_permission(Permission.EditFormSemestre):
H.append(
f"""<ul>
<li><a class="stdlink" href="{
<li><b><a class="stdlink" href="{
url_for('notes.formsemestre_createwithmodules', scodoc_dept=g.scodoc_dept,
formation_id=formation_id, semestre_id=1)
}">Mettre en place un nouveau semestre de formation {formation.acronyme}</a>
}">Mettre en place un nouveau semestre de formation {formation.acronyme}</a></b>
</li>
</ul>"""
)
@ -1109,12 +1124,18 @@ def _ue_table_ues(
klass = "span_apo_edit"
else:
klass = ""
ue["code_apogee_str"] = (
""", Apo: <span class="%s" data-url="edit_ue_set_code_apogee" id="%s" data-placeholder="%s">"""
% (klass, ue["ue_id"], scu.APO_MISSING_CODE_STR)
+ (ue["code_apogee"] or "")
+ "</span>"
edit_url = url_for(
"apiweb.ue_set_code_apogee",
scodoc_dept=g.scodoc_dept,
ue_id=ue["ue_id"],
)
ue[
"code_apogee_str"
] = f""", Apo: <span
class="{klass}" data-url="{edit_url}" id="{ue['ue_id']}"
data-placeholder="{scu.APO_MISSING_CODE_STR}">{
ue["code_apogee"] or ""
}</span>"""
if cur_ue_semestre_id != ue["semestre_id"]:
cur_ue_semestre_id = ue["semestre_id"]
@ -1348,16 +1369,17 @@ def _ue_table_modules(
heurescoef = (
"%(heures_cours)s/%(heures_td)s/%(heures_tp)s, coef. %(coefficient)s" % mod
)
if mod_editable:
klass = "span_apo_edit"
else:
klass = ""
heurescoef += (
', Apo: <span class="%s" data-url="edit_module_set_code_apogee" id="%s" data-placeholder="%s">'
% (klass, mod["module_id"], scu.APO_MISSING_CODE_STR)
+ (mod["code_apogee"] or "")
+ "</span>"
edit_url = url_for(
"apiweb.formation_module_set_code_apogee",
scodoc_dept=g.scodoc_dept,
module_id=mod["module_id"],
)
heurescoef += f""", Apo: <span
class="{'span_apo_edit' if editable else ''}"
data-url="{edit_url}" id="{mod["module_id"]}"
data-placeholder="{scu.APO_MISSING_CODE_STR}">{
mod["code_apogee"] or ""
}</span>"""
if tag_editable:
tag_cls = "module_tag_editor"
else:
@ -1493,28 +1515,6 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
formation.invalidate_module_coefs()
# essai edition en ligne:
def edit_ue_set_code_apogee(id=None, value=None):
"set UE code apogee"
ue_id = id
value = value.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque
log("edit_ue_set_code_apogee: ue_id=%s code_apogee=%s" % (ue_id, value))
ues = ue_list(args={"ue_id": ue_id})
if not ues:
return "ue invalide"
do_ue_edit(
{"ue_id": ue_id, "code_apogee": value},
bypass_lock=True,
dont_invalidate_cache=False,
)
if not value:
value = scu.APO_MISSING_CODE_STR
return value
def ue_is_locked(ue_id):
"""True if UE should not be modified
(contains modules used in a locked formsemestre)

View File

@ -247,9 +247,7 @@ def apo_csv_check_etape(semset, set_nips, etape_apo):
return nips_ok, apo_nips, nips_no_apo, nips_no_sco, maq_elems, sem_elems
def apo_csv_semset_check(
semset, allow_missing_apo=False, allow_missing_csv=False
): # was apo_csv_check
def apo_csv_semset_check(semset, allow_missing_apo=False, allow_missing_csv=False):
"""
check students in stored maqs vs students in semset
Cas à détecter:
@ -346,120 +344,3 @@ def apo_csv_retreive_etuds_by_nip(semset, nips):
etuds[nip] = apo_etuds_by_nips.get(nip, {"nip": nip, "etape_apo": "?"})
return etuds
"""
Tests:
from debug import *
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_formsemestre
from app.scodoc.sco_etape_apogee import *
from app.scodoc.sco_apogee_csv import *
from app.scodoc.sco_semset import *
app.set_sco_dept('RT')
csv_data = open('/opt/misc/VDTRT_V1RT.TXT').read()
annee_scolaire=2015
sem_id=1
apo_data = sco_apogee_csv.ApoData(csv_data, periode=sem_id)
print apo_data.etape_apogee
apo_data.setup()
e = apo_data.etuds[0]
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco( apo_data)
print apo_csv_list_stored_archives()
# apo_csv_store(csv_data, annee_scolaire, sem_id)
groups_infos = sco_groups_view.DisplayedGroupsInfos( [sco_groups.get_default_group(formsemestre_id)], formsemestre_id=formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
#
s = SemSet('NSS29902')
apo_data = sco_apogee_csv.ApoData(open('/opt/scodoc/var/scodoc/archives/apo_csv/RT/2015-2/2016-07-10-11-26-15/V1RT.csv').read(), periode=1)
# cas Tiziri K. (inscrite en S1, démission en fin de S1, pas inscrite en S2)
# => pas de décision, ce qui est voulu (?)
#
apo_data.setup()
e = [ e for e in apo_data.etuds if e['nom'] == 'XYZ' ][0]
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco(apo_data)
self=e
col_id='apoL_c0129'
# --
from app.scodoc import sco_portal_apogee
_ = go_dept(app, 'GEA').Notes
#csv_data = sco_portal_apogee.get_maquette_apogee(etape='V1GE', annee_scolaire=2015)
csv_data = open('/tmp/V1GE.txt').read()
apo_data = sco_apogee_csv.ApoData(csv_data, periode=1)
# ------
# les elements inconnus:
from debug import *
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_formsemestre
from app.scodoc.sco_etape_apogee import *
from app.scodoc.sco_apogee_csv import *
from app.scodoc.sco_semset import *
_ = go_dept(app, 'RT').Notes
csv_data = open('/opt/misc/V2RT.csv').read()
annee_scolaire=2015
sem_id=1
apo_data = sco_apogee_csv.ApoData(csv_data, periode=1)
print apo_data.etape_apogee
apo_data.setup()
for e in apo_data.etuds:
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco(apo_data)
# ------
# test export jury intermediaire
from debug import *
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_formsemestre
from app.scodoc.sco_etape_apogee import *
from app.scodoc.sco_apogee_csv import *
from app.scodoc.sco_semset import *
_ = go_dept(app, 'CJ').Notes
csv_data = open('/opt/scodoc/var/scodoc/archives/apo_csv/CJ/2016-1/2017-03-06-21-46-32/V1CJ.csv').read()
annee_scolaire=2016
sem_id=1
apo_data = sco_apogee_csv.ApoData(csv_data, periode=1)
print apo_data.etape_apogee
apo_data.setup()
e = [ e for e in apo_data.etuds if e['nom'] == 'XYZ' ][0] #
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco(apo_data)
self=e
sco_elts = {}
col_id='apoL_c0001'
code = apo_data.cols[col_id]['Code'] # 'V1RT'
sem = apo_data.sems_periode[0] # le S1
"""

View File

@ -125,14 +125,19 @@ def apo_semset_maq_status(
H.append("""<p><em>Aucune maquette chargée</em></p>""")
# Upload fichier:
H.append(
"""<form id="apo_csv_add" action="view_apo_csv_store" method="post" enctype="multipart/form-data">
Charger votre fichier maquette Apogée:
f"""<form id="apo_csv_add" action="view_apo_csv_store"
method="post" enctype="multipart/form-data"
style="margin-bottom: 8px;"
>
<div style="margin-top: 12px; margin-bottom: 8px;">
{'Charger votre fichier' if tab_archives.is_empty() else 'Ajouter un autre fichier'}
maquette Apogée:
</div>
<input type="file" size="30" name="csvfile"/>
<input type="hidden" name="semset_id" value="%s"/>
<input type="hidden" name="semset_id" value="{semset_id}"/>
<input type="submit" value="Ajouter ce fichier"/>
<input type="checkbox" name="autodetect" checked/>autodétecter encodage</input>
</form>"""
% (semset_id,)
)
# Récupération sur portail:
maquette_url = sco_portal_apogee.get_maquette_url()
@ -335,7 +340,7 @@ def apo_semset_maq_status(
missing = maq_elems - sem_elems
H.append('<div id="apo_elements">')
H.append(
'<p>Elements Apogée: <span class="apo_elems">%s</span></p>'
'<p>Élements Apogée: <span class="apo_elems">%s</span></p>'
% ", ".join(
[
e if not e in missing else '<span class="missing">' + e + "</span>"
@ -351,7 +356,7 @@ def apo_semset_maq_status(
]
H.append(
f"""<div class="apo_csv_status_missing_elems">
<span class="fontred">Elements Apogée absents dans ScoDoc: </span>
<span class="fontred">Élements Apogée absents dans ScoDoc: </span>
<span class="apo_elems fontred">{
", ".join(sorted(missing))
}</span>
@ -442,11 +447,11 @@ def table_apo_csv_list(semset):
annee_scolaire = semset["annee_scolaire"]
sem_id = semset["sem_id"]
T = sco_etape_apogee.apo_csv_list_stored_archives(
rows = sco_etape_apogee.apo_csv_list_stored_archives(
annee_scolaire, sem_id, etapes=semset.list_etapes()
)
for t in T:
for t in rows:
# Ajoute qq infos pour affichage:
csv_data = sco_etape_apogee.apo_csv_get(t["etape_apo"], annee_scolaire, sem_id)
apo_data = sco_apogee_csv.ApoData(csv_data, periode=semset["sem_id"])
@ -484,7 +489,7 @@ def table_apo_csv_list(semset):
"date_str": "Enregistré le",
},
columns_ids=columns_ids,
rows=T,
rows=rows,
html_class="table_leftalign apo_maq_list",
html_sortable=True,
# base_url = '%s?formsemestre_id=%s' % (request.base_url, formsemestre_id),
@ -540,7 +545,8 @@ def view_scodoc_etuds(semset_id, title="", nip_list="", fmt="html"):
if not isinstance(nip_list, str):
nip_list = str(nip_list)
nips = nip_list.split(",")
etuds = [sco_etud.get_etud_info(code_nip=nip, filled=True)[0] for nip in nips]
etuds_lst = [sco_etud.get_etud_info(code_nip=nip, filled=True) for nip in nips]
etuds = [lst[0] for lst in etuds_lst if lst]
for e in etuds:
tgt = url_for(
@ -773,12 +779,18 @@ def view_apo_csv(etape_apo="", semset_id="", fmt="html"):
e["in_scodoc"] = e["nip"] not in nips_no_sco
e["in_scodoc_str"] = {True: "oui", False: "non"}[e["in_scodoc"]]
if e["in_scodoc"]:
e.update(sco_etud.get_etud_info(code_nip=e["nip"], filled=True)[0])
e["_in_scodoc_str_target"] = url_for(
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"]
)
e["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"],)
e["_prenom_td_attrs"] = 'id="pre-%s" class="etudinfo"' % (e["etudid"],)
etud = sco_etud.get_etud_info(code_nip=e["nip"], filled=True)
if etud:
e.update(etud[0])
e["_in_scodoc_str_target"] = url_for(
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"]
)
e["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"],)
e["_prenom_td_attrs"] = 'id="pre-%s" class="etudinfo"' % (e["etudid"],)
else:
# race condition?
e["in_scodoc"] = False
e["_css_row_class"] = "apo_not_scodoc"
else:
e["_css_row_class"] = "apo_not_scodoc"

View File

@ -93,7 +93,7 @@ import json
from flask import url_for, g
from app.scodoc.sco_portal_apogee import get_inscrits_etape
from app.scodoc import sco_portal_apogee
from app import log
from app.scodoc.sco_utils import annee_scolaire_debut
from app.scodoc.gen_tables import GenTable
@ -136,11 +136,16 @@ class DataEtudiant(object):
self.etudid = etudid
self.data_apogee = None
self.data_scodoc = None
self.etapes = set() # l'ensemble des étapes où il est inscrit
self.semestres = set() # l'ensemble des formsemestre_id où il est inscrit
self.tags = set() # les anomalies relevées
self.ind_row = "-" # là où il compte dans les effectifs (ligne et colonne)
self.etapes = set()
"l'ensemble des étapes où il est inscrit"
self.semestres = set()
"l'ensemble des formsemestre_id où il est inscrit"
self.tags = set()
"les anomalies relevées"
self.ind_row = "-"
"ligne où il compte dans les effectifs"
self.ind_col = "-"
"colonne où il compte dans les effectifs"
def add_etape(self, etape):
self.etapes.add(etape)
@ -163,9 +168,9 @@ class DataEtudiant(object):
def set_ind_col(self, indicatif):
self.ind_col = indicatif
def get_identity(self):
def get_identity(self) -> str:
"""
Calcul le nom/prénom de l'étudiant (données ScoDoc en priorité, sinon données Apogée)
Calcule le nom/prénom de l'étudiant (données ScoDoc en priorité, sinon données Apogée)
:return: L'identité calculée
"""
if self.data_scodoc is not None:
@ -176,9 +181,12 @@ class DataEtudiant(object):
def _help() -> str:
return """
<div id="export_help" class="pas_help"> <span>Explications sur les tableaux des effectifs et liste des
étudiants</span>
<div> <p>Le tableau des effectifs présente le nombre d'étudiants selon deux critères:</p>
<div id="export_help" class="pas_help">
<span>Explications sur les tableaux des effectifs
et liste des étudiants</span>
<div>
<p>Le tableau des effectifs présente le nombre d'étudiants selon deux critères:
</p>
<ul>
<li>En colonne le statut de l'étudiant par rapport à Apogée:
<ul>
@ -406,7 +414,8 @@ class EtapeBilan:
for key_etape in self.etapes:
annee_apogee, etapestr = key_to_values(key_etape)
self.etu_etapes[key_etape] = set()
for etud in get_inscrits_etape(etapestr, annee_apogee):
# get_inscrits_etape interroge portail Apo:
for etud in sco_portal_apogee.get_inscrits_etape(etapestr, annee_apogee):
key_etu = self.register_etud_apogee(etud, key_etape)
self.etu_etapes[key_etape].add(key_etu)
@ -444,7 +453,6 @@ class EtapeBilan:
data_etu = self.etudiants[key_etu]
ind_col = "-"
ind_row = "-"
# calcul de la colonne
if len(data_etu.etapes) == 1:
ind_col = self.indicatifs[list(data_etu.etapes)[0]]
@ -478,32 +486,34 @@ class EtapeBilan:
affichage de l'html
:return: Le code html à afficher
"""
if not sco_portal_apogee.has_portal():
return """<div id="synthese" class="semset_description">
<em>Pas de portail Apogée configuré</em>
</div>"""
self.load_listes() # chargement des données
self.dispatch() # analyse et répartition
# calcul de la liste des colonnes et des lignes de la table des effectifs
self.all_rows_str = "'" + ",".join(["." + r for r in self.all_rows_ind]) + "'"
self.all_cols_str = "'" + ",".join(["." + c for c in self.all_cols_ind]) + "'"
H = [
"""<div id="synthese" class="semset_description">
return f"""
<div id="synthese" class="semset_description">
<details open="true">
<summary><b>Tableau des effectifs</b>
</summary>
""",
self._diagtable(),
"""</details>""",
self.display_tags(),
"""<details open="true">
<summary><b id="effectifs">Liste des étudiants <span id="compte"></span></b>
</summary>
""",
entete_liste_etudiant(),
self.table_effectifs(),
"""</details>""",
_help(),
]
return "\n".join(H)
<summary><b>Tableau des effectifs</b>
</summary>
{self._diagtable()}
</details>
{self.display_tags()}
<details open="true">
<summary>
<b id="effectifs">Liste des étudiants <span id="compte"></span></b>
</summary>
{entete_liste_etudiant()}
{self.table_effectifs()}
</details>
{_help()}
</div>
"""
def _inc_count(self, ind_row, ind_col):
if (ind_row, ind_col) not in self.repartition:
@ -692,26 +702,34 @@ class EtapeBilan:
return "\n".join(H)
@staticmethod
def link_etu(etudid, nom):
return '<a class="stdlink" href="%s">%s</a>' % (
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid),
nom,
)
def link_etu(etudid, nom) -> str:
"Lien html vers fiche de l'étudiant"
return f"""<a class="stdlink" href="{
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}">{nom}</a>"""
def link_semestre(self, semestre, short=False):
if short:
return (
'<a class="stdlink" href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%('
"formsemestre_id)s</a> " % self.semestres[semestre]
)
else:
return (
'<a class="stdlink" href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titre_num)s'
" %(mois_debut)s - %(mois_fin)s)</a>" % self.semestres[semestre]
)
def link_semestre(self, semestre, short=False) -> str:
"Lien html vers tableau de bord semestre"
key = "session_id" if short else "titremois"
sem = self.semestres[semestre]
return f"""<a class="stdlink" href="{
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
formsemestre_id=sem['formsemestre_id']
)}">{sem[key]}</a>
"""
def table_effectifs(self):
H = []
def table_effectifs(self) -> str:
"Table html donnant les étudiants dans chaque semestre"
H = [
"""
<style>
table#apo-detail td.semestre {
white-space: nowrap;
word-break: normal;
}
</style>
"""
]
col_ids = ["tag", "etudiant", "prenom", "nip", "semestre", "apogee", "annee"]
titles = {
@ -766,6 +784,7 @@ class EtapeBilan:
titles,
html_class="table_leftalign",
html_sortable=True,
html_with_td_classes=True,
table_id="apo-detail",
).gen(fmt="html")
)

View File

@ -37,7 +37,7 @@ from flask import url_for, g
from app import db, email
from app import log
from app.models import Admission, Identite
from app.models import Admission, Identite, Scolog
from app.models.etudiants import (
check_etud_duplicate_code,
input_civilite,
@ -53,10 +53,9 @@ from app.scodoc.sco_utils import (
format_prenom,
)
import app.scodoc.notesdb as ndb
from app.scodoc.sco_exceptions import ScoGenError, ScoValueError
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import safehtml
from app.scodoc import sco_preferences
from app.scodoc.scolog import logdb
def format_etud_ident(etud: dict):
@ -511,8 +510,7 @@ def create_etud(cnx, args: dict = None):
etudid = etud.id
# log
logdb(
cnx,
Scolog.logdb(
method="etudident_edit_form",
etudid=etudid,
msg="creation initiale",
@ -681,17 +679,6 @@ o.close()
"""
def list_scolog(etudid):
"liste des operations effectuees sur cet etudiant"
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(
"SELECT * FROM scolog WHERE etudid=%(etudid)s ORDER BY DATE DESC",
{"etudid": etudid},
)
return cursor.dictfetchall()
def fill_etuds_info(etuds: list[dict], add_admission=True):
"""etuds est une liste d'etudiants (mappings)
Pour chaque etudiant, ajoute ou formatte les champs

View File

@ -256,7 +256,7 @@ def evaluation_create_form(
+ (
"(pondéré par poids et ajouté aux moyennes de ce module)"
if is_apc
else "(ajouté à la moyenne de ce module)"
else "(ajouté à la moyenne de ce module, différent du bonus sport !)"
)
),
),

View File

@ -64,18 +64,24 @@ import sco_version
# --------------------------------------------------------------------
def notes_moyenne_median_mini_maxi(notes):
"calcule moyenne et mediane d'une liste de valeurs (floats)"
notes = [
notes_num = [
x
for x in notes
if (x != None) and (x != scu.NOTES_NEUTRALISE) and (x != scu.NOTES_ATTENTE)
if (x is not None) and (x != scu.NOTES_NEUTRALISE) and (x != scu.NOTES_ATTENTE)
]
n = len(notes)
n = len(notes_num)
if not n:
return None, None, None, None
moy = sum(notes) / n
median = list_median(notes)
mini = min(notes)
maxi = max(notes)
# Aucune note numérique
# si elles sont toutes du même type, renvoie ce type (ABS, EXC, ATT)
for type_note in (scu.NOTES_NEUTRALISE, scu.NOTES_ATTENTE, None):
if all(x == type_note for x in notes):
return (type_note, type_note, type_note, type_note)
# sinon renvoie "???"
return "???", None, None, None
moy = sum(notes_num) / n
median = list_median(notes_num)
mini = min(notes_num)
maxi = max(notes_num)
return moy, median, mini, maxi

View File

@ -26,13 +26,14 @@
##############################################################################
""" Excel file handling
"""
"""Excel file handling"""
import datetime
import io
import time
from enum import Enum
from tempfile import NamedTemporaryFile
from typing import AnyStr
import openpyxl.utils.datetime
from openpyxl.styles.numbers import FORMAT_NUMBER_00, FORMAT_GENERAL, FORMAT_DATE_DDMMYY
@ -59,12 +60,12 @@ class COLORS(Enum):
LIGHT_YELLOW = "FFFFFF99"
# Un style est enregistré comme un dictionnaire qui précise la valeur d'un attribut dans la liste suivante:
# Un style est enregistré comme un dictionnaire avec des attributs dans la liste suivante:
# font, border, number_format, fill,...
# (cf https://openpyxl.readthedocs.io/en/stable/styles.html#working-with-styles)
def xldate_as_datetime(xldate, datemode=0):
def xldate_as_datetime(xldate):
"""Conversion d'une date Excel en datetime python
Deux formats de chaîne acceptés:
* JJ/MM/YYYY (chaîne naïve)
@ -186,8 +187,8 @@ def excel_make_style(
class ScoExcelSheet:
"""Représente une feuille qui peut être indépendante ou intégrée dans un ScoExcelBook.
En application des directives de la bibliothèque sur l'écriture optimisée, l'ordre des opérations
est imposé:
En application des directives de la bibliothèque sur l'écriture optimisée,
l'ordre des opérations est imposé:
* instructions globales (largeur/maquage des colonnes et ligne, ...)
* construction et ajout des cellules et ligne selon le sens de lecture (occidental)
ligne de haut en bas et cellules de gauche à droite (i.e. A1, A2, .. B1, B2, ..)
@ -198,7 +199,7 @@ class ScoExcelSheet:
"""Création de la feuille. sheet_name
-- le nom de la feuille default_style
-- le style par défaut des cellules ws
-- None si la feuille est autonome (dans ce cas elle crée son propre wb), sinon c'est la worksheet
-- None si la feuille est autonome (elle crée son propre wb), sinon c'est la worksheet
créée par le workbook propriétaire un workbook est créé et associé à cette feuille.
"""
# Le nom de la feuille ne peut faire plus de 31 caractères.
@ -227,7 +228,8 @@ class ScoExcelSheet:
fill=None,
number_format=None,
font=None,
):
) -> dict:
"création d'un dict"
style = {}
if font is not None:
style["font"] = font
@ -244,27 +246,37 @@ class ScoExcelSheet:
return style
@staticmethod
def i2col(idx):
def i2col(idx: int | str) -> str:
"traduit un index ou lettre de colonne en lettre de colonne"
if isinstance(idx, str):
return idx
if idx < 26: # one letter key
return chr(idx + 65)
else: # two letters AA..ZZ
first = (idx // 26) + 66
second = (idx % 26) + 65
return "" + chr(first) + chr(second)
# two letters AA..ZZ
first = (idx // 26) + 64
second = (idx % 26) + 65
return "" + chr(first) + chr(second)
def set_column_dimension_width(self, cle=None, value=21):
"""Détermine la largeur d'une colonne. cle -- identifie la colonne ("A" "B", ... ou 0, 1, 2, ...) si None,
value donne la liste des largeurs de colonnes depuis A, B, C, ... value -- la dimension (unité : 7 pixels
comme affiché dans Excel)
def set_column_dimension_width(self, cle=None, value: int | str | list = 21):
"""Détermine la largeur d'une colonne.
cle -- identifie la colonne (lettre ou indice à partir de 0),
Si cle is None, affecte toutes les colonnes.
value est soit la liste des largeurs de colonnes [ (0, width0), (1, width1), ...]
soit la largeur de la colonne indiquée par cle, soit "auto".
Largeurs en unité : 7 pixels comme affiché dans Excel)
ou value == "auto", ajuste la largeur au contenu
"""
if cle is None:
for i, val in enumerate(value):
self.ws.column_dimensions[self.i2col(i)].width = val
# No keys: value is a list of widths
elif isinstance(cle, str): # accepts set_column_with("D", ...)
self.ws.column_dimensions[cle].width = value
cols_widths = enumerate(value)
else:
self.ws.column_dimensions[self.i2col(cle)].width = value
cols_widths = [(cle, value)]
for idx, width in cols_widths:
if width == "auto":
self.adjust_column_widths(column_letter=self.i2col(idx))
else:
self.ws.column_dimensions[self.i2col(idx)].width = width
def set_row_dimension_height(self, cle=None, value=21):
"""Détermine la hauteur d'une ligne. cle -- identifie la ligne (1, 2, ...) si None,
@ -285,16 +297,58 @@ class ScoExcelSheet:
self.ws.row_dimensions[cle].hidden = value
def set_column_dimension_hidden(self, cle, value):
"""Masque ou affiche une ligne.
"""Masque ou affiche une colonne.
cle -- identifie la colonne (1...)
value -- boolean (vrai = colonne cachée)
"""
self.ws.column_dimensions[cle].hidden = value
def set_auto_filter(self, range):
self.auto_filter = range
def set_auto_filter(self, filter_range):
"met en place un auto-filter excel: le range désigne les cellules de titres"
self.auto_filter = filter_range
def make_cell(self, value: any = None, style=None, comment=None):
def adjust_column_widths(
self, column_letter=None, min_row=None, max_row=None, min_col=None, max_col=None
):
"""Adjust columns widths to fit their content.
If column_letter, adjust only this column, else adjust all.
(min_row, max_row, min_col, max_col) can be used to restrinct the area to consider
while determining the widths.
"""
# Create a dictionary to store the maximum width of each column
col_widths = {}
if column_letter is None:
# Iterate over each row and cell in the worksheet
for row in self.ws.iter_rows(
min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col
):
for cell in row:
# Get the length of the cell value (converted to string)
cell_value = str(cell.value)
# Update the maximum width for the column
col_widths[cell.column_letter] = max(
col_widths.get(cell.column_letter, 0), len(cell_value)
)
else:
min_row = self.ws.min_row if min_row is None else min_row
max_row = self.ws.max_row if max_row is None else max_row
for row in range(min_row, max_row + 1):
cell = self.ws[f"{column_letter}{row}"]
cell_value = str(cell.value)
col_widths[cell.column_letter] = max(
col_widths.get(cell.column_letter, 0), len(cell_value)
)
# Set the column widths based on the maximum length found
# (nb: the width is expressed in characters, in the default font)
for col, width in col_widths.items():
self.ws.column_dimensions[col].width = width
def make_cell(
self, value: any = None, style: dict = None, comment=None
) -> WriteOnlyCell:
"""Construit une cellule.
value -- contenu de la cellule (texte, numérique, booléen ou date)
style -- style par défaut (dictionnaire cf. excel_make_style) de la feuille si non spécifié
@ -307,9 +361,8 @@ class ScoExcelSheet:
elif value is False:
value = 0
elif isinstance(value, datetime.datetime):
value = value.replace(
tzinfo=None
) # make date naive (cf https://openpyxl.readthedocs.io/en/latest/datetime.html#timezones)
# make date naive (cf https://openpyxl.readthedocs.io/en/latest/datetime.html#timezones)
value = value.replace(tzinfo=None)
# création de la cellule
cell = WriteOnlyCell(self.ws, value)
@ -341,7 +394,7 @@ class ScoExcelSheet:
if isinstance(value, datetime.date):
cell.data_type = "d"
cell.number_format = FORMAT_DATE_DDMMYY
elif isinstance(value, int) or isinstance(value, float):
elif isinstance(value, (int, float)):
cell.data_type = "n"
else:
cell.data_type = "s"
@ -358,13 +411,14 @@ class ScoExcelSheet:
for value, comment in zip(values, comments)
]
def append_single_cell_row(self, value: any, style=None):
def append_single_cell_row(self, value: any, style=None, prefix=None):
"""construit une ligne composée d'une seule cellule et l'ajoute à la feuille.
mêmes paramètres que make_cell:
value -- contenu de la cellule (texte ou numérique)
style -- style par défaut de la feuille si non spécifié
prefix -- cellules ajoutées au début de la ligne
"""
self.append_row([self.make_cell(value, style)])
self.append_row((prefix or []) + [self.make_cell(value, style)])
def append_blank_row(self):
"""construit une ligne vide et l'ajoute à la feuille."""
@ -379,21 +433,29 @@ class ScoExcelSheet:
Ce flux pourra ensuite être repris dans send_excel_file (classeur mono feille)
ou pour la génération d'un classeur multi-feuilles
"""
for row in self.column_dimensions.keys():
self.ws.column_dimensions[row] = self.column_dimensions[row]
for row in self.row_dimensions.keys():
self.ws.row_dimensions[row] = self.row_dimensions[row]
for k, v in self.column_dimensions.items():
self.ws.column_dimensions[k] = v
for k, v in self.row_dimensions.items():
self.ws.row_dimensions[k] = self.row_dimensions[v]
for row in self.rows:
self.ws.append(row)
def generate(self):
def generate(self, column_widths=None) -> AnyStr:
"""génération d'un classeur mono-feuille"""
# this method makes sense only if it is a standalone worksheet (else call workbook.generate()
# this method makes sense for standalone worksheet (else call workbook.generate())
if self.wb is None: # embeded sheet
raise ScoValueError("can't generate a single sheet from a ScoWorkbook")
# construction d'un flux (https://openpyxl.readthedocs.io/en/stable/tutorial.html#saving-as-a-stream)
# construction d'un flux
# https://openpyxl.readthedocs.io/en/stable/tutorial.html#saving-as-a-stream
self.prepare()
# largeur des colonnes
if column_widths:
for k, v in column_widths.items():
self.set_column_dimension_width(k, v)
if self.auto_filter is not None:
self.ws.auto_filter.ref = self.auto_filter
with NamedTemporaryFile() as tmp:
@ -446,211 +508,26 @@ def excel_simple_table(
return ws.generate()
def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, lines):
"""Genere feuille excel pour saisie des notes.
E: evaluation (dict)
lines: liste de tuples
(etudid, nom, prenom, etat, groupe, val, explanation)
"""
sheet_name = "Saisie notes"
ws = ScoExcelSheet(sheet_name)
# ajuste largeurs colonnes (unite inconnue, empirique)
ws.set_column_dimension_width("A", 11.0 / 7) # codes
# ws.set_column_dimension_hidden("A", True) # codes
ws.set_column_dimension_width("B", 164.00 / 7) # noms
ws.set_column_dimension_width("C", 109.0 / 7) # prenoms
ws.set_column_dimension_width("D", 164.0 / 7) # groupes
ws.set_column_dimension_width("E", 115.0 / 7) # notes
ws.set_column_dimension_width("F", 355.0 / 7) # remarques
ws.set_column_dimension_width("G", 72.0 / 7) # colonne NIP
ws.set_column_dimension_hidden("G", True) # colonne NIP cachée
# fontes
font_base = Font(name="Arial", size=12)
font_bold = Font(name="Arial", bold=True)
font_italic = Font(name="Arial", size=12, italic=True, color=COLORS.RED.value)
font_titre = Font(name="Arial", bold=True, size=14)
font_purple = Font(name="Arial", color=COLORS.PURPLE.value)
font_brown = Font(name="Arial", color=COLORS.BROWN.value)
font_blue = Font(name="Arial", size=9, color=COLORS.BLUE.value)
# bordures
side_thin = Side(border_style="thin", color=COLORS.BLACK.value)
border_top = Border(top=side_thin)
border_right = Border(right=side_thin)
# fonds
fill_light_yellow = PatternFill(
patternType="solid", fgColor=COLORS.LIGHT_YELLOW.value
)
# styles
style = {"font": font_base}
style_titres = {"font": font_titre}
style_expl = {"font": font_italic}
style_ro = { # cells read-only
"font": font_purple,
"border": border_right,
}
style_dem = {
"font": font_brown,
"border": border_top,
}
style_nom = { # style pour nom, prenom, groupe
"font": font_base,
"border": border_top,
}
style_notes = {
"font": font_bold,
"number_format": FORMAT_GENERAL,
"fill": fill_light_yellow,
"border": border_top,
}
style_comment = {
"font": font_blue,
"border": border_top,
}
# filtre
filter_top = 8
filter_bottom = 8 + len(lines)
filter_left = "A"
filter_right = "G"
ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}")
# ligne de titres
ws.append_single_cell_row(
"Feuille saisie note (à enregistrer au format excel)", style_titres
)
# lignes d'instructions
ws.append_single_cell_row(
"Saisir les notes dans la colonne E (cases jaunes)", style_expl
)
ws.append_single_cell_row("Ne pas modifier les cases en mauve !", style_expl)
# Nom du semestre
ws.append_single_cell_row(scu.unescape_html(titreannee), style_titres)
# description evaluation
ws.append_single_cell_row(scu.unescape_html(description), style_titres)
ws.append_single_cell_row(
f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient):g})",
style,
)
# ligne blanche
ws.append_blank_row()
# code et titres colonnes
ws.append_row(
[
ws.make_cell("!%s" % evaluation.id, style_ro),
ws.make_cell("Nom", style_titres),
ws.make_cell("Prénom", style_titres),
ws.make_cell("Groupe", style_titres),
ws.make_cell("Note sur %g" % (evaluation.note_max or 0.0), style_titres),
ws.make_cell("Remarque", style_titres),
ws.make_cell("NIP", style_titres),
]
)
# etudiants
for line in lines:
st = style_nom
if line[3] != "I":
st = style_dem
if line[3] == "D": # demissionnaire
s = "DEM"
else:
s = line[3] # etat autre
else:
s = line[4] # groupes TD/TP/...
try:
val = float(line[5])
except ValueError:
val = line[5]
ws.append_row(
[
ws.make_cell("!" + line[0], style_ro), # code
ws.make_cell(line[1], st),
ws.make_cell(line[2], st),
ws.make_cell(s, st),
ws.make_cell(val, style_notes), # note
ws.make_cell(line[6], style_comment), # comment
ws.make_cell(line[7], style_ro), # NIP
]
)
# ligne blanche
ws.append_blank_row()
# explication en bas
ws.append_row([None, ws.make_cell("Code notes", style_titres)])
ws.append_row(
[
None,
ws.make_cell("ABS", style_expl),
ws.make_cell("absent (0)", style_expl),
]
)
ws.append_row(
[
None,
ws.make_cell("EXC", style_expl),
ws.make_cell("pas prise en compte", style_expl),
]
)
ws.append_row(
[
None,
ws.make_cell("ATT", style_expl),
ws.make_cell("en attente", style_expl),
]
)
ws.append_row(
[
None,
ws.make_cell("SUPR", style_expl),
ws.make_cell("pour supprimer note déjà entrée", style_expl),
]
)
ws.append_row(
[
None,
ws.make_cell("", style_expl),
ws.make_cell("cellule vide -> note non modifiée", style_expl),
]
)
return ws.generate()
def excel_bytes_to_list(bytes_content):
def excel_bytes_to_list(bytes_content) -> tuple[list, list[list]]:
"Lecture d'un flux xlsx"
try:
filelike = io.BytesIO(bytes_content)
return _excel_to_list(filelike)
except Exception as exc:
raise ScoValueError(
"""Le fichier xlsx attendu n'est pas lisible !
"""Le fichier xlsx attendu n'est pas lisible ! (1)
Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ..)
"""
) from exc
return _excel_to_list(filelike)
def excel_file_to_list(filename):
def excel_file_to_list(filelike) -> tuple[list, list[list]]:
"Lecture d'un flux xlsx"
try:
return _excel_to_list(filename)
return _excel_to_list(filelike)
except Exception as exc:
raise ScoValueError(
"""Le fichier xlsx attendu n'est pas lisible !
Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
"""
) from exc
def excel_workbook_to_list(filename):
try:
return _excel_workbook_to_list(filename)
except Exception as exc:
raise ScoValueError(
"""Le fichier xlsx attendu n'est pas lisible !
"""Le fichier xlsx attendu n'est pas lisible ! (2)
Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
"""
) from exc
@ -675,7 +552,7 @@ def _open_workbook(filelike, dump_debug=False) -> Workbook:
return workbook
def _excel_to_list(filelike):
def _excel_to_list(filelike) -> tuple[list, list[list]]:
"""returns list of list"""
workbook = _open_workbook(filelike)
diag = [] # liste de chaines pour former message d'erreur
@ -692,7 +569,7 @@ def _excel_to_list(filelike):
return diag, matrix
def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list]:
def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list[list]]:
"""read a spreadsheet sheet, and returns:
- diag : a list of strings (error messages aimed at helping the user)
- a list of lists: the spreadsheet cells
@ -725,14 +602,21 @@ def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list]
return diag, matrix
def _excel_workbook_to_list(filelike):
def excel_workbook_to_list(filelike):
"""Lit un classeur (workbook): chaque feuille est lue
et est convertie en une liste de listes.
Returns:
- diag : a list of strings (error messages aimed at helping the user)
- a list of lists: the spreadsheet cells
"""
workbook = _open_workbook(filelike)
try:
workbook = _open_workbook(filelike)
except Exception as exc:
raise ScoValueError(
"""Le fichier xlsx attendu n'est pas lisible ! (3)
Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
"""
) from exc
diag = [] # liste de chaines pour former message d'erreur
if len(workbook.sheetnames) < 1:
diag.append("Aucune feuille trouvée dans le classeur !")
@ -747,6 +631,7 @@ def _excel_workbook_to_list(filelike):
return diag, matrix_list
# TODO déplacer dans un autre fichier
def excel_feuille_listeappel(
sem,
groupname,
@ -755,8 +640,23 @@ def excel_feuille_listeappel(
with_codes=False,
with_paiement=False,
server_name=None,
edt_params: dict = None,
):
"""generation feuille appel"""
"""generation feuille appel
edt_params :
- "discipline" : Discipline
- "ens" : Enseignant
- "date" : Date (format JJ/MM/AAAA)
- "heure" : Heure (format HH:MM)
"""
# Obligatoire sinon import circulaire
# pylint: disable=import-outside-toplevel
from app.scodoc.sco_groups import listgroups_abbrev, get_etud_groups
if edt_params is None:
edt_params = {}
if partitions is None:
partitions = []
formsemestre_id = sem["formsemestre_id"]
@ -764,8 +664,8 @@ def excel_feuille_listeappel(
ws = ScoExcelSheet(sheet_name)
ws.set_column_dimension_width("A", 3)
ws.set_column_dimension_width("B", 35)
ws.set_column_dimension_width("C", 12)
max_name_width: int = 35
letter_int: int = ord("B")
font1 = Font(name="Arial", size=11)
font1i = Font(name="Arial", size=10, italic=True)
@ -791,11 +691,6 @@ def excel_feuille_listeappel(
"font": Font(name="Arial", size=14),
}
style2b = {
"font": font1i,
"border": border_tblr,
}
style2t3 = {
"border": border_tblr,
}
@ -809,8 +704,6 @@ def excel_feuille_listeappel(
"font": Font(name="Arial", bold=True, size=14),
}
nb_weeks = 4 # nombre de colonnes pour remplir absences
# ligne 1
title = "%s %s (%s - %s)" % (
sco_preferences.get_preference("DeptName", formsemestre_id),
@ -822,35 +715,67 @@ def excel_feuille_listeappel(
ws.append_row([None, ws.make_cell(title, style2)])
# ligne 2
ws.append_row([None, ws.make_cell("Discipline :", style2)])
ws.append_row(
[
None,
ws.make_cell("Discipline :", style2),
ws.make_cell(edt_params.get("discipline", ""), style3),
]
)
# ligne 3
cell_2 = ws.make_cell("Enseignant :", style2)
cell_6 = ws.make_cell(f"Groupe {groupname}", style3)
ws.append_row([None, cell_2, None, None, None, None, cell_6])
ws.append_row([None, cell_2, ws.make_cell(edt_params.get("ens", ""), style3)])
# ligne 4: Avertissement pour ne pas confondre avec listes notes
# ligne 4: Avertissement pour ne pas confondre avec listes notes + Date
cell_1 = ws.make_cell("Date :", style2)
cell_2 = ws.make_cell(
"Ne pas utiliser cette feuille pour saisir les notes !", style1i
)
ws.append_row([None, None, cell_2])
ws.append_row([None, cell_1, ws.make_cell(edt_params.get("date", ""))])
# ligne 5 : Heure
ws.append_row(
[
None,
ws.make_cell("Heure :", style2),
ws.make_cell(edt_params.get("heure", "")),
]
)
# ligne 6: groupe
ws.append_row([None, ws.make_cell(f"Groupe {groupname}", style3)])
ws.append_blank_row()
ws.append_blank_row()
ws.append_row([None, cell_2])
# ligne 7: Entête (contruction dans une liste cells)
# ligne 9: Entête (contruction dans une liste cells)
cell_2 = ws.make_cell("Nom", style3)
cells = [None, cell_2]
letter_int += 1
p_name: list = []
for partition in partitions:
cells.append(ws.make_cell(partition["partition_name"], style3))
p_name.append(partition["partition_name"])
p_name: str = " / ".join(p_name)
ws.set_column_dimension_width(chr(letter_int), len(p_name))
if with_codes:
cells.append(ws.make_cell("etudid", style3))
cells.append(ws.make_cell("code_nip", style3))
cells.append(ws.make_cell("code_ine", style3))
for i in range(nb_weeks):
cells.append(ws.make_cell("", style2b))
# case Groupes
cells.append(ws.make_cell("Groupes", style3))
letter_int += 1
ws.set_column_dimension_width(chr(letter_int), 30)
# case émargement
cells.append(ws.make_cell("Émargement", style3))
letter_int += 1
ws.set_column_dimension_width(chr(letter_int), 30)
ws.append_row(cells)
row_id: int = len(ws.rows) + 1
n = 0
# pour chaque étudiant
for t in lines:
@ -858,6 +783,8 @@ def excel_feuille_listeappel(
nomprenom = (
t["civilite_str"] + " " + t["nom"] + " " + t["prenom"].lower().capitalize()
)
name_width = min(max_name_width, (len(nomprenom) + 2.0) * 1.25)
ws.set_column_dimension_width("B", name_width)
style_nom = style2t3
if with_paiement:
paie = t.get("paiementinscription", None)
@ -870,22 +797,19 @@ def excel_feuille_listeappel(
cell_1 = ws.make_cell(n, style1b)
cell_2 = ws.make_cell(nomprenom, style_nom)
cells = [cell_1, cell_2]
for partition in partitions:
if partition["partition_name"]:
cells.append(
ws.make_cell(t.get(partition["partition_id"], ""), style2t3)
)
group = get_etud_groups(t["etudid"], formsemestre_id=formsemestre_id)
cells.append(ws.make_cell(listgroups_abbrev(group), style2t3))
if with_codes:
cells.append(ws.make_cell(t["etudid"], style2t3))
code_nip = t.get("code_nip", "")
cells.append(ws.make_cell(code_nip, style2t3))
code_ine = t.get("code_ine", "")
cells.append(ws.make_cell(code_ine, style2t3))
cells.append(ws.make_cell(t.get("etath", ""), style2b))
for i in range(1, nb_weeks):
cells.append(ws.make_cell(style=style2t3))
cells.append(ws.make_cell(style=style2t3))
ws.append_row(cells)
ws.set_row_dimension_height(row_id, 30)
row_id += 1
ws.append_blank_row()

View File

@ -61,12 +61,12 @@ class ScoValueError(ScoException):
class ScoPermissionDenied(ScoValueError):
"""Permission non accordée (appli web)"""
def __init__(self, msg=None, dest_url=None):
def __init__(self, msg=None, dest_url=None, safe=False):
if msg is None:
msg = f"""Opération non autorisée pour {
current_user.get_nomcomplet() if current_user else "?"
}. Pas la permission, ou objet verrouillé."""
super().__init__(msg, dest_url=dest_url)
super().__init__(msg, dest_url=dest_url, safe=safe)
class ScoBugCatcher(ScoException):
@ -84,8 +84,8 @@ class InvalidEtudId(NoteProcessError):
class ScoFormatError(ScoValueError):
"Erreur lecture d'un fichier fourni par l'utilisateur"
def __init__(self, msg, filename="", dest_url=None):
super().__init__(msg, dest_url=dest_url)
def __init__(self, msg, filename="", dest_url=None, safe=False):
super().__init__(msg, dest_url=dest_url, safe=safe)
self.filename = filename
@ -95,15 +95,15 @@ class ScoInvalidParamError(ScoValueError):
(id strings, ...)
"""
def __init__(self, msg=None, dest_url=None):
def __init__(self, msg=None, dest_url=None, safe=False):
msg = msg or "Adresse invalide. Vérifiez vos signets."
super().__init__(msg, dest_url=dest_url)
super().__init__(msg, dest_url=dest_url, safe=safe)
class ScoPDFFormatError(ScoValueError):
"erreur génération PDF (templates platypus, ...)"
def __init__(self, msg, dest_url=None):
def __init__(self, msg, dest_url=None, safe=False):
super().__init__(
f"""Erreur dans un format pdf:
<p>{msg}</p>
@ -112,6 +112,7 @@ class ScoPDFFormatError(ScoValueError):
</p>
""",
dest_url=dest_url,
safe=safe,
)
@ -130,33 +131,33 @@ class ScoConfigurationError(ScoValueError):
class ScoLockedFormError(ScoValueError):
"Modification d'une formation verrouillée"
def __init__(self, msg="", dest_url=None):
def __init__(self, msg="", dest_url=None, safe=False):
msg = (
"Cette formation est verrouillée (car il y a un semestre verrouillé qui s'y réfère). "
+ str(msg)
)
super().__init__(msg=msg, dest_url=dest_url)
super().__init__(msg=msg, dest_url=dest_url, safe=safe)
class ScoLockedSemError(ScoValueError):
"Modification d'un formsemestre verrouillé"
def __init__(self, msg="", dest_url=None):
def __init__(self, msg="", dest_url=None, safe=False):
msg = "Ce semestre est verrouillé ! " + str(msg)
super().__init__(msg=msg, dest_url=dest_url)
super().__init__(msg=msg, dest_url=dest_url, safe=safe)
class ScoNonEmptyFormationObject(ScoValueError):
"""On ne peut pas supprimer un module/matiere ou UE si des formsemestre s'y réfèrent"""
def __init__(self, type_objet="objet'", msg="", dest_url=None):
def __init__(self, type_objet="objet'", msg="", dest_url=None, safe=False):
msg = f"""<h3>{type_objet} "{msg}" utilisé(e) dans des semestres: suppression impossible.</h3>
<p class="help">Il faut d'abord supprimer le semestre (ou en retirer ce {type_objet}).
Mais il est peut-être préférable de laisser ce programme intact et d'en créer une
nouvelle version pour la modifier sans affecter les semestres déjà en place.
</p>
"""
super().__init__(msg=msg, dest_url=dest_url)
super().__init__(msg=msg, dest_url=dest_url, safe=safe)
class ScoInvalidIdType(ScoValueError):
@ -199,11 +200,11 @@ class ScoNoReferentielCompetences(ScoValueError):
super().__init__(msg)
class ScoGenError(ScoException):
class ScoGenError(ScoValueError):
"exception avec affichage d'une page explicative ad-hoc"
def __init__(self, msg=""):
super().__init__(msg)
def __init__(self, msg="", safe=False):
super().__init__(msg, safe=safe)
class AccessDenied(ScoGenError):

View File

@ -30,8 +30,9 @@
import flask
from flask import url_for, g, request
from flask_login import current_user
import sqlalchemy as sa
import app
from app import db
from app.models import Departement, Identite
import app.scodoc.notesdb as ndb
from app.scodoc.gen_tables import GenTable
@ -101,9 +102,12 @@ def form_search_etud(
return "\n".join(H)
def search_etuds_infos_from_exp(expnom: str = "") -> list[Identite]:
def search_etuds_infos_from_exp(
expnom: str = "", dept_id: int | None = None
) -> list[Identite]:
"""Cherche étudiants, expnom peut être, dans cet ordre:
un etudid (int), un code NIP, ou une partie d'un nom (case insensitive).
Si dept_id est None, cherche dans le dept courant, sinon cherche dans le dept indiqué.
"""
if not isinstance(expnom, int) and len(expnom) <= 1:
return [] # si expnom est trop court, n'affiche rien
@ -111,23 +115,31 @@ def search_etuds_infos_from_exp(expnom: str = "") -> list[Identite]:
etudid = int(expnom)
except ValueError:
etudid = None
dept_id = g.scodoc_dept_id if dept_id is None else dept_id
if etudid is not None:
etud = Identite.query.filter_by(dept_id=g.scodoc_dept_id, id=etudid).first()
etud = Identite.query.filter_by(dept_id=dept_id, id=etudid).first()
if etud:
return [etud]
expnom_str = str(expnom)
if scu.is_valid_code_nip(expnom_str):
etuds = Identite.query.filter_by(
dept_id=g.scodoc_dept_id, code_nip=expnom_str
).all()
etuds = sorted(
Identite.query.filter_by(dept_id=dept_id, code_nip=expnom_str).all(),
key=lambda e: e.sort_key,
)
if etuds:
return etuds
return (
Identite.query.filter_by(dept_id=g.scodoc_dept_id)
.filter(Identite.nom.op("~*")(expnom_str))
.all()
)
try:
return sorted(
Identite.query.filter_by(dept_id=dept_id)
.filter(
Identite.nom.op("~*")(expnom_str)
) # ~* matches regular expression, case-insensitive
.all(),
key=lambda e: e.sort_key,
)
except sa.exc.DataError:
db.session.rollback()
return []
def search_etud_in_dept(expnom=""):
@ -191,7 +203,7 @@ def search_etud_in_dept(expnom=""):
# Choix dans la liste des résultats:
rows = []
e: Identite
for e in etuds:
for e in sorted(etuds, key=lambda e: e.sort_key):
url_args["etudid"] = e.id
target = url_for(endpoint, **url_args)
cur_inscription = e.inscription_courante()
@ -219,6 +231,7 @@ def search_etud_in_dept(expnom=""):
"inscription_target": target,
"groupes": groupes,
"nomprenom": e.nomprenom,
"_nomprenom_order": e.sort_key,
"_nomprenom_target": target,
"_nomprenom_td_attrs": f'id="{e.id}" class="etudinfo"',
}
@ -257,7 +270,6 @@ def search_etud_in_dept(expnom=""):
return "\n".join(H) + html_sco_header.sco_footer()
# Was chercheEtudsInfo()
def search_etuds_infos(expnom=None, code_nip=None) -> list[dict]:
"""recherche les étudiants correspondants à expnom ou au code_nip
et ramene liste de mappings utilisables en DTML.
@ -287,71 +299,36 @@ def search_etud_by_name(term: str) -> list:
{ "label" : "<nip> <nom> <prenom>", "value" : etudid }
"""
may_be_nip = scu.is_valid_code_nip(term)
# term = term.upper() # conserve les accents
term = term.upper()
if (
not scu.ALPHANUM_EXP.match(term) # n'autorise pas les caractères spéciaux
and not may_be_nip
):
data = []
else:
if may_be_nip:
r = ndb.SimpleDictFetch(
"""SELECT nom, prenom, code_nip
FROM identite
WHERE
dept_id = %(dept_id)s
AND code_nip ILIKE %(beginning)s
ORDER BY nom
""",
{"beginning": term + "%", "dept_id": g.scodoc_dept_id},
)
data = [
{
"label": "%s %s %s"
% (x["code_nip"], x["nom"], scu.format_prenom(x["prenom"])),
"value": x["code_nip"],
}
for x in r
]
else:
r = ndb.SimpleDictFetch(
"""SELECT id AS etudid, nom, prenom
FROM identite
WHERE
dept_id = %(dept_id)s
AND nom ILIKE %(beginning)s
ORDER BY nom
""",
{"beginning": term + "%", "dept_id": g.scodoc_dept_id},
)
etuds = search_etuds_infos_from_exp(term)
data = [
{
"label": "%s %s" % (x["nom"], scu.format_prenom(x["prenom"])),
"value": x["etudid"],
}
for x in r
]
return data
return [
{
"label": f"""{(etud.code_nip+' ') if (etud.code_nip and may_be_nip) else ""}{
etud.nom_prenom()}""",
"value": etud.id,
}
for etud in etuds
]
# ---------- Recherche sur plusieurs département
def search_etud_in_accessible_depts(expnom=None, code_nip=None):
def search_etud_in_accessible_depts(
expnom=None,
) -> tuple[list[list[Identite]], list[str]]:
"""
result is a list of (sorted) etuds, one list per dept.
result: list of (sorted) etuds, one list per dept.
accessible_depts: list of dept acronyms
"""
result = []
accessible_depts = []
depts = Departement.query.filter_by(visible=True).all()
for dept in depts:
if current_user.has_permission(Permission.ScoView, dept=dept.acronym):
if expnom or code_nip:
if expnom:
accessible_depts.append(dept.acronym)
app.set_sco_dept(dept.acronym)
etuds = search_etuds_infos(expnom=expnom, code_nip=code_nip)
etuds = search_etuds_infos_from_exp(expnom=expnom, dept_id=dept.id)
else:
etuds = []
result.append(etuds)
@ -371,21 +348,26 @@ def table_etud_in_accessible_depts(expnom=None):
]
for etuds in result:
if etuds:
dept_id = etuds[0]["dept"]
# H.append('<h3>Département %s</h3>' % DeptId)
for e in etuds:
e["_nomprenom_target"] = url_for(
"scolar.fiche_etud", scodoc_dept=dept_id, etudid=e["etudid"]
)
e["_nomprenom_td_attrs"] = f"""id="{e['etudid']}" class="etudinfo" """
dept = etuds[0].departement
rows = [
{
"nomprenom": etud.nom_prenom(),
"_nomprenom_target": url_for(
"scolar.fiche_etud", scodoc_dept=dept.acronym, etudid=etud.id
),
"_nomprenom_td_attrs": f"""id="{etud.id}" class="etudinfo" """,
"_nomprenom_order": etud.sort_key,
}
for etud in etuds
]
tab = GenTable(
titles={"nomprenom": "Étudiants en " + dept_id},
titles={"nomprenom": "Étudiants en " + dept.acronym},
columns_ids=("nomprenom",),
rows=etuds,
rows=rows,
html_sortable=True,
html_class="table_leftalign",
table_id="etud_in_accessible_depts",
# table_id="etud_in_accessible_depts",
)
H.append('<div class="table_etud_in_dept">')
@ -410,48 +392,3 @@ def table_etud_in_accessible_depts(expnom=None):
+ "\n".join(H)
+ html_sco_header.standard_html_footer()
)
def search_inscr_etud_by_nip(code_nip, fmt="json"):
"""Recherche multi-departement d'un étudiant par son code NIP
Seuls les départements accessibles par l'utilisateur sont cherchés.
Renvoie une liste des inscriptions de l'étudiants dans tout ScoDoc:
code_nip, nom, prenom, civilite_str, dept, formsemestre_id, date_debut_sem, date_fin_sem
"""
result, _ = search_etud_in_accessible_depts(code_nip=code_nip)
rows = []
for etuds in result:
if etuds:
dept_id = etuds[0]["dept"]
for e in etuds:
for sem in e["sems"]:
rows.append(
{
"dept": dept_id,
"etudid": e["etudid"],
"code_nip": e["code_nip"],
"civilite_str": e["civilite_str"],
"nom": e["nom"],
"prenom": e["prenom"],
"formsemestre_id": sem["formsemestre_id"],
"date_debut_iso": sem["date_debut_iso"],
"date_fin_iso": sem["date_fin_iso"],
}
)
columns_ids = (
"dept",
"etudid",
"code_nip",
"civilite_str",
"nom",
"prenom",
"formsemestre_id",
"date_debut_iso",
"date_fin_iso",
)
tab = GenTable(columns_ids=columns_ids, rows=rows, table_id="inscr_etud_by_nip")
return tab.make_page(fmt=fmt, with_html_headers=False, publish=True)

View File

@ -45,28 +45,29 @@ import app.scodoc.sco_utils as scu
# ---- Table recap formation
def formation_table_recap(formation_id, fmt="html") -> Response:
def formation_table_recap(formation: Formation, fmt="html") -> Response:
"""Table recapitulant formation."""
T = []
formation = Formation.query.get_or_404(formation_id)
rows = []
ues = formation.ues.order_by(UniteEns.semestre_idx, UniteEns.numero)
can_edit = current_user.has_permission(Permission.EditFormation)
li = 0
for ue in ues:
# L'UE
T.append(
rows.append(
{
"sem": f"S{ue.semestre_idx}" if ue.semestre_idx is not None else "-",
"_sem_order": f"{li:04d}",
"code": ue.acronyme,
"titre": ue.titre or "",
"_titre_target": url_for(
"notes.ue_edit",
scodoc_dept=g.scodoc_dept,
ue_id=ue.id,
)
if can_edit
else None,
"_titre_target": (
url_for(
"notes.ue_edit",
scodoc_dept=g.scodoc_dept,
ue_id=ue.id,
)
if can_edit
else None
),
"apo": ue.code_apogee or "",
"_apo_td_attrs": f""" data-oid="{ue.id}" data-value="{ue.code_apogee or ''}" """,
"coef": ue.coefficient or "",
@ -81,21 +82,25 @@ def formation_table_recap(formation_id, fmt="html") -> Response:
for mod in modules:
nb_moduleimpls = mod.modimpls.count()
# le module (ou ressource ou sae)
T.append(
rows.append(
{
"sem": f"S{mod.semestre_id}"
if mod.semestre_id is not None
else "-",
"sem": (
f"S{mod.semestre_id}"
if mod.semestre_id is not None
else "-"
),
"_sem_order": f"{li:04d}",
"code": mod.code,
"titre": mod.abbrev or mod.titre,
"_titre_target": url_for(
"notes.module_edit",
scodoc_dept=g.scodoc_dept,
module_id=mod.id,
)
if can_edit
else None,
"_titre_target": (
url_for(
"notes.module_edit",
scodoc_dept=g.scodoc_dept,
module_id=mod.id,
)
if can_edit
else None
),
"apo": mod.code_apogee,
"_apo_td_attrs": f""" data-oid="{mod.id}" data-value="{mod.code_apogee or ''}" """,
"coef": mod.coefficient,
@ -146,7 +151,7 @@ def formation_table_recap(formation_id, fmt="html") -> Response:
tab = GenTable(
columns_ids=columns_ids,
rows=T,
rows=rows,
titles=titles,
origin=f"Généré par {scu.sco_version.SCONAME} le {scu.timedate_human_repr()}",
caption=title,
@ -154,11 +159,15 @@ def formation_table_recap(formation_id, fmt="html") -> Response:
html_class=html_class,
html_class_ignore_default=True,
html_table_attrs=f"""
data-apo_ue_save_url="{url_for('notes.ue_set_apo', scodoc_dept=g.scodoc_dept)}"
data-apo_mod_save_url="{url_for('notes.module_set_apo', scodoc_dept=g.scodoc_dept)}"
data-apo_ue_save_url="{
url_for('apiweb.ue_set_code_apogee', scodoc_dept=g.scodoc_dept)
}"
data-apo_mod_save_url="{
url_for('apiweb.formation_module_set_code_apogee', scodoc_dept=g.scodoc_dept)
}"
""",
html_with_td_classes=True,
base_url=f"{request.base_url}?formation_id={formation_id}",
base_url=f"{request.base_url}",
page_title=title,
html_title=f"<h2>{title}</h2>",
pdf_title=title,
@ -182,7 +191,7 @@ def export_recap_formations_annee_scolaire(annee_scolaire):
formation_ids = {formsemestre.formation.id for formsemestre in formsemestres}
for formation_id in formation_ids:
formation = db.session.get(Formation, formation_id)
xls = formation_table_recap(formation_id, fmt="xlsx").data
xls = formation_table_recap(formation, fmt="xlsx").data
filename = (
scu.sanitize_filename(formation.get_titre_version()) + scu.XLSX_SUFFIX
)

View File

@ -125,6 +125,9 @@ def formation_export_dict(
if formation.is_apc():
# BUT: indique niveau de compétence associé à l'UE
if ue.niveau_competence:
ue_dict["apc_niveau_competence_titre"] = (
ue.niveau_competence.competence.titre
)
ue_dict["apc_niveau_libelle"] = ue.niveau_competence.libelle
ue_dict["apc_niveau_annee"] = ue.niveau_competence.annee
ue_dict["apc_niveau_ordre"] = ue.niveau_competence.ordre
@ -143,6 +146,7 @@ def formation_export_dict(
if not export_codes_apo:
ue_dict.pop("code_apogee", None)
ue_dict.pop("code_apogee_rcue", None)
if ue_dict.get("ects") is None:
ue_dict.pop("ects", None)
mats = sco_edit_matiere.matiere_list({"ue_id": ue.id})
@ -218,7 +222,7 @@ def formation_export(
"""Get a formation, with UE, matieres, modules
in desired format
"""
formation: Formation = Formation.query.get_or_404(formation_id)
formation = Formation.get_formation(formation_id)
f_dict = formation_export_dict(
formation,
export_ids=export_ids,
@ -257,29 +261,39 @@ def _formation_retreive_refcomp(f_dict: dict) -> int:
return refcomp.id
else:
flash(
f"Impossible de trouver le référentiel de compétence pour {refcomp_specialite} : est-il chargé ?"
f"""Impossible de trouver le référentiel de compétence pour {
refcomp_specialite} : est-il chargé ?"""
)
return None
def _formation_retreive_apc_niveau(
referentiel_competence_id: int, ue_dict: dict
) -> int:
) -> int | None:
"""Recherche dans le ref. de comp. un niveau pour cette UE.
Utilise (libelle, annee, ordre) comme clé.
Utilise (libelle, annee, ordre) comme clé, ou
(competence_titre, libelle, annee, ordre) si présent.
"""
libelle = ue_dict.get("apc_niveau_libelle")
annee = ue_dict.get("apc_niveau_annee")
ordre = ue_dict.get("apc_niveau_ordre")
if all((libelle, annee, ordre)):
competence_titre = ue_dict.get("apc_niveau_competence_titre")
niveau = None
if all((competence_titre, libelle, annee, ordre)):
niveau = (
ApcNiveau.query.filter_by(libelle=libelle, annee=annee, ordre=ordre)
.join(ApcCompetence)
.filter_by(referentiel_id=referentiel_competence_id, titre=competence_titre)
).first()
elif all((libelle, annee, ordre)):
niveau = (
ApcNiveau.query.filter_by(libelle=libelle, annee=annee, ordre=ordre)
.join(ApcCompetence)
.filter_by(referentiel_id=referentiel_competence_id)
).first()
if niveau is not None:
return niveau.id
return None
return niveau.id if niveau is not None else None
def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False):

View File

@ -39,7 +39,7 @@ import app.scodoc.sco_utils as scu
from app import log
from app.models import Departement
from app.models import Formation, FormSemestre
from app.scodoc import sco_cache, codes_cursus, sco_formations, sco_preferences
from app.scodoc import sco_cache, codes_cursus, sco_preferences
from app.scodoc.gen_tables import GenTable
from app.scodoc.codes_cursus import NO_SEMESTRE_ID
from app.scodoc.sco_exceptions import ScoInvalidIdType, ScoValueError
@ -68,6 +68,7 @@ _formsemestreEditor = ndb.EditableTable(
"ens_can_edit_eval",
"elt_sem_apo",
"elt_annee_apo",
"elt_passage_apo",
"edt_id",
),
filter_dept=True,
@ -229,11 +230,14 @@ def etapes_apo_str(etapes):
return ", ".join([str(x) for x in etapes])
def do_formsemestre_create(args, silent=False):
def do_formsemestre_create( # DEPRECATED, use FormSemestre.create_formsemestre()
args, silent=False
):
"create a formsemestre"
from app.models import ScolarNews
from app.scodoc import sco_groups
log("Warning: do_formsemestre_create is deprecated")
cnx = ndb.GetDBConnexion()
formsemestre_id = _formsemestreEditor.create(cnx, args)
if args["etapes"]:
@ -419,49 +423,23 @@ def sem_set_responsable_name(sem):
)
def sem_in_semestre_scolaire(
sem,
year=False,
periode=None,
mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
) -> bool:
"""Vrai si la date du début du semestre est dans la période indiquée (1,2,0)
du semestre `periode` de l'année scolaire indiquée
(ou, à défaut, de celle en cours).
La période utilise les même conventions que semset["sem_id"];
* 1 : première période
* 2 : deuxième période
* 0 ou période non précisée: annualisé (donc inclut toutes les périodes)
)
"""
if not year:
year = scu.annee_scolaire()
# n'utilise pas le jour pivot
jour_pivot_annee = jour_pivot_periode = 1
# calcule l'année universitaire et la période
sem_annee, sem_periode = FormSemestre.comp_periode(
datetime.datetime.fromisoformat(sem["date_debut_iso"]),
mois_pivot_annee,
mois_pivot_periode,
jour_pivot_annee,
jour_pivot_periode,
)
if periode is None or periode == 0:
return sem_annee == year
return sem_annee == year and sem_periode == periode
def sem_in_annee_scolaire(sem, year=False):
def sem_in_annee_scolaire(sem: dict, year=False): # OBSOLETE
"""Test si sem appartient à l'année scolaire year (int).
N'utilise que la date de début, pivot au 1er août.
Si année non specifiée, année scolaire courante
"""
return sem_in_semestre_scolaire(sem, year, periode=0)
return FormSemestre.est_in_semestre_scolaire(
datetime.date.fromisoformat(sem["date_debut_iso"]), year, periode=0
)
def sem_est_courant(sem): # -> FormSemestre.est_courant
def sem_in_semestre_scolaire(sem, year=False, periode=None): # OBSOLETE
return FormSemestre.est_in_semestre_scolaire(
datetime.date.fromisoformat(sem["date_debut_iso"]), year, periode=periode
)
def sem_est_courant(sem: dict): # -> FormSemestre.est_courant
"""Vrai si la date actuelle (now) est dans le semestre (les dates de début et fin sont incluses)"""
now = time.strftime("%Y-%m-%d")
debut = ndb.DateDMYtoISO(sem["date_debut"])

View File

@ -37,16 +37,17 @@ from app import db
from app.auth.models import User
from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN
from app.models import (
Module,
ModuleImpl,
Evaluation,
UniteEns,
ScoDocSiteConfig,
ScolarFormSemestreValidation,
ScolarAutorisationInscription,
ApcValidationAnnee,
ApcValidationRCUE,
Evaluation,
FormSemestreUECoef,
Module,
ModuleImpl,
ScoDocSiteConfig,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
ScolarNews,
UniteEns,
)
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
@ -62,7 +63,6 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc import html_sco_header
from app.scodoc import codes_cursus
from app.scodoc import sco_compute_moy
from app.scodoc import sco_edit_module
from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups_copy
@ -76,7 +76,7 @@ from app.scodoc import sco_users
def _default_sem_title(formation):
"""Default title for a semestre in formation"""
return formation.titre
return formation.acronyme
def formsemestre_createwithmodules():
@ -438,8 +438,24 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
"elt_sem_apo",
{
"size": 32,
"title": "Element(s) Apogée:",
"explanation": "associé(s) au résultat du semestre (ex: VRTW1). Inutile en BUT. Séparés par des virgules.",
"title": "Element(s) Apogée sem.:",
"explanation": """associé(s) au résultat du semestre (ex: VRTW1).
Inutile en BUT. Séparés par des virgules.""",
"allow_null": (
not sco_preferences.get_preference("always_require_apo_sem_codes")
or (formsemestre and formsemestre.modalite == "EXT")
or (formsemestre and formsemestre.formation.is_apc())
),
},
)
)
modform.append(
(
"elt_annee_apo",
{
"size": 32,
"title": "Element(s) Apogée année:",
"explanation": "associé(s) au résultat de l'année (ex: VRT1A). Séparés par des virgules.",
"allow_null": not sco_preferences.get_preference(
"always_require_apo_sem_codes"
)
@ -449,15 +465,12 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
)
modform.append(
(
"elt_annee_apo",
"elt_passage_apo",
{
"size": 32,
"title": "Element(s) Apogée:",
"explanation": "associé(s) au résultat de l'année (ex: VRT1A). Séparés par des virgules.",
"allow_null": not sco_preferences.get_preference(
"always_require_apo_sem_codes"
)
or (formsemestre and formsemestre.modalite == "EXT"),
"title": "Element(s) Apogée passage:",
"explanation": "associé(s) au passage. Séparés par des virgules.",
"allow_null": True, # toujours optionnel car rarement utilisé
},
)
)
@ -1249,7 +1262,7 @@ def formsemestre_clone(formsemestre_id):
raise ScoValueError("id responsable invalide")
new_formsemestre_id = do_formsemestre_clone(
formsemestre_id,
resp.id,
resp,
tf[2]["date_debut"],
tf[2]["date_fin"],
clone_evaluations=tf[2]["clone_evaluations"],
@ -1267,7 +1280,7 @@ def formsemestre_clone(formsemestre_id):
def do_formsemestre_clone(
orig_formsemestre_id,
responsable_id, # new resp.
responsable: User, # new resp.
date_debut,
date_fin, # 'dd/mm/yyyy'
clone_evaluations=False,
@ -1280,49 +1293,68 @@ def do_formsemestre_clone(
formsemestre_orig: FormSemestre = FormSemestre.query.get_or_404(
orig_formsemestre_id
)
orig_sem = sco_formsemestre.get_formsemestre(orig_formsemestre_id)
cnx = ndb.GetDBConnexion()
# 1- create sem
args = orig_sem.copy()
args = formsemestre_orig.to_dict()
del args["formsemestre_id"]
args["responsables"] = [responsable_id]
del args["id"]
del args["parcours"] # copiés ensuite
args["responsables"] = [responsable]
args["date_debut"] = date_debut
args["date_fin"] = date_fin
args["etat"] = 1 # non verrouillé
formsemestre_id = sco_formsemestre.do_formsemestre_create(args)
log(f"created formsemestre {formsemestre_id}")
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
formsemestre = FormSemestre.create_formsemestre(args)
log(f"created formsemestre {formsemestre}")
# 2- create moduleimpls
modimpl_orig: ModuleImpl
for modimpl_orig in formsemestre_orig.modimpls:
assert isinstance(modimpl_orig, ModuleImpl)
assert isinstance(modimpl_orig.id, int)
log(f"cloning {modimpl_orig}")
args = modimpl_orig.to_dict(with_module=False)
args["formsemestre_id"] = formsemestre_id
args["formsemestre_id"] = formsemestre.id
modimpl_new = ModuleImpl.create_from_dict(args)
log(f"created ModuleImpl from {args}")
db.session.flush()
# copy enseignants
for ens in modimpl_orig.enseignants:
modimpl_new.enseignants.append(ens)
db.session.add(modimpl_new)
db.session.flush()
log(f"new moduleimpl.id = {modimpl_new.id}")
# optionally, copy evaluations
if clone_evaluations:
e: Evaluation
for e in Evaluation.query.filter_by(moduleimpl_id=modimpl_orig.id):
log(f"cloning evaluation {e.id}")
# copie en enlevant la date
new_eval = e.clone(
not_copying=("date_debut", "date_fin", "moduleimpl_id")
)
new_eval.moduleimpl_id = modimpl_new.id
args = dict(e.__dict__)
args.pop("_sa_instance_state")
args.pop("id")
args.pop("date_debut", None)
args.pop("date_fin", None)
args["moduleimpl_id"] = modimpl_new.id
new_eval = Evaluation(**args)
db.session.add(new_eval)
db.session.commit()
# Copie les poids APC de l'évaluation
new_eval.set_ue_poids_dict(e.get_ue_poids_dict())
db.session.commit()
if clone_evaluations:
flash(
"Attention: les évaluations n'ont plus de dates: n'oubliez pas de les indiquer"
)
# 3- copy uecoefs
objs = sco_formsemestre.formsemestre_uecoef_list(
cnx, args={"formsemestre_id": orig_formsemestre_id}
)
for obj in objs:
args = obj.copy()
args["formsemestre_id"] = formsemestre_id
_ = sco_formsemestre.formsemestre_uecoef_create(cnx, args)
for ue_coef in FormSemestreUECoef.query.filter_by(
formsemestre_id=formsemestre_orig.id
):
new_ue_coef = FormSemestreUECoef(
formsemestre_id=formsemestre.id,
ue_id=ue_coef.ue_id,
coefficient=ue_coef.coefficient,
)
db.session.add(new_ue_coef)
db.session.flush()
# NB: don't copy notes_formsemestre_custommenu (usually specific)
@ -1334,11 +1366,11 @@ def do_formsemestre_clone(
if not prefs.is_global(pname):
pvalue = prefs[pname]
try:
prefs.base_prefs.set(formsemestre_id, pname, pvalue)
prefs.base_prefs.set(formsemestre.id, pname, pvalue)
except ValueError:
log(
"do_formsemestre_clone: ignoring old preference %s=%s for %s"
% (pname, pvalue, formsemestre_id)
f"""do_formsemestre_clone: ignoring old preference {
pname}={pvalue} for {formsemestre}"""
)
# 5- Copie les parcours
@ -1349,10 +1381,10 @@ def do_formsemestre_clone(
# 6- Copy partitions and groups
if clone_partitions:
sco_groups_copy.clone_partitions_and_groups(
orig_formsemestre_id, formsemestre_id
orig_formsemestre_id, formsemestre.id
)
return formsemestre_id
return formsemestre.id
def formsemestre_delete(formsemestre_id: int) -> str | flask.Response:

View File

@ -42,7 +42,6 @@ from app.models.groups import Partition, GroupDescr
from app.models.scolar_event import ScolarEvent
import app.scodoc.sco_utils as scu
from app import log
from app.scodoc.scolog import logdb
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.codes_cursus import UE_STANDARD, UE_SPORT, UE_TYPE_NAME
import app.scodoc.notesdb as ndb
@ -112,12 +111,11 @@ def do_formsemestre_inscription_create(args, method=None):
},
)
# Log etudiant
logdb(
cnx,
Scolog.logdb(
method=method,
etudid=args["etudid"],
msg=f"inscription en semestre {args['formsemestre_id']}",
commit=False,
commit=True,
)
#
sco_cache.invalidate_formsemestre(formsemestre_id=args["formsemestre_id"])
@ -265,12 +263,11 @@ def do_formsemestre_desinscription(
db.session.commit()
flash(f"Semestre extérieur supprimé: {formsemestre.titre_annee()}")
logdb(
cnx,
Scolog.logdb(
method="formsemestre_desinscription",
etudid=etudid,
msg=f"desinscription semestre {formsemestre_id}",
commit=False,
commit=True,
)

View File

@ -58,12 +58,13 @@ from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
from app.scodoc import codes_cursus
from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_archives_formsemestre
from app.scodoc import sco_assiduites as scass
from app.scodoc import sco_bulletins
from app.scodoc import codes_cursus
from app.scodoc import sco_compute_moy
from app.scodoc import sco_cache
from app.scodoc import sco_evaluations
from app.scodoc import sco_formations
from app.scodoc import sco_formsemestre
@ -74,6 +75,7 @@ from app.scodoc import sco_users
from app.scodoc.gen_tables import GenTable
from app.scodoc.html_sidebar import retreive_formsemestre_from_request
from app.scodoc.sco_formsemestre_custommenu import formsemestre_custommenu_html
import sco_version
@ -426,6 +428,12 @@ def formsemestre_status_menubar(formsemestre: FormSemestre | None) -> str:
"endpoint": "notes.formsemestre_list_saisies_notes",
"args": {"formsemestre_id": formsemestre_id},
},
{
"title": "Importer les notes",
"endpoint": "notes.formsemestre_import_notes",
"args": {"formsemestre_id": formsemestre_id},
"enabled": formsemestre.est_chef_or_diretud(),
},
]
menu_jury = [
{
@ -783,6 +791,10 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
)
#
H.append('<div class="sem-groups-abs">')
disable_abs: str | bool = scass.has_assiduites_disable_pref(formsemestre)
show_abs: str = "hidden" if disable_abs else ""
# Genere liste pour chaque partition (categorie de groupes)
for partition in formsemestre.get_partitions_list():
groups = partition.groups.all()
@ -794,13 +806,14 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
<div class="sem-groups-partition-titre">{
'Groupes de ' + partition.partition_name
if partition.partition_name else
'Tous les étudiants'}
('aucun étudiant inscrit' if partition_is_empty else 'Tous les étudiants')}
</div>
<div class="sem-groups-partition-titre">{
"Assiduité" if not partition_is_empty else ""
"Assiduité" if not partition_is_empty and not show_abs else ""
}</div>
"""
)
if groups:
for group in groups:
n_members = effectifs[group.id]
@ -821,8 +834,7 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
- {n_members} étudiants</a>
</div>
</div>
<div class="sem-groups-assi">
<div class="sem-groups-assi {show_abs}">
"""
)
if can_edit_abs:
@ -885,7 +897,7 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
)
H.append("</div>") # /sem-groups-assi
if partition_is_empty:
if partition_is_empty and not partition.is_default():
H.append(
'<div class="help sem-groups-none">Aucun groupe peuplé dans cette partition'
)
@ -910,7 +922,45 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
}">Ajouter une partition</a></h4>"""
)
# --- Formulaire importation Assiduité excel (si autorisé)
if current_user.has_permission(Permission.AbsChange) and not disable_abs:
H.append(
f"""<p>
<a class="stdlink" href="{url_for('assiduites.feuille_abs_formsemestre',
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id)}">
Importation de l'assiduité depuis un fichier excel</a>
</p>"""
)
# --- Lien Traitement Justificatifs:
if (
current_user.has_permission(Permission.AbsJustifView)
and current_user.has_permission(Permission.JustifValidate)
and not disable_abs
):
H.append(
f"""<p>
<a class="stdlink" href="{url_for('assiduites.traitement_justificatifs',
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id)}">
Traitement des justificatifs d'absence</a>
</p>"""
)
H.append("</div>")
if disable_abs:
H.append(
f"""
<div class="scobox" style="width:fit-content; font-style: italic;">
La gestion des absences est désactivée dans ScoDoc pour ce semestre:
{disable_abs}
</div>
"""
)
return "\n".join(H)
@ -1018,9 +1068,6 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id)
H = [
html_sco_header.sco_header(
page_title=f"{formsemestre.sem_modalite()} {formsemestre.titre_annee()}"
),
'<div class="formsemestre_status">',
formsemestre_status_head(
formsemestre_id=formsemestre_id, page_title="Tableau de bord"
@ -1134,18 +1181,6 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
"</div>",
]
# --- Lien Traitement Justificatifs:
if current_user.has_permission(Permission.AbsJustifView):
H.append(
f"""<p>
<a class="stdlink" href="{url_for('assiduites.traitement_justificatifs',
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id)}">
Traitement des justificatifs d'absence</a>
</p>"""
)
# --- Lien mail enseignants:
adrlist = list(mails_enseignants - {None, ""})
if adrlist:
@ -1155,7 +1190,11 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
len(adrlist)} enseignants du semestre</a>
</p>"""
)
return "".join(H) + html_sco_header.sco_footer()
return render_template(
"sco_page.j2",
content="".join(H),
title=f"{formsemestre.sem_modalite()} {formsemestre.titre_annee()}",
)
_TABLEAU_MODULES_HEAD = """
@ -1218,18 +1257,6 @@ def formsemestre_tableau_modules(
<td colspan="2">"""
)
expr = sco_compute_moy.get_ue_expression(
formsemestre.id, ue.id, html_quote=True
)
if expr:
H.append(
f""" <span class="formula" title="mode de calcul de la moyenne d'UE">{expr}</span>
<span class="warning">formule inutilisée en ScoDoc 9: <a href="{
url_for("notes.delete_ue_expr", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id, ue_id=ue.id )
}
">supprimer</a></span>"""
)
H.append("</td></tr>")
if ue.type != codes_cursus.UE_STANDARD:
@ -1428,7 +1455,10 @@ def formsemestre_note_etuds_sans_notes(
):
"""Affichage et saisie des étudiants sans notes
Si etudid est spécifié, traite un seul étudiant."""
Si etudid est spécifié, traite un seul étudiant.
"""
from app.views import ScoData
formsemestre: FormSemestre = FormSemestre.query.filter_by(
id=formsemestre_id, dept_id=g.scodoc_dept_id
).first_or_404()
@ -1443,8 +1473,9 @@ def formsemestre_note_etuds_sans_notes(
if request.method == "POST":
if not code in ("ATT", "EXC", "ABS"):
raise ScoValueError("code invalide: doit être ATT, ABS ou EXC")
for etud in etuds:
formsemestre.etud_set_all_missing_notes(etud, code)
with sco_cache.DeferredSemCacheManager():
for etud in etuds:
formsemestre.etud_set_all_missing_notes(etud, code)
flash(f"Notes de {len(etuds)} étudiants affectées à {code}")
return redirect(
url_for(
@ -1453,61 +1484,19 @@ def formsemestre_note_etuds_sans_notes(
formsemestre_id=formsemestre.id,
)
)
if not etuds:
if etudid is None:
message = """<h3>aucun étudiant sans notes</h3>"""
else:
flash(
f"""{Identite.get_etud(etudid).nomprenom}
if not etuds and etudid is not None:
flash(
f"""{Identite.get_etud(etudid).nomprenom}
a déjà des notes"""
)
return redirect(
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
else:
noms = "</li><li>".join(
[
f"""<a href="{
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}" class="discretelink">{etud.nomprenom}</a>"""
for etud in etuds
]
)
message = f"""
<h3>Étudiants sans notes:</h3>
<ul>
<li>{noms}</li>
</ul>
"""
return f"""
{html_sco_header.sco_header(
page_title=f"{formsemestre.sem_modalite()} {formsemestre.titre_annee()}"
)}
<div class="formsemestre_status">
{formsemestre_status_head(
formsemestre_id=formsemestre_id, page_title="Étudiants sans notes"
)}
</div>
{message}
<style>
.sco-std-form select, .sco-std-form input[type="submit"] {{
height: 24px;
}}
</style>
<form class="sco-std-form" method="post">
<input type="hidden" name="formsemestre_id" value="{formsemestre.id}">
<input type="hidden" name="etudid" value="{etudid or ""}">
Mettre toutes les notes de {"ces étudiants" if len(etuds)> 1 else "cet étudiant"}
à&nbsp;:
<select name="code">
<option value="ABS">ABS (absent, compte zéro)</option>
<option value="ATT" selected>ATT (en attente)</option>
<option value="EXC">EXC (neutralisée)</option>
</select>
<input type="submit" value="Enregistrer">
</form>
{html_sco_header.sco_footer()}
"""
return redirect(
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
etud = Identite.get_etud(etudid) if etudid is not None else None
return render_template(
"formsemestre/etuds_sans_notes.j2",
etudid=etudid,
etuds=etuds,
sco=ScoData(formsemestre=formsemestre, etud=etud),
title=f"{formsemestre.sem_modalite()} {formsemestre.titre_annee()}",
)

View File

@ -31,7 +31,7 @@ import time
import flask
from flask import url_for, flash, g, request
from flask_login import current_user
from flask.templating import render_template
import sqlalchemy as sa
from app.models import Identite, Evaluation
@ -41,7 +41,7 @@ from app import db, log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Formation, FormSemestre, UniteEns, ScolarNews
from app.models import Formation, FormSemestre, UniteEns, ScolarNews, Scolog
from app.models.notes import etud_has_notes_attente
from app.models.validations import (
ScolarAutorisationInscription,
@ -49,7 +49,6 @@ from app.models.validations import (
)
from app.models.but_validations import ApcValidationRCUE, ApcValidationAnnee
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.scolog import logdb
from app.scodoc.codes_cursus import *
from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
@ -65,7 +64,6 @@ from app.scodoc import sco_cursus_dut
from app.scodoc.sco_cursus_dut import etud_est_inscrit_ue
from app.scodoc import sco_preferences
from app.scodoc import sco_pv_dict
from app.scodoc.sco_permissions import Permission
# ------------------------------------------------------------------------------------
@ -116,7 +114,7 @@ def formsemestre_validation_etud_form(
check = True
etud_d = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
Se = sco_cursus.get_situation_etud_cursus(etud_d, formsemestre_id)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
if not Se.sem["etat"]:
raise ScoValueError("validation: semestre verrouille")
@ -262,8 +260,8 @@ def formsemestre_validation_etud_form(
return "\n".join(H + footer)
# Infos si pas de semestre précédent
if not Se.prev:
if Se.sem["semestre_id"] == 1:
if not Se.prev_formsemestre:
if Se.cur_sem.semestre_id == 1:
H.append("<p>Premier semestre (pas de précédent)</p>")
else:
H.append("<p>Pas de semestre précédent !</p>")
@ -274,7 +272,7 @@ def formsemestre_validation_etud_form(
f"""Le jury n'a pas statué sur le semestre précédent ! (<a href="{
url_for("notes.formsemestre_validation_etud_form",
scodoc_dept=g.scodoc_dept,
formsemestre_id=Se.prev["formsemestre_id"],
formsemestre_id=Se.prev_formsemestre.id,
etudid=etudid)
}">le faire maintenant</a>)
"""
@ -310,9 +308,9 @@ def formsemestre_validation_etud_form(
H.append("</p>")
# Cas particulier pour ATJ: corriger precedent avant de continuer
if Se.prev_decision and Se.prev_decision["code"] == ATJ:
if Se.prev_formsemestre and Se.prev_decision and Se.prev_decision["code"] == ATJ:
H.append(
"""<div class="sfv_warning"><p>La décision du semestre précédent est en
f"""<div class="sfv_warning"><p>La décision du semestre précédent est en
<b>attente</b> à cause d\'un <b>problème d\'assiduité<b>.</p>
<p>Vous devez la corriger avant de continuer ce jury. Soit vous considérez que le
problème d'assiduité n'est pas réglé et choisissez de ne pas valider le semestre
@ -320,14 +318,16 @@ def formsemestre_validation_etud_form(
l'assiduité.</p>
<form method="get" action="formsemestre_validation_etud_form">
<input type="submit" value="Statuer sur le semestre précédent"/>
<input type="hidden" name="formsemestre_id" value="%s"/>
<input type="hidden" name="etudid" value="%s"/>
<input type="hidden" name="desturl" value="formsemestre_validation_etud_form?etudid=%s&formsemestre_id=%s"/>
<input type="hidden" name="formsemestre_id" value="{Se.prev_formsemestre.id}"/>
<input type="hidden" name="etudid" value="{etudid}"/>
<input type="hidden" name="desturl" value="{
url_for("notes.formsemestre_validation_etud_form",
etudid=etudid, formsemestre_id=formsemestre_id, scodoc_dept=g.scodoc_dept
)}"/>
"""
% (Se.prev["formsemestre_id"], etudid, etudid, formsemestre_id)
)
if sortcol:
H.append('<input type="hidden" name="sortcol" value="%s"/>' % sortcol)
H.append(f"""<input type="hidden" name="sortcol" value="{sortcol}"/>""")
H.append("</form></div>")
H.append(html_sco_header.sco_footer())
@ -405,7 +405,7 @@ def formsemestre_validation_etud(
sortcol=None,
):
"""Enregistre validation"""
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
# retrouve la decision correspondant au code:
choices = Se.get_possible_choices(assiduite=True)
@ -438,7 +438,7 @@ def formsemestre_validation_etud_manu(
"""Enregistre validation"""
if assidu:
assidu = True
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
if code_etat in Se.parcours.UNUSED_CODES:
raise ScoValueError("code decision invalide dans ce parcours")
@ -494,32 +494,35 @@ def decisions_possible_rows(Se, assiduite, subtitle="", trclass=""):
choices = Se.get_possible_choices(assiduite=assiduite)
if not choices:
return ""
TitlePrev = ""
if Se.prev:
if Se.prev["semestre_id"] >= 0:
TitlePrev = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.prev["semestre_id"])
prev_title = ""
if Se.prev_formsemestre:
if Se.prev_formsemestre.semestre_id >= 0:
prev_title = "%s%d" % (
Se.parcours.SESSION_ABBRV,
Se.prev_formsemestre.semestre_id,
)
else:
TitlePrev = "Prec."
prev_title = "Prec."
if Se.sem["semestre_id"] >= 0:
TitleCur = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.sem["semestre_id"])
if Se.cur_sem.semestre_id >= 0:
cur_title = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.cur_sem.semestre_id)
else:
TitleCur = Se.parcours.SESSION_NAME
cur_title = Se.parcours.SESSION_NAME
H = [
'<tr class="%s titles"><th class="sfv_subtitle">%s</em></th>'
% (trclass, subtitle)
]
if Se.prev:
H.append("<th>Code %s</th>" % TitlePrev)
H.append("<th>Code %s</th><th>Devenir</th></tr>" % TitleCur)
if Se.prev_formsemestre:
H.append(f"<th>Code {prev_title}</th>")
H.append(f"<th>Code {cur_title}</th><th>Devenir</th></tr>")
for ch in choices:
H.append(
"""<tr class="%s"><td title="règle %s"><input type="radio" name="codechoice" value="%s" onClick="document.getElementById('subut').disabled=false;">"""
% (trclass, ch.rule_id, ch.codechoice)
)
H.append("%s </input></td>" % ch.explication)
if Se.prev:
if Se.prev_formsemestre:
H.append('<td class="centercell">%s</td>' % _dispcode(ch.new_code_prev))
H.append(
'<td class="centercell">%s</td><td>%s</td>'
@ -535,7 +538,6 @@ def formsemestre_recap_parcours_table(
etudid,
with_links=False,
with_all_columns=True,
a_url="",
sem_info=None,
show_details=False,
):
@ -576,14 +578,14 @@ def formsemestre_recap_parcours_table(
H.append("<th></th></tr>")
num_sem = 0
for sem in situation_etud_cursus.get_semestres():
is_prev = situation_etud_cursus.prev and (
situation_etud_cursus.prev["formsemestre_id"] == sem["formsemestre_id"]
for formsemestre in situation_etud_cursus.formsemestres:
is_prev = situation_etud_cursus.prev_formsemestre and (
situation_etud_cursus.prev_formsemestre.id == formsemestre.id
)
is_cur = situation_etud_cursus.formsemestre_id == sem["formsemestre_id"]
is_cur = situation_etud_cursus.formsemestre_id == formsemestre.id
num_sem += 1
dpv = sco_pv_dict.dict_pvjury(sem["formsemestre_id"], etudids=[etudid])
dpv = sco_pv_dict.dict_pvjury(formsemestre.id, etudids=[etudid])
pv = dpv["decisions"][0]
decision_sem = pv["decision_sem"]
decisions_ue = pv["decisions_ue"]
@ -592,7 +594,6 @@ def formsemestre_recap_parcours_table(
else:
ass = ""
formsemestre = db.session.get(FormSemestre, sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if is_cur:
type_sem = "*" # now unused
@ -603,20 +604,24 @@ def formsemestre_recap_parcours_table(
else:
type_sem = ""
class_sem = "sem_autre"
if sem["formation_code"] != situation_etud_cursus.formation.formation_code:
if (
formsemestre.formation.formation_code
!= situation_etud_cursus.formation.formation_code
):
class_sem += " sem_autre_formation"
if sem["bul_bgcolor"]:
bgcolor = sem["bul_bgcolor"]
else:
bgcolor = "background-color: rgb(255,255,240)"
bgcolor = (
formsemestre.bul_bgcolor
if formsemestre.bul_bgcolor
else "background-color: rgb(255,255,240)"
)
# 1ere ligne: titre sem, decision, acronymes UE
H.append('<tr class="%s rcp_l1 sem_%s">' % (class_sem, sem["formsemestre_id"]))
H.append('<tr class="%s rcp_l1 sem_%s">' % (class_sem, formsemestre.id))
if is_cur:
pm = ""
elif is_prev:
pm = minuslink % sem["formsemestre_id"]
pm = minuslink % formsemestre.id
else:
pm = plusminus % sem["formsemestre_id"]
pm = plusminus % formsemestre.id
inscr = formsemestre.etuds_inscriptions.get(etudid)
parcours_name = ""
@ -638,9 +643,12 @@ def formsemestre_recap_parcours_table(
H.append(
f"""
<td class="rcp_type_sem" style="background-color:{bgcolor};">{num_sem}{pm}</td>
<td class="datedebut">{sem['mois_debut']}</td>
<td class="datedebut">{formsemestre.mois_debut()}</td>
<td class="rcp_titre_sem"><a class="formsemestre_status_link"
href="{a_url}formsemestre_bulletinetud?formsemestre_id={formsemestre.id}&etudid={etudid}"
href="{
url_for("notes.formsemestre_bulletinetud", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id, etudid=etudid
)}"
title="Bulletin de notes">{formsemestre.titre_annee()}{parcours_name}</a>
"""
)
@ -675,7 +683,7 @@ def formsemestre_recap_parcours_table(
ues = [
ue
for ue in ues
if etud_est_inscrit_ue(cnx, etudid, sem["formsemestre_id"], ue.id)
if etud_est_inscrit_ue(cnx, etudid, formsemestre.id, ue.id)
or etud_ue_status[ue.id]["is_capitalized"]
]
@ -697,7 +705,7 @@ def formsemestre_recap_parcours_table(
H.append("<td></td>")
H.append("</tr>")
# 2eme ligne: notes
H.append(f"""<tr class="{class_sem} rcp_l2 sem_{sem["formsemestre_id"]}">""")
H.append(f"""<tr class="{class_sem} rcp_l2 sem_{formsemestre.id}">""")
H.append(
f"""<td class="rcp_type_sem"
style="background-color:{bgcolor};">&nbsp;</td>"""
@ -706,21 +714,28 @@ def formsemestre_recap_parcours_table(
default_sem_info = '<span class="fontred">[sem. précédent]</span>'
else:
default_sem_info = ""
if not sem["etat"]: # locked
if not formsemestre.etat: # locked
lockicon = scu.icontag("lock32_img", title="verrouillé", border="0")
default_sem_info += lockicon
if sem["formation_code"] != situation_etud_cursus.formation.formation_code:
default_sem_info += f"""Autre formation: {sem["formation_code"]}"""
if (
formsemestre.formation.formation_code
!= situation_etud_cursus.formation.formation_code
):
default_sem_info += (
f"""Autre formation: {formsemestre.formation.formation_code}"""
)
H.append(
'<td class="datefin">%s</td><td class="sem_info">%s</td>'
% (sem["mois_fin"], sem_info.get(sem["formsemestre_id"], default_sem_info))
% (formsemestre.mois_fin(), sem_info.get(formsemestre.id, default_sem_info))
)
# Moy Gen (sous le code decision)
H.append(
f"""<td class="rcp_moy">{scu.fmt_note(nt.get_etud_moy_gen(etudid))}</td>"""
)
# Absences (nb d'abs non just. dans ce semestre)
nbabsnj = sco_assiduites.get_assiduites_count(etudid, sem)[0]
nbabsnj = sco_assiduites.formsemestre_get_assiduites_count(
etudid, formsemestre
)[0]
H.append(f"""<td class="rcp_abs">{nbabsnj}</td>""")
# UEs
@ -767,26 +782,30 @@ def formsemestre_recap_parcours_table(
H.append("<td></td>")
if with_links:
H.append(
'<td><a href="%sformsemestre_validation_etud_form?formsemestre_id=%s&etudid=%s">modifier</a></td>'
% (a_url, sem["formsemestre_id"], etudid)
f"""<td><a class="stdlink" href="{
url_for("notes.formsemestre_validation_etud_form", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id, etudid=etudid
)}">modifier</a></td>"""
)
H.append("</tr>")
# 3eme ligne: ECTS
if (
sco_preferences.get_preference("bul_show_ects", sem["formsemestre_id"])
sco_preferences.get_preference("bul_show_ects", formsemestre.id)
or nt.parcours.ECTS_ONLY
):
etud_ects_infos = nt.get_etud_ects_pot(etudid) # ECTS potentiels
H.append(
f"""<tr class="{class_sem} rcp_l2 sem_{sem["formsemestre_id"]}">
f"""<tr class="{class_sem} rcp_l2 sem_{formsemestre.id}">
<td class="rcp_type_sem" style="background-color:{bgcolor};">&nbsp;</td>
<td></td>"""
)
# Total ECTS (affiché sous la moyenne générale)
H.append(
f"""<td class="sem_ects_tit"><a title="crédit acquis">ECTS:</a></td>
<td class="sem_ects">{pv.get("sum_ects",0):2.2g} / {etud_ects_infos["ects_total"]:2.2g}</td>
<td class="sem_ects">{
pv.get("sum_ects",0):2.3g} / {etud_ects_infos["ects_total"]:2.3g}
</td>
<td class="rcp_abs"></td>
"""
)
@ -798,7 +817,7 @@ def formsemestre_recap_parcours_table(
ects_pot = ue_status["ects_pot"]
H.append(
f"""<td class="ue"
title="{ects:2.2g}/{ects_pot:2.2g} ECTS">{ects:2.2g}</td>"""
title="{ects:2.3g}/{ects_pot:2.3g} ECTS">{ects:2.3g}</td>"""
)
else:
H.append("""<td class="ue"></td>""")
@ -865,7 +884,7 @@ def form_decision_manuelle(Se, formsemestre_id, etudid, desturl="", sortcol=None
# précédent n'est pas géré dans ScoDoc (code ADC_)
# log(str(Se.sems))
for sem in Se.sems:
if sem["can_compensate"]:
if sem["formsemestre_id"] in Se.can_compensate:
H.append(
'<option value="%s_%s">Admis par compensation avec S%s (%s)</option>'
% (
@ -882,7 +901,7 @@ def form_decision_manuelle(Se, formsemestre_id, etudid, desturl="", sortcol=None
H.append("</select></td></tr>")
# Choix code semestre precedent:
if Se.prev:
if Se.prev_formsemestre:
H.append(
'<tr><td>Code semestre précédent: </td><td><select name="new_code_prev"><option value="">Choisir une décision...</option>'
)
@ -975,7 +994,7 @@ def do_formsemestre_validation_auto(formsemestre_id):
conflicts = [] # liste des etudiants avec decision differente déjà saisie
with sco_cache.DeferredSemCacheManager():
for etudid in etudids:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
{"etudid": etudid, "formsemestre_id": formsemestre_id}
@ -984,7 +1003,7 @@ def do_formsemestre_validation_auto(formsemestre_id):
# Conditions pour validation automatique:
if ins["etat"] == scu.INSCRIT and (
(
(not Se.prev)
(not Se.prev_formsemestre)
or (
Se.prev_decision and Se.prev_decision["code"] in (ADM, ADC, ADJ)
)
@ -1055,8 +1074,8 @@ def do_formsemestre_validation_auto(formsemestre_id):
f"""<li><a href="{
url_for('notes.formsemestre_validation_etud_form',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id,
etudid=etud["etudid"], check=1)
}">{etud["nomprenom"]}</li>"""
etudid=etud.id, check=1)
}">{etud.nom_prenom()}</li>"""
)
H.append("</ul>")
H.append(
@ -1229,7 +1248,7 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
<p>On ne peut valider ici que les UEs du cursus <b>{formation.titre}</b></p>
</div>
{_get_etud_ue_cap_html(etud, formsemestre)}
{_get_etud_ue_validations_html(etud, formsemestre)}
<div class="scobox">
<div class="scobox-title">
@ -1280,7 +1299,7 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
return flask.redirect(dest_url)
def _get_etud_ue_cap_html(etud: Identite, formsemestre: FormSemestre) -> str:
def _get_etud_ue_validations_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é.
"""
@ -1299,39 +1318,13 @@ def _get_etud_ue_cap_html(etud: Identite, formsemestre: FormSemestre) -> str:
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.EtudInscrit)
):
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)
return render_template(
"jury/ue_list_etud_validations.j2",
edit_mode=True,
etud=etud,
titre_boite="Validations d'UEs dans cette formation",
validations=validations,
)
def do_formsemestre_validate_previous_ue(
@ -1371,12 +1364,11 @@ def do_formsemestre_validate_previous_ue(
is_external=True,
)
logdb(
cnx,
Scolog.logdb(
method="formsemestre_validate_previous_ue",
etudid=etudid,
msg=f"Validation UE prec. {ue_id} {ue.acronyme}: {code}",
commit=False,
commit=True,
)
_invalidate_etud_formation_caches(etudid, formsemestre.formation_id)
cnx.commit()

View File

@ -44,9 +44,7 @@ from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, Identite, Scolog
from app.models import SHORT_STR_LEN
from app.models.groups import GroupDescr, Partition
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc.scolog import logdb
from app.scodoc import html_sco_header
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
@ -466,9 +464,9 @@ def etud_add_group_infos(
etud['groupes'] = "TDB, Gr2, TPB1"
etud['partitionsgroupes'] = "Groupes TD:TDB, Groupes TP:Gr2 (...)"
"""
etud[
"partitions"
] = collections.OrderedDict() # partition_id : group + partition_name
etud["partitions"] = (
collections.OrderedDict()
) # partition_id : group + partition_name
if not formsemestre_id:
etud["groupes"] = ""
return etud
@ -769,12 +767,12 @@ groupsToDelete={groupsToDelete}
{"etudid": etudid, "group_id": group_id},
cursor=cursor,
)
logdb(
cnx,
Scolog.logdb(
method="removeFromGroup",
etudid=etudid,
msg=f"""formsemestre_id={partition.formsemestre.id},partition_name={
partition.partition_name}, group_name={group.group_name}""",
commit=True,
)
# Supprime les groupes indiqués comme supprimés:
@ -1409,21 +1407,17 @@ def groups_auto_repartition(partition: Partition):
return flask.redirect(dest_url)
def _get_prev_moy(etudid, formsemestre_id):
def _get_prev_moy(etudid: int, formsemestre_id: int) -> float | str:
"""Donne la derniere moyenne generale calculee pour cette étudiant,
ou 0 si on n'en trouve pas (nouvel inscrit,...).
"""
info = sco_etud.get_etud_info(etudid=etudid, filled=True)
if not info:
raise ScoValueError("etudiant invalide: etudid=%s" % etudid)
etud = info[0]
etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
if Se.prev:
prev_sem = db.session.get(FormSemestre, Se.prev["formsemestre_id"])
if Se.prev_formsemestre:
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:
return 0.0
return nt.get_etud_moy_gen(etud.id)
return 0.0
def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"):
@ -1476,7 +1470,7 @@ def do_evaluation_listeetuds_groups(
include_demdef: bool = False,
) -> list[tuple[int, str]]:
"""Donne la liste non triée des etudids inscrits à cette évaluation dans les
groupes indiqués.
groupes indiqués (donc inscrits au modimpl ET au formsemestre).
Si getallstudents==True, donne tous les étudiants inscrits à cette
evaluation.
Si include_demdef, compte aussi les etudiants démissionnaires et défaillants

View File

@ -27,10 +27,8 @@
"""Exports groupes
"""
from flask import request
from app.scodoc import notesdb as ndb
from app.scodoc import sco_excel
from app.scodoc import sco_groups_view
from app.scodoc import sco_preferences
from app.scodoc.gen_tables import GenTable
@ -83,9 +81,7 @@ def groups_export_annotations(group_ids, formsemestre_id=None, fmt="html"):
"date_str": "Date",
"comment": "Annotation",
},
origin="Généré par %s le " % sco_version.SCONAME
+ scu.timedate_human_repr()
+ "",
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
page_title=f"Annotations sur les étudiants de {groups_infos.groups_titles}",
caption="Annotations",
base_url=groups_infos.base_url,

View File

@ -39,9 +39,10 @@ from flask import url_for, g, request
from flask_login import current_user
from app import db
from app.models import FormSemestre
from app.models import FormSemestre, Identite
import app.scodoc.sco_utils as scu
from app.scodoc import html_sco_header
from app.scodoc import sco_assiduites as scass
from app.scodoc import sco_excel
from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups
@ -747,8 +748,6 @@ def groups_table(
tab.html(),
f"""
<ul>
<li><a class="stdlink" href="{tab.base_url}&fmt=xlsappel">Feuille d'appel Excel</a>
</li>
<li><a class="stdlink" href="{tab.base_url}&fmt=xls">Table Excel</a>
</li>
<li><a class="stdlink" href="{tab.base_url}&fmt=moodlecsv">Fichier CSV pour Moodle (groupe sélectionné)</a>
@ -862,21 +861,25 @@ def groups_table(
# et ajoute infos inscription
for m in groups_infos.members:
etud_info = sco_etud.get_etud_info(m["etudid"], filled=True)[0]
# TODO utiliser Identite
etud = Identite.get_etud(m["etudid"])
m.update(etud_info)
sco_etud.etud_add_lycee_infos(etud_info)
# et ajoute le parcours
Se = sco_cursus.get_situation_etud_cursus(
etud_info, groups_infos.formsemestre_id
etud, groups_infos.formsemestre_id
)
m["parcours"] = Se.get_cursus_descr()
m["code_cursus"], _ = sco_report.get_code_cursus_etud(
etud_info["etudid"], sems=etud_info["sems"]
etud.id, formsemestres=etud.get_formsemestres()
)
# TODO utiliser Identite:
rows = [[m.get(k, "") for k in keys] for m in groups_infos.members]
title = "etudiants_%s" % groups_infos.groups_filename
title = f"etudiants_{groups_infos.groups_filename}"
xls = sco_excel.excel_simple_table(titles=titles, lines=rows, sheet_name=title)
filename = title
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE)
return scu.send_file(
xls, filename=title, suffix=scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE
)
else:
raise ScoValueError("unsupported format")
@ -890,29 +893,51 @@ def tab_absences_html(groups_infos, etat=None):
group_ids: str = ",".join(map(str, groups_infos.group_ids))
formsemestre: FormSemestre = groups_infos.get_formsemestre()
disable_abs: str | bool = scass.has_assiduites_disable_pref(formsemestre)
H.extend(
[
"<h3>Assiduité</h3>",
'<ul class="ul_abs">',
"<li>",
form_choix_saisie_semaine(groups_infos), # Ajout Le Havre
"</li>",
"<li>",
form_choix_jour_saisie_hebdo(groups_infos),
"</li>",
f"""<li><a class="stdlink" href="{
liens_abs: list = [
'<ul class="ul_abs">',
"<li>",
form_choix_saisie_semaine(groups_infos), # Ajout Le Havre
"</li>",
"<li>",
form_choix_jour_saisie_hebdo(groups_infos),
"</li>",
f"""<li><a class="stdlink" href="{
url_for("assiduites.visu_assi_group", scodoc_dept=g.scodoc_dept,
group_ids=group_ids,
date_debut=formsemestre.date_debut.isoformat(),
date_fin=formsemestre.date_fin.isoformat()
)
}">État de l'assiduité du groupe</a></li>""",
"</ul>",
"</ul>",
]
if disable_abs:
liens_abs = [
f"""
<div class="scobox" style="width:fit-content; font-style:italic;">
La gestion des absences est désactivée dans ScoDoc pour ce semestre:
{disable_abs}
</div>
"""
]
url_feuille_appel: str = url_for(
"scolar.formulaire_feuille_appel",
scodoc_dept=g.scodoc_dept,
formsemestre_id=groups_infos.formsemestre_id,
group_ids=group_ids,
)
H.extend(
[
"<h3>Assiduité</h3>",
*liens_abs,
"<h3>Feuilles</h3>",
'<ul class="ul_feuilles">',
"""<li><a class="stdlink" href="%s&fmt=xlsappel">Feuille d'émargement %s (Excel)</a></li>"""
% (groups_infos.base_url, groups_infos.groups_titles),
"""<li><a class="stdlink" href="%s">Feuille d'émargement %s (Excel)</a></li>"""
% (url_feuille_appel, groups_infos.groups_titles),
"""<li><a class="stdlink" href="trombino?%s&fmt=pdf">Trombinoscope en PDF</a></li>"""
% groups_infos.groups_query_args,
"""<li><a class="stdlink" href="pdf_trombino_tours?%s&fmt=pdf">Trombinoscope en PDF (format "IUT de Tours", beta)</a></li>"""

View File

@ -28,7 +28,6 @@
""" Importation des étudiants à partir de fichiers CSV
"""
import collections
import io
import os
import re
@ -64,6 +63,7 @@ import app.scodoc.sco_utils as scu
FORMAT_FILE = "format_import_etudiants.txt"
# Champs modifiables via "Import données admission"
# (nom/prénom modifiables en mode "avec etudid")
ADMISSION_MODIFIABLE_FIELDS = (
"code_nip",
"code_ine",
@ -132,19 +132,27 @@ def sco_import_format(with_codesemestre=True):
return r
def sco_import_format_dict(with_codesemestre=True):
def sco_import_format_dict(with_codesemestre=True, use_etudid: bool = False):
"""Attribut: { 'type': , 'table', 'allow_nulls' , 'description' }"""
fmt = sco_import_format(with_codesemestre=with_codesemestre)
R = collections.OrderedDict()
formats = {}
for l in fmt:
R[l[0]] = {
formats[l[0]] = {
"type": l[1],
"table": l[2],
"allow_nulls": l[3],
"description": l[4],
"aliases": l[5],
}
return R
if use_etudid:
formats["etudid"] = {
"type": "int",
"table": "identite",
"allow_nulls": False,
"description": "",
"aliases": ["etudid", "id"],
}
return formats
def sco_import_generate_excel_sample(
@ -188,8 +196,7 @@ def sco_import_generate_excel_sample(
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
members = groups_infos.members
log(
"sco_import_generate_excel_sample: group_ids=%s %d members"
% (group_ids, len(members))
f"sco_import_generate_excel_sample: group_ids={group_ids}, {len(members)} members"
)
titles = ["etudid"] + titles
titles_styles = [style] + titles_styles
@ -234,21 +241,26 @@ def students_import_excel(
exclude_cols=["photo_filename"],
)
if return_html:
if formsemestre_id:
dest = url_for(
dest_url = (
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
else:
dest = url_for("notes.index_html", scodoc_dept=g.scodoc_dept)
if formsemestre_id
else url_for("notes.index_html", scodoc_dept=g.scodoc_dept)
)
H = [html_sco_header.sco_header(page_title="Import etudiants")]
H.append("<ul>")
for d in diag:
H.append("<li>%s</li>" % d)
H.append("</ul>")
H.append("<p>Import terminé !</p>")
H.append('<p><a class="stdlink" href="%s">Continuer</a></p>' % dest)
H.append(f"<li>{d}</li>")
H.append(
f"""
</ul>)
<p>Import terminé !</p>
<p><a class="stdlink" href="{dest_url}">Continuer</a></p>
"""
)
return "\n".join(H) + html_sco_header.sco_footer()
@ -308,13 +320,13 @@ def scolars_import_excel_file(
titleslist = []
for t in fs:
if t not in titles:
raise ScoValueError('Colonne invalide: "%s"' % t)
raise ScoValueError(f'Colonne invalide: "{t}"')
titleslist.append(t) #
# ok, same titles
# Start inserting data, abort whole transaction in case of error
created_etudids = []
np_imported_homonyms = 0
GroupIdInferers = {}
group_id_inferer = {}
try: # --- begin DB transaction
linenum = 0
for line in data[1:]:
@ -429,7 +441,7 @@ def scolars_import_excel_file(
_import_one_student(
formsemestre_id,
values,
GroupIdInferers,
group_id_inferer,
annee_courante,
created_etudids,
linenum,
@ -496,13 +508,14 @@ def scolars_import_excel_file(
def students_import_admission(
csvfile, type_admission="", formsemestre_id=None, return_html=True
):
csvfile, type_admission="", formsemestre_id=None, return_html=True, use_etudid=False
) -> str:
"import donnees admission from Excel file (v2016)"
diag = scolars_import_admission(
csvfile,
formsemestre_id=formsemestre_id,
type_admission=type_admission,
use_etudid=use_etudid,
)
if return_html:
H = [html_sco_header.sco_header(page_title="Import données admissions")]
@ -524,6 +537,7 @@ def students_import_admission(
)
return "\n".join(H) + html_sco_header.sco_footer()
return ""
def _import_one_student(
@ -599,13 +613,15 @@ def _is_new_ine(cnx, code_ine):
# ------ Fonction ré-écrite en nov 2016 pour lire des fichiers sans etudid (fichiers APB)
def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None):
def scolars_import_admission(
datafile, formsemestre_id=None, type_admission=None, use_etudid=False
):
"""Importe données admission depuis un fichier Excel quelconque
par exemple ceux utilisés avec APB
par exemple ceux utilisés avec APB, avec ou sans etudid
Cherche dans ce fichier les étudiants qui correspondent à des inscrits du
semestre formsemestre_id.
Le fichier n'a pas l'INE ni le NIP ni l'etudid, la correspondance se fait
Si le fichier n'a pas d'etudid (use_etudid faux), la correspondance se fait
via les noms/prénoms qui doivent être égaux (la casse, les accents et caractères spéciaux
étant ignorés).
@ -617,23 +633,24 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
dans le fichier importé) du champ type_admission.
Si une valeur existe ou est présente dans le fichier importé, ce paramètre est ignoré.
TODO:
- choix onglet du classeur
"""
log(f"scolars_import_admission: formsemestre_id={formsemestre_id}")
diag: list[str] = []
members = sco_groups.get_group_members(
sco_groups.get_default_group(formsemestre_id)
)
etuds_by_nomprenom = {} # { nomprenom : etud }
diag = []
for m in members:
np = (adm_normalize_string(m["nom"]), adm_normalize_string(m["prenom"]))
if np in etuds_by_nomprenom:
msg = "Attention: hononymie pour %s %s" % (m["nom"], m["prenom"])
log(msg)
diag.append(msg)
etuds_by_nomprenom[np] = m
etuds_by_etudid = {} # { etudid : etud }
if use_etudid:
etuds_by_etudid = {m["etudid"]: m for m in members}
else:
for m in members:
np = (adm_normalize_string(m["nom"]), adm_normalize_string(m["prenom"]))
if np in etuds_by_nomprenom:
msg = f"""Attention: hononymie pour {m["nom"]} {m["prenom"]}"""
log(msg)
diag.append(msg)
etuds_by_nomprenom[np] = m
exceldata = datafile.read()
diag2, data = sco_excel.excel_bytes_to_list(exceldata)
@ -644,19 +661,29 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
titles = data[0]
# idx -> ('field', convertor)
fields = adm_get_fields(titles, formsemestre_id)
idx_nom = None
idx_prenom = None
fields = adm_get_fields(titles, formsemestre_id, use_etudid=use_etudid)
idx_nom = idx_prenom = idx_etudid = None
for idx, field in fields.items():
if field[0] == "nom":
idx_nom = idx
if field[0] == "prenom":
idx_prenom = idx
if (idx_nom is None) or (idx_prenom is None):
match field[0]:
case "nom":
idx_nom = idx
case "prenom":
idx_prenom = idx
case "etudid":
idx_etudid = idx
if (not use_etudid and ((idx_nom is None) or (idx_prenom is None))) or (
use_etudid and idx_etudid is None
):
log("fields indices=" + ", ".join([str(x) for x in fields]))
log("fields titles =" + ", ".join([fields[x][0] for x in fields]))
log("fields titles =" + ", ".join([x[0] for x in fields.values()]))
raise ScoFormatError(
"scolars_import_admission: colonnes nom et prenom requises",
(
"""colonne etudid requise
(si l'option "Utiliser l'identifiant d'étudiant ScoDoc" est cochée)"""
if use_etudid
else "colonnes nom et prenom requises"
),
dest_url=url_for(
"scolar.form_students_import_infos_admissions",
scodoc_dept=g.scodoc_dept,
@ -665,18 +692,31 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
)
modifiable_fields = set(ADMISSION_MODIFIABLE_FIELDS)
if use_etudid:
modifiable_fields |= {"nom", "prenom"}
nline = 2 # la premiere ligne de donnees du fichier excel est 2
n_import = 0
for line in data[1:]:
# Retrouve l'étudiant parmi ceux du semestre par (nom, prenom)
nom = adm_normalize_string(line[idx_nom])
prenom = adm_normalize_string(line[idx_prenom])
if (nom, prenom) not in etuds_by_nomprenom:
msg = f"""Étudiant <b>{line[idx_nom]} {line[idx_prenom]} inexistant</b>"""
diag.append(msg)
if use_etudid:
try:
etud = etuds_by_etudid.get(int(line[idx_etudid]))
except ValueError:
etud = None
if not etud:
msg = f"""Étudiant avec code etudid=<b>{line[idx_etudid]}</b> inexistant"""
diag.append(msg)
else:
etud = etuds_by_nomprenom[(nom, prenom)]
# Retrouve l'étudiant parmi ceux du semestre par (nom, prenom)
nom = adm_normalize_string(line[idx_nom])
prenom = adm_normalize_string(line[idx_prenom])
etud = etuds_by_nomprenom.get((nom, prenom))
if not etud:
msg = (
f"""Étudiant <b>{line[idx_nom]} {line[idx_prenom]}</b> inexistant"""
)
diag.append(msg)
if etud:
cur_adm = sco_etud.admission_list(cnx, args={"id": etud["admission_id"]})[0]
# peuple les champs presents dans le tableau
args = {}
@ -727,15 +767,16 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
)
for group_id in group_ids:
group = db.session.get(GroupDescr, group_id)
group: GroupDescr = GroupDescr.get_instance(group_id)
if group.partition.groups_editable:
sco_groups.change_etud_group_in_partition(
args["etudid"], group
)
else:
elif not group.partition.is_parcours:
log("scolars_import_admission: partition non editable")
diag.append(
f"Attention: partition {group.partition} non editable (ignorée)"
f"""Attention: partition {
group.partition} (g{group.id}) non editable et ignorée"""
)
#
@ -758,19 +799,19 @@ def adm_normalize_string(s):
)
def adm_get_fields(titles, formsemestre_id):
def adm_get_fields(titles, formsemestre_id: int, use_etudid: bool = False):
"""Cherche les colonnes importables dans les titres (ligne 1) du fichier excel
return: { idx : (field_name, convertor) }
"""
format_dict = sco_import_format_dict()
format_dict = sco_import_format_dict(use_etudid=use_etudid)
fields = {}
idx = 0
for title in titles:
title_n = adm_normalize_string(title)
for k in format_dict:
for v in format_dict[k]["aliases"]:
for k, fmt in format_dict.items():
for v in fmt["aliases"]:
if adm_normalize_string(v) == title_n:
typ = format_dict[k]["type"]
typ = fmt["type"]
if typ == "real":
convertor = adm_convert_real
elif typ == "integer" or typ == "int":

View File

@ -140,7 +140,7 @@ def read_users_excel_file(datafile, titles=TITLES) -> list[dict]:
for line in data[1:]:
d = {}
for i, field in enumerate(xls_titles):
d[field] = line[i]
d[field] = (line[i] or "").strip()
users.append(d)
return users

View File

@ -754,4 +754,4 @@ def etuds_select_box_xls(src_cat):
table_id="etuds_select_box_xls",
titles=titles,
)
return tab.excel() # tab.make_page(filename=src_cat["infos"]["filename"])
return tab.excel()

View File

@ -30,10 +30,7 @@
import psycopg2
from app import db
from app.models import Formation
from app.scodoc import scolog
from app.models import Scolog
from app.scodoc import sco_cache
import app.scodoc.notesdb as ndb
from app.scodoc.sco_exceptions import ScoValueError
@ -56,7 +53,8 @@ _moduleimplEditor = ndb.EditableTable(
def do_moduleimpl_create(args):
"create a moduleimpl"
# TODO remplacer par une methode de ModuleImpl qui appelle super().create_from_dict() puis invalide le formsemestre
# TODO remplacer par une methode de ModuleImpl qui appelle
# super().create_from_dict() puis invalide le formsemestre
cnx = ndb.GetDBConnexion()
r = _moduleimplEditor.create(cnx, args)
sco_cache.invalidate_formsemestre(
@ -184,12 +182,11 @@ def do_moduleimpl_inscription_create(args, formsemestre_id=None, cnx=None):
sco_cache.invalidate_formsemestre(
formsemestre_id=formsemestre_id
) # > moduleimpl_inscription
scolog.logdb(
cnx,
Scolog.logdb(
method="moduleimpl_inscription",
etudid=args["etudid"],
msg=f"inscription module {args['moduleimpl_id']}",
commit=False,
commit=True,
)
return r

View File

@ -44,8 +44,8 @@ from app.models import (
Partition,
ScolarFormSemestreValidation,
UniteEns,
Scolog,
)
from app.scodoc.scolog import logdb
from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_cache
@ -79,9 +79,9 @@ def moduleimpl_inscriptions_edit(
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
module = modimpl.module
formsemestre = modimpl.formsemestre
# -- check lock
if not formsemestre.etat:
raise ScoValueError("opération impossible: semestre verrouille")
# -- check permission (and lock)
if not modimpl.can_change_inscriptions():
return # can_change_inscriptions raises exception
header = html_sco_header.sco_header(
page_title="Inscription au module",
init_qtip=True,
@ -774,12 +774,11 @@ def do_etud_desinscrit_ue_classic(etudid, formsemestre_id, ue_id):
""",
{"etudid": etudid, "formsemestre_id": formsemestre_id, "ue_id": ue_id},
)
logdb(
cnx,
Scolog.logdb(
method="etud_desinscrit_ue",
etudid=etudid,
msg=f"desinscription UE {ue_id}",
commit=False,
commit=True,
)
sco_cache.invalidate_formsemestre(
formsemestre_id=formsemestre_id

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