Compare commits

..

9 Commits

271 changed files with 7992 additions and 15859 deletions

152
README.md
View File

@ -2,7 +2,7 @@
(c) Emmanuel Viennet 1999 - 2024 (voir LICENCE.txt).
Installation: voir instructions à jour sur <https://scodoc.org/GuideInstallDebian12>
Installation: voir instructions à jour sur <https://scodoc.org/GuideInstallDebian11>
Documentation utilisateur: <https://scodoc.org>
@ -23,7 +23,7 @@ Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes).
### Lignes de commandes
Voir [le guide de configuration](https://scodoc.org/GuideConfig).
Voir [https://scodoc.org/GuideConfig](le guide de configuration).
## Organisation des fichiers
@ -41,41 +41,45 @@ Ils ne doivent pas être modifiés à la main, sauf certains fichiers de configu
Le répertoire `/opt/scodoc-data` doit être régulièrement sauvegardé.
Principaux contenus:
```
/opt/scodoc-data
/opt/scodoc-data/log # Fichiers de log ScoDoc
/opt/scodoc-data/config # Fichiers de configuration
.../config/logos # Logos de l'établissement
.../config/depts # un fichier par département
/opt/scodoc-data/photos # Photos des étudiants
/opt/scodoc-data/archives # Archives: PV de jury, maquettes Apogée, fichiers étudiants
```
/opt/scodoc-data
/opt/scodoc-data/log # Fichiers de log ScoDoc
/opt/scodoc-data/config # Fichiers de configuration
.../config/logos # Logos de l'établissement
.../config/depts # un fichier par département
/opt/scodoc-data/photos # Photos des étudiants
/opt/scodoc-data/archives # Archives: PV de jury, maquettes Apogée, fichiers étudiants
## Pour les développeurs
### Installation du code
Installer ScoDoc 9 normalement ([voir la doc](https://scodoc.org/GuideInstallDebian12)).
Installer ScoDoc 9 normalement ([voir la doc](https://scodoc.org/GuideInstallDebian11)).
Puis remplacer `/opt/scodoc` par un clone du git.
```bash
sudo su
mv /opt/scodoc /opt/off-scodoc # ou ce que vous voulez
apt-get install git # si besoin
git clone https://scodoc.org/git/ScoDoc/ScoDoc.git /opt/scodoc
# (ou bien utiliser votre clone gitea si vous l'avez déjà créé !)
# Donner ce répertoire à l'utilisateur scodoc:
chown -R scodoc:scodoc /opt/scodoc
```
sudo su
mv /opt/scodoc /opt/off-scodoc # ou ce que vous voulez
apt-get install git # si besoin
cd /opt
git clone https://scodoc.org/git/viennet/ScoDoc.git
# (ou bien utiliser votre clone gitea si vous l'avez déjà créé !)
# Renommer le répertoire:
mv ScoDoc scodoc
# Et donner ce répertoire à l'utilisateur scodoc:
chown -R scodoc.scodoc /opt/scodoc
Il faut ensuite installer l'environnement et le fichier de configuration:
```bash
# Le plus simple est de piquer le virtualenv configuré par l'installeur:
mv /opt/off-scodoc/venv /opt/scodoc
```
# Le plus simple est de piquer le virtualenv configuré par l'installeur:
mv /opt/off-scodoc/venv /opt/scodoc
Et la config:
```bash
ln -s /opt/scodoc-data/.env /opt/scodoc
```
ln -s /opt/scodoc-data/.env /opt/scodoc
Cette dernière commande utilise le `.env` crée lors de l'install, ce qui
n'est pas toujours le plus judicieux: vous pouvez modifier son contenu, par
exemple pour travailler en mode "développement" avec `FLASK_ENV=development`.
@ -84,11 +88,11 @@ exemple pour travailler en mode "développement" avec `FLASK_ENV=development`.
Les tests unitaires utilisent normalement la base postgresql `SCODOC_TEST`.
Avant le premier lancement, créer cette base ainsi:
```bash
./tools/create_database.sh SCODOC_TEST
export FLASK_ENV=test
flask db upgrade
```
./tools/create_database.sh SCODOC_TEST
export FLASK_ENV=test
flask db upgrade
Cette commande n'est nécessaire que la première fois (le contenu de la base
est effacé au début de chaque test, mais son schéma reste) et aussi si des
migrations (changements de schéma) ont eu lieu dans le code.
@ -96,17 +100,17 @@ migrations (changements de schéma) ont eu lieu dans le code.
Certains tests ont besoin d'un département déjà créé, qui n'est pas créé par les
scripts de tests:
Lancer au préalable:
```bash
flask delete-dept -fy TEST00 && flask create-dept TEST00
```
flask delete-dept -fy TEST00 && flask create-dept TEST00
Puis dérouler les tests unitaires:
```bash
pytest tests/unit
```
pytest tests/unit
Ou avec couverture (`pip install pytest-cov`)
```bash
pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/*
```
pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/*
#### Utilisation des tests unitaires pour initialiser la base de dev
On peut aussi utiliser les tests unitaires pour mettre la base de données de
@ -115,43 +119,43 @@ développement dans un état connu, par exemple pour éviter de recréer à la m
Il suffit de positionner une variable d'environnement indiquant la BD utilisée
par les tests:
```bash
export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
```
export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
(si elle n'existe pas, voir plus loin pour la créer) puis de les lancer
normalement, par exemple:
```bash
pytest tests/unit/test_sco_basic.py
```
pytest tests/unit/test_sco_basic.py
Il est en général nécessaire d'affecter ensuite un mot de passe à (au moins) un
utilisateur:
```bash
flask user-password admin
```
flask user-password admin
**Attention:** les tests unitaires **effacent** complètement le contenu de la
base de données (tous les départements, et les utilisateurs) avant de commencer !
#### Modification du schéma de la base
On utilise SQLAlchemy avec Alembic et Flask-Migrate.
```bash
flask db migrate -m "message explicatif....."
flask db upgrade
```
flask db migrate -m "message explicatif....."
flask db upgrade
Ne pas oublier de d'ajouter le script de migration à git (`git add migrations/...`).
**Mémo**: séquence re-création d'une base (vérifiez votre `.env`
ou variables d'environnement pour interroger la bonne base !).
```bash
dropdb SCODOC_DEV
tools/create_database.sh SCODOC_DEV # créé base SQL
flask db upgrade # créé les tables à partir des migrations
flask sco-db-init # ajoute au besoin les constantes (fait en migration 0)
# puis imports:
flask import-scodoc7-users
flask import-scodoc7-dept STID SCOSTID
```
dropdb SCODOC_DEV
tools/create_database.sh SCODOC_DEV # créé base SQL
flask db upgrade # créé les tables à partir des migrations
flask sco-db-init # ajoute au besoin les constantes (fait en migration 0)
# puis imports:
flask import-scodoc7-users
flask import-scodoc7-dept STID SCOSTID
Si la base utilisée pour les dev n'est plus en phase avec les scripts de
migration, utiliser les commandes `flask db history`et `flask db stamp`pour se
positionner à la bonne étape.
@ -159,23 +163,23 @@ positionner à la bonne étape.
### Profiling
Sur une machine de DEV, lancer
```bash
flask profile --host 0.0.0.0 --length 32 --profile-dir /opt/scodoc-data
```
flask profile --host 0.0.0.0 --length 32 --profile-dir /opt/scodoc-data
le fichier `.prof` sera alors écrit dans `/opt/scodoc-data` (on peut aussi utiliser `/tmp`).
Pour la visualisation, [snakeviz](https://jiffyclub.github.io/snakeviz/) est bien:
```bash
pip install snakeviz
```
pip install snakeviz
puis
```bash
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
```
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
## Paquet Debian 12
Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus
important est `postinst` qui se charge de configurer le système (install ou
important est `postinst`qui se charge de configurer le système (install ou
upgrade de scodoc9).
La préparation d'une release se fait à l'aide du script

View File

@ -637,12 +637,14 @@ def critical_error(msg):
import app.scodoc.sco_utils as scu
log(f"\n*** CRITICAL ERROR: {msg}")
subject = f"CRITICAL ERROR: {msg}".strip()[:68]
send_scodoc_alarm(subject, msg)
send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
clear_scodoc_cache()
raise ScoValueError(
f"""
Une erreur est survenue, veuillez -essayer.
Une erreur est survenue.
Si le problème persiste, merci de contacter le support ScoDoc via
{scu.SCO_DISCORD_ASSISTANCE}
{msg}
"""

View File

@ -1,14 +1,10 @@
"""api.__init__
"""
from functools import wraps
from flask_json import as_json
from flask import Blueprint
from flask import current_app, g, request
from flask import request, g
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
@ -20,28 +16,6 @@ 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,18 +12,15 @@ 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.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.decorators import permission_required, scodoc
from app.models import (
Assiduite,
Evaluation,
FormSemestre,
Identite,
ModuleImpl,
@ -48,8 +45,6 @@ def assiduite(assiduite_id: int = None):
"""Retourne un objet assiduité à partir de son id
Exemple de résultat:
```json
{
"assiduite_id": 1,
"etudid": 2,
@ -58,17 +53,11 @@ 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)
@ -86,23 +75,15 @@ 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 justifient cette assiduité.
"""Retourne la liste des justificatifs qui justifie cette assiduitée
Exemple de résultat:
```json
[
1,
2,
3,
...
]
```
SAMPLES
-------
/assiduite/1/justificatifs;
/assiduite/1/justificatifs/long;
"""
return get_assiduites_justif(assiduite_id, long)
@ -136,42 +117,52 @@ def assiduite_justificatifs(assiduite_id: int = None, long: bool = False):
@scodoc
@as_json
@permission_required(Permission.ScoView)
def assiduites_count(
def count_assiduites(
etudid: int = None, nip: str = None, ine: str = None, with_query: bool = False
):
"""
Retourne le nombre d'assiduités d'un étudiant.
Retourne le nombre d'assiduités d'un étudiant
chemin : /assiduites/<int:etudid>/count
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/count/query?
Les différents filtres :
Type (type de comptage -> journee, demi, heure, nombre d'assiduite):
query?type=(journee, demi, heure) -> une seule valeur parmis les trois
ex: .../query?type=heure
Comportement par défaut : compte le nombre d'assiduité enregistrée
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemestre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
QUERY
-----
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;
"""
@ -228,35 +219,40 @@ def assiduites_count(
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>
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>
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/query?
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é
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
SAMPLES
-------
/assiduites/1;
/assiduites/1/query?etat=retard;
/assiduites/1/query?moduleimpl_id=1;
/assiduites/1/query?with_justifs=;
"""
@ -268,7 +264,6 @@ 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
@ -291,108 +286,6 @@ 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
@ -404,34 +297,38 @@ 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
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>
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
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;
"""
@ -491,34 +388,7 @@ 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
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;
"""
"""Retourne toutes les assiduités du formsemestre"""
# Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None
@ -565,42 +435,10 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
@scodoc
@as_json
@permission_required(Permission.ScoView)
def assiduites_formsemestre_count(
def count_assiduites_formsemestre(
formsemestre_id: int = None, with_query: bool = False
):
"""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;
"""
"""Comptage des assiduités du formsemestre"""
# Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None
@ -651,10 +489,7 @@ def assiduites_formsemestre_count(
def assiduite_create(etudid: int = None, nip=None, ine=None):
"""
Enregistrement d'assiduités pour un étudiant (etudid)
DATA
----
```json
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
@ -670,12 +505,6 @@ 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
@ -729,10 +558,7 @@ def assiduite_create(etudid: int = None, nip=None, ine=None):
def assiduites_create():
"""
Création d'une assiduité ou plusieurs assiduites
DATA
----
```json
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
@ -745,17 +571,12 @@ 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""}]
"""
@ -926,18 +747,13 @@ def assiduite_delete():
"""
Suppression d'une assiduité à partir de son id
DATA
----
```json
Forme des données envoyées :
[
<assiduite_id:int>,
...
]
```
SAMPLES
-------
/assiduite/delete;[2,2,3]
"""
# Récupération des ids envoyés dans la liste
@ -1012,24 +828,13 @@ def _delete_one(assiduite_id: int) -> tuple[int, str]:
def assiduite_edit(assiduite_id: int):
"""
Edition d'une assiduité à partir de son id
DATA
----
```json
La requête doit avoir un content type "application/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
@ -1071,10 +876,7 @@ def assiduite_edit(assiduite_id: int):
def assiduites_edit():
"""
Edition de plusieurs assiduités
DATA
----
```json
La requête doit avoir un content type "application/json":
[
{
"assiduite_id" : int,
@ -1084,13 +886,6 @@ 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,11 +6,6 @@
"""
API : billets d'absences
CATEGORY
--------
Billets d'absence
"""
from flask import g, request
@ -34,7 +29,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]
@ -46,20 +41,7 @@ def billets_absence_etudiant(etudid: int):
@permission_required(Permission.AbsAddBillet)
@as_json
def billets_absence_create():
"""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
}
```
"""
"""Ajout d'un billet d'absence"""
data = request.get_json(force=True) # may raise 400 Bad Request
etudid = data.get("etudid")
abs_begin = data.get("abs_begin")

View File

@ -9,11 +9,6 @@
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
@ -21,15 +16,26 @@ from flask import request
from flask_json import as_json
from flask_login import login_required
from app import db, log
import app
from app import db
from app.api import api_bp as bp, API_CLIENT_ERROR
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.decorators import scodoc, permission_required
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
from app.scodoc.sco_utils import json_error
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)
@bp.route("/departements")
@ -38,7 +44,7 @@ from app.scodoc.sco_utils import json_error
@permission_required(Permission.ScoView)
@as_json
def departements_list():
"""Liste tous les départements."""
"""Liste les départements"""
return [dept.to_dict(with_dept_name=True) for dept in Departement.query]
@ -48,7 +54,7 @@ def departements_list():
@permission_required(Permission.ScoView)
@as_json
def departements_ids():
"""Liste des ids de tous les départements."""
"""Liste des ids de départements"""
return [dept.id for dept in Departement.query]
@ -57,12 +63,11 @@ def departements_ids():
@scodoc
@permission_required(Permission.ScoView)
@as_json
def departement_by_acronym(acronym: str):
def departement(acronym: str):
"""
Info sur un département. Accès par acronyme.
Exemple de résultat :
```json
{
"id": 1,
"acronym": "TAPI",
@ -71,7 +76,6 @@ def departement_by_acronym(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)
@ -98,15 +102,11 @@ def departement_by_id(dept_id: int):
def departement_create():
"""
Création d'un département.
Le content type doit être `application/json`.
DATA
----
```json
The request content type should be "application/json":
{
"acronym": str,
"visible": bool,
"visible":bool,
}
```
"""
data = request.get_json(force=True) # may raise 400 Bad Request
acronym = str(data.get("acronym", ""))
@ -117,9 +117,6 @@ 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()
@ -130,12 +127,10 @@ def departement_create():
@as_json
def departement_edit(acronym):
"""
Édition d'un département: seul le champ `visible` peut être modifié.
DATA
----
Edition d'un département: seul visible peut être modifié
The request content type should be "application/json":
{
"visible": bool,
"visible":bool,
}
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
@ -147,7 +142,6 @@ def departement_edit(acronym):
dept.visible = visible
db.session.add(dept)
db.session.commit()
log(f"departement_edit {dept.acronym}")
return dept.to_dict()
@ -157,13 +151,11 @@ def departement_edit(acronym):
@permission_required(Permission.ScoSuperAdmin)
def departement_delete(acronym):
"""
Suppression d'un département identifié par son acronyme.
Suppression d'un département.
"""
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}
@ -172,16 +164,13 @@ def departement_delete(acronym):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def departement_etudiants(acronym: str):
def dept_etudiants(acronym: str):
"""
Retourne la liste des étudiants d'un département.
Retourne la liste des étudiants d'un département
PARAMS
------
acronym : l'acronyme d'un département
acronym: l'acronyme d'un département
Exemple de résultat :
```json
[
{
"civilite": "M",
@ -196,7 +185,6 @@ def departement_etudiants(acronym: str):
},
...
]
```
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return [etud.to_dict_short() for etud in dept.etudiants]
@ -207,7 +195,7 @@ def departement_etudiants(acronym: str):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def departement_etudiants_by_id(dept_id: int):
def dept_etudiants_by_id(dept_id: int):
"""
Retourne la liste des étudiants d'un département d'id donné.
"""
@ -220,8 +208,8 @@ def departement_etudiants_by_id(dept_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def departement_formsemestres_ids(acronym: str):
"""Liste des ids de tous les formsemestres du département."""
def dept_formsemestres_ids(acronym: str):
"""liste des ids formsemestre du département"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return [formsemestre.id for formsemestre in dept.formsemestres]
@ -231,34 +219,57 @@ def departement_formsemestres_ids(acronym: str):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def departement_formsemestres_ids_by_id(dept_id: int):
"""Liste des ids de tous les formsemestres du département."""
def dept_formsemestres_ids_by_id(dept_id: int):
"""liste des ids formsemestre 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 departement_formsemestres_courants(acronym: str = "", dept_id: int | None = None):
def dept_formsemestres_courants(acronym: str):
"""
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>
Liste des semestres actifs d'un département d'acronyme donné
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()
if acronym
else Departement.query.get_or_404(dept_id)
)
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
date_courante = request.args.get("date_courante")
date_courante = datetime.fromisoformat(date_courante) if date_courante else None
return [
@ -267,3 +278,29 @@ def departement_formsemestres_courants(acronym: str = "", dept_id: int | None =
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,10 +6,6 @@
"""
API : accès aux étudiants
CATEGORY
--------
Étudiants
"""
from datetime import datetime
from operator import attrgetter
@ -25,9 +21,8 @@ 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
from app.decorators import scodoc, permission_required
from app.models import (
Admission,
Departement,
@ -42,8 +37,9 @@ 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:
@ -93,20 +89,14 @@ def _get_etud_by_code(
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants_courants(long: bool = False):
def etudiants_courants(long=False):
"""
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>
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).
Exemple de résultat :
```json
[
{
"id": 1234,
@ -119,7 +109,6 @@ def etudiants_courants(long: bool = False):
}
...
]
```
En format "long": voir documentation.
@ -165,13 +154,10 @@ 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.
"""
@ -195,18 +181,11 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
@login_required
@scodoc
@permission_required(Permission.ScoView)
def etudiant_get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
"""
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).
Retourne la photo de l'étudiant
correspondant ou un placeholder si non existant.
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
@ -236,7 +215,7 @@ def etudiant_get_photo_image(etudid: int = None, nip: str = None, ine: str = Non
@scodoc
@permission_required(Permission.EtudChangeAdr)
@as_json
def etudiant_set_photo_image(etudid: int = None):
def 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)
@ -279,12 +258,9 @@ def etudiant_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 correspondants.
Comme `/etudiant` mais renvoie toujours une liste.
Info sur le ou les étudiants correspondant. 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.).
"""
@ -317,9 +293,8 @@ 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:
@ -354,13 +329,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 formsemestres qu'un étudiant a suivi, triés par ordre chronologique.
Liste des semestres 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 formsemestres peuvent être de départements
Attention, si accès via NIP ou INE, les semestres 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 formsemestres suivis dans le département.
Si accès par département, ne retourne que les formsemestre suivis dans le département.
"""
if etudid is not None:
q_etud = Identite.query.filter_by(id=etudid)
@ -428,14 +403,13 @@ 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"
@ -489,13 +463,10 @@ 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,
@ -520,7 +491,6 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
"group_name": "A"
}
]
```
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
@ -548,12 +518,9 @@ 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
@ -621,13 +588,7 @@ def etudiant_edit(
code_type: str = "etudid",
code: str = None,
):
"""Édition des données étudiant (identité, admission, adresses).
PARAMS
------
`code_type`: le type du code, `etudid`, `ine` ou `nip`.
`code`: la valeur du code
"""
"""Edition des données étudiant (identité, admission, adresses)"""
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
@ -666,23 +627,7 @@ def etudiant_annotation(
code_type: str = "etudid",
code: str = None,
):
"""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
}
```
"""
"""Ajout d'une annotation sur un étudiant"""
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)
@ -719,13 +664,7 @@ def etudiant_annotation_delete(
code_type: str = "etudid", code: str = None, annotation_id: int = None
):
"""
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
Suppression d'une annotation
"""
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:

View File

@ -6,10 +6,6 @@
"""
ScoDoc 9 API : accès aux évaluations
CATEGORY
--------
Évaluations
"""
from flask import g, request
from flask_json import as_json
@ -18,8 +14,7 @@ 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.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.decorators import scodoc, permission_required
from app.models import Evaluation, ModuleImpl, FormSemestre
from app.scodoc import sco_evaluation_db, sco_saisie_notes
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
@ -36,28 +31,24 @@ 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 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 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
}
"""
query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept:
@ -78,13 +69,11 @@ 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]
@ -98,36 +87,30 @@ 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 :
```json
{
"11": {
{
"11": {
"etudid": 11,
"evaluation_id": 1,
"value": 15.0,
"note_max" : 20.0,
"comment": "",
"date": "2024-07-19T19:08:44+02:00",
"date": "Wed, 20 Apr 2022 06:49:05 GMT",
"uid": 2
},
"12": {
},
"12": {
"etudid": 12,
"evaluation_id": 1,
"value": "ABS",
"note_max" : 20.0,
"value": 12.0,
"comment": "",
"date": "2024-07-19T19:08:44+02:00",
"date": "Wed, 20 Apr 2022 06:49:06 GMT",
"uid": 2
},
...
}
```
},
...
}
"""
query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept:
@ -161,18 +144,13 @@ def evaluation_notes(evaluation_id: int):
@as_json
def evaluation_set_notes(evaluation_id: int): # evaluation-notes-set
"""Écriture de notes dans une évaluation.
DATA
----
```json
The request content type should be "application/json",
and contains:
{
'notes' : [ [etudid, value], ... ],
'comment' : optional string
}
```
Résultat:
Result:
- nb_changed: nombre de notes changées
- nb_suppress: nombre de notes effacées
- etudids_with_decision: liste des etudiants dont la note a changé
@ -207,9 +185,8 @@ def evaluation_set_notes(evaluation_id: int): # evaluation-notes-set
@as_json
def evaluation_create(moduleimpl_id: int):
"""Création d'une évaluation.
DATA
----
The request content type should be "application/json",
and contains:
{
"description" : str,
"evaluation_type" : int, // {0,1,2} default 0 (normale)
@ -222,8 +199,7 @@ def evaluation_create(moduleimpl_id: int):
"coefficient" : float, // si non spécifié, 1.0
"poids" : { ue_id : poids } // optionnel
}
Résultat: l'évaluation créée.
Result: l'évaluation créée.
"""
moduleimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id)
if not moduleimpl.can_edit_evaluation(current_user):
@ -273,7 +249,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,10 +6,6 @@
"""
ScoDoc 9 API : accès aux formations
CATEGORY
--------
Formations
"""
from flask import flash, g, request
@ -19,15 +15,12 @@ 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
@ -42,8 +35,7 @@ from app.scodoc.sco_permissions import Permission
@as_json
def formations():
"""
Retourne la liste de toutes les formations (tous départements,
sauf si route départementale).
Retourne la liste de toutes les formations (tous départements)
"""
query = Formation.query
if g.scodoc_dept:
@ -63,7 +55,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:
@ -79,26 +71,24 @@ 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 :
```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
}
```
{
"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:
@ -130,102 +120,97 @@ 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_with_ids : si présent, exporte aussi les ids des objets ScoDoc de la formation.
export_ids : True ou False, si l'on veut ou non exporter les ids
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": [
{
"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": [
"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": [
{
"titre": "Administrer les r\u00e9seaux et l\u2019Internet",
"numero": 1,
"module": [
"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": "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": "Administrer les r\u00e9seaux et l\u2019Internet",
"numero": 1,
"module": [
{
"ue_reference": "1",
"coef": "12.0"
"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": "2",
"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"
}
]
},
{
"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:
@ -248,8 +233,11 @@ 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 de la formation
ou null si pas de référentiel associé.
Retourne le référentiel de compétences
formation_id : l'id d'une formation
return null si pas de référentiel associé.
"""
query = Formation.query.filter_by(id=formation_id)
if g.scodoc_dept:
@ -260,22 +248,16 @@ def referentiel_competences(formation_id: int):
return formation.referentiel_competence.to_dict()
@bp.route("/formation/ue/<int:ue_id>/set_parcours", methods=["POST"])
@api_web_bp.route("/formation/ue/<int:ue_id>/set_parcours", methods=["POST"])
@bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
@api_web_bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.EditFormation)
@as_json
def ue_set_parcours(ue_id: int):
def set_ue_parcours(ue_id: int):
"""Associe UE et parcours BUT.
La liste des ids de parcours est passée en argument JSON.
DATA
----
```json
[ parcour_id1, parcour_id2, ... ]
```
JSON arg: [parcour_id1, parcour_id2, ...]
"""
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
@ -288,7 +270,7 @@ def ue_set_parcours(ue_id: int):
parcours = [
ApcParcours.query.get_or_404(int(parcour_id)) for parcour_id in parcours_ids
]
log(f"ue_set_parcours: ue_id={ue.id} parcours_ids={parcours_ids}")
log(f"set_ue_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)
@ -296,19 +278,19 @@ def ue_set_parcours(ue_id: int):
@bp.route(
"/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>",
"/assoc_ue_niveau/<int:ue_id>/<int:niveau_id>",
methods=["POST"],
)
@api_web_bp.route(
"/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>",
"/assoc_ue_niveau/<int:ue_id>/<int:niveau_id>",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
@as_json
def ue_assoc_niveau(ue_id: int, niveau_id: int):
"""Associe l'UE au niveau de compétence."""
def assoc_ue_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)
@ -325,278 +307,32 @@ def ue_assoc_niveau(ue_id: int, niveau_id: int):
@bp.route(
"/formation/ue/<int:ue_id>/desassoc_niveau",
"/desassoc_ue_niveau/<int:ue_id>",
methods=["POST"],
)
@api_web_bp.route(
"/formation/ue/<int:ue_id>/desassoc_niveau",
"/desassoc_ue_niveau/<int:ue_id>",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
@as_json
def ue_desassoc_niveau(ue_id: int):
def desassoc_ue_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()
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"
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"
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,12 +6,6 @@
"""
ScoDoc 9 API : accès aux formsemestres
CATEGORY
--------
FormSemestre
"""
from operator import attrgetter, itemgetter
@ -20,10 +14,9 @@ from flask_json import as_json
from flask_login import current_user, login_required
import sqlalchemy as sa
import app
from app import db, log
from app import db
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.decorators import scodoc
from app.decorators import scodoc, permission_required
from app.scodoc.sco_utils import json_error
from app.comp import res_sem
from app.comp.moy_mod import ModuleImplResults
@ -61,37 +54,35 @@ def formsemestre_infos(formsemestre_id: int):
formsemestre_id : l'id du formsemestre
Exemple de résultat :
```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",
}
```
{
"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",
}
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
@ -108,28 +99,15 @@ 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")
@ -199,36 +177,7 @@ def formsemestres_query():
@permission_required(Permission.EditFormSemestre)
@as_json
def formsemestre_edit(formsemestre_id: int):
"""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,
}
```
"""
"""Modifie les champs d'un formsemestre."""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
args = request.get_json(force=True) # may raise 400 Bad Request
editable_keys = {
@ -260,154 +209,6 @@ 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")
@ -418,12 +219,9 @@ def formsemestre_set_elt_passage_apo():
@as_json
def bulletins(formsemestre_id: int, version: str = "long"):
"""
Retourne les bulletins d'un formsemestre.
Retourne les bulletins d'un formsemestre donné
PARAMS
------
formsemestre_id : int
version : string ("long", "short", "selectedevals")
formsemestre_id : l'id d'un formesemestre
Exemple de résultat : liste, voir https://scodoc.org/ScoDoc9API/#bulletin
"""
@ -455,67 +253,66 @@ 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": [
{
"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,
"matiere_id": 3,
"module_id": 15,
"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": [
{
"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
},
...
},
...
],
"modules" : [ ... les modules qui ne sont ni des SAEs ni des ressources ... ]
}
```
],
"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,
"matiere_id": 3,
"module_id": 15,
"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": [
{
...
},
...
],
"modules" : [ ... les modules qui ne sont ni des SAEs ni des ressources ... ]
}
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
@ -579,16 +376,7 @@ def formsemestre_programme(formsemestre_id: int):
def formsemestre_etudiants(
formsemestre_id: int, with_query: bool = False, long: bool = False
):
"""É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>
"""
"""Étudiants d'un formsemestre."""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
@ -630,13 +418,13 @@ def formsemestre_etudiants(
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_etat_evaluations(formsemestre_id: int):
def etat_evals(formsemestre_id: int):
"""
Informations sur l'état des évaluations d'un formsemestre.
Exemple de résultat :
formsemestre_id : l'id d'un semestre
```json
Exemple de résultat :
[
{
"id": 1, // moduleimpl_id
@ -664,9 +452,11 @@ def formsemestre_etat_evaluations(formsemestre_id: int):
]
},
]
```
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
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)
app.set_sco_dept(formsemestre.departement.acronym)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
@ -739,16 +529,8 @@ def formsemestre_etat_evaluations(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":
@ -788,14 +570,14 @@ def formsemestre_resultat(formsemestre_id: int):
return rows
@bp.route("/formsemestre/<int:formsemestre_id>/groups_get_auto_assignment")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/groups_get_auto_assignment")
@bp.route("/formsemestre/<int:formsemestre_id>/get_groups_auto_assignment")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/get_groups_auto_assignment")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def groups_get_auto_assignment(formsemestre_id: int):
"""Rend les données stockées par `groups_save_auto_assignment`."""
def get_groups_auto_assignment(formsemestre_id: int):
"""rend les données"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
@ -806,27 +588,22 @@ def groups_get_auto_assignment(formsemestre_id: int):
@bp.route(
"/formsemestre/<int:formsemestre_id>/groups_save_auto_assignment", methods=["POST"]
"/formsemestre/<int:formsemestre_id>/save_groups_auto_assignment", methods=["POST"]
)
@api_web_bp.route(
"/formsemestre/<int:formsemestre_id>/groups_save_auto_assignment", methods=["POST"]
"/formsemestre/<int:formsemestre_id>/save_groups_auto_assignment", methods=["POST"]
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
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.
"""
def save_groups_auto_assignment(formsemestre_id: int):
"""enregistre les données"""
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
@ -841,16 +618,11 @@ def groups_save_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.
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.
group_ids permet de filtrer sur les groupes ScoDoc.
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,12 +5,7 @@
##############################################################################
"""
ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions.
CATEGORY
--------
Jury
ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions
"""
import datetime
@ -22,8 +17,7 @@ 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.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.decorators import scodoc, permission_required
from app.scodoc.sco_exceptions import ScoException
from app.but import jury_but_results
from app.models import (
@ -38,7 +32,6 @@ from app.models import (
ScolarNews,
Scolog,
UniteEns,
ValidationDUT120,
)
from app.scodoc import codes_cursus
from app.scodoc import sco_cache
@ -69,7 +62,7 @@ def decisions_jury(formsemestre_id: int):
raise ScoException("non implemente")
def _news_delete_jury_etud(etud: Identite, detail: str = ""):
def _news_delete_jury_etud(etud: Identite):
"génère news sur effacement décision"
# n'utilise pas g.scodoc_dept, pas toujours dispo en mode API
url = url_for(
@ -78,7 +71,7 @@ def _news_delete_jury_etud(etud: Identite, detail: str = ""):
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=etud.id,
text=f"""Suppression décision jury {detail} pour <a href="{url}">{etud.nomprenom}</a>""",
text=f"""Suppression décision jury pour <a href="{url}">{etud.nomprenom}</a>""",
url=url,
)
@ -96,7 +89,7 @@ def _news_delete_jury_etud(etud: Identite, detail: str = ""):
@permission_required(Permission.ScoView)
@as_json
def validation_ue_delete(etudid: int, validation_id: int):
"Efface cette validation d'UE."
"Efface cette validation"
return _validation_ue_delete(etudid, validation_id)
@ -113,7 +106,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 de semestre."
"Efface cette validation"
# c'est la même chose (formations classiques)
return _validation_ue_delete(etudid, validation_id)
@ -165,7 +158,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 autorisation d'inscription."
"Efface cette validation"
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
@ -194,12 +187,8 @@ 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.
DATA
----
```json
The request content type should be "application/json":
{
"code" : str,
"ue1_id" : int,
@ -209,7 +198,6 @@ 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:
@ -301,12 +289,13 @@ 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()
@ -324,18 +313,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 de RCUE."
"Efface cette validation"
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ApcValidationRCUE.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
log(f"delete validation_ue_delete: etuid={etudid} {validation}")
log(f"validation_ue_delete: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud, detail="UE")
_news_delete_jury_etud(etud)
return "ok"
@ -352,45 +341,16 @@ 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 d'année BUT."
"Efface cette validation"
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ApcValidationAnnee.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
ordre = validation.ordre
log(f"delete validation_annee_but: etuid={etudid} {validation}")
log(f"validation_annee_but: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud, 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")
_news_delete_jury_etud(etud)
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,8 +19,7 @@ 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.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.decorators import permission_required, scodoc
from app.models import Identite, Justificatif, Departement, FormSemestre, Scolog
from app.models.assiduites import (
get_formsemestre_from_data,
@ -38,11 +37,9 @@ 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,
@ -54,11 +51,6 @@ def justificatif(justif_id: int = None):
"entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null,
}
```
SAMPLES
-------
/justificatif/1;
"""
@ -99,32 +91,28 @@ 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>
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;
Un filtrage peut être donné avec une query
chemin : /justificatifs/<int:etudid>/query?
Les différents filtres :
Etat (etat du justificatif -> validé, non validé, modifé, en attente):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=validé,modifié
Date debut
(date de début du justificatif, sont affichés les justificatifs
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin du justificatif, sont affichés les justificatifs
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
user_id (l'id de l'auteur du justificatif)
query?user_id=[int]
ex query?user_id=3
"""
# Récupération de l'étudiant
etud: Identite = tools.get_etud(etudid, nip, ine)
@ -166,32 +154,6 @@ 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
@ -263,34 +225,7 @@ 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
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;
"""
"""Retourne tous les justificatifs du formsemestre"""
# Récupération du formsemestre
formsemestre: FormSemestre = None
@ -338,10 +273,7 @@ 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)
DATA
----
```json
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
@ -356,10 +288,6 @@ 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""}]
"""
@ -413,10 +341,6 @@ 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)
@ -490,23 +414,14 @@ 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
@ -525,10 +440,7 @@ def justif_edit(justif_id: int):
if etat is None:
errors.append("param 'etat': invalide")
else:
if current_user.has_permission(Permission.JustifValidate):
justificatif_unique.etat = etat
else:
errors.append("param 'etat': non autorisé (Permission.JustifValidate)")
justificatif_unique.etat = etat
# Cas 2 : raison
raison: str = data.get("raison", False)
@ -581,12 +493,13 @@ 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
@ -613,18 +526,13 @@ def justif_delete():
"""
Suppression d'un justificatif à partir de son id
DATA
----
```json
Forme des données envoyées :
[
<justif_id:int>,
...
]
```
SAMPLES
-------
/justificatif/delete;[2, 2, 3]
"""
@ -679,11 +587,6 @@ 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)
@ -700,8 +603,6 @@ 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é
@ -753,8 +654,6 @@ 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)
@ -792,20 +691,14 @@ 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
> Procédure de suppression de fichier : [supprimer un justificatif](FichiersJustificatifs.md#supprimer-un-fichier)
DATA
----
```json
{
"remove": <"all"/"list">,
"remove": <"all"/"list">
"filenames"?: [
<filename:str>,
...
]
}
```
"""
# On récupère le dictionnaire
@ -871,11 +764,6 @@ 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é
@ -918,11 +806,6 @@ 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,13 +34,11 @@ from flask import Response, send_file
from flask_json import as_json
from app.api import api_bp as bp
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 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)
@ -49,8 +47,8 @@ from app.scodoc.sco_utils import json_error
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def logo_list_globals():
"""Liste des noms des logos définis pour le site ScoDoc."""
def api_get_glob_logos():
"""Liste tous les logos"""
logos = list_logos()[None]
return list(logos.keys())
@ -58,12 +56,7 @@ def logo_list_globals():
@bp.route("/logo/<string:logoname>")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
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.
"""
def api_get_glob_logo(logoname):
logo = find_logo(logoname=logoname)
if logo is None:
return json_error(404, message="logo not found")
@ -84,10 +77,7 @@ def _core_get_logos(dept_id) -> list:
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def logo_get_local_by_acronym(departement):
"""Liste des noms des logos définis pour le département
désigné par son acronyme.
"""
def api_get_local_logos_by_acronym(departement):
dept_id = Departement.from_acronym(departement).id
return _core_get_logos(dept_id)
@ -96,10 +86,7 @@ def logo_get_local_by_acronym(departement):
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def logo_get_local_by_id(dept_id):
"""Liste des noms des logos définis pour le département
désigné par son id.
"""
def api_get_local_logos_by_id(dept_id):
return _core_get_logos(dept_id)
@ -118,13 +105,7 @@ def _core_get_logo(dept_id, logoname) -> Response:
@bp.route("/departement/<string:departement>/logo/<string:logoname>")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
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`
"""
def api_get_local_logo_dept_by_acronym(departement, logoname):
dept_id = Departement.from_acronym(departement).id
return _core_get_logo(dept_id, logoname)
@ -132,11 +113,5 @@ def logo_get_local_dept_by_acronym(departement, logoname):
@bp.route("/departement/id/<int:dept_id>/logo/<string:logoname>")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
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`
"""
def api_get_local_logo_dept_by_id(dept_id, logoname):
return _core_get_logo(dept_id, logoname)

View File

@ -6,10 +6,6 @@
"""
ScoDoc 9 API : accès aux moduleimpl
CATEGORY
--------
ModuleImpl
"""
from flask_json import as_json
@ -17,8 +13,7 @@ from flask_login import login_required
import app
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.decorators import scodoc, permission_required
from app.models import ModuleImpl
from app.scodoc import sco_liste_notes
from app.scodoc.sco_permissions import Permission
@ -32,43 +27,38 @@ from app.scodoc.sco_permissions import Permission
@as_json
def moduleimpl(moduleimpl_id: int):
"""
Retourne le moduleimpl.
Retourne un moduleimpl en fonction de son id
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,
"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
"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
}
}
}
```
"""
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
return modimpl.to_dict(convert_objects=True)
@ -81,20 +71,16 @@ 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 :
```json
[
{
"id": 1,
"etudid": 666,
"moduleimpl_id": 1234,
},
...
]
```
[
{
"id": 1,
"etudid": 666,
"moduleimpl_id": 1234,
},
...
]
"""
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
return [i.to_dict() for i in modimpl.inscriptions]
@ -106,26 +92,22 @@ 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 :
```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
},
...
]
```
[
{
"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,11 +6,6 @@
"""
ScoDoc 9 API : partitions
CATEGORY
--------
Groupes et Partitions
"""
from operator import attrgetter
@ -23,8 +18,7 @@ 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.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.decorators import scodoc, permission_required
from app.scodoc.sco_utils import json_error
from app.models import FormSemestre, FormSemestreInscription, Identite
from app.models import GroupDescr, Partition, Scolog
@ -46,8 +40,7 @@ def partition_info(partition_id: int):
"""Info sur une partition.
Exemple de résultat :
```json
```
{
'bul_show_rank': False,
'formsemestre_id': 39,
@ -77,11 +70,10 @@ 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
Exemple de résultat :
formsemestre_id : l'id d'un formsemestre
```json
{
partition_id : {
"bul_show_rank": False,
@ -95,7 +87,7 @@ def formsemestre_partitions(formsemestre_id: int):
},
...
}
```
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
@ -115,18 +107,13 @@ def formsemestre_partitions(formsemestre_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def group_etudiants(group_id: int):
def etud_in_group(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',
@ -139,7 +126,6 @@ def group_etudiants(group_id: int):
},
...
]
```
"""
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
@ -164,14 +150,8 @@ def group_etudiants(group_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def group_etudiants_query(group_id: int):
"""Étudiants du groupe, filtrés par état (aucun, `I`, `D`, `DEF`)
QUERY
-----
etat : string
"""
def etud_in_group_query(group_id: int):
"""Étudiants du groupe, filtrés par état"""
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")
@ -198,8 +178,8 @@ def group_etudiants_query(group_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def group_set_etudiant(group_id: int, etudid: int):
"""Affecte l'étudiant au groupe indiqué."""
def set_etud_group(etudid: int, group_id: 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:
@ -261,8 +241,7 @@ 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)
@ -307,15 +286,12 @@ 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
DATA
----
```json
The request content type should be "application/json":
{
"group_name" : nom_du_groupe,
}
```
"""
query = Partition.query.filter_by(id=partition_id)
if g.scodoc_dept:
@ -362,7 +338,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 = (
@ -391,7 +367,7 @@ def group_delete(group_id: int):
@permission_required(Permission.ScoView)
@as_json
def group_edit(group_id: int):
"""Édition d'un groupe."""
"""Edit a group"""
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
query = (
@ -432,10 +408,9 @@ 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 du groupe.
Contrairement à `/edit`, peut-être changé pour toute partition
d'un formsemestre non verrouillé.
"""Set edt_id for this group.
Contrairement à /edit, peut-être changé pour toute partition
ou formsemestre non verrouillé.
"""
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
@ -461,19 +436,16 @@ 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
DATA
----
```json
The request content type should be "application/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:
@ -528,14 +500,9 @@ def partition_create(formsemestre_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_set_partitions_order(formsemestre_id: int):
"""Modifie l'ordre des partitions du formsemestre.
DATA
----
```json
[ partition_id1, partition_id2, ... ]
```
def formsemestre_order_partitions(formsemestre_id: int):
"""Modifie l'ordre des partitions du formsemestre
JSON args: [partition_id1, partition_id2, ...]
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
@ -546,7 +513,7 @@ def formsemestre_set_partitions_order(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, list) and not all(
if not isinstance(partition_ids, int) and not all(
isinstance(x, int) for x in partition_ids
):
return json_error(
@ -560,7 +527,7 @@ def formsemestre_set_partitions_order(formsemestre_id: int):
db.session.commit()
app.set_sco_dept(formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(formsemestre_id)
log(f"formsemestre_set_partitions_order({partition_ids})")
log(f"formsemestre_order_partitions({partition_ids})")
return [
partition.to_dict()
for partition in formsemestre.partitions.order_by(Partition.numero)
@ -575,13 +542,8 @@ def formsemestre_set_partitions_order(formsemestre_id: int):
@permission_required(Permission.ScoView)
@as_json
def partition_order_groups(partition_id: int):
"""Modifie l'ordre des groupes de la partition.
DATA
----
```json
[ group_id1, group_id2, ... ]
```
"""Modifie l'ordre des groupes de la partition
JSON args: [group_id1, group_id2, ...]
"""
query = Partition.query.filter_by(id=partition_id)
if g.scodoc_dept:
@ -592,7 +554,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, list) and not all(
if not isinstance(group_ids, int) and not all(
isinstance(x, int) for x in group_ids
):
return json_error(
@ -617,13 +579,10 @@ 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
Tous les champs sont optionnels.
DATA
----
```json
The request content type should be "application/json"
All fields are optional:
{
"partition_name": str,
"numero":int,
@ -631,7 +590,6 @@ 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:
@ -695,9 +653,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,18 +3,12 @@ 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 token_get():
"Renvoie un jeton jwt pour l'utilisateur courant."
def get_token():
"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()
@ -23,8 +17,8 @@ def token_get():
@bp.route("/tokens", methods=["DELETE"])
@token_auth.login_required
def token_revoke():
"Révoque le jeton de l'utilisateur courant."
def revoke_token():
"révoque le jeton de l'utilisateur courant"
user = token_auth.current_user()
user.revoke_token()
db.session.commit()

View File

@ -6,10 +6,6 @@
"""
ScoDoc 9 API : accès aux utilisateurs
CATEGORY
--------
Utilisateurs
"""
from flask import g, request
@ -18,14 +14,15 @@ 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
from app.models import Departement
from app.decorators import scodoc, permission_required
from app.models import Departement, ScoDocSiteConfig
from app.scodoc import sco_edt_cal
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>")
@ -36,7 +33,7 @@ from app.scodoc.sco_utils import json_error
@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:
@ -57,22 +54,11 @@ 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")
@ -121,10 +107,7 @@ def _is_allowed_user_edit(args: dict) -> tuple[bool, str]:
@as_json
def user_create():
"""Création d'un utilisateur
DATA
----
```json
The request content type should be "application/json":
{
"active":bool (default True),
"dept": str or null,
@ -133,7 +116,6 @@ def user_create():
"user_name": str,
...
}
```
"""
args = request.get_json(force=True) # may raise 400 Bad Request
user_name = args.get("user_name")
@ -170,10 +152,8 @@ 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,
@ -181,7 +161,6 @@ 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)
@ -220,15 +199,11 @@ 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
@ -262,7 +237,7 @@ def user_password(uid: int):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def user_role_add(uid: int, role_name: str, dept: str = None):
"""Ajoute un rôle à l'utilisateur dans le département donné."""
"""Add a role in the given dept to the user"""
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
@ -291,7 +266,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):
"""Retire le rôle (dans le département donné) à cet utilisateur."""
"""Remove the role (in the given dept) from the user"""
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
@ -317,8 +292,8 @@ def user_role_remove(uid: int, role_name: str, dept: str = None):
@scodoc
@permission_required(Permission.UsersView)
@as_json
def permissions_list():
"""Liste des noms de permissions définies."""
def list_permissions():
"""Liste des noms de permissions définies"""
return list(Permission.permission_by_name.keys())
@ -328,7 +303,7 @@ def permissions_list():
@scodoc
@permission_required(Permission.UsersView)
@as_json
def role_get(role_name: str):
def list_role(role_name: str):
"""Un rôle"""
return Role.query.filter_by(name=role_name).first_or_404().to_dict()
@ -339,8 +314,8 @@ def role_get(role_name: str):
@scodoc
@permission_required(Permission.UsersView)
@as_json
def roles_list():
"""Tous les rôles définis."""
def list_roles():
"""Tous les rôles définis"""
return [role.to_dict() for role in Role.query]
@ -357,7 +332,7 @@ def roles_list():
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_permission_add(role_name: str, perm_name: str):
"""Ajoute une permission à un rôle."""
"""Add permission to role"""
role: Role = Role.query.filter_by(name=role_name).first_or_404()
permission = Permission.get_by_name(perm_name)
if permission is None:
@ -382,7 +357,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):
"""Retire une permission d'un rôle."""
"""Remove permission from role"""
role: Role = Role.query.filter_by(name=role_name).first_or_404()
permission = Permission.get_by_name(perm_name)
if permission is None:
@ -401,15 +376,10 @@ def role_permission_remove(role_name: str, perm_name: str):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_create(role_name: str):
"""Création d'un nouveau rôle avec les permissions données.
DATA
----
```json
"""Create a new role with permissions.
{
"permissions" : [ 'ScoView', ... ]
}
```
"""
role: Role = Role.query.filter_by(name=role_name).first()
if role:
@ -434,16 +404,11 @@ def role_create(role_name: str):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_edit(role_name: str):
"""Édition d'un rôle. On peut spécifier un nom et/ou des permissions.
DATA
----
```json
"""Edit a role. On peut spécifier un nom et/ou des permissions.
{
"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
@ -471,7 +436,7 @@ def role_edit(role_name: str):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_delete(role_name: str):
"""Suprression d'un rôle."""
"""Delete a role"""
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,10 +45,8 @@ 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()
@ -57,17 +55,12 @@ def after_cas_login():
current_app.logger.info(
f"CAS login denied for {user.user_name} (not allowed to use CAS)"
)
else: # pas d'utilisateur ScoDoc ou bien compte inactif
else:
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,15 +14,6 @@ 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
@ -57,13 +48,13 @@ def is_valid_password(cleartxt) -> bool:
return False
def is_valid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is valid"
def invalid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is invalid"
return (
user_name
and (len(user_name) >= 2)
and (len(user_name) < USERNAME_STR_LEN)
and VALID_LOGIN_EXP.match(user_name)
not user_name
or (len(user_name) < 2)
or (len(user_name) >= USERNAME_STR_LEN)
or not VALID_LOGIN_EXP.match(user_name)
)
@ -132,7 +123,7 @@ class User(UserMixin, ScoDocModel):
# check login:
if not "user_name" in kwargs:
raise ValueError("missing user_name argument")
if not is_valid_user_name(kwargs["user_name"]):
if invalid_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 ""
@ -338,8 +329,7 @@ class User(UserMixin, ScoDocModel):
if new_user:
if "user_name" in data:
# never change name of existing users
# (see change_user_name method to do that)
if not is_valid_user_name(data["user_name"]):
if invalid_user_name(data["user_name"]):
raise ValueError(f"invalid user_name: {data['user_name']}")
self.user_name = data["user_name"]
if "password" in data:
@ -532,64 +522,6 @@ 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, is_valid_user_name
from app.auth.models import Role, User, invalid_user_name
from app.auth.email import send_password_reset_email
from app.decorators import admin_required
from app.forms.generic import SimpleConfirmationForm
@ -35,12 +35,10 @@ 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
user = (
User.query.filter_by(user_name=form.user_name.data).first()
if is_valid_user_name(form.user_name.data)
else None
)
if invalid_user_name(form.user_name.data):
user = None
else:
user = User.query.filter_by(user_name=form.user_name.data).first()
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.ue_set_parcours",
data-setter="{url_for("apiweb.set_ue_parcours",
scodoc_dept=g.scodoc_dept, ue_id=ue.id)}"
>{parcour.code}{ects_parcour_txt}</label>"""
)

View File

@ -576,6 +576,7 @@ 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,7 +39,6 @@ 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
@ -68,6 +67,7 @@ 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)
@ -124,9 +124,7 @@ def _build_bulletin_but_infos(
formsemestre, bulletins_sem.res
)
if warn_html:
raise ScoValueError(
"<b>Formation mal configurée pour le BUT</b>" + warn_html, safe=True
)
raise ScoValueError("<b>Formation mal configurée pour le BUT</b>" + warn_html)
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
refcomp, etud
)
@ -153,5 +151,4 @@ 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,8 +97,6 @@ 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
@ -112,8 +110,7 @@ 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")
@ -246,17 +243,13 @@ 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(
[table_content],
[
[
self.table_cursus_but(),
[Spacer(1, 8 * mm), self.boite_decisions_jury()],
]
],
colWidths=(self.width_page_avail - 84 * mm, 84 * mm),
style=style_table_2cols,
)
@ -530,17 +523,14 @@ 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 année BUT{self.bul["semestre"]["decision_annee"]["ordre"]}
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"]}
<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

@ -73,7 +73,6 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
html_class="notes_bulletin",
html_class_ignore_default=True,
html_with_td_classes=True,
table_id="bul-table",
)
table_objects = table.gen(fmt=fmt)
objects += table_objects
@ -428,11 +427,12 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE
else "*"
)
note_value = e["note"].get("value", "")
t = {
"titre": f"{e['description'] or ''}",
"moyenne": note_value,
"_moyenne_pdf": Paragraph(f"""<para align=right>{note_value}</para>"""),
"moyenne": e["note"]["value"],
"_moyenne_pdf": Paragraph(
f"""<para align=right>{e["note"]["value"]}</para>"""
),
"coef": coef,
"_coef_pdf": Paragraph(
f"""<para align=right fontSize={self.small_fontsize}><i>{

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_fin else ""
e.date_fin.isoformat() if e.date_debut else ""
),
coefficient=str(e.coefficient),
# pas les poids en XML compat

View File

@ -14,11 +14,9 @@ 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
@ -31,7 +29,7 @@ from app.models.but_refcomp import (
ApcReferentielCompetences,
)
from app.models.ues import UEParcours
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.but_validations import ApcValidationRCUE
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
@ -44,9 +42,9 @@ from app.scodoc import sco_cursus_dut
class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
"""Pour compat ScoDoc 7"""
"""Pour compat ScoDoc 7: à revoir pour le BUT"""
def __init__(self, etud: Identite, formsemestre_id: int, res: ResultatsSemestreBUT):
def __init__(self, etud: dict, 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
@ -56,22 +54,8 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
return False
def parcours_validated(self):
"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
)
)
"True si le parcours est validé"
return False # XXX TODO
class EtudCursusBUT:
@ -209,10 +193,6 @@ 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 ] }
@ -307,136 +287,104 @@ 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,
annees_but: None | Iterable[str] = None,
) -> int:
def but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float:
"""Nombre d'ECTS validés par etud dans le BUT de référentiel indiqué.
Ne prend que les UE associées à des niveaux de compétences,
et ne les compte qu'une fois même en cas de redoublement avec re-validation.
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)
)
# 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
.join(ApcCompetence)
.filter_by(referentiel_id=referentiel_competence_id)
)
# 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)
),
)
ects_dict = {}
for v in validations:
key = (v.ue.semestre_idx, v.ue.niveau_competence.id)
if v.code in CODES_UE_VALIDES:
ects_dict[key] = v.ue.ects
return sum(ects_dict.values()) if ects_dict else 0.0
def etud_ues_de_but1_non_validees(

View File

@ -64,7 +64,6 @@ 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
@ -82,10 +81,8 @@ 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
@ -93,7 +90,6 @@ 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,
@ -425,11 +421,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
+ '</div><div class="warning warning-info">'.join(messages)
+ "</div>"
)
# 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)
self.codes = [self.codes[0]] + sorted((c or "") for c in self.codes[1:])
def passage_de_droit_en_but3(self) -> tuple[bool, str]:
"""Vérifie si les conditions supplémentaires de passage BUT2 vers BUT3 sont satisfaites"""
@ -761,13 +753,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()
@ -893,7 +885,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):
@ -904,8 +896,6 @@ 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.)
"""
@ -955,17 +945,6 @@ 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 ?)
@ -1007,36 +986,6 @@ 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 = [
@ -1045,10 +994,7 @@ 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]
# présentation de la liste des UEs:
if valids:
validations.append(", ".join(v for v in valids if v))
validations.append(", ".join(v for v in valids if v))
return line_sep.join(validations)
def descr_pb_coherence(self) -> list[str]:
@ -1088,8 +1034,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
return messages
def valide_diplome(self) -> bool:
"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)
"Vrai si l'étudiant à validé son diplôme"
return False # TODO XXX
def list_ue_parcour_etud(
@ -1228,12 +1174,13 @@ 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
@ -1646,12 +1593,13 @@ 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,12 +8,11 @@
"""
from flask import g, request, url_for
from openpyxl.styles import Alignment
from openpyxl.styles import Font, Border, Side, Alignment, PatternFill
from app import log
from app.but import jury_but
from app.but.cursus_but import but_ects_valides
from app.models.but_validations import ValidationDUT120
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.scodoc.gen_tables import GenTable
@ -156,14 +155,6 @@ 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
@ -181,12 +172,8 @@ 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}"""
if deca
else ""
),
"_ects_xls": deca.ects_annee() if deca else "",
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {ects_but_valides:g}""",
"_ects_xls": deca.ects_annee(),
"ects_but": ects_but_valides,
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": (
@ -194,15 +181,10 @@ def pvjury_table_but(
),
"decision_but": deca.code_valide if deca else "",
"devenir": (
"Diplôme obtenu"
if has_diplome
else (
", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
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,
@ -211,7 +193,7 @@ def pvjury_table_but(
"code_nip": etud.code_nip,
"code_ine": etud.code_ine,
}
if (deca and deca.valide_diplome()) or not only_diplome:
if 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, jury_dut120
from app.models import Identite, FormSemestre, ScolarNews, ValidationDUT120
from app.but import jury_but
from app.models import Identite, FormSemestre, ScolarNews
from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but(
formsemestre: FormSemestre, only_adm: bool = True, dry_run=False, with_dut120=True
formsemestre: FormSemestre, only_adm: bool = True, dry_run=False
) -> tuple[int, list[jury_but.DecisionsProposeesAnnee]]:
"""Calcul automatique des décisions de jury sur une "année" BUT.
@ -27,8 +27,6 @@ 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])
@ -42,10 +40,7 @@ def formsemestre_validation_auto_but(
etud = Identite.get_etud(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if not dry_run:
modified = deca.record_all(only_validantes=only_adm)
modified |= validation_dut120_auto(etud, formsemestre)
if modified:
nb_etud_modif += 1
nb_etud_modif += deca.record_all(only_validantes=only_adm)
else:
decas.append(deca)
@ -61,28 +56,3 @@ 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

View File

@ -1,112 +0,0 @@
##############################################################################
# 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,25 +10,23 @@ 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 présentant *toutes* les décisions de jury concernant cet étudiant
et permettant (si permission) de les supprimer une à une.
"""Vue (réservée au chef de dept.)
présentant *toutes* les décisions de jury concernant cet étudiant
et permettant de les supprimer une à une.
"""
sem_vals = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, ue_id=None
@ -62,12 +60,8 @@ 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,10 +4,13 @@
# See LICENSE
##############################################################################
"""Jury édition manuelle des décisions RCUE antérieures
"""Jury édition manuelle des décisions (correction d'erreurs, parcours hors normes)
Non spécifique au BUT.
"""
from flask import render_template
import sqlalchemy as sa
from app import log
from app.but import cursus_but

View File

@ -27,7 +27,6 @@
"""caches pour tables APC
"""
from flask import g
from app.scodoc import sco_cache
@ -48,27 +47,3 @@ class EvaluationsPoidsCache(sco_cache.ScoDocCache):
"""
prefix = "EPC"
@classmethod
def invalidate_all(cls):
"delete all cached evaluations poids (in current dept)"
from app.models.formsemestre import FormSemestre
from app.models.moduleimpls import ModuleImpl
moduleimpl_ids = [
mi.id
for mi in ModuleImpl.query.join(FormSemestre).filter_by(
dept_id=g.scodoc_dept_id
)
]
cls.delete_many(moduleimpl_ids)
@classmethod
def invalidate_sem(cls, formsemestre_id):
"delete cached evaluations poids for this formsemestre from cache"
from app.models.moduleimpls import ModuleImpl
moduleimpl_ids = [
mi.id for mi in ModuleImpl.query.filter_by(formsemestre_id=formsemestre_id)
]
cls.delete_many(moduleimpl_ids)

View File

@ -45,6 +45,7 @@ from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoBugCatcher
from app.scodoc.sco_utils import ModuleType
@ -112,8 +113,6 @@ class ModuleImplResults:
"""
self.evals_etudids_sans_note = {}
"""dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
self.evals_type = {}
"""Type de chaque eval { evaluation.id : evaluation.evaluation_type }"""
self.load_notes(etudids, etudids_actifs)
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module vient de la session2"""
@ -165,10 +164,7 @@ class ModuleImplResults:
self.evaluations_completes = []
self.evaluations_completes_dict = {}
self.etudids_attente = set() # empty
self.evals_type = {}
evaluation: Evaluation
for evaluation in moduleimpl.evaluations:
self.evals_type[evaluation.id] = evaluation.evaluation_type
eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi
# tous les inscrits (non dem) au module ont une note
@ -274,24 +270,6 @@ class ModuleImplResults:
* self.evaluations_completes
).reshape(-1, 1)
def get_evaluations_special_coefs(
self, modimpl: ModuleImpl, evaluation_type=Evaluation.EVALUATION_SESSION2
) -> np.array:
"""Coefficients des évaluations de session 2 ou rattrapage.
Les évals de session 2 et rattrapage sont réputées "complètes": elles sont toujours
prises en compte mais seules les notes numériques et ABS sont utilisées.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[
(e.coefficient if e.evaluation_type == evaluation_type else 0.0)
for e in modimpl.evaluations
],
dtype=float,
)
).reshape(-1, 1)
# was _list_notes_evals_titles
def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"Liste des évaluations complètes"
@ -318,41 +296,47 @@ class ModuleImplResults:
for (etudid, x) in self.evals_notes[evaluation_id].items()
}
def get_evaluations_rattrapage(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations de rattrapage de ce module.
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl) -> Evaluation | None:
"""L'évaluation de rattrapage de ce module, ou None s'il n'en a pas.
Rattrapage: la moyenne du module est la meilleure note entre moyenne
des autres évals et la moyenne des notes de rattrapage.
des autres évals et la note eval rattrapage.
"""
return [
eval_list = [
e
for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE
]
if eval_list:
return eval_list[0]
return None
def get_evaluations_session2(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations de deuxième session de ce module, ou None s'il n'en a pas.
La moyenne des notes de Session 2 remplace la note de moyenne des autres évals.
def get_evaluation_session2(self, moduleimpl: ModuleImpl) -> Evaluation | None:
"""L'évaluation de deuxième session de ce module, ou None s'il n'en a pas.
Session 2: remplace la note de moyenne des autres évals.
"""
return [
eval_list = [
e
for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_SESSION2
]
if eval_list:
return eval_list[0]
return None
def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations bonus non bloquées de ce module, ou liste vide s'il n'en a pas."""
"""Les évaluations bonus 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 and not e.is_blocked()
if e.evaluation_type == Evaluation.EVALUATION_BONUS
]
def get_evaluations_bonus_idx(self, modimpl: ModuleImpl) -> list[int]:
"""Les indices des évaluations bonus non bloquées"""
"""Les indices des évaluations bonus"""
return [
i
for (i, e) in enumerate(modimpl.evaluations)
if e.evaluation_type == Evaluation.EVALUATION_BONUS and not e.is_blocked()
if e.evaluation_type == Evaluation.EVALUATION_BONUS
]
@ -360,13 +344,12 @@ class ModuleImplResultsAPC(ModuleImplResults):
"Calcul des moyennes de modules à la mode BUT"
def compute_module_moy(
self, evals_poids_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
self,
evals_poids_df: pd.DataFrame,
) -> pd.DataFrame:
"""Calcule les moyennes des étudiants dans ce module
Argument:
evals_poids: DataFrame, colonnes: UEs, lignes: EVALs
modimpl_coefs_df: DataFrame, colonnes: modimpl_id, lignes: ue_id
Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs
Résultat: DataFrame, colonnes UE, lignes etud
= la note de l'étudiant dans chaque UE pour ce module.
@ -387,7 +370,6 @@ class ModuleImplResultsAPC(ModuleImplResults):
return pd.DataFrame(index=[], columns=evals_poids_df.columns)
if nb_ues == 0:
return pd.DataFrame(index=self.evals_notes.index, columns=[])
# coefs des évals complètes normales (pas rattr., session 2 ni bonus):
evals_coefs = self.get_evaluations_coefs(modimpl)
evals_poids = evals_poids_df.values * evals_coefs
# -> evals_poids shape : (nb_evals, nb_ues)
@ -416,47 +398,6 @@ class ModuleImplResultsAPC(ModuleImplResults):
) / np.sum(evals_poids_etuds, axis=1)
# etuds_moy_module shape: nb_etuds x nb_ues
evals_session2 = self.get_evaluations_session2(modimpl)
evals_rat = self.get_evaluations_rattrapage(modimpl)
if evals_session2:
# Session2 : quand elle existe, remplace la note de module
# Calcul moyenne notes session2 et remplace (si la note session 2 existe)
etuds_moy_module_s2 = self._compute_moy_special(
modimpl,
evals_notes_stacked,
evals_poids_df,
Evaluation.EVALUATION_SESSION2,
)
# Vrai si toutes les UEs avec coef non nul ont bien une note de session 2 calculée:
mod_coefs = modimpl_coefs_df[modimpl.id]
etuds_use_session2 = np.all(
np.isfinite(etuds_moy_module_s2[:, mod_coefs != 0]), axis=1
)
etuds_moy_module = np.where(
etuds_use_session2[:, np.newaxis],
etuds_moy_module_s2,
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
elif evals_rat:
etuds_moy_module_rat = self._compute_moy_special(
modimpl,
evals_notes_stacked,
evals_poids_df,
Evaluation.EVALUATION_RATTRAPAGE,
)
etuds_ue_use_rattrapage = (
etuds_moy_module_rat > etuds_moy_module
) # etud x UE
etuds_moy_module = np.where(
etuds_ue_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
np.any(etuds_ue_use_rattrapage, axis=1), index=self.evals_notes.index
)
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
@ -464,6 +405,47 @@ class ModuleImplResultsAPC(ModuleImplResults):
evals_poids_df,
evals_notes_stacked,
)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
notes_session2 = self.evals_notes[eval_session2.id].values
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
etuds_moy_module = np.where(
etuds_use_session2[:, np.newaxis],
np.tile(
(notes_session2 / (eval_session2.note_max / 20.0))[:, np.newaxis],
nb_ues,
),
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
else:
# Rattrapage: remplace la note de module ssi elle est supérieure
eval_rat = self.get_evaluation_rattrapage(modimpl)
if eval_rat:
notes_rat = self.evals_notes[eval_rat.id].values
# remplace les notes invalides (ATT, EXC...) par des NaN
notes_rat = np.where(
notes_rat > scu.NOTES_ABSENCE,
notes_rat / (eval_rat.note_max / 20.0),
np.nan,
)
# "Étend" le rattrapage sur les UE: la note de rattrapage est la même
# pour toutes les UE mais ne remplace que là où elle est supérieure
notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1)
# prend le max
etuds_use_rattrapage = notes_rat_ues > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
)
# Serie indiquant que l'étudiant utilise une note de rattrapage sur l'une des UE:
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
)
self.etuds_moy_module = pd.DataFrame(
etuds_moy_module,
index=self.evals_notes.index,
@ -471,34 +453,6 @@ class ModuleImplResultsAPC(ModuleImplResults):
)
return self.etuds_moy_module
def _compute_moy_special(
self,
modimpl: ModuleImpl,
evals_notes_stacked: np.array,
evals_poids_df: pd.DataFrame,
evaluation_type: int,
) -> np.array:
"""Calcul moyenne APC sur évals rattrapage ou session2"""
nb_etuds = self.evals_notes.shape[0]
nb_ues = evals_poids_df.shape[1]
evals_coefs_s2 = self.get_evaluations_special_coefs(
modimpl, evaluation_type=evaluation_type
)
evals_poids_s2 = evals_poids_df.values * evals_coefs_s2
poids_stacked_s2 = np.stack(
[evals_poids_s2] * nb_etuds
) # nb_etuds, nb_evals, nb_ues
evals_poids_etuds_s2 = np.where(
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
poids_stacked_s2,
0,
)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module_s2 = np.sum(
evals_poids_etuds_s2 * evals_notes_stacked, axis=1
) / np.sum(evals_poids_etuds_s2, axis=1)
return etuds_moy_module_s2
def apply_bonus(
self,
etuds_moy_module: pd.DataFrame,
@ -571,7 +525,6 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
return evals_poids, ues
# appelé par ModuleImpl.check_apc_conformity()
def moduleimpl_is_conforme(
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> bool:
@ -593,12 +546,12 @@ def moduleimpl_is_conforme(
if len(modimpl_coefs_df) != nb_ues:
# il arrive (#bug) que le cache ne soit pas à jour...
sco_cache.invalidate_formsemestre()
return app.critical_error("moduleimpl_is_conforme: err 1")
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent")
if moduleimpl.id not in modimpl_coefs_df:
# soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre()
return app.critical_error("moduleimpl_is_conforme: err 2")
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
@ -640,43 +593,46 @@ class ModuleImplResultsClassic(ModuleImplResults):
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1)
evals_session2 = self.get_evaluations_session2(modimpl)
evals_rat = self.get_evaluations_rattrapage(modimpl)
if evals_session2:
# Session2 : quand elle existe, remplace la note de module
# Calcule la moyenne des évaluations de session2
etuds_moy_module_s2 = self._compute_moy_special(
modimpl, evals_notes_20, Evaluation.EVALUATION_SESSION2
)
etuds_use_session2 = np.isfinite(etuds_moy_module_s2)
etuds_moy_module = np.where(
etuds_use_session2,
etuds_moy_module_s2,
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
elif evals_rat:
# Rattrapage: remplace la note de module ssi elle est supérieure
# Calcule la moyenne des évaluations de rattrapage
etuds_moy_module_rat = self._compute_moy_special(
modimpl, evals_notes_20, Evaluation.EVALUATION_RATTRAPAGE
)
etuds_use_rattrapage = etuds_moy_module_rat > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage, index=self.evals_notes.index
)
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_notes_20,
)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
notes_session2 = self.evals_notes[eval_session2.id].values
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
etuds_moy_module = np.where(
etuds_use_session2,
notes_session2 / (eval_session2.note_max / 20.0),
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
else:
# Rattrapage: remplace la note de module ssi elle est supérieure
eval_rat = self.get_evaluation_rattrapage(modimpl)
if eval_rat:
notes_rat = self.evals_notes[eval_rat.id].values
# remplace les notes invalides (ATT, EXC...) par des NaN
notes_rat = np.where(
notes_rat > scu.NOTES_ABSENCE,
notes_rat / (eval_rat.note_max / 20.0),
np.nan,
)
# prend le max
etuds_use_rattrapage = notes_rat > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage, index=self.evals_notes.index
)
self.etuds_moy_module = pd.Series(
etuds_moy_module,
index=self.evals_notes.index,
@ -684,28 +640,6 @@ class ModuleImplResultsClassic(ModuleImplResults):
return self.etuds_moy_module
def _compute_moy_special(
self, modimpl: ModuleImpl, evals_notes_20: np.array, evaluation_type: int
) -> np.array:
"""Calcul moyenne sur évals rattrapage ou session2"""
# n'utilise que les notes valides et ABS (0).
# Même calcul que pour les évals normales, mais avec seulement les
# coefs des évals de session 2 ou rattrapage:
nb_etuds = self.evals_notes.shape[0]
evals_coefs = self.get_evaluations_special_coefs(
modimpl, evaluation_type=evaluation_type
).reshape(-1)
coefs_stacked = np.stack([evals_coefs] * nb_etuds)
# zéro partout sauf si une note ou ABS:
evals_coefs_etuds = np.where(
self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked, 0
)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module = np.sum(
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1)
return etuds_moy_module # array 1d (nb_etuds)
def apply_bonus(
self,
etuds_moy_module: np.ndarray,

View File

@ -183,9 +183,7 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray:
return modimpls_notes.swapaxes(0, 1)
def notes_sem_load_cube(
formsemestre: FormSemestre, modimpl_coefs_df: pd.DataFrame
) -> tuple:
def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
"""Construit le "cube" (tenseur) des notes du semestre.
Charge toutes les notes (sql), calcule les moyennes des modules
et assemble le cube.
@ -209,8 +207,8 @@ def notes_sem_load_cube(
etudids, etudids_actifs = formsemestre.etudids_actifs()
for modimpl in formsemestre.modimpls_sorted:
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs)
evals_poids = modimpl.get_evaluations_poids()
etuds_moy_module = mod_results.compute_module_moy(evals_poids, modimpl_coefs_df)
evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
etuds_moy_module = mod_results.compute_module_moy(evals_poids)
modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids
modimpls_notes.append(etuds_moy_module)

View File

@ -59,17 +59,16 @@ class ResultatsSemestreBUT(NotesTableCompat):
def compute(self):
"Charge les notes et inscriptions et calcule les moyennes d'UE et gen."
self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs(
self.formsemestre, modimpls=self.formsemestre.modimpls_sorted
)
(
self.sem_cube,
self.modimpls_evals_poids,
self.modimpls_results,
) = moy_ue.notes_sem_load_cube(self.formsemestre, self.modimpl_coefs_df)
) = moy_ue.notes_sem_load_cube(self.formsemestre)
self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre)
self.ues_inscr_parcours_df = self.load_ues_inscr_parcours()
self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs(
self.formsemestre, modimpls=self.formsemestre.modimpls_sorted
)
# l'idx de la colonne du mod modimpl.id est
# modimpl_coefs_df.columns.get_loc(modimpl.id)
# idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id)

View File

@ -242,8 +242,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
)
}">saisir le coefficient de cette UE avant de continuer</a></p>
</div>
""",
safe=True,
"""
)

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.0 for ue in etud_ues]) if etud_ues else 0.0
return sum([ue.ects or 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.
@ -518,8 +518,7 @@ class ResultatsSemestre(ResultatsCache):
Corrigez ou faite corriger le programme
<a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept,
formation_id=ue_capitalized.formation_id)}">via cette page</a>.
""",
safe=True,
"""
)
else:
# Coefs de l'UE capitalisée en formation classique:

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) -> float:
def get_etud_ects_valides(self, etudid: int, decisions_ues: dict = False) -> 0:
"""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 float(sum(d.get("ects", 0) for d in decisions_ues.values()))
return sum([d.get("ects", 0.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,9 +84,6 @@ 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

@ -9,9 +9,9 @@ import datetime
from threading import Thread
from flask import current_app, g
from flask_mail import BadHeaderError, Message
from flask_mail import Message
from app import log, mail
from app import mail
from app.models.departements import Departement
from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_preferences
@ -20,15 +20,7 @@ from app.scodoc import sco_preferences
def send_async_email(app, msg):
"Send an email, async"
with app.app_context():
try:
mail.send(msg)
except BadHeaderError:
log(
f"""send_async_email: BadHeaderError
msg={msg}
"""
)
raise
mail.send(msg)
def send_email(

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) # user_name login sans contrainte
authenticated_user = db.Column(db.Text)
entreprise_id = db.Column(db.Integer)
object = db.Column(db.Text)
object_id = db.Column(db.Integer)

View File

@ -338,11 +338,9 @@ def add_entreprise():
if form.validate_on_submit():
entreprise = Entreprise(
nom=form.nom_entreprise.data.strip(),
siret=(
form.siret.data.strip()
if form.siret.data.strip()
else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}"
), # siret provisoire
siret=form.siret.data.strip()
if form.siret.data.strip()
else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}", # siret provisoire
siret_provisoire=False if form.siret.data.strip() else True,
association=form.association.data,
adresse=form.adresse.data.strip(),
@ -354,7 +352,7 @@ def add_entreprise():
db.session.add(entreprise)
db.session.commit()
db.session.refresh(entreprise)
except Exception:
except:
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
@ -806,9 +804,9 @@ def add_offre(entreprise_id):
missions=form.missions.data.strip(),
duree=form.duree.data.strip(),
expiration_date=form.expiration_date.data,
correspondant_id=(
form.correspondant.data if form.correspondant.data != "" else None
),
correspondant_id=form.correspondant.data
if form.correspondant.data != ""
else None,
)
db.session.add(offre)
db.session.commit()
@ -1330,11 +1328,9 @@ def add_contact(entreprise_id):
).first_or_404(description=f"entreprise {entreprise_id} inconnue")
form = ContactCreationForm(
date=f"{datetime.now().strftime('%Y-%m-%dT%H:%M')}",
utilisateur=(
f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
if current_user.nom and current_user.prenom
else ""
),
utilisateur=f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
if current_user.nom and current_user.prenom
else "",
)
if request.method == "POST" and form.cancel.data:
return redirect(url_for("entreprises.contacts", entreprise_id=entreprise_id))
@ -1500,9 +1496,9 @@ def add_stage_apprentissage(entreprise_id):
date_debut=form.date_debut.data,
date_fin=form.date_fin.data,
formation_text=formation.formsemestre.titre if formation else None,
formation_scodoc=(
formation.formsemestre.formsemestre_id if formation else None
),
formation_scodoc=formation.formsemestre.formsemestre_id
if formation
else None,
notes=form.notes.data.strip(),
)
db.session.add(stage_apprentissage)
@ -1806,7 +1802,7 @@ def import_donnees():
db.session.add(entreprise)
db.session.commit()
db.session.refresh(entreprise)
except Exception:
except:
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(

View File

@ -62,11 +62,6 @@ class AjoutAssiOrJustForm(FlaskForm):
if field:
field.errors.append(err_msg)
def disable_all(self):
"Disable all fields"
for field in self:
field.render_kw = {"disabled": True}
date_debut = StringField(
"Date de début",
validators=[validators.Length(max=10)],
@ -170,7 +165,46 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
)
etat = SelectField(
"État du justificatif",
choices=[], # sera rempli dynamiquement
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"),
],
validators=[DataRequired(message="This field is required.")],
)
fichiers = MultipleFileField(label="Ajouter des fichiers")
class ChoixDateForm(FlaskForm):
"""
Formulaire de choix de date
(utilisé par la page de choix de date
si la date courante n'est pas dans le semestre)
"""
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] = [] # used to report our errors
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)
date = StringField(
"Date",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "date",
},
)
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -1,122 +0,0 @@
""" """
from flask_wtf import FlaskForm
from wtforms import (
StringField,
SelectField,
RadioField,
TextAreaField,
validators,
SubmitField,
)
from app.scodoc.sco_utils import EtatAssiduite
class EditAssiForm(FlaskForm):
"""
Formulaire de modification d'une assiduité
"""
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] = [] # used to report our errors
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)
def disable_all(self):
"Disable all fields"
for field in self:
field.render_kw = {"disabled": True}
assi_etat = RadioField(
"État:",
choices=[
(EtatAssiduite.ABSENT.value, EtatAssiduite.ABSENT.version_lisible()),
(EtatAssiduite.RETARD.value, EtatAssiduite.RETARD.version_lisible()),
(EtatAssiduite.PRESENT.value, EtatAssiduite.PRESENT.version_lisible()),
],
default="absent",
validators=[
validators.DataRequired("spécifiez le type d'évènement à signaler"),
],
)
modimpl = SelectField(
"Module",
choices={}, # will be populated dynamically
)
description = TextAreaField(
"Description",
render_kw={
"id": "description",
"cols": 75,
"rows": 4,
"maxlength": 500,
},
)
date_debut = StringField(
"Date de début",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "assi_date_debut",
},
)
heure_debut = StringField(
"Heure début",
default="",
validators=[validators.Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_heure_debut",
},
)
heure_fin = StringField(
"Heure fin",
default="",
validators=[validators.Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_heure_fin",
},
)
date_fin = StringField(
"Date de fin",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "assi_date_fin",
},
)
entry_date = StringField(
"Date de dépôt ou saisie",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "entry_date",
},
)
entry_time = StringField(
"Heure dépôt",
default="",
validators=[validators.Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_heure_fin",
},
)
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

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
sera le titre de l'évènement affiché dans le calendrier ScoDoc.
description=r"""expression régulière python dont le premier groupe doit
sera le titre de l'évènement affcihé dans le calendrier ScoDoc.
Exemple: <tt>Matière : \w+ - ([\w\.\s']+)</tt>
""",
validators=[Optional(), check_ics_regexp],

View File

@ -1,66 +0,0 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# 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
#
##############################################################################
"""
Formulaire création de ticket de bug
"""
from flask_wtf import FlaskForm
from wtforms import SubmitField, validators
from wtforms.fields.simple import StringField, TextAreaField, BooleanField
from app.scodoc import sco_preferences
class CreateBugReport(FlaskForm):
"""Formulaire permettant la création d'un ticket de bug"""
title = StringField(
label="Titre du ticket",
validators=[
validators.DataRequired("titre du ticket requis"),
],
)
message = TextAreaField(
label="Message",
id="ticket_message",
validators=[
validators.DataRequired("message du ticket requis"),
],
)
etab = StringField(label="Etablissement")
include_dump = BooleanField(
"""Inclure une copie anonymisée de la base de données ?
Ces données faciliteront le traitement du problème et resteront strictement confidentielles.
""",
default=False,
)
submit = SubmitField("Envoyer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
def __init__(self, *args, **kwargs):
super(CreateBugReport, self).__init__(*args, **kwargs)
self.etab.data = sco_preferences.get_preference("InstituteName") or ""

View File

@ -1,56 +0,0 @@
"""
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,9 +3,7 @@
"""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
@ -118,42 +116,6 @@ 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
@ -211,11 +173,7 @@ from app.models.but_refcomp import (
ApcReferentielCompetences,
ApcSituationPro,
)
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
ValidationDUT120,
)
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.config import ScoDocSiteConfig

View File

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

View File

@ -21,7 +21,6 @@ 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,
@ -189,12 +188,6 @@ 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):
@ -348,7 +341,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 retourne l'objet Module ou None
Sinon rentourne l'objet Module ou None
"""
if self.moduleimpl_id is not None:
@ -358,24 +351,14 @@ class Assiduite(ScoDocModel):
return f"{mod.code} {mod.titre}"
return mod
if self.external_data is not None and "module" in self.external_data:
elif self.external_data is not None and "module" in self.external_data:
return (
"Autre module (pas dans la liste)"
"Tout module"
if self.external_data["module"] == "Autre"
else self.external_data["module"]
)
return "Module non spécifié" if traduire else None
def get_moduleimpl_id(self) -> int | str | None:
"""
Retourne le ModuleImpl associé à l'assiduité
"""
if self.moduleimpl_id is not None:
return self.moduleimpl_id
if self.external_data is not None and "module" in self.external_data:
return self.external_data["module"]
return None
return "Non spécifié" if traduire else None
def get_saisie(self) -> str:
"""
@ -412,14 +395,6 @@ class Assiduite(ScoDocModel):
if force:
raise ScoValueError("Module non renseigné")
@classmethod
def get_assiduite(cls, assiduite_id: int) -> "Assiduite":
"""Assiduité ou 404, cherche uniquement dans le département courant"""
query = Assiduite.query.filter_by(id=assiduite_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
return query.first_or_404()
class Justificatif(ScoDocModel):
"""
@ -565,13 +540,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):
@ -710,14 +685,10 @@ def is_period_conflicting(
date_fin: datetime,
collection: Query,
collection_cls: Assiduite | Justificatif,
obj_id: int = -1,
) -> bool:
"""
Vérifie si une date n'entre pas en collision
avec les justificatifs ou assiduites déjà présentes
On peut donner un objet_id pour exclure un objet de la vérification
(utile pour les modifications)
"""
# On s'assure que les dates soient avec TimeZone
@ -725,9 +696,7 @@ def is_period_conflicting(
date_fin = localize_datetime(date_fin)
count: int = collection.filter(
collection_cls.date_debut < date_fin,
collection_cls.date_fin > date_debut,
collection_cls.id != obj_id,
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut
).count()
return count > 0
@ -824,29 +793,3 @@ 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

@ -274,11 +274,6 @@ class ApcReferentielCompetences(db.Model, XMLModel):
return "type_departement mismatch"
# Table d'équivalences entre refs:
equiv = self._load_config_equivalences()
# Même specialité (ou alias) ?
if self.specialite != other.specialite and other.specialite not in equiv.get(
"alias", []
):
return "specialite mismatch"
# mêmes parcours ?
eq_parcours = equiv.get("parcours", {})
parcours_by_code_1 = {eq_parcours.get(p.code, p.code): p for p in self.parcours}
@ -322,9 +317,6 @@ class ApcReferentielCompetences(db.Model, XMLModel):
def _load_config_equivalences(self) -> dict:
"""Load config file ressources/referentiels/equivalences.yaml
used to define equivalences between distinct referentiels
return a dict, with optional keys:
alias: list of equivalent names for speciality (eg SD == STID)
parcours: dict with equivalent parcours acronyms
"""
try:
with open(REFCOMP_EQUIVALENCE_FILENAME, encoding="utf-8") as f:
@ -604,7 +596,6 @@ 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,12 +2,9 @@
"""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, ScoDocModel
from app.models import CODE_STR_LEN
from app.models.but_refcomp import ApcNiveau
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
@ -16,7 +13,7 @@ from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
class ApcValidationRCUE(ScoDocModel):
class ApcValidationRCUE(db.Model):
"""Validation des niveaux de compétences
aka "regroupements cohérents d'UE" dans le jargon BUT.
@ -61,8 +58,6 @@ class ApcValidationRCUE(ScoDocModel):
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}>"""
@ -118,14 +113,8 @@ class ApcValidationRCUE(ScoDocModel):
"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(ScoDocModel):
class ApcValidationAnnee(db.Model):
"""Validation des années du BUT"""
__tablename__ = "apc_validation_annee"
@ -156,8 +145,6 @@ class ApcValidationAnnee(ScoDocModel):
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}>"""
@ -215,9 +202,17 @@ 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 = _build_decisions_rcue_list(decisions["decision_rcue"])
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"]}"""
)
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"]
)
@ -241,112 +236,3 @@ 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 bulletins"""
"""True si on interdit les exports PDF des bulltins"""
cfg = ScoDocSiteConfig.query.filter_by(name="disable_bul_pdf").first()
return cfg is not None and cfg.value

View File

@ -52,17 +52,6 @@ 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,9 +179,7 @@ class Identite(models.ScoDocModel):
def html_link_fiche(self) -> str:
"lien vers la fiche"
return (
f"""<a class="etudlink" href="{self.url_fiche()}">{self.nom_prenom()}</a>"""
)
return f"""<a class="etudlink" href="{self.url_fiche()}">{self.nomprenom}</a>"""
def url_fiche(self) -> str:
"url de la fiche étudiant"
@ -199,28 +197,13 @@ class Identite(models.ScoDocModel):
return cls.query.filter_by(**args).first_or_404()
@classmethod
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")
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()
def get_etud(cls, etudid: int) -> "Identite":
"""Etudiant ou 404, cherche uniquement dans le département courant"""
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()
@classmethod
def create_etud(cls, **args) -> "Identite":
@ -316,10 +299,9 @@ class Identite(models.ScoDocModel):
@property
def nomprenom(self, reverse=False) -> str:
"""DEPRECATED: préférer nom_prenom()
Civilité/prénom/nom pour affichages: "M. Pierre Dupont"
"""Civilité/nom/prenom 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.
Prend l'identité courant et non celle de l'état civile si elles diffèrent.
"""
nom = self.nom_usuel or self.nom
prenom = self.prenom_str
@ -327,12 +309,6 @@ class Identite(models.ScoDocModel):
return f"{nom} {prenom}".strip()
return f"{self.civilite_str} {prenom} {nom}".strip()
def nom_prenom(self) -> str:
"""Civilite NOM Prénom
Prend l'identité courante et non celle de l'état civil si elles diffèrent.
"""
return f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()} {self.prenom_str}"
@property
def prenom_str(self):
"""Prénom à afficher. Par exemple: "Jean-Christophe" """
@ -371,15 +347,14 @@ 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, recent_first=True) -> list:
def get_formsemestres(self) -> list:
"""Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit,
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)
triée par date_debut
"""
return sorted(
[ins.formsemestre for ins in self.formsemestre_inscriptions],
key=attrgetter("date_debut"),
reverse=recent_first,
reverse=True,
)
def get_modimpls_by_formsemestre(
@ -418,18 +393,6 @@ class Identite(models.ScoDocModel):
modimpls_by_formsemestre[formsemestre.id] = modimpls_sem
return modimpls_by_formsemestre
def get_modimpls_from_formsemestre(
self, formsemestre: "FormSemestre"
) -> list["ModuleImpl"]:
"""
Liste des ModuleImpl auxquels l'étudiant est inscrit dans le formsemestre.
"""
modimpls = ModuleImpl.query.join(ModuleImplInscription).filter(
ModuleImplInscription.etudid == self.id,
ModuleImpl.formsemestre_id == formsemestre.id,
)
return modimpls.all()
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect.
@ -588,7 +551,7 @@ class Identite(models.ScoDocModel):
.all()
)
def inscription_courante(self) -> "FormSemestreInscription | None":
def inscription_courante(self):
"""La première inscription à un formsemestre _actuellement_ en cours.
None s'il n'y en a pas (ou plus, ou pas encore).
"""
@ -830,7 +793,7 @@ def check_etud_duplicate_code(args, code_name, edit=True):
dest_endpoint = "notes.index_html"
parameters = {}
err_page = f"""<h3>Code étudiant ({code_name}) dupliqué !</h3>
err_page = f"""<h3><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>
@ -845,7 +808,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, safe=True)
raise ScoGenError(err_page)
def make_etud_args(
@ -1080,9 +1043,8 @@ class Admission(models.ScoDocModel):
return args_dict
class ItemSuivi(models.ScoDocModel):
"""Suivi scolarité / débouchés"""
# Suivi scolarité / débouchés
class ItemSuivi(db.Model):
__tablename__ = "itemsuivi"
id = db.Column(db.Integer, primary_key=True)
@ -1094,8 +1056,6 @@ class ItemSuivi(models.ScoDocModel):
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"
@ -1117,7 +1077,7 @@ itemsuivi_tags_assoc = db.Table(
)
class EtudAnnotation(models.ScoDocModel):
class EtudAnnotation(db.Model):
"""Annotation sur un étudiant"""
__tablename__ = "etud_annotations"
@ -1128,8 +1088,6 @@ class EtudAnnotation(models.ScoDocModel):
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,8 +60,6 @@ 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
@ -73,15 +71,6 @@ class Evaluation(models.ScoDocModel):
EVALUATION_BONUS,
}
def type_abbrev(self) -> str:
"Le nom abrégé du type de cette éval."
return {
self.EVALUATION_NORMALE: "std",
self.EVALUATION_RATTRAPAGE: "rattrapage",
self.EVALUATION_SESSION2: "session 2",
self.EVALUATION_BONUS: "bonus",
}.get(self.evaluation_type, "?")
def __repr__(self):
return f"""<Evaluation {self.id} {
self.date_debut.isoformat() if self.date_debut else ''} "{
@ -269,12 +258,10 @@ class Evaluation(models.ScoDocModel):
@classmethod
def get_evaluation(
cls, evaluation_id: int | str, dept_id: int = None, accept_none=False
cls, evaluation_id: int | str, dept_id: int = None
) -> "Evaluation":
"""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
"""Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant."""
from app.models import FormSemestre, ModuleImpl
if not isinstance(evaluation_id, int):
try:
@ -286,8 +273,6 @@ 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
@ -369,8 +354,6 @@ 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)}"
)
@ -434,13 +417,12 @@ class Evaluation(models.ScoDocModel):
return modified
def set_ue_poids(self, ue, poids: float) -> None:
"""Set poids évaluation vers cette UE. Commit."""
"""Set poids évaluation vers cette UE"""
self.update_ue_poids_dict({ue.id: poids})
def set_ue_poids_dict(self, ue_poids_dict: dict) -> None:
"""set poids vers les UE (remplace existants)
ue_poids_dict = { ue_id : poids }
Commit session.
"""
from app.models.ues import UniteEns
@ -450,12 +432,9 @@ class Evaluation(models.ScoDocModel):
if ue is None:
raise ScoValueError("poids vers une UE inexistante")
ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids)
db.session.add(ue_poids)
L.append(ue_poids)
db.session.add(ue_poids)
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
db.session.commit()
self.moduleimpl.invalidate_evaluations_poids() # inval cache
def update_ue_poids_dict(self, ue_poids_dict: dict) -> None:

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 ScoDocModel, SHORT_STR_LEN
from app.models import SHORT_STR_LEN
import app.scodoc.sco_utils as scu
from app.scodoc import sco_preferences
class Scolog(ScoDocModel):
class Scolog(db.Model):
"""Log des actions (journal modif etudiants)"""
__tablename__ = "scolog"
@ -27,15 +27,14 @@ class Scolog(ScoDocModel):
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) # user_name login, sans contrainte
authenticated_user = db.Column(db.Text) # 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).
Par défaut ne commite pas."""
"""Add entry in student's log (replacement for old scolog.logdb)"""
entry = Scolog(
method=method,
msg=msg,
@ -46,21 +45,6 @@ class Scolog(ScoDocModel):
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"""
@ -92,9 +76,7 @@ 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
) # user_name login, sans contrainte
authenticated_user = db.Column(db.Text, index=True) # 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 ScoDocModel, SHORT_STR_LEN
from app.models import 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(ScoDocModel):
class Formation(db.Model):
"""Programme pédagogique d'une formation"""
__tablename__ = "notes_formations"
@ -297,7 +297,7 @@ class Formation(ScoDocModel):
db.session.commit()
class Matiere(ScoDocModel):
class Matiere(db.Model):
"""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,7 +313,6 @@ class Matiere(ScoDocModel):
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,7 +36,6 @@ 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 (
@ -123,11 +122,9 @@ 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 Apogée, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
"code element semestre Apogee, 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)
@ -210,70 +207,6 @@ 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)"""
@ -677,41 +610,6 @@ 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 (
@ -796,7 +694,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]
@ -809,9 +707,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 | ApoEtapeVDI):
def add_etape(self, etape_apo: str):
"Ajoute une étape"
etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=str(etape_apo))
etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=etape_apo)
db.session.add(etape)
def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
@ -845,11 +743,11 @@ class FormSemestre(models.ScoDocModel):
else:
return ", ".join([u.get_nomcomplet() for u in self.responsables])
def est_responsable(self, user: User) -> bool:
def est_responsable(self, user: User):
"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 = None) -> bool:
def est_chef_or_diretud(self, user: User = None):
"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(
@ -867,7 +765,7 @@ class FormSemestre(models.ScoDocModel):
return True # typiquement admin, chef dept
return self.est_responsable(user)
def can_edit_jury(self, user: User | None = None):
def can_edit_jury(self, user: User = None):
"""Vrai si utilisateur (par def. current) peut saisir decision de jury
dans ce semestre: vérifie permission et verrouillage.
"""
@ -995,12 +893,7 @@ 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
"passage": code passage
category: None: tous, "etapes": étapes associées, "sem: code semestre", "annee": code annuel
"""
codes = set()
if category is None or category == "etapes":
@ -1009,8 +902,6 @@ 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]:
@ -1047,7 +938,7 @@ class FormSemestre(models.ScoDocModel):
def etudids_actifs(self) -> tuple[list[int], set[int]]:
"""Liste les etudids inscrits (incluant DEM et DEF),
qui sera l'index des dataframes de notes
qui ser al'index des dataframes de notes
et donne l'ensemble des inscrits non DEM ni DEF.
"""
return [inscr.etudid for inscr in self.inscriptions], {
@ -1334,18 +1225,10 @@ 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 __str__(self):
return self.etape_apo or ""
def as_apovdi(self) -> "ApoEtapeVDI":
def as_apovdi(self) -> ApoEtapeVDI:
return ApoEtapeVDI(self.etape_apo)
@ -1498,9 +1381,8 @@ class FormSemestreInscription(db.Model):
def __repr__(self):
return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={
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 ''}>"""
self.formsemestre_id} etat={self.etat} {
('parcours='+str(self.parcour)) if self.parcour else ''}>"""
class NotesSemSet(db.Model):

View File

@ -54,7 +54,6 @@ 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)
@ -94,10 +93,6 @@ 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
@ -226,11 +221,6 @@ 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",
@ -342,12 +332,13 @@ 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

@ -6,7 +6,6 @@ from flask import abort, g
from flask_login import current_user
from flask_sqlalchemy.query import Query
import app
from app import db
from app.auth.models import User
from app.comp import df_cache
@ -60,8 +59,6 @@ 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)}>"
@ -81,9 +78,7 @@ class ModuleImpl(ScoDocModel):
] or self.module.get_edt_ids()
def get_evaluations_poids(self) -> pd.DataFrame:
"""Les poids des évaluations vers les UEs (accès via cache redis).
Toutes les évaluations sont considérées (normales, bonus, rattr., etc.)
"""
"""Les poids des évaluations vers les UE (accès via cache)"""
evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id)
if evaluations_poids is None:
from app.comp import moy_mod
@ -113,37 +108,20 @@ class ModuleImpl(ScoDocModel):
"""Invalide poids cachés"""
df_cache.EvaluationsPoidsCache.delete(self.id)
def check_apc_conformity(
self, res: "ResultatsSemestreBUT", evaluation_type=Evaluation.EVALUATION_NORMALE
) -> bool:
"""true si les poids des évaluations du type indiqué (normales par défaut)
du module permettent de satisfaire les coefficients du PN.
def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool:
"""true si les poids des évaluations du module permettent de satisfaire
les coefficients du PN.
"""
# appelé par formsemestre_status, liste notes, et moduleimpl_status
if not self.module.formation.get_cursus().APC_SAE or (
self.module.module_type
not in {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE}
self.module.module_type != scu.ModuleType.RESSOURCE
and self.module.module_type != scu.ModuleType.SAE
):
return True # Non BUT, toujours conforme
from app.comp import moy_mod
mod_results = res.modimpls_results.get(self.id)
if mod_results is None:
app.critical_error("check_apc_conformity: err 1")
selected_evaluations_ids = [
eval_id
for eval_id, eval_type in mod_results.evals_type.items()
if eval_type == evaluation_type
]
if not selected_evaluations_ids:
return True # conforme si pas d'évaluations
selected_evaluations_poids = self.get_evaluations_poids().loc[
selected_evaluations_ids
]
return moy_mod.moduleimpl_is_conforme(
self,
selected_evaluations_poids,
self.get_evaluations_poids(),
res.modimpl_coefs_df,
)
@ -255,60 +233,21 @@ class ModuleImpl(ScoDocModel):
return False
return True
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):
def est_inscrit(self, etud: Identite) -> bool:
"""
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 ModuleImplInscription si inscrit au module, False sinon.
Retourne Vrai si inscrit au module, faux sinon.
"""
# vérifie inscrit au moduleimpl ET au formsemestre
from app.models.formsemestre import FormSemestre, FormSemestreInscription
inscription = (
ModuleImplInscription.query.filter_by(etudid=etud.id, moduleimpl_id=self.id)
.join(ModuleImpl)
.join(FormSemestre)
.join(FormSemestreInscription)
.filter_by(etudid=etud.id)
.first()
is_module: int = (
ModuleImplInscription.query.filter_by(
etudid=etud.id, moduleimpl_id=self.id
).count()
> 0
)
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)
)
return is_module
# Enseignants (chargés de TD ou TP) d'un moduleimpl

View File

@ -75,8 +75,6 @@ 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)
@ -108,76 +106,31 @@ class Module(models.ScoDocModel):
return args_dict
@classmethod
def filter_model_attributes(cls, args: dict, excluded: set[str] = None) -> dict:
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 'id' to excluded."""
# on ne peut pas affecter directement 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
return super().filter_model_attributes(data, (excluded or set()) | {"parcours"})
@classmethod
def create_from_dict(cls, data: dict) -> "Module":
"""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:
"""Create from given dict, add parcours"""
mod = super().create_from_dict(data)
for p in data.get("parcours", []) or []:
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:
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)
)
pid = int(p)
query = ApcParcours.query.filter_by(id=pid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
query = query.join(ApcReferentielCompetences).filter_by(
dept_id=g.scodoc_dept_id
)
parcour: ApcParcours = query.first()
if parcour is None:
raise ScoValueError("Parcours invalide")
self.parcours.append(parcour)
mod.parcours.append(parcour)
return mod
def clone(self):
"""Create a new copy of this module."""
@ -210,29 +163,16 @@ 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,
with_parcours_ids=False,
) -> dict:
def to_dict(self, convert_objects=False, with_matiere=False, with_ue=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:
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["parcours"] = [p.to_dict() for p in self.parcours]
d["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
c.to_dict(convert_objects=convert_objects) for c in self.ue_coefs
]
d["app_critiques"] = {x.code: x.to_dict() for x in self.app_critiques}
if not with_matiere:
@ -400,21 +340,6 @@ class Module(models.ScoDocModel):
# Liste seulement les coefs définis:
return [(c.ue, c.coef) for c in self.get_ue_coefs_sorted()]
def get_ue_coefs_descr(self) -> str:
"""Description des coefficients vers les UEs (APC)"""
coefs_descr = ", ".join(
[
f"{ue.acronyme}: {co}"
for ue, co in self.ue_coefs_list()
if isinstance(co, float) and co > 0
]
)
if coefs_descr:
descr = "Coefs: " + coefs_descr
else:
descr = "(pas de coefficients) "
return descr
def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée (codés en base comme "VRT1,VRT2")"""
if self.code_apogee:

View File

@ -5,12 +5,11 @@
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(models.ScoDocModel):
class BulAppreciations(db.Model):
"""Appréciations sur bulletins"""
__tablename__ = "notes_appreciations"
@ -28,8 +27,6 @@ class BulAppreciations(models.ScoDocModel):
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, models
from app import db
class ScoPreference(models.ScoDocModel):
class ScoPreference(db.Model):
"""ScoDoc preferences (par département)"""
__tablename__ = "sco_prefs"
@ -19,8 +19,5 @@ class ScoPreference(models.ScoDocModel):
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 abort, g
from flask import g
import pandas as pd
from app import db, log
@ -46,8 +46,6 @@ 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")
@ -123,16 +121,6 @@ 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
@ -265,30 +253,6 @@ 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()
@ -310,12 +274,6 @@ 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(
@ -396,21 +354,7 @@ class UniteEns(models.ScoDocModel):
return True, ""
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]:
def set_niveau_competence(self, niveau: ApcNiveau) -> 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).
@ -418,12 +362,7 @@ class UniteEns(models.ScoDocModel):
Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie.
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)
Returns True if (de)association done, False on error.
"""
# Sanity checks
if not self.formation.referentiel_competence:
@ -431,12 +370,6 @@ 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,15 +2,12 @@
"""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
@ -116,7 +113,6 @@ 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:
@ -135,27 +131,6 @@ 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"""
@ -211,7 +186,7 @@ class ScolarAutorisationInscription(db.Model):
origin_formsemestre_id: int,
semestre_id: int,
):
"""Ajoute une autorisation (don't commit)"""
"""Ajoute une autorisation"""
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 = {ADJ, ADM, ADSUP}
CODES_ANNEE_BUT_VALIDES = {ADM, ADSUP}
CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE
BUT_BARRE_UE = BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE

View File

@ -55,8 +55,6 @@ 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
@ -178,7 +176,6 @@ class GenTable:
self.xml_link = xml_link
# HTML parameters:
if not table_id: # random id
log("Warning: GenTable() called without table_id")
self.table_id = "gt_" + str(random.randint(0, 1000000))
else:
self.table_id = table_id
@ -317,10 +314,7 @@ class GenTable:
def get_titles_list(self):
"list of titles"
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
return [self.titles.get(cid, "") for cid in self.columns_ids]
def gen(self, fmt="html", columns_ids=None):
"""Build representation of the table in the specified format.
@ -683,15 +677,16 @@ class GenTable:
fmt="html",
page_title="",
filename=None,
javascripts=(),
cssstyles=[],
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, do not set response header for non HTML formats.
If with_html_headers, render a full page using ScoDoc template.
If not publish, does not set response header
"""
if not filename:
filename = self.filename
@ -699,16 +694,21 @@ 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:
return render_template(
"sco_page.j2",
content="\n".join(H),
title=page_title,
javascripts=javascripts,
)
H.append(html_sco_header.sco_footer())
return "\n".join(H)
elif fmt == "pdf":
pdf_objs = self.pdf()

View File

@ -25,7 +25,8 @@
#
##############################################################################
"""HTML Header/Footer for ScoDoc pages"""
"""HTML Header/Footer for ScoDoc pages
"""
import html
@ -97,18 +98,10 @@ _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(){{
if (document.getElementById('gtrcontent')) {{
enableTooltips("gtrcontent");
}}
if (document.getElementById('sidebar')) {{
enableTooltips("sidebar");
}}
}};
window.onload=function(){{enableTooltips("gtrcontent")}};
</script>
<script src="{scu.STATIC_DIR}/jQuery/jquery.js"></script>
@ -116,7 +109,6 @@ _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" />
@ -226,14 +218,8 @@ 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(){{
if (document.getElementById('gtrcontent')) {{
enableTooltips("gtrcontent");
}}
if (document.getElementById('sidebar')) {{
enableTooltips("sidebar");
}}
}};
window.onload=function(){{enableTooltips("gtrcontent")}};
const SCO_URL="{url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)}";
const SCO_TIMEZONE="{scu.TIME_ZONE}";
</script>"""

View File

@ -28,7 +28,6 @@
"""
Génération de la "sidebar" (marge gauche des pages HTML)
"""
from flask import render_template, url_for
from flask import g, request
from flask_login import current_user
@ -152,7 +151,7 @@ def sidebar(etudid: int = None):
H = [
f"""
<!-- sidebar py -->
<div class="sidebar" id="sidebar">
<div class="sidebar">
{ sidebar_common() }
<div class="box-chercheetud">Chercher étudiant:<br>
<form method="get" id="form-chercheetud"
@ -194,7 +193,7 @@ def sidebar(etudid: int = None):
formsemestre.date_debut.strftime(scu.DATE_FMT)
} au {
formsemestre.date_fin.strftime(scu.DATE_FMT)
}" data-tooltip>({
}">({
sco_preferences.get_preference("assi_metrique", None)})
<br>{nbabsjust:1g} J., {nbabsnj:1g} N.J.</span>"""
)
@ -228,9 +227,12 @@ def sidebar(etudid: int = None):
<li><a href="{ url_for('assiduites.calendrier_assi_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Calendrier</a></li>
<li><a href="{ url_for('assiduites.liste_assiduites_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Liste</a></li>
<li><a href="{ url_for('assiduites.bilan_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}" title="Les pages bilan et liste ont été fusionnées">Liste/Bilan</a></li>
}">Bilan</a></li>
</ul>
"""
)

View File

@ -157,6 +157,5 @@ def table_billets(
rows=rows,
html_sortable=True,
html_class="table_leftalign",
table_id="table_billets",
)
return tab

View File

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

View File

@ -43,15 +43,52 @@ Pour chaque étudiant commun:
comparer les résultats
"""
from flask import g, render_template, url_for
from flask import g, 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"""
@ -77,12 +114,17 @@ def apo_compare_csv(file_a, file_b, autodetect=True):
""",
dest_url=dest_url,
) from exc
return render_template(
"apogee/apo_compare_csv.j2",
title="Comparaison de fichiers Apogée",
content=_apo_compare_csv(apo_data_a, apo_data_b),
)
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)
def _load_apo_data(csvfile, autodetect=True):
@ -246,7 +288,6 @@ def apo_table_compare_etud_results(A, B):
html_class="table_leftalign",
html_with_td_classes=True,
preferences=sco_preferences.SemPreferences(),
table_id="apo_table_compare_etud_results",
)
return T

View File

@ -43,13 +43,14 @@ import re
import time
from zipfile import ZipFile
from flask import g, send_file
from flask import 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,
@ -78,6 +79,7 @@ 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"):
@ -97,7 +99,7 @@ class EtuCol:
"""Valeurs colonnes d'un element pour un etudiant"""
def __init__(self, nip, apo_elt, init_vals):
pass
pass # XXX
ETUD_OK = "ok"
@ -130,7 +132,7 @@ class ApoEtud(dict):
"Vrai si BUT"
self.col_elts = {}
"{'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}}"
self.etud: Identite | None = None
self.etud: Identite = None
"etudiant ScoDoc associé"
self.etat = None # ETUD_OK, ...
self.is_nar = False
@ -148,9 +150,9 @@ class ApoEtud(dict):
_apo_fmt_note, fmt=ScoDocSiteConfig.get_code_apo("NOTES_FMT") or "3.2f"
)
# Initialisés par associate_sco:
self.autre_formsemestre: FormSemestre = None
self.autre_sem: dict = None
self.autre_res: NotesTableCompat = None
self.cur_formsemestre: FormSemestre = None
self.cur_sem: dict = None
self.cur_res: NotesTableCompat = None
self.new_cols = {}
"{ col_id : value to record in csv }"
@ -169,18 +171,24 @@ class ApoEtud(dict):
Sinon, cherche le semestre, et met l'état à ETUD_OK ou ETUD_NON_INSCRIT.
"""
self.etud = Identite.query.filter_by(
code_nip=self["nip"], dept_id=g.scodoc_dept_id
).first()
if not self.etud:
# 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:
# 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 = {
ins.formsemestre_id for ins in self.etud.formsemestre_inscriptions
}
formsemestre_ids = {s["formsemestre_id"] for s in self.etud["sems"]}
in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids)
if not in_formsemestre_ids:
self.log.append(
@ -220,9 +228,7 @@ 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 ?""",
safe=True,
f"""Fichier Apogee invalide : ligne mal formatée ? <br>colonne <tt>{col_id}</tt> non déclarée ?"""
) from exc
else:
try:
@ -248,7 +254,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: str, sem: dict) -> dict:
def search_elt_in_sem(self, code, sem) -> dict:
"""
VET code jury etape (en BUT, le code annuel)
ELP élément pédagogique: UE, module
@ -261,17 +267,13 @@ class ApoEtud(dict):
Args:
code (str): code apo de l'element cherché
sem (dict): semestre dans lequel on cherche l'élément
Utilise notamment:
cur_formsemestre : semestre "courant" pour résultats annuels (VET)
autre_formsemestre : autre formsemestre utilisé pour les résultats annuels (VET)
cur_sem (dict): semestre "courant" pour résultats annuels (VET)
autre_sem (dict): autre semestre utilisé pour calculer les résultats annuels (VET)
Returns:
dict: with N, B, J, R keys, ou None si elt non trouvé
"""
if not self.etud:
return None
etudid = self.etud.id
etudid = self.etud["etudid"]
if not self.cur_res:
log("search_elt_in_sem: no cur_res !")
return None
@ -314,10 +316,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_formsemestre:
if (not export_res_etape) and self.cur_sem:
# 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_formsemestre.id
self.etud, self.cur_sem["formsemestre_id"]
)
export_res_etape = Se.all_other_validated()
@ -327,15 +329,35 @@ 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
res_ue = self.search_elt_ue(code, res)
if res_ue:
return res_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
# Elements Modules
modimpls = res.get_modimpls_dict()
@ -355,79 +377,9 @@ 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.
@ -466,10 +418,11 @@ class ApoEtud(dict):
#
# XXX cette règle est discutable, à valider
if not self.cur_formsemestre:
# log('comp_elt_annuel cur_sem=%s autre_sem=%s' % (cur_sem['formsemestre_id'], autre_sem['formsemestre_id']))
if not self.cur_sem:
# 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_formsemestre")
log(f"comp_elt_annuel: etudid {etudid} has no cur_sem")
return VOID_APO_RES
if self.is_apc:
@ -485,7 +438,7 @@ class ApoEtud(dict):
# ne touche pas aux RATs
return VOID_APO_RES
if not self.autre_formsemestre:
if not self.autre_sem:
# formations monosemestre, ou code VET semestriel,
# ou jury intermediaire et etudiant non redoublant...
return self.comp_elt_semestre(self.cur_res, cur_decision, etudid)
@ -565,7 +518,7 @@ class ApoEtud(dict):
self.validation_annee_but: ApcValidationAnnee = (
ApcValidationAnnee.query.filter_by(
formsemestre_id=formsemestre.id,
etudid=self.etud.id,
etudid=self.etud["etudid"],
referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id,
).first()
)
@ -574,7 +527,7 @@ class ApoEtud(dict):
)
def etud_set_semestres_de_etape(self, apo_data: "ApoData"):
"""Set .cur_formsemestre and .autre_formsemestre et charge les résultats.
"""Set .cur_sem and .autre_sem 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)).
@ -582,48 +535,52 @@ 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_formsemestre: le formsemestre "courant"
et autre_formsemestre, ou None s'il n'y en a pas.
Set cur_sem: le semestre "courant" et autre_sem, ou None s'il n'y en a pas.
"""
# Cherche le formsemestre "courant":
cur_formsemestres = [
formsemestre
for formsemestre in self.etud.get_formsemestres()
# Cherche le semestre "courant":
cur_sems = [
sem
for sem in self.etud["sems"]
if (
(formsemestre.semestre_id == apo_data.cur_semestre_id)
and (apo_data.etape in formsemestre.etapes)
(sem["semestre_id"] == apo_data.cur_semestre_id)
and (apo_data.etape in sem["etapes"])
and (
FormSemestre.est_in_semestre_scolaire(
formsemestre.date_debut,
sco_formsemestre.sem_in_semestre_scolaire(
sem,
apo_data.annee_scolaire,
0, # annee complete
)
)
)
]
cur_formsemestre = None
if cur_formsemestres:
# prend le plus récent avec décision
for formsemestre in cur_formsemestres:
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"])
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if apo_data.export_res_sdj or res.etud_has_decision(self.etud.id):
cur_formsemestre = formsemestre
has_decision = res.etud_has_decision(self.etud["etudid"])
if has_decision:
cur_sem = sem
self.cur_res = res
break
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:
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"]:
self.cur_res = res
else:
self.cur_res = res_sem.load_formsemestre_results(cur_formsemestre)
formsemestre = FormSemestre.query.get_or_404(
cur_sem["formsemestre_id"]
)
self.cur_res = res_sem.load_formsemestre_results(formsemestre)
self.cur_formsemestre = cur_formsemestre
self.cur_sem = cur_sem
if apo_data.cur_semestre_id <= 0:
# autre_formsemestre non pertinent pour sessions sans semestres:
self.autre_formsemestre = None
# "autre_sem" non pertinent pour sessions sans semestres:
self.autre_sem = None
self.autre_res = None
return
@ -644,49 +601,52 @@ class ApoEtud(dict):
courant_mois_debut = 1 # ou 2 (fev-jul)
else:
raise ValueError("invalid periode value !") # bug ?
courant_date_debut = datetime.date(
day=1, month=courant_mois_debut, year=courant_annee_debut
courant_date_debut = "%d-%02d-01" % (
courant_annee_debut,
courant_mois_debut,
)
else:
courant_date_debut = datetime.date(day=31, month=12, year=9999)
courant_date_debut = "9999-99-99"
# 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 formsemestre in self.etud.get_formsemestres():
for sem in self.etud["sems"]:
if (
formsemestre.semestre_id == autre_semestre_id
and apo_data.etape_apogee in formsemestre.etapes
sem["semestre_id"] == autre_semestre_id
and apo_data.etape_apogee in sem["etapes"]
):
if (
formsemestre.date_debut < courant_date_debut
sem["date_debut_iso"] < courant_date_debut
): # on demande juste qu'il ait démarré avant
autres_sems.append(formsemestre)
autres_sems.append(sem)
if not autres_sems:
autre_formsemestre = None
autre_sem = None
elif len(autres_sems) == 1:
autre_formsemestre = autres_sems[0]
autre_sem = autres_sems[0]
else:
autre_formsemestre = None
for formsemestre in autres_sems:
autre_sem = None
for sem in autres_sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if res.is_apc:
has_decision = res.etud_has_decision(self.etud.id)
has_decision = res.etud_has_decision(self.etud["etudid"])
else:
has_decision = res.get_etud_decision_sem(self.etud.id)
if has_decision or apo_data.export_res_sdj:
autre_formsemestre = formsemestre
has_decision = res.get_etud_decision_sem(self.etud["etudid"])
if has_decision:
autre_sem = sem
break
if autre_formsemestre is None:
autre_formsemestre = autres_sems[
0
] # aucun avec decision, prend le plus recent
if autre_sem is None:
autre_sem = autres_sems[0] # aucun avec decision, prend le plus recent
self.autre_formsemestre = autre_formsemestre
self.autre_sem = autre_sem
# Charge les résultats:
if autre_formsemestre:
self.autre_res = res_sem.load_formsemestre_results(self.autre_formsemestre)
if autre_sem:
formsemestre = FormSemestre.query.get_or_404(autre_sem["formsemestre_id"])
self.autre_res = res_sem.load_formsemestre_results(formsemestre)
else:
self.autre_res = None
@ -728,8 +688,7 @@ 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>""",
safe=True,
<p>{e.args[0]}</p>"""
) from e
self.etape_apogee = self.get_etape_apogee() # 'V1RT'
self.vdi_apogee = self.get_vdi_apogee() # '111'
@ -821,9 +780,7 @@ 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 VET doit être parmi les colonnes de la section XX-APO_COLONNES-XX
"""
"""Le code etape: 'V1RT', donné par le code de l'élément VET"""
for elt in self.apo_csv.apo_elts.values():
if elt.type_objet == "VET":
return elt.code
@ -888,8 +845,7 @@ 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>""",
safe=True,
<br>Colonnes presentes: <tt>{present}</tt>"""
) 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())
@ -917,16 +873,6 @@ 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:]:
@ -939,18 +885,13 @@ 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) -> GenTable:
def build_cr_table(self):
"""Table compte rendu des décisions"""
rows = [] # tableau compte rendu des decisions
for apo_etud in self.etuds:
@ -972,14 +913,13 @@ class ApoData:
columns_ids = ["NIP", "nom", "prenom"]
columns_ids.extend(("etape", "etape_note", "est_NAR", "commentaire"))
table = GenTable(
T = 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 table
return T
def build_adsup_table(self):
"""Construit une table listant les ADSUP émis depuis les formsemestres
@ -1029,7 +969,6 @@ class ApoData:
"rcue": "RCUE",
},
rows=rows,
table_id="adsup_table",
xls_sheet_name="ADSUPs",
)
@ -1115,7 +1054,6 @@ def nar_etuds_table(apo_data, nar_etuds):
columns_ids=columns_ids,
titles=dict(zip(columns_ids, columns_ids)),
rows=rows,
table_id="nar_etuds_table",
xls_sheet_name="NAR ScoDoc",
)
return table.excel()

View File

@ -299,14 +299,11 @@ 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
@ -340,8 +337,6 @@ 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) -> list[str]:
def list_obj_archives(self, oid: int, dept_id: int = None):
"""Returns
:return: list of archive identifiers for this object (paths to non empty dirs)
"""

View File

@ -3,15 +3,13 @@ Ecrit par Matthias Hartmann.
"""
from datetime import date, datetime, time, timedelta
from functools import wraps
from pytz import UTC
from flask import g, request
from flask import g
from flask_sqlalchemy.query import Query
from app import log, db, set_sco_dept
from app.models import (
Evaluation,
Identite,
FormSemestre,
FormSemestreInscription,
@ -19,13 +17,12 @@ from app.models import (
ModuleImplInscription,
ScoDocSiteConfig,
)
from app.models.assiduites import Assiduite, Justificatif, has_assiduites_disable_pref
from app.models.assiduites import Assiduite, Justificatif
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:
@ -734,125 +731,6 @@ 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:
@ -873,7 +751,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 (si moduleimpl_id n'est pas spécifié).
Utilise un cache.
"""
metrique = sco_preferences.get_preference("assi_metrique", formsemestre.id)
return get_assiduites_count_in_interval(
@ -901,7 +779,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 (si moduleimpl_id n'est pas spécifié).
Utilise un cache.
"""
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

@ -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
#
##############################################################################
"""Rapport de bug ScoDoc
Permet de créer un rapport de bug (ticket) sur la plateforme git scodoc.org.
Le principe est le suivant:
1- Si l'utilisateur le demande, on dump la base de données et on l'envoie
2- ScoDoc envoie une requête POST à scodoc.org pour qu'un ticket git soit créé avec les
informations fournies par l'utilisateur + quelques métadonnées.
"""
from flask import g
from flask_login import current_user
import requests
import app.scodoc.sco_utils as scu
import sco_version
from app import log
from app.scodoc.sco_dump_db import sco_dump_and_send_db
from app.scodoc.sco_exceptions import ScoValueError
def sco_bug_report(
title: str = "", message: str = "", etab: str = "", include_dump: bool = False
) -> requests.Response:
"""Envoi d'un bug report (ticket)"""
dump_id = None
if include_dump:
dump = sco_dump_and_send_db()
try:
dump_id = dump.json()["dump_id"]
except (requests.exceptions.JSONDecodeError, KeyError):
dump_id = "inconnu (erreur)"
log(f"sco_bug_report: {scu.SCO_BUG_REPORT_URL} by {current_user.user_name}")
try:
r = requests.post(
scu.SCO_BUG_REPORT_URL,
json={
"ticket": {
"title": title,
"message": message,
"etab": etab,
"dept": getattr(g, "scodoc_dept", "-"),
},
"user": {
"name": current_user.get_nomcomplet(),
"email": current_user.email,
},
"dump": {
"included": include_dump,
"id": dump_id,
},
"scodoc": {
"version": sco_version.SCOVERSION,
},
},
timeout=scu.SCO_ORG_TIMEOUT,
)
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
log("ConnectionError: Impossible de joindre le serveur d'assistance")
raise ScoValueError(
"""
Impossible de joindre le serveur d'assistance (scodoc.org).
Veuillez contacter le service informatique de votre établissement pour
corriger la configuration de ScoDoc. Dans la plupart des cas, il
s'agit d'un proxy mal configuré.
"""
) from exc
return r

View File

@ -69,7 +69,6 @@ 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
@ -710,11 +709,7 @@ 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)
@ -765,19 +760,17 @@ 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])
@ -832,18 +825,12 @@ 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],
@ -898,9 +885,7 @@ 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]
@ -1102,9 +1087,7 @@ def do_formsemestre_bulletinetud(
flash(f"{etud.nomprenom} n'a pas d'adresse e-mail !")
return False, bul_dict["filigranne"]
else:
mail_bulletin(
formsemestre, etud, bul_dict, pdfdata, filename, recipient_addr
)
mail_bulletin(formsemestre.id, bul_dict, pdfdata, filename, recipient_addr)
flash(f"mail envoyé à {recipient_addr}")
return True, bul_dict["filigranne"]
@ -1112,28 +1095,22 @@ def do_formsemestre_bulletinetud(
raise ValueError(f"do_formsemestre_bulletinetud: invalid format ({fmt})")
def mail_bulletin(
formsemestre: FormSemestre,
etud: Identite,
infos: dict,
pdfdata,
filename,
recipient_addr,
):
def mail_bulletin(formsemestre_id, infos, 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).
"""
webmaster = sco_preferences.get_preference("bul_mail_contact_addr", formsemestre.id)
etud = infos["etud"]
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.nom_prenom(),
"nomprenom": etud["nomprenom"],
"dept": dept,
"webmaster": webmaster,
}
@ -1147,12 +1124,12 @@ def mail_bulletin(
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, formsemestre)
hea += generate_bul_list(etud_identite, form_semestre)
subject = f"""Relevé de notes du semestre {
formsemestre.semestre_id if formsemestre.semestre_id >= 0 else ''
} de {etud.nom_prenom()}"""
subject = f"""Relevé de notes de {etud["nomprenom"]}"""
recipients = [recipient_addr]
sender = email.get_from_addr()
if copy_addr:

View File

@ -226,7 +226,6 @@ class BulletinGenerator:
server_name=self.server_name,
filigranne=self.filigranne,
preferences=sco_preferences.SemPreferences(formsemestre_id),
with_page_numbers=self.multi_pages,
)
)
try:

View File

@ -445,10 +445,7 @@ def dict_decision_jury(
...
],
'situation': 'Inscrit le 25/06/2021. Décision jury: Validé. UE acquises: '
'UE31, UE32. Diplôme obtenu.',
'diplomation' : 'Diplôme obtenu.' # (ou vide)
}
'UE31, UE32. Diplôme obtenu.'}
"""
from app.scodoc import sco_bulletins
@ -461,10 +458,7 @@ 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

@ -106,7 +106,6 @@ def assemble_bulletins_pdf(
pagesbookmarks=pagesbookmarks,
filigranne=filigranne,
preferences=sco_preferences.SemPreferences(formsemestre_id),
with_page_numbers=False, # on ne veut pas de no de pages sur les bulletins imprimés en masse
)
)
document.multiBuild(story)
@ -123,8 +122,7 @@ def replacement_function(match) -> str:
return r'<img %s src="%s"%s/>' % (match.group(2), logo.filepath, match.group(4))
raise ScoValueError(
'balise "%s": logo "%s" introuvable'
% (pydoc.html.escape(balise), pydoc.html.escape(name)),
safe=True,
% (pydoc.html.escape(balise), pydoc.html.escape(name))
)

View File

@ -114,7 +114,6 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
html_class="notes_bulletin",
html_class_ignore_default=True,
html_with_td_classes=True,
table_id="std_bul_table",
)
return T.gen(fmt=fmt)

View File

@ -274,7 +274,6 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa
"""expire cache pour un semestre (ou tous ceux du département si formsemestre_id non spécifié).
Si pdfonly, n'expire que les bulletins pdf cachés.
"""
from app.comp import df_cache
from app.models.formsemestre import FormSemestre
from app.scodoc import sco_cursus
@ -316,14 +315,12 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa
and fid in g.formsemestre_results_cache
):
del g.formsemestre_results_cache[fid]
df_cache.EvaluationsPoidsCache.invalidate_sem(formsemestre_id)
else:
# optimization when we invalidate all evaluations:
EvaluationCache.invalidate_all_sems()
df_cache.EvaluationsPoidsCache.invalidate_all()
if hasattr(g, "formsemestre_results_cache"):
del g.formsemestre_results_cache
SemInscriptionsCache.delete_many(formsemestre_ids)
ResultatsSemestreCache.delete_many(formsemestre_ids)
ValidationsSemestreCache.delete_many(formsemestre_ids)

View File

@ -0,0 +1,102 @@
# -*- 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

@ -141,7 +141,6 @@ def formsemestre_table_estim_cost(
""",
origin=f"""Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}""",
filename=f"EstimCout-S{formsemestre.semestre_id}",
table_id="formsemestre_table_estim_cost",
)
return tab

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, Identite
from app.models import FormSemestre
import app.scodoc.notesdb as ndb
# SituationEtudParcours -> get_situation_etud_cursus
def get_situation_etud_cursus(
etud: Identite, formsemestre_id: int
etud: dict, 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,18 +31,13 @@
from app import db
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import (
FormSemestre,
Identite,
ScolarAutorisationInscription,
Scolog,
UniteEns,
)
from app.models import FormSemestre, UniteEns, ScolarAutorisationInscription
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app import log
from app.scodoc import sco_cache
from app.scodoc.scolog import logdb
from app.scodoc import sco_cache, sco_etud
from app.scodoc import sco_formsemestre
from app.scodoc.codes_cursus import (
CMP,
@ -77,7 +72,7 @@ class DecisionSem(object):
def __init__(
self,
code_etat=None,
code_etat_ues: dict = None, # { ue_id : code }
code_etat_ues={}, # { ue_id : code }
new_code_prev="",
explication="", # aide pour le jury
formsemestre_id_utilise_pour_compenser=None, # None si code != ADC
@ -86,7 +81,7 @@ class DecisionSem(object):
rule_id=None, # id regle correspondante
):
self.code_etat = code_etat
self.code_etat_ues = code_etat_ues or {}
self.code_etat_ues = code_etat_ues
self.new_code_prev = new_code_prev
self.explication = explication
self.formsemestre_id_utilise_pour_compenser = (
@ -114,27 +109,20 @@ class DecisionSem(object):
class SituationEtudCursus:
"Semestre dans un cursus"
pass
class SituationEtudCursusClassic(SituationEtudCursus):
"Semestre dans un parcours"
def __init__(self, etud: Identite, formsemestre_id: int, nt: NotesTableCompat):
def __init__(self, etud: dict, formsemestre_id: int, nt: NotesTableCompat):
"""
etud: dict filled by fill_etuds_info()
"""
assert formsemestre_id == nt.formsemestre.id
self.etud = etud
self.etudid = etud.id
self.etudid = etud["etudid"]
self.formsemestre_id = 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.sem = sco_formsemestre.get_formsemestre(formsemestre_id)
self.nt: NotesTableCompat = nt
self.formation = self.nt.formsemestre.formation
self.parcours = self.nt.parcours
@ -142,20 +130,18 @@ 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.cur_sem.semestre_id != self.parcours.NB_SEM
if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
self.semestre_non_terminal = self.sem["semestre_id"] != self.parcours.NB_SEM
if self.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._search_prev()
self.prev_formsemestre_id = self._search_prev()
# Verifie barres
self._comp_barres()
# Verifie compensation
if self.prev_formsemestre and self.cur_sem.gestion_compensation:
self.can_compensate_with_prev = (
self.prev_formsemestre.id in self.can_compensate
)
if self.prev and self.sem["gestion_compensation"]:
self.can_compensate_with_prev = self.prev["can_compensate"]
else:
self.can_compensate_with_prev = False
@ -184,20 +170,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.cur_sem.gestion_semestrielle) and rule.conclusion[
if (not self.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(f"get_possible_choices: inconsistency: state={state}")
log("get_possible_choices: inconsistency: state=%s" % str(state))
#
# valid_semestre = code_semestre_validant(rule.conclusion[0])
choices.append(
@ -217,15 +203,15 @@ class SituationEtudCursusClassic(SituationEtudCursus):
"Phrase d'explication pour le code devenir"
if not devenir:
return ""
s_idx = self.cur_sem.semestre_id # numero semestre courant
if s_idx < 0: # formation sans semestres (eg licence)
s = self.sem["semestre_id"] # numero semestre courant
if s < 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))
sess_abrv = self.parcours.SESSION_ABBRV # 'S' ou 'A'
SA = self.parcours.SESSION_ABBRV # 'S' ou 'A'
if self.semestre_non_terminal and not self.all_other_validated():
passage = f"Passe en {sess_abrv}{next_s}"
passage = "Passe en %s%s" % (SA, next_s)
else:
passage = "Formation terminée"
if devenir == NEXT:
@ -233,23 +219,29 @@ class SituationEtudCursusClassic(SituationEtudCursus):
elif devenir == REO:
return "Réorienté"
elif devenir == REDOANNEE:
return f"Redouble année (recommence {sess_abrv}{s_idx - 1})"
return "Redouble année (recommence %s%s)" % (SA, (s - 1))
elif devenir == REDOSEM:
return f"Redouble semestre (recommence en {sess_abrv}{s_idx})"
return "Redouble semestre (recommence en %s%s)" % (SA, s)
elif devenir == RA_OR_NEXT:
return passage + ", ou redouble année (en {sess_abrv}{s_idx - 1})"
return passage + ", ou redouble année (en %s%s)" % (SA, (s - 1))
elif devenir == RA_OR_RS:
return f"""Redouble semestre {sess_abrv}{s_idx}, ou redouble année (en {
sess_abrv}{s_idx - 1})"""
return "Redouble semestre %s%s, ou redouble année (en %s%s)" % (
SA,
s,
SA,
s - 1,
)
elif devenir == RS_OR_NEXT:
return f"{passage}, ou semestre {sess_abrv}{s_idx}"
return passage + ", ou semestre %s%s" % (SA, s)
elif devenir == NEXT_OR_NEXT2:
# coherent avec get_next_semestre_ids
return f"{passage}, ou en semestre {sess_abrv}{s_idx + 2}"
return passage + ", ou en semestre %s%s" % (
SA,
s + 2,
) # coherent avec get_next_semestre_ids
elif devenir == NEXT2:
return f"Passe en {sess_abrv}{s_idx + 2}"
return "Passe en %s%s" % (SA, s + 2)
else:
log(f"explique_devenir: code devenir inconnu: {devenir}")
log("explique_devenir: code devenir inconnu: %s" % devenir)
return "Code devenir inconnu !"
def all_other_validated(self):
@ -266,7 +258,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
def _sems_validated(self, exclude_current=False):
"True si semestres du parcours validés"
if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
if self.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"])
@ -274,8 +266,8 @@ class SituationEtudCursusClassic(SituationEtudCursus):
to_validate = set(
range(1, self.parcours.NB_SEM + 1)
) # ensemble des indices à valider
if exclude_current and self.cur_sem.semestre_id in to_validate:
to_validate.remove(self.cur_sem.semestre_id)
if exclude_current and self.sem["semestre_id"] in to_validate:
to_validate.remove(self.sem["semestre_id"])
return self._sem_list_validated(to_validate)
def can_jump_to_next2(self):
@ -283,20 +275,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)
"""
s_idx = self.cur_sem.semestre_id
if not self.cur_sem.gestion_semestrielle:
n = self.sem["semestre_id"]
if not self.sem["gestion_semestrielle"]:
return False # pas de semestre décalés
if s_idx == NO_SEMESTRE_ID or s_idx > self.parcours.NB_SEM - 2:
if n == NO_SEMESTRE_ID or n > self.parcours.NB_SEM - 2:
return False # n+2 en dehors du parcours
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 self._sem_list_validated(set(range(1, n))):
# antérieurs validé, teste suivant
n1 = n + 1
for sem in self.get_semestres():
if (
formsemestre.semestre_id == n1
and formsemestre.formation.formation_code
== self.formation.formation_code
sem["semestre_id"] == n1
and sem["formation_code"] == self.formation.formation_code
):
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre
)
@ -323,17 +315,19 @@ class SituationEtudCursusClassic(SituationEtudCursus):
return not sem_idx_set
def _comp_semestres(self):
# plus ancien en tête:
self.formsemestres = self.etud.get_formsemestres(recent_first=False)
# 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()
# Nb max d'UE et acronymes
ue_acros = {} # acronyme ue : 1
nb_max_ue = 0
sems = []
for formsemestre in self.formsemestres: # plus ancien en tête
for sem in sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
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
@ -344,48 +338,37 @@ class SituationEtudCursusClassic(SituationEtudCursus):
sem["formation_code"] = formsemestre.formation.formation_code
# si sem peut servir à compenser le semestre courant, positionne
# can_compensate
if self.check_compensation_dut(sem, nt):
self.can_compensate.add(formsemestre.id)
sem["can_compensate"] = self.check_compensation_dut(sem, nt)
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) -> list[dict]:
def get_semestres(self):
"""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) -> str:
def get_cursus_descr(self, filter_futur=False):
"""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.cur_sem.date_debut
cur_formation_code = self.cur_sem.formation.formation_code
cur_begin_date = self.sem["dateord"]
p = []
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:
for s in self.sems:
if s["ins"]["etat"] == scu.DEMISSION:
dem = " (dem.)"
else:
dem = ""
if filter_futur and formsemestre.date_debut > cur_begin_date:
if filter_futur and s["dateord"] > cur_begin_date:
continue # skip semestres demarrant apres le courant
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 formsemestre.semestre_id < 0:
session_abbrv = "A" # force, cas des DUT annuels par exemple
p.append("%s%d%s" % (session_abbrv, -formsemestre.semestre_id, dem))
SA = self.parcours.SESSION_ABBRV # 'S' ou 'A'
if s["semestre_id"] < 0:
SA = "A" # force, cas des DUT annuels par exemple
p.append("%s%d%s" % (SA, -s["semestre_id"], dem))
else:
p.append("%s%d%s" % (session_abbrv, formsemestre.semestre_id, dem))
p.append("%s%d%s" % (SA, s["semestre_id"], dem))
return ", ".join(p)
def get_parcours_decisions(self):
@ -394,7 +377,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
Returns: { semestre_id : code }
"""
r = {}
if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
if self.sem["semestre_id"] == NO_SEMESTRE_ID:
indices = [NO_SEMESTRE_ID]
else:
indices = list(range(1, self.parcours.NB_SEM + 1))
@ -437,83 +420,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) -> FormSemestre | None:
def _search_prev(self):
"""Recherche semestre 'precedent'.
positionne .prev_decision
return prev_formsemestre_id
"""
self.prev_formsemestre = None
self.prev = None
self.prev_decision = None
if len(self.formsemestres) < 2:
if len(self.sems) < 2:
return None
# Cherche sem courant dans la liste triee par date_debut
cur = None
icur = -1
for cur in self.formsemestres:
for cur in self.sems:
icur += 1
if cur.id == self.formsemestre_id:
if cur["formsemestre_id"] == self.formsemestre_id:
break
if not cur or cur.id != self.formsemestre_id:
if not cur or cur["formsemestre_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.formsemestres) - 1 # par du dernier, remonte vers le passé
prev_formsemestre = None
i = len(self.sems) - 1 # par du dernier, remonte vers le passé
prev = None
while i >= 0:
if (
self.formsemestres[i].formation.formation_code
== self.formation.formation_code
and self.formsemestres[i].semestre_id == cur.semestre_id - 1
self.sems[i]["formation_code"] == self.formation.formation_code
and self.sems[i]["semestre_id"] == cur["semestre_id"] - 1
):
prev_formsemestre = self.formsemestres[i]
prev = self.sems[i]
break
i -= 1
if not prev_formsemestre:
if not prev:
return None # pas de precedent trouvé
self.prev_formsemestre = prev_formsemestre
self.prev = prev
# Verifications basiques:
# ?
# Code etat du semestre precedent:
nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_formsemestre)
formsemestre = FormSemestre.query.get_or_404(prev["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(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: str) -> list[int]:
def get_next_semestre_ids(self, devenir):
"""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_idx = self.cur_sem.semestre_id
s = self.sem["semestre_id"]
if devenir == NEXT:
ids = [self._get_next_semestre_id()]
elif devenir == REDOANNEE:
ids = [s_idx - 1]
ids = [s - 1]
elif devenir == REDOSEM:
ids = [s_idx]
ids = [s]
elif devenir == RA_OR_NEXT:
ids = [s_idx - 1, self._get_next_semestre_id()]
ids = [s - 1, self._get_next_semestre_id()]
elif devenir == RA_OR_RS:
ids = [s_idx - 1, s_idx]
ids = [s - 1, s]
elif devenir == RS_OR_NEXT:
ids = [s_idx, self._get_next_semestre_id()]
ids = [s, self._get_next_semestre_id()]
elif devenir == NEXT_OR_NEXT2:
ids = [
self._get_next_semestre_id(),
s_idx + 2,
s + 2,
] # cohérent avec explique_devenir()
elif devenir == NEXT2:
ids = [s_idx + 2]
ids = [s + 2]
else:
ids = [] # reoriente ou autre: pas de next !
# clip [1..NB_SEM]
r = []
for idx in ids:
if 0 < idx <= self.parcours.NB_SEM:
if idx > 0 and idx <= self.parcours.NB_SEM:
r.append(idx)
return r
@ -521,27 +504,27 @@ class SituationEtudCursusClassic(SituationEtudCursus):
"""Indice du semestre suivant non validé.
S'il n'y en a pas, ramène NB_SEM+1
"""
s_idx = self.cur_sem.semestre_id
if s_idx >= self.parcours.NB_SEM:
s = self.sem["semestre_id"]
if s >= self.parcours.NB_SEM:
return self.parcours.NB_SEM + 1
validated = True
while validated and (s_idx < self.parcours.NB_SEM):
s_idx = s_idx + 1
while validated and (s < self.parcours.NB_SEM):
s = s + 1
# semestre s validé ?
validated = False
for formsemestre in self.formsemestres:
for sem in self.sems:
if (
formsemestre.formation.formation_code
== self.formation.formation_code
and formsemestre.semestre_id == s_idx
sem["formation_code"] == self.formation.formation_code
and sem["semestre_id"] == s
):
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_idx
return s
def valide_decision(self, decision):
"""Enregistre la decision (instance de DecisionSem)
@ -556,11 +539,8 @@ class SituationEtudCursusClassic(SituationEtudCursus):
fsid = decision.formsemestre_id_utilise_pour_compenser
if fsid:
ok = False
for formsemestre in self.formsemestres:
if (
formsemestre.id == fsid
and formsemestre.id in self.can_compensate
):
for sem in self.sems:
if sem["formsemestre_id"] == fsid and sem["can_compensate"]:
ok = True
break
if not ok:
@ -585,11 +565,13 @@ class SituationEtudCursusClassic(SituationEtudCursus):
decision.assiduite,
decision.formsemestre_id_utilise_pour_compenser,
)
Scolog.logdb(
logdb(
cnx,
method="validate_sem",
etudid=self.etudid,
commit=False,
msg=f"formsemestre_id={self.formsemestre_id} code={decision.code_etat}",
msg="formsemestre_id=%s code=%s"
% (self.formsemestre_id, decision.code_etat),
)
# -- decisions UEs
formsemestre_validate_ues(
@ -599,7 +581,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
decision.assiduite,
)
# -- modification du code du semestre precedent
if self.prev_formsemestre and decision.new_code_prev:
if self.prev and decision.new_code_prev:
if decision.new_code_prev == ADC:
# ne compense le prec. qu'avec le sem. courant
fsid = self.formsemestre_id
@ -607,29 +589,30 @@ 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,
)
Scolog.logdb(
logdb(
cnx,
method="validate_sem",
etudid=self.etudid,
commit=False,
msg=f"formsemestre_id={self.prev_formsemestre.id} code={decision.new_code_prev}",
msg="formsemestre_id=%s code=%s"
% (self.prev["formsemestre_id"], 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:
@ -711,7 +694,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
class SituationEtudCursusECTS(SituationEtudCursusClassic):
"""Gestion parcours basés sur ECTS"""
def __init__(self, etud: Identite, formsemestre_id: int, nt):
def __init__(self, etud, formsemestre_id, nt):
SituationEtudCursusClassic.__init__(self, etud, formsemestre_id, nt)
def could_be_compensated(self):
@ -755,6 +738,14 @@ 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",
@ -925,13 +916,13 @@ def formsemestre_validate_ues(formsemestre_id, etudid, code_etat_sem, assiduite)
cnx, nt, formsemestre_id, etudid, ue_id, code_ue
)
Scolog.logdb(
logdb(
cnx,
method="validate_ue",
etudid=etudid,
msg=f"ue_id={ue_id} code={code_ue}",
msg="ue_id=%s code=%s" % (ue_id, code_ue),
commit=False,
)
db.session.commit()
cnx.commit()

View File

@ -34,10 +34,11 @@ 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, Scolog
from app.models import FormSemestre
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
@ -76,6 +77,7 @@ 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,
@ -220,7 +222,6 @@ def table_debouche_etudids(etudids, keep_numeric=True):
html_sortable=True,
html_class="table_leftalign table_listegroupe",
preferences=sco_preferences.SemPreferences(),
table_id="table_debouche_etudids",
)
return tab
@ -289,8 +290,8 @@ def itemsuivi_suppress(itemsuivi_id):
item = itemsuivi_get(cnx, itemsuivi_id, ignore_errors=True)
if item:
_itemsuivi_delete(cnx, itemsuivi_id)
Scolog.logdb(method="itemsuivi_suppress", etudid=item["etudid"], commit=True)
log(f"suppressed itemsuivi {itemsuivi_id}")
logdb(cnx, method="itemsuivi_suppress", etudid=item["etudid"])
log("suppressed itemsuivi %s" % (itemsuivi_id,))
return ("", 204)
@ -302,7 +303,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}
)
Scolog.logdb(method="itemsuivi_create", etudid=etudid, commit=True)
logdb(cnx, method="itemsuivi_create", etudid=etudid)
log("created itemsuivi %s for %s" % (itemsuivi_id, etudid))
item = itemsuivi_get(cnx, itemsuivi_id)
if fmt == "json":

View File

@ -137,7 +137,6 @@ 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}",
@ -190,7 +189,6 @@ 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:
@ -200,27 +198,6 @@ def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable
if current_user.has_permission(Permission.EditApogee):
html_class += " apo_editable"
tab = GenTable(
columns_ids=columns_ids,
html_class_ignore_default=True,
html_class=html_class,
html_sortable=True,
html_table_attrs=f"""
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(),
rows=sems,
titles={
"formsemestre_id": "id",
"semestre_id_n": "S#",
@ -232,10 +209,21 @@ 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",
},
columns_ids=columns_ids,
rows=sems,
table_id="semlist",
html_class_ignore_default=True,
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)}"
""",
html_with_td_classes=True,
preferences=sco_preferences.SemPreferences(),
)
return tab
@ -294,9 +282,6 @@ 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

@ -67,7 +67,7 @@ SCO_DUMP_LOCK = "/tmp/scodump.lock"
def sco_dump_and_send_db(
message: str = "", request_url: str = "", traceback_str_base64: str = ""
) -> requests.Response:
):
"""Dump base de données et l'envoie anonymisée pour debug"""
traceback_str = base64.urlsafe_b64decode(traceback_str_base64).decode(
scu.SCO_ENCODING
@ -97,6 +97,7 @@ def sco_dump_and_send_db(
# Send
r = _send_db(ano_db_name, message, request_url, traceback_str=traceback_str)
code = r.status_code
finally:
# Drop anonymized database
@ -106,7 +107,7 @@ def sco_dump_and_send_db(
log("sco_dump_and_send_db: done.")
return r
return code
def _duplicate_db(db_name, ano_db_name):
@ -215,11 +216,11 @@ def _drop_ano_db(ano_db_name):
log("_drop_ano_db: no temp db, nothing to drop")
return
cmd = ["dropdb", ano_db_name]
log(f"sco_dump_and_send_db: {cmd}")
log("sco_dump_and_send_db: {}".format(cmd))
try:
_ = subprocess.check_output(cmd)
except subprocess.CalledProcessError as exc:
log(f"sco_dump_and_send_db: exception dropdb {exc}")
except subprocess.CalledProcessError as e:
log("sco_dump_and_send_db: exception dropdb {}".format(e))
raise ScoValueError(
f"erreur lors de la suppression de la base {ano_db_name}"
) from exc
"erreur lors de la suppression de la base {}".format(ano_db_name)
)

View File

@ -47,6 +47,7 @@ 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):
@ -186,7 +187,7 @@ def formation_edit(formation_id=None, create=False):
"acronyme",
{
"size": 12,
"explanation": "identifiant de la formation (par ex. BUT R&T)",
"explanation": "identifiant de la formation (par ex. DUT R&T)",
"allow_null": False,
},
),
@ -194,7 +195,7 @@ def formation_edit(formation_id=None, create=False):
"titre",
{
"size": 80,
"explanation": "nom de la formation (ex: BUT Réseaux et Télécommunications)",
"explanation": "nom complet de la formation (ex: DUT Réseaux et Télécommunications",
"allow_null": False,
},
),
@ -325,7 +326,6 @@ def do_formation_create(args: dict) -> Formation:
scodoc_dept=g.scodoc_dept,
formation_id=formation.id,
),
safe=True,
) from exc
ScolarNews.add(

View File

@ -691,13 +691,9 @@ def module_edit(
str(parcour.id) for parcour in ref_comp.parcours
]
+ ["-1"],
"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.
""",
"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.""",
},
)
]
@ -894,6 +890,23 @@ 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,7 +84,6 @@ _ueEditor = ndb.EditableTable(
"ects",
"is_external",
"code_apogee",
"code_apogee_rcue",
"coefficient",
"coef_rcue",
"color",
@ -426,20 +425,6 @@ 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",
{
@ -528,7 +513,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" class="scobox"></div>
<div id="bonus_description"></div>
<div id="ue_list_code" class="sco_box sco_green_bg"></div>
{html_sco_header.sco_footer()}
@ -1056,10 +1041,10 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
if current_user.has_permission(Permission.EditFormSemestre):
H.append(
f"""<ul>
<li><b><a class="stdlink" href="{
<li><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></b>
}">Mettre en place un nouveau semestre de formation {formation.acronyme}</a>
</li>
</ul>"""
)
@ -1124,18 +1109,12 @@ def _ue_table_ues(
klass = "span_apo_edit"
else:
klass = ""
edit_url = url_for(
"apiweb.ue_set_code_apogee",
scodoc_dept=g.scodoc_dept,
ue_id=ue["ue_id"],
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>"
)
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"]
@ -1369,17 +1348,16 @@ def _ue_table_modules(
heurescoef = (
"%(heures_cours)s/%(heures_td)s/%(heures_tp)s, coef. %(coefficient)s" % mod
)
edit_url = url_for(
"apiweb.formation_module_set_code_apogee",
scodoc_dept=g.scodoc_dept,
module_id=mod["module_id"],
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>"
)
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:
@ -1515,6 +1493,28 @@ 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

@ -30,7 +30,6 @@
Lecture et conversion des ics.
"""
from datetime import timezone
import glob
import os
@ -230,7 +229,7 @@ def translate_calendar(
heure_deb=event["heure_deb"],
heure_fin=event["heure_fin"],
moduleimpl_id=modimpl.id,
day=event["jour"],
jour=event["jour"],
)
if modimpl and group
else None

View File

@ -247,7 +247,9 @@ 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):
def apo_csv_semset_check(
semset, allow_missing_apo=False, allow_missing_csv=False
): # was apo_csv_check
"""
check students in stored maqs vs students in semset
Cas à détecter:
@ -344,3 +346,120 @@ 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,19 +125,14 @@ def apo_semset_maq_status(
H.append("""<p><em>Aucune maquette chargée</em></p>""")
# Upload fichier:
H.append(
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>
"""<form id="apo_csv_add" action="view_apo_csv_store" method="post" enctype="multipart/form-data">
Charger votre fichier maquette Apogée:
<input type="file" size="30" name="csvfile"/>
<input type="hidden" name="semset_id" value="{semset_id}"/>
<input type="hidden" name="semset_id" value="%s"/>
<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()
@ -340,7 +335,7 @@ def apo_semset_maq_status(
missing = maq_elems - sem_elems
H.append('<div id="apo_elements">')
H.append(
'<p>Élements Apogée: <span class="apo_elems">%s</span></p>'
'<p>Elements Apogée: <span class="apo_elems">%s</span></p>'
% ", ".join(
[
e if not e in missing else '<span class="missing">' + e + "</span>"
@ -356,7 +351,7 @@ def apo_semset_maq_status(
]
H.append(
f"""<div class="apo_csv_status_missing_elems">
<span class="fontred">Élements Apogée absents dans ScoDoc: </span>
<span class="fontred">Elements Apogée absents dans ScoDoc: </span>
<span class="apo_elems fontred">{
", ".join(sorted(missing))
}</span>
@ -447,11 +442,11 @@ def table_apo_csv_list(semset):
annee_scolaire = semset["annee_scolaire"]
sem_id = semset["sem_id"]
rows = sco_etape_apogee.apo_csv_list_stored_archives(
T = sco_etape_apogee.apo_csv_list_stored_archives(
annee_scolaire, sem_id, etapes=semset.list_etapes()
)
for t in rows:
for t in T:
# 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"])
@ -489,13 +484,12 @@ def table_apo_csv_list(semset):
"date_str": "Enregistré le",
},
columns_ids=columns_ids,
rows=rows,
rows=T,
html_class="table_leftalign apo_maq_list",
html_sortable=True,
# base_url = '%s?formsemestre_id=%s' % (request.base_url, formsemestre_id),
# caption='Maquettes enregistrées',
preferences=sco_preferences.SemPreferences(),
table_id="apo_csv_list",
)
return tab
@ -545,8 +539,7 @@ 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_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]
etuds = [sco_etud.get_etud_info(code_nip=nip, filled=True)[0] for nip in nips]
for e in etuds:
tgt = url_for(
@ -589,7 +582,6 @@ def _view_etuds_page(
html_class="table_leftalign",
filename="students_apo",
preferences=sco_preferences.SemPreferences(),
table_id="view_etuds_page",
)
if fmt != "html":
return tab.make_page(fmt=fmt)
@ -779,18 +771,12 @@ 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"]:
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"
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"],)
else:
e["_css_row_class"] = "apo_not_scodoc"
@ -812,7 +798,6 @@ def view_apo_csv(etape_apo="", semset_id="", fmt="html"):
filename="students_" + etape_apo,
caption="Étudiants Apogée en " + etape_apo,
preferences=sco_preferences.SemPreferences(),
table_id="view_apo_csv",
)
if fmt != "html":

View File

@ -93,7 +93,7 @@ import json
from flask import url_for, g
from app.scodoc import sco_portal_apogee
from app.scodoc.sco_portal_apogee import get_inscrits_etape
from app import log
from app.scodoc.sco_utils import annee_scolaire_debut
from app.scodoc.gen_tables import GenTable
@ -136,16 +136,11 @@ 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 = "-"
"ligne où il compte dans les effectifs"
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.ind_col = "-"
"colonne où il compte dans les effectifs"
def add_etape(self, etape):
self.etapes.add(etape)
@ -168,9 +163,9 @@ class DataEtudiant(object):
def set_ind_col(self, indicatif):
self.ind_col = indicatif
def get_identity(self) -> str:
def get_identity(self):
"""
Calcule le nom/prénom de l'étudiant (données ScoDoc en priorité, sinon données Apogée)
Calcul 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:
@ -181,12 +176,9 @@ 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>
@ -414,8 +406,7 @@ class EtapeBilan:
for key_etape in self.etapes:
annee_apogee, etapestr = key_to_values(key_etape)
self.etu_etapes[key_etape] = set()
# get_inscrits_etape interroge portail Apo:
for etud in sco_portal_apogee.get_inscrits_etape(etapestr, annee_apogee):
for etud in get_inscrits_etape(etapestr, annee_apogee):
key_etu = self.register_etud_apogee(etud, key_etape)
self.etu_etapes[key_etape].add(key_etu)
@ -453,6 +444,7 @@ 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]]
@ -486,34 +478,32 @@ 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]) + "'"
return f"""
<div id="synthese" class="semset_description">
H = [
"""<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()}
</div>
"""
<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)
def _inc_count(self, ind_row, ind_col):
if (ind_row, ind_col) not in self.repartition:
@ -676,9 +666,7 @@ class EtapeBilan:
col_ids,
self.titres,
html_class="repartition",
html_sortable=True,
html_with_td_classes=True,
table_id="apo-repartition",
).gen(fmt="html")
)
return "\n".join(H)
@ -702,34 +690,26 @@ class EtapeBilan:
return "\n".join(H)
@staticmethod
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_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_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 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 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>
"""
]
def table_effectifs(self):
H = []
col_ids = ["tag", "etudiant", "prenom", "nip", "semestre", "apogee", "annee"]
titles = {
@ -782,10 +762,9 @@ class EtapeBilan:
rows,
col_ids,
titles,
table_id="detail",
html_class="table_leftalign",
html_sortable=True,
html_with_td_classes=True,
table_id="apo-detail",
).gen(fmt="html")
)
return "\n".join(H)

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