Compare commits

..

1 Commits

Author SHA1 Message Date
Iziram
bd15b2f419 Assiduites + EDT : ajout heures liens EDT 2023-11-13 15:20:06 +01:00
599 changed files with 35470 additions and 51281 deletions

3
.gitignore vendored
View File

@ -176,6 +176,3 @@ copy
# Symlinks static ScoDoc
app/static/links/[0-9]*.*[0-9]
# Essais locaux
xp/

View File

@ -1,2 +0,0 @@
[mypy-flask_login.*]
ignore_missing_imports = True

154
README.md
View File

@ -1,8 +1,8 @@
# ScoDoc - Gestion de la scolarité - Version ScoDoc 9
(c) Emmanuel Viennet 1999 - 2024 (voir LICENCE.txt).
(c) Emmanuel Viennet 1999 - 2022 (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

@ -19,7 +19,11 @@ from flask import current_app, g, request
from flask import Flask
from flask import abort, flash, has_request_context
from flask import render_template
# from flask.json import JSONEncoder
from flask.logging import default_handler
from flask_bootstrap import Bootstrap
from flask_caching import Cache
from flask_json import FlaskJSON, json_response
from flask_login import LoginManager, current_user
@ -30,7 +34,6 @@ from flask_sqlalchemy import SQLAlchemy
from jinja2 import select_autoescape
import sqlalchemy as sa
import werkzeug.debug
from wtforms.fields import HiddenField
from flask_cas import CAS
@ -56,6 +59,8 @@ login.login_view = "auth.login"
login.login_message = "Identifiez-vous pour accéder à cette page."
mail = Mail()
bootstrap = Bootstrap()
# moment = Moment()
CACHE_TYPE = os.environ.get("CACHE_TYPE")
cache = Cache(
@ -86,9 +91,8 @@ def handle_invalid_csrf(exc):
return render_template("error_csrf.j2", exc=exc), 404
# def handle_pdf_format_error(exc):
# return "ay ay ay"
handle_pdf_format_error = handle_sco_value_error
def handle_pdf_format_error(exc):
return "ay ay ay"
def internal_server_error(exc):
@ -300,6 +304,7 @@ def create_app(config_class=DevConfig):
login.init_app(app)
mail.init_app(app)
app.extensions["mail"].debug = 0 # disable copy of mails to stderr
bootstrap.init_app(app)
cache.init_app(app)
sco_cache.CACHE = cache
if CACHE_TYPE: # non default
@ -332,15 +337,8 @@ def create_app(config_class=DevConfig):
from app.api import api_bp
from app.api import api_web_bp
# Jinja2 configuration
# Enable autoescaping of all templates, including .j2
app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True)
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
# previously in Flask-Bootstrap:
app.jinja_env.globals["bootstrap_is_hidden_field"] = lambda field: isinstance(
field, HiddenField
)
# https://scodoc.fr/ScoDoc
app.register_blueprint(scodoc_bp)
@ -551,8 +549,8 @@ def truncate_database():
CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$
DECLARE
statements CURSOR FOR
SELECT sequence_name
FROM information_schema.sequences
SELECT sequence_name
FROM information_schema.sequences
ORDER BY sequence_name ;
BEGIN
FOR stmt IN statements LOOP
@ -637,12 +635,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,17 +1,11 @@
"""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_login import current_user
from flask import request, g
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
api_bp = Blueprint("api", __name__)
api_web_bp = Blueprint("apiweb", __name__)
@ -20,28 +14,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)
@ -76,35 +48,20 @@ def requested_format(default_format="json", allowed_formats=None):
@as_json
def get_model_api_object(
model_cls: db.Model,
model_id: int,
join_cls: db.Model = None,
restrict: bool | None = None,
):
def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None):
"""
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]"
Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemestre) -> join_cls
Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemstre) -> join_cls
exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py
L'agument restrict est passé to_dict, est signale que l'on veut une version restreinte
(sans données personnelles, ou sans informations sur le justificatif d'absence)
"""
query = model_cls.query.filter_by(id=model_id)
if g.scodoc_dept and join_cls is not None:
query = query.join(join_cls).filter_by(dept_id=g.scodoc_dept_id)
unique: model_cls = query.first()
unique: model_cls = query.first_or_404()
if unique is None:
return scu.json_error(
404,
message=f"{model_cls.__name__} inexistant(e)",
)
if restrict is None:
return unique.to_dict(format_api=True)
return unique.to_dict(format_api=True, restrict=restrict)
return unique.to_dict(format_api=True)
from app.api import tokens

View File

@ -1,34 +1,30 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités"""
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask import g, request
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,
Scolog,
)
from flask_sqlalchemy.query import Query
from app.models.assiduites import (
get_assiduites_justif,
get_justifs_from_date,
@ -43,13 +39,10 @@ from app.scodoc.sco_utils import json_error
@api_web_bp.route("/assiduite/<int:assiduite_id>")
@scodoc
@permission_required(Permission.ScoView)
@as_json
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 +51,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,26 +73,18 @@ 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)
return get_assiduites_justif(assiduite_id, True)
# etudid
@ -136,42 +115,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;
"""
@ -183,7 +172,6 @@ def assiduites_count(
404,
message="étudiant inconnu",
)
set_sco_dept(etud.departement.acronym)
# Les filtres qui seront appliqués au comptage (type, date, etudid...)
filtered: dict[str, object] = {}
@ -228,35 +216,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 +261,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 +283,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 +294,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;
"""
@ -441,7 +335,7 @@ def assiduites_group(with_query: bool = False):
try:
etuds = [int(etu) for etu in etuds]
except ValueError:
return json_error(404, "Le champ etudids n'est pas correctement formé")
return json_error(404, "Le champs etudids n'est pas correctement formé")
# Vérification que tous les étudiants sont du même département
query = Identite.query.filter(Identite.id.in_(etuds))
@ -491,34 +385,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 +432,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
@ -609,8 +444,6 @@ def assiduites_formsemestre_count(
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
set_sco_dept(formsemestre.departement.acronym)
# Récupération des étudiants du formsemestre
etuds = formsemestre.etuds.all()
etuds_id = [etud.id for etud in etuds]
@ -651,10 +484,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 +500,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 +553,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 +566,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""}]
"""
@ -789,9 +605,9 @@ def _create_one(
etud: Identite,
) -> tuple[int, object]:
"""
Création d'une assiduité à partir d'un dict
_create_one Création d'une assiduité à partir d'une représentation JSON
Cette fonction vérifie les données du dict (qui vient du JSON API)
Cette fonction vérifie la représentation JSON
Puis crée l'assiduité si la représentation est valide.
@ -926,18 +742,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,33 +823,25 @@ 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
assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first()
if assiduite_unique is None:
return json_error(404, "Assiduité non existante")
assiduite_unique: Assiduite = Assiduite.query.filter_by(
id=assiduite_id
).first_or_404()
# Récupération des valeurs à modifier
data = request.get_json(force=True)
# Préparation du retour
errors: list[str] = []
# Code 200 si modification réussie
# Code 404 si raté + message d'erreur
code, obj = _edit_one(assiduite_unique, data)
@ -1054,10 +857,7 @@ def assiduite_edit(assiduite_id: int):
msg=f"assiduite: modif {assiduite_unique}",
)
db.session.commit()
try:
scass.simple_invalidate_cache(assiduite_unique.to_dict())
except ObjectDeletedError:
return json_error(404, "Assiduité supprimée / inexistante")
scass.simple_invalidate_cache(assiduite_unique.to_dict())
return {"OK": True}
@ -1071,10 +871,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 +881,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)
@ -1198,7 +988,9 @@ def _edit_one(assiduite_unique: Assiduite, data: dict) -> tuple[int, str]:
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
else:
if not moduleimpl.est_inscrit(assiduite_unique.etudiant):
if not moduleimpl.est_inscrit(
Identite.query.filter_by(id=assiduite_unique.etudid).first()
):
errors.append("param 'moduleimpl_id': etud non inscrit")
else:
# Mise à jour du moduleimpl
@ -1214,9 +1006,7 @@ def _edit_one(assiduite_unique: Assiduite, data: dict) -> tuple[int, str]:
if formsemestre:
force = scu.is_assiduites_module_forced(formsemestre_id=formsemestre.id)
else:
force = scu.is_assiduites_module_forced(
dept_id=assiduite_unique.etudiant.dept_id
)
force = scu.is_assiduites_module_forced(dept_id=etud.dept_id)
external_data = (
external_data
@ -1224,9 +1014,7 @@ def _edit_one(assiduite_unique: Assiduite, data: dict) -> tuple[int, str]:
else assiduite_unique.external_data
)
if force and not (
external_data is not None and external_data.get("module", False) != ""
):
if force and not external_data.get("module", False):
errors.append(
"param 'moduleimpl_id' : le moduleimpl_id ne peut pas être nul"
)
@ -1444,8 +1232,8 @@ def _filter_manager(requested, assiduites_query: Query) -> Query:
annee: int = scu.annee_scolaire()
assiduites_query: Query = assiduites_query.filter(
Assiduite.date_debut >= scu.date_debut_annee_scolaire(annee),
Assiduite.date_fin <= scu.date_fin_annee_scolaire(annee),
Assiduite.date_debut >= scu.date_debut_anne_scolaire(annee),
Assiduite.date_fin <= scu.date_fin_anne_scolaire(annee),
)
return assiduites_query

View File

@ -1,16 +1,11 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
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

@ -1,19 +1,14 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
ScoDoc 9 API : accès aux départements
Note: les routes /departement[s] sont publiées sur l'API (/ScoDoc/api/),
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,39 +219,100 @@ 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")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_formsemestres_courants(acronym: str):
"""
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()
date_courante = request.args.get("date_courante")
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = app.db.func.now()
# 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.order_by(
FormSemestre.date_debut.desc(),
FormSemestre.modalite,
FormSemestre.semestre_id,
FormSemestre.titre,
)
]
@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_by_id(dept_id: int):
"""
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'id donné
"""
dept = (
Departement.query.filter_by(acronym=acronym).first_or_404()
if acronym
else Departement.query.get_or_404(dept_id)
)
# 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")
date_courante = datetime.fromisoformat(date_courante) if date_courante else None
return [
formsemestre.to_dict_api()
for formsemestre in FormSemestre.get_dept_formsemestres_courants(
dept, date_courante
)
]
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = app.db.func.now()
# 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

@ -1,20 +1,16 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
API : accès aux étudiants
CATEGORY
--------
Étudiants
"""
from datetime import datetime
from operator import attrgetter
from flask import g, request, Response
from flask import g, request
from flask_json import as_json
from flask_login import current_user
from flask_login import login_required
@ -22,28 +18,24 @@ from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import VARCHAR
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,
EtudAnnotation,
FormSemestreInscription,
FormSemestre,
Identite,
ScolarNews,
)
from app.scodoc import sco_bulletins
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:
@ -59,32 +51,6 @@ import app.scodoc.sco_utils as scu
#
def _get_etud_by_code(
code_type: str, code: str, dept: Departement
) -> tuple[bool, Response | Identite]:
"""Get etud, using etudid, NIP or INE
Returns True, etud if ok, or False, error response.
"""
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return False, json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code)
else:
return False, json_error(404, "invalid code_type")
if dept:
query = query.filter_by(dept_id=dept.id)
etud = query.first()
if etud is None:
return False, json_error(404, message="etudiant inexistant")
return True, etud
@bp.route("/etudiants/courants", defaults={"long": False})
@bp.route("/etudiants/courants/long", defaults={"long": True})
@api_web_bp.route("/etudiants/courants", defaults={"long": False})
@ -93,20 +59,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 +79,6 @@ def etudiants_courants(long: bool = False):
}
...
]
```
En format "long": voir documentation.
@ -142,10 +101,7 @@ def etudiants_courants(long: bool = False):
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
if long:
restrict = not current_user.has_permission(Permission.ViewEtudData)
data = [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in etuds
]
data = [etud.to_dict_api() for etud in etuds]
else:
data = [etud.to_dict_short() for etud in etuds]
return data
@ -165,13 +121,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.
"""
@ -182,8 +135,8 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
404,
message="étudiant inconnu",
)
restrict = not current_user.has_permission(Permission.ViewEtudData)
return etud.to_dict_api(restrict=restrict, with_annotations=True)
return etud.to_dict_api()
@bp.route("/etudiant/etudid/<int:etudid>/photo")
@ -195,18 +148,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 +182,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 +225,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.).
"""
@ -305,10 +248,7 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
restrict = not current_user.has_permission(Permission.ViewEtudData)
return [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in query
]
return [etud.to_dict_api() for etud in query]
@bp.route("/etudiants/name/<string:start>")
@ -317,9 +257,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:
@ -336,11 +275,7 @@ def etudiants_by_name(start: str = "", min_len=3, limit=32):
)
etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit)
# Note: on raffine le tri pour les caractères spéciaux et nom usuel ici:
restrict = not current_user.has_permission(Permission.ViewEtudData)
return [
etud.to_dict_api(restrict=restrict)
for etud in sorted(etuds, key=attrgetter("sort_key"))
]
return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))]
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@ -354,13 +289,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)
@ -421,43 +356,48 @@ def bulletin(
code_type: str = "etudid",
code: str = None,
formsemestre_id: int = None,
version: str = "selectedevals",
version: str = "long",
pdf: bool = False,
with_img_signatures_pdf: bool = True,
):
"""
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
version : type de bulletin (par défaut, "long"): 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"
pdf = True
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if version not in (
scu.BULLETINS_VERSIONS_BUT
if formsemestre.formation.is_apc()
else scu.BULLETINS_VERSIONS
):
if version not in scu.BULLETINS_VERSIONS_BUT:
return json_error(404, "version invalide")
if formsemestre.bul_hide_xml and pdf:
return json_error(403, "bulletin non disponible")
# note: la version json est réduite si bul_hide_xml
# return f"{code_type}={code}, version={version}, pdf={pdf}"
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
if g.scodoc_dept and dept.acronym != g.scodoc_dept:
return json_error(404, "formsemestre inexistant")
app.set_sco_dept(dept.acronym)
ok, etud = _get_etud_by_code(code_type, code, dept)
if not ok:
return etud # json error
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code, dept_id=dept.id)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code, dept_id=dept.id)
else:
return json_error(404, "invalid code_type")
etud = query.first()
if etud is None:
return json_error(404, message="etudiant inexistant")
if version == "butcourt":
if pdf:
@ -478,9 +418,9 @@ def bulletin(
)
@bp.route("/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups")
@api_web_bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups"
@bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups",
methods=["GET"],
)
@scodoc
@permission_required(Permission.ScoView)
@ -489,13 +429,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,8 +457,8 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
"group_name": "A"
}
]
```
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
@ -538,204 +475,3 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
data = sco_groups.get_etud_groups(etud.id, formsemestre.id)
return data
@bp.route("/etudiant/create", methods=["POST"], defaults={"force": False})
@bp.route("/etudiant/create/force", methods=["POST"], defaults={"force": True})
@api_web_bp.route("/etudiant/create", methods=["POST"], defaults={"force": False})
@api_web_bp.route("/etudiant/create/force", methods=["POST"], defaults={"force": True})
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def etudiant_create(force=False):
"""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
dept = args.get("dept", None)
if not dept:
return scu.json_error(400, "dept requis")
dept_o = Departement.query.filter_by(acronym=dept).first()
if not dept_o:
return scu.json_error(400, "dept invalide")
if g.scodoc_dept and g.scodoc_dept_id != dept_o.id:
return scu.json_error(400, "dept invalide (route departementale)")
else:
app.set_sco_dept(dept)
args["dept_id"] = dept_o.id
# vérifie que le département de création est bien autorisé
if not current_user.has_permission(Permission.EtudInscrit, dept):
return json_error(403, "departement non autorisé")
nom = args.get("nom", None)
prenom = args.get("prenom", None)
ok, homonyms = sco_etud.check_nom_prenom_homonyms(nom=nom, prenom=prenom)
if not ok:
return scu.json_error(400, "nom ou prénom invalide")
if len(homonyms) > 0 and not force:
return scu.json_error(
400, f"{len(homonyms)} homonymes détectés. Vous pouvez utiliser /force."
)
etud = Identite.create_etud(**args)
db.session.flush()
# --- Données admission
admission_args = args.get("admission", None)
if admission_args:
etud.admission.from_dict(admission_args)
# --- Adresse
adresses = args.get("adresses", [])
if adresses:
# ne prend en compte que la première adresse
# car si la base est concue pour avoir plusieurs adresses par étudiant,
# l'application n'en gère plus qu'une seule.
adresse = etud.adresses.first()
adresse.from_dict(adresses[0])
# Poste une nouvelle dans le département concerné:
ScolarNews.add(
typ=ScolarNews.NEWS_INSCR,
text=f"Nouvel étudiant {etud.html_link_fiche()}",
url=etud.url_fiche(),
max_frequency=0,
dept_id=dept_o.id,
)
db.session.commit()
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
db.session.refresh(etud)
r = etud.to_dict_api(restrict=False) # pas de restriction, on vient de le créer
return r
@bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
@api_web_bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
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
"""
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
#
args = request.get_json(force=True) # may raise 400 Bad Request
etud.from_dict(args)
admission_args = args.get("admission", None)
if admission_args:
etud.admission.from_dict(admission_args)
# --- Adresse
adresses = args.get("adresses", [])
if adresses:
# ne prend en compte que la première adresse
# car si la base est concue pour avoir plusieurs adresses par étudiant,
# l'application n'en gère plus qu'une seule.
adresse = etud.adresses.first()
adresse.from_dict(adresses[0])
db.session.commit()
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
db.session.refresh(etud)
restrict = not current_user.has_permission(Permission.ViewEtudData)
r = etud.to_dict_api(restrict=restrict)
return r
@bp.route("/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"])
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"]
)
@scodoc
@permission_required(Permission.EtudInscrit) # il faut en plus ViewEtudData
@as_json
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
}
```
"""
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)
if not ok:
return etud # json error
#
args = request.get_json(force=True) # may raise 400 Bad Request
comment = args.get("comment", None)
if not isinstance(comment, str):
return json_error(404, "invalid comment (expected string)")
if len(comment) > scu.MAX_TEXT_LEN:
return json_error(404, "invalid comment (too large)")
annotation = EtudAnnotation(comment=comment, author=current_user.user_name)
etud.annotations.append(annotation)
db.session.add(etud)
db.session.commit()
log(f"etudiant_annotation/{etud.id}/{annotation.id}")
return annotation.to_dict()
@bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@as_json
@permission_required(Permission.EtudInscrit)
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
"""
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
annotation = EtudAnnotation.query.filter_by(
etudid=etud.id, id=annotation_id
).first()
if annotation is None:
return json_error(404, "annotation not found")
log(f"etudiant_annotation_delete/{etud.id}/{annotation.id}")
db.session.delete(annotation)
db.session.commit()
return "ok"

View File

@ -1,15 +1,11 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
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:
@ -76,18 +67,22 @@ def get_evaluation(evaluation_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def moduleimpl_evaluations(moduleimpl_id: int):
def 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]
query = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id)
if g.scodoc_dept:
query = (
query.join(ModuleImpl)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
)
return [e.to_dict_api() for e in query]
@bp.route("/evaluation/<int:evaluation_id>/notes")
@ -98,36 +93,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:
@ -159,20 +148,15 @@ def evaluation_notes(evaluation_id: int):
@scodoc
@permission_required(Permission.EnsView)
@as_json
def evaluation_set_notes(evaluation_id: int): # evaluation-notes-set
def evaluation_set_notes(evaluation_id: int):
"""É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 +191,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 +205,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 +255,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

@ -1,15 +1,11 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
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:
@ -60,10 +52,9 @@ def formations():
@as_json
def formations_ids():
"""
Retourne la liste de toutes les id de formations
(tous départements, ou du département indiqué dans la route)
Retourne la liste de toutes les id de formations (tous départements)
Exemple de résultat : `[ 17, 99, 32 ]`.
Exemple de résultat : [ 17, 99, 32 ]
"""
query = Formation.query
if g.scodoc_dept:
@ -79,26 +70,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 +119,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 +232,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 +247,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 +269,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 +277,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 +306,30 @@ 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()
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

@ -1,29 +1,22 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
ScoDoc 9 API : accès aux formsemestres
CATEGORY
--------
FormSemestre
"""
from operator import attrgetter, itemgetter
from flask import g, make_response, request
from flask_json import as_json
from flask_login import current_user, login_required
import sqlalchemy as sa
from flask_login import login_required
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
@ -45,7 +38,7 @@ from app.scodoc import sco_groups
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import ModuleType
import app.scodoc.sco_utils as scu
from app.tables.recap import TableRecap, RowRecap
from app.tables.recap import TableRecap
@bp.route("/formsemestre/<int:formsemestre_id>")
@ -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")
@ -146,8 +124,8 @@ def formsemestres_query():
annee_scolaire_int = int(annee_scolaire)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid annee_scolaire: not int")
debut_annee = scu.date_debut_annee_scolaire(annee_scolaire_int)
fin_annee = scu.date_fin_annee_scolaire(annee_scolaire_int)
debut_annee = scu.date_debut_anne_scolaire(annee_scolaire_int)
fin_annee = scu.date_fin_anne_scolaire(annee_scolaire_int)
formsemestres = formsemestres.filter(
FormSemestre.date_fin >= debut_annee, FormSemestre.date_debut <= fin_annee
)
@ -193,221 +171,6 @@ def formsemestres_query():
]
@bp.route("/formsemestre/<int:formsemestre_id>/edit", methods=["POST"])
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/edit", methods=["POST"])
@scodoc
@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,
}
```
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
args = request.get_json(force=True) # may raise 400 Bad Request
editable_keys = {
"semestre_id",
"titre",
"date_debut",
"date_fin",
"edt_id",
"etat",
"modalite",
"gestion_compensation",
"bul_hide_xml",
"block_moyennes",
"block_moyenne_generale",
"mode_calcul_moyennes",
"gestion_semestrielle",
"bul_bgcolor",
"resp_can_edit",
"resp_can_change_ens",
"ens_can_edit_eval",
"elt_sem_apo",
"elt_annee_apo",
}
formsemestre.from_dict({k: v for (k, v) in args.items() if k in editable_keys})
try:
db.session.commit()
except sa.exc.StatementError as exc:
return json_error(404, f"invalid argument(s): {exc.args[0]}")
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 +181,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 +215,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 +338,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)
@ -610,8 +360,7 @@ def formsemestre_etudiants(
inscriptions = formsemestre.inscriptions
if long:
restrict = not current_user.has_permission(Permission.ViewEtudData)
etuds = [ins.etud.to_dict_api(restrict=restrict) for ins in inscriptions]
etuds = [ins.etud.to_dict_api() for ins in inscriptions]
else:
etuds = [ins.etud.to_dict_short() for ins in inscriptions]
# Ajout des groupes de chaque étudiants
@ -630,13 +379,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 +413,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)
@ -674,7 +425,7 @@ def formsemestre_etat_evaluations(formsemestre_id: int):
for modimpl_id in nt.modimpls_results:
modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl_id]
modimpl: ModuleImpl = ModuleImpl.query.get_or_404(modimpl_id)
modimpl_dict = modimpl.to_dict(convert_objects=True, with_module=False)
modimpl_dict = modimpl.to_dict(convert_objects=True)
list_eval = []
for evaluation_id in modimpl_results.evaluations_etat:
@ -716,13 +467,13 @@ def formsemestre_etat_evaluations(formsemestre_id: int):
date_mediane = notes_sorted[len(notes_sorted) // 2].date
eval_dict["saisie_notes"] = {
"datetime_debut": (
date_debut.isoformat() if date_debut is not None else None
),
"datetime_debut": date_debut.isoformat()
if date_debut is not None
else None,
"datetime_fin": date_fin.isoformat() if date_fin is not None else None,
"datetime_mediane": (
date_mediane.isoformat() if date_mediane is not None else None
),
"datetime_mediane": date_mediane.isoformat()
if date_mediane is not None
else None,
}
list_eval.append(eval_dict)
@ -739,16 +490,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":
@ -761,41 +504,27 @@ def formsemestre_resultat(formsemestre_id: int):
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
app.set_sco_dept(formsemestre.departement.acronym)
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
# Ajoute le groupe de chaque partition,
etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id)
class RowRecapAPI(RowRecap):
"""Pour table avec partitions et sort_key"""
def add_etud_cols(self):
"""Ajoute colonnes étudiant: codes, noms"""
super().add_etud_cols()
self.add_cell("partitions", "partitions", etud_groups.get(self.etud.id, {}))
self.add_cell("sort_key", "sort_key", self.etud.sort_key)
table = TableRecap(
res,
convert_values=convert_values,
include_evaluations=False,
mode_jury=False,
row_class=RowRecapAPI,
res, convert_values=convert_values, include_evaluations=False, mode_jury=False
)
# Supprime les champs inutiles (mise en forme)
rows = table.to_list()
# Ajoute le groupe de chaque partition:
etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id)
for row in rows:
row["partitions"] = etud_groups.get(row["etudid"], {})
# for row in rows:
# row["partitions"] = etud_groups.get(row["etudid"], {})
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 +535,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,23 +565,9 @@ 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.
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.
"""
"""l'emploi du temps du semestre"""
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)
group_ids = request.args.getlist("group_ids", int)
show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False))
return sco_edt_cal.formsemestre_edt_dict(
formsemestre, group_ids=group_ids, show_modules_titles=show_modules_titles
)
return sco_edt_cal.formsemestre_edt_dict(formsemestre)

View File

@ -1,16 +1,11 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
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
@ -56,11 +49,6 @@ def decisions_jury(formsemestre_id: int):
"""Décisions du jury des étudiants du formsemestre."""
# APC, pair:
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
if formsemestre is None:
return json_error(
404,
message="formsemestre inconnu",
)
if formsemestre.formation.is_apc():
app.set_sco_dept(formsemestre.departement.acronym)
rows = jury_but_results.get_jury_but_results(formsemestre)
@ -69,16 +57,16 @@ 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(
"scolar.fiche_etud", scodoc_dept=etud.departement.acronym, etudid=etud.id
"scolar.ficheEtud", scodoc_dept=etud.departement.acronym, etudid=etud.id
)
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=etud.id,
text=f"""Suppression décision jury {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 +84,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 +101,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 +153,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 +182,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 +193,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 +284,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 +308,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 +336,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,26 +3,31 @@
# 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
from flask import g, request
from flask_login import login_required, current_user
from flask_sqlalchemy.query import Query
from werkzeug.exceptions import NotFound
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db, set_sco_dept
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object, tools
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.models import Identite, Justificatif, Departement, FormSemestre, Scolog
from app.decorators import permission_required, scodoc
from app.models import (
Identite,
Justificatif,
Departement,
FormSemestre,
FormSemestreInscription,
)
from app.models.assiduites import (
compute_assiduites_justified,
get_formsemestre_from_data,
)
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
@ -38,11 +43,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,
@ -50,24 +53,14 @@ def justificatif(justif_id: int = None):
"date_fin": "2022-10-31T10:00+01:00",
"etat": "valide",
"fichier": "archive_id",
"raison": "une raison", // VIDE si pas le droit
"raison": "une raison",
"entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null,
}
```
SAMPLES
-------
/justificatif/1;
"""
return get_model_api_object(
Justificatif,
justif_id,
Identite,
restrict=not current_user.has_permission(Permission.AbsJustifView),
)
return get_model_api_object(Justificatif, justif_id, Identite)
# etudid
@ -99,32 +92,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)
@ -144,9 +133,8 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
# Mise en forme des données puis retour en JSON
data_set: list[dict] = []
restrict = not current_user.has_permission(Permission.AbsJustifView)
for just in justificatifs_query.all():
data = just.to_dict(format_api=True, restrict=restrict)
data = just.to_dict(format_api=True)
data_set.append(data)
return data_set
@ -163,41 +151,10 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
@as_json
@permission_required(Permission.ScoView)
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
dept: Departement = Departement.query.get(dept_id)
if dept is None:
return json_error(404, "Assiduité non existante")
dept: Departement = Departement.query.get_or_404(dept_id)
etuds: list[int] = [etud.id for etud in dept.etudiants]
# Récupération des justificatifs des étudiants du département
@ -210,15 +167,14 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False):
justificatifs_query: Query = _filter_manager(request, justificatifs_query)
# Mise en forme des données et retour JSON
restrict = not current_user.has_permission(Permission.AbsJustifView)
data_set: list[dict] = []
for just in justificatifs_query:
data_set.append(_set_sems(just, restrict=restrict))
data_set.append(_set_sems(just))
return data_set
def _set_sems(justi: Justificatif, restrict: bool) -> dict:
def _set_sems(justi: Justificatif) -> dict:
"""
_set_sems Ajoute le formsemestre associé au justificatif s'il existe
@ -231,7 +187,7 @@ def _set_sems(justi: Justificatif, restrict: bool) -> dict:
dict: La représentation de l'assiduité en dictionnaire
"""
# Conversion du justificatif en dictionnaire
data = justi.to_dict(format_api=True, restrict=restrict)
data = justi.to_dict(format_api=True)
# Récupération du formsemestre de l'assiduité
formsemestre: FormSemestre = get_formsemestre_from_data(justi.to_dict())
@ -263,34 +219,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
@ -312,10 +241,9 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
justificatifs_query: Query = _filter_manager(request, justificatifs_query)
# Retour des justificatifs en JSON
restrict = not current_user.has_permission(Permission.AbsJustifView)
data_set: list[dict] = []
for justi in justificatifs_query.all():
data = justi.to_dict(format_api=True, restrict=restrict)
data = justi.to_dict(format_api=True)
data_set.append(data)
return data_set
@ -338,10 +266,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 +281,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""}]
"""
@ -371,7 +292,6 @@ def justif_create(etudid: int = None, nip=None, ine=None):
404,
message="étudiant inconnu",
)
set_sco_dept(etud.departement.acronym)
# Récupération des justificatifs à créer
create_list: list[object] = request.get_json(force=True)
@ -381,6 +301,7 @@ def justif_create(etudid: int = None, nip=None, ine=None):
errors: list[dict] = []
success: list[dict] = []
justifs: list[Justificatif] = []
# énumération des justificatifs
for i, data in enumerate(create_list):
@ -392,9 +313,11 @@ def justif_create(etudid: int = None, nip=None, ine=None):
errors.append({"indice": i, "message": obj})
else:
success.append({"indice": i, "message": obj})
justi.justifier_assiduites()
justifs.append(justi)
scass.simple_invalidate_cache(data, etud.id)
# Actualisation des assiduités justifiées en fonction de tous les nouveaux justificatifs
compute_assiduites_justified(etud.etudid, justifs)
return {"errors": errors, "success": success}
@ -413,10 +336,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)
@ -455,7 +374,7 @@ def _create_one(
date_debut=deb,
date_fin=fin,
etat=etat,
etudiant=etud,
etud=etud,
raison=raison,
user_id=current_user.id,
external_data=external_data,
@ -490,27 +409,20 @@ 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
justificatif_unique = Justificatif.get_justificatif(justif_id)
justificatif_unique: Query = Justificatif.query.filter_by(
id=justif_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
@ -525,10 +437,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)
@ -579,13 +488,7 @@ def justif_edit(justif_id: int):
return json_error(404, err)
# Mise à jour du justificatif
justificatif_unique.dejustifier_assiduites()
db.session.add(justificatif_unique)
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
@ -595,7 +498,11 @@ def justif_edit(justif_id: int):
retour = {
"couverture": {
"avant": avant_ids,
"apres": justificatif_unique.justifier_assiduites(),
"après": compute_assiduites_justified(
justificatif_unique.etudid,
[justificatif_unique],
True,
),
}
}
# Invalide le cache
@ -613,18 +520,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]
"""
@ -660,10 +562,12 @@ def _delete_one(justif_id: int) -> tuple[int, str]:
message : OK si réussi, message d'erreur sinon
"""
# Récupération du justificatif à supprimer
try:
justificatif_unique = Justificatif.get_justificatif(justif_id)
except NotFound:
justificatif_unique: Justificatif = Justificatif.query.filter_by(
id=justif_id
).first()
if justificatif_unique is None:
return (404, "Justificatif non existant")
# Récupération de l'archive du justificatif
archive_name: str = justificatif_unique.fichier
@ -677,15 +581,14 @@ def _delete_one(justif_id: int) -> tuple[int, str]:
# On invalide le cache
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)
# On actualise les assiduités justifiées de l'étudiant concerné
compute_assiduites_justified(
justificatif_unique.etudid,
Justificatif.query.filter_by(etudid=justificatif_unique.etudid).all(),
True,
)
return (200, "OK")
@ -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é
@ -712,7 +613,10 @@ def justif_import(justif_id: int = None):
return json_error(404, "Il n'y a pas de fichier joint")
# On récupère le justificatif auquel on va importer le fichier
justificatif_unique = Justificatif.get_justificatif(justif_id)
query: Query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
# Récupération de l'archive si elle existe
archive_name: str = justificatif_unique.fichier
@ -737,34 +641,26 @@ def justif_import(justif_id: int = None):
db.session.commit()
return {"filename": fname}
except ScoValueError as exc:
except ScoValueError as err:
# Si cela ne fonctionne pas on renvoie une erreur
return json_error(404, exc.args[0])
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"])
@api_web_bp.route(
"/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"]
)
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
def justif_export(justif_id: int | None = None, filename: str | None = None):
@permission_required(Permission.AbsChange)
def justif_export(justif_id: int = None, filename: str = None):
"""
Retourne un fichier d'une archive d'un justificatif
"""
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)
# Vérification des permissions
if not (
current_user.has_permission(Permission.AbsJustifView)
or justificatif_unique.user_id == current_user.id
):
return json_error(401, "non autorisé à voir ce fichier")
query: Query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
# On récupère l'archive concernée
archive_name: str = justificatif_unique.fichier
@ -792,27 +688,24 @@ 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
data: dict = request.get_json(force=True)
# On récupère le justificatif concerné
justificatif_unique = Justificatif.get_justificatif(justif_id)
query: Query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
# On récupère l'archive
archive_name: str = justificatif_unique.fichier
@ -871,15 +764,13 @@ 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é
justificatif_unique = Justificatif.get_justificatif(justif_id)
query: Query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
# Récupération de l'archive avec l'archiver
archive_name: str = justificatif_unique.fichier
@ -918,15 +809,13 @@ 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é
justificatif_unique = Justificatif.get_justificatif(justif_id)
query: Query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
# On récupère la liste des assiduités justifiées par le justificatif
assiduites_list: list[int] = scass.justifies(justificatif_unique)
@ -940,7 +829,6 @@ def justif_justifies(justif_id: int = None):
def _filter_manager(requested, justificatifs_query: Query):
"""
Retourne les justificatifs entrés filtrés en fonction de la request
et du département courant s'il y en a un
"""
# cas 1 : etat justificatif
etat: str = requested.args.get("etat")
@ -975,7 +863,7 @@ def _filter_manager(requested, justificatifs_query: Query):
formsemestre: FormSemestre = None
try:
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
justificatifs_query = scass.filter_by_formsemestre(
justificatifs_query, Justificatif, formsemestre
)
@ -994,8 +882,8 @@ def _filter_manager(requested, justificatifs_query: Query):
annee: int = scu.annee_scolaire()
justificatifs_query: Query = justificatifs_query.filter(
Justificatif.date_debut >= scu.date_debut_annee_scolaire(annee),
Justificatif.date_fin <= scu.date_fin_annee_scolaire(annee),
Justificatif.date_debut >= scu.date_debut_anne_scolaire(annee),
Justificatif.date_fin <= scu.date_fin_anne_scolaire(annee),
)
# cas 8 : group_id filtre les justificatifs d'un groupe d'étudiant
@ -1010,10 +898,4 @@ def _filter_manager(requested, justificatifs_query: Query):
except ValueError:
group_id = None
# Département
if g.scodoc_dept:
justificatifs_query = justificatifs_query.join(Identite).filter_by(
dept_id=g.scodoc_dept_id
)
return justificatifs_query

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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
@ -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

@ -1,26 +1,23 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
ScoDoc 9 API : accès aux moduleimpl
CATEGORY
--------
ModuleImpl
"""
from flask import g
from flask_json import as_json
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.models import ModuleImpl
from app.scodoc import sco_liste_notes
from app.decorators import scodoc, permission_required
from app.models import (
FormSemestre,
ModuleImpl,
)
from app.scodoc.sco_permissions import Permission
@ -32,45 +29,43 @@ 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)
query = ModuleImpl.query.filter_by(id=moduleimpl_id)
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
modimpl: ModuleImpl = query.first_or_404()
return modimpl.to_dict(convert_objects=True)
@ -81,55 +76,19 @@ 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)
query = ModuleImpl.query.filter_by(id=moduleimpl_id)
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
modimpl: ModuleImpl = query.first_or_404()
return [i.to_dict() for i in modimpl.inscriptions]
@bp.route("/moduleimpl/<int:moduleimpl_id>/notes")
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>/notes")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def moduleimpl_notes(moduleimpl_id: int):
"""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
},
...
]
```
"""
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
app.set_sco_dept(modimpl.formsemestre.departement.acronym)
table, _ = sco_liste_notes.do_evaluation_listenotes(
moduleimpl_id=modimpl.id, fmt="json"
)
return table

View File

@ -1,16 +1,11 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
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:
@ -327,26 +303,15 @@ def group_create(partition_id: int): # partition-group-create
return json_error(403, "partition non editable")
if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
args = request.get_json(force=True) # may raise 400 Bad Request
group_name = args.get("group_name")
if not isinstance(group_name, str):
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is None:
return json_error(API_CLIENT_ERROR, "missing group name or invalid data format")
args["group_name"] = args["group_name"].strip()
if not GroupDescr.check_name(partition, args["group_name"]):
if not GroupDescr.check_name(partition, group_name):
return json_error(API_CLIENT_ERROR, "invalid group_name")
group_name = group_name.strip()
# le numero est optionnel
numero = args.get("numero")
if numero is None:
numeros = [gr.numero or 0 for gr in partition.groups]
numero = (max(numeros) + 1) if numeros else 0
args["numero"] = numero
args["partition_id"] = partition_id
try:
group = GroupDescr(**args)
except TypeError:
return json_error(API_CLIENT_ERROR, "invalid arguments")
group = GroupDescr(group_name=group_name, partition_id=partition_id)
db.session.add(group)
db.session.commit()
log(f"created group {group}")
@ -362,7 +327,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 +356,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 = (
@ -404,54 +369,21 @@ def group_edit(group_id: int):
return json_error(403, "partition non editable")
if not group.partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
args = request.get_json(force=True) # may raise 400 Bad Request
if "group_name" in args:
if not isinstance(args["group_name"], str):
return json_error(API_CLIENT_ERROR, "invalid data format for group_name")
args["group_name"] = args["group_name"].strip() if args["group_name"] else ""
if not GroupDescr.check_name(
group.partition, args["group_name"], existing=True
):
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is not None:
group_name = group_name.strip()
if not GroupDescr.check_name(group.partition, group_name, existing=True):
return json_error(API_CLIENT_ERROR, "invalid group_name")
group.from_dict(args)
db.session.add(group)
db.session.commit()
log(f"modified {group}")
group.group_name = group_name
db.session.add(group)
db.session.commit()
log(f"modified {group}")
app.set_sco_dept(group.partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return group.to_dict(with_partition=True)
@bp.route("/group/<int:group_id>/set_edt_id/<string:edt_id>", methods=["POST"])
@api_web_bp.route("/group/<int:group_id>/set_edt_id/<string:edt_id>", methods=["POST"])
@login_required
@scodoc
@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é.
"""
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
query = (
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group: GroupDescr = query.first_or_404()
if not group.partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
log(f"group_set_edt_id( {group_id}, '{edt_id}' )")
group.edt_id = edt_id
db.session.add(group)
db.session.commit()
return group.to_dict(with_partition=True)
@bp.route("/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"])
@api_web_bp.route(
"/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"]
@ -461,19 +393,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 +457,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 +470,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 +484,6 @@ 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})")
return [
partition.to_dict()
for partition in formsemestre.partitions.order_by(Partition.numero)
@ -575,13 +498,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 +510,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 +535,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 +546,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 +609,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

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

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

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : outils

View File

@ -1,31 +1,28 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
ScoDoc 9 API : accès aux utilisateurs
CATEGORY
--------
Utilisateurs
"""
from flask import g, request
from flask_json import as_json
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.scodoc.sco_utils import json_error
from app.auth.models import User, Role, UserRole
from app.auth.models import is_valid_password
from app.decorators import scodoc
from app.decorators import scodoc, permission_required
from app.models import Departement
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
from app.scodoc import sco_utils as scu
@bp.route("/user/<int:uid>")
@ -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")
@ -99,20 +85,6 @@ def users_info_query():
return [user.to_dict() for user in query]
def _is_allowed_user_edit(args: dict) -> tuple[bool, str]:
"Vrai si on peut"
if "cas_id" in args and not current_user.has_permission(
Permission.UsersChangeCASId
):
return False, "non autorise a changer cas_id"
if not current_user.is_administrator():
for field in ("cas_allow_login", "cas_allow_scodoc_login"):
if field in args:
return False, f"non autorise a changer {field}"
return True, ""
@bp.route("/user/create", methods=["POST"])
@api_web_bp.route("/user/create", methods=["POST"])
@login_required
@ -121,28 +93,23 @@ 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),
"user_name": str,
"dept": str or null,
"nom": str,
"prenom": str,
"user_name": str,
...
"active":bool (default True)
}
```
"""
args = request.get_json(force=True) # may raise 400 Bad Request
user_name = args.get("user_name")
data = request.get_json(force=True) # may raise 400 Bad Request
user_name = data.get("user_name")
if not user_name:
return json_error(404, "empty user_name")
user = User.query.filter_by(user_name=user_name).first()
if user:
return json_error(404, f"user_create: user {user} already exists\n")
dept = args.get("dept")
dept = data.get("dept")
if dept == "@all":
dept = None
allowed_depts = current_user.get_depts_with_permission(Permission.UsersAdmin)
@ -152,12 +119,10 @@ def user_create():
Departement.query.filter_by(acronym=dept).first() is None
):
return json_error(404, "user_create: departement inexistant")
args["dept"] = dept
ok, msg = _is_allowed_user_edit(args)
if not ok:
return json_error(403, f"user_create: {msg}")
user = User(user_name=user_name)
user.from_dict(args, new_user=True)
nom = data.get("nom")
prenom = data.get("prenom")
active = scu.to_bool(data.get("active", True))
user = User(user_name=user_name, active=active, dept=dept, nom=nom, prenom=prenom)
db.session.add(user)
db.session.commit()
return user.to_dict()
@ -170,24 +135,20 @@ 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,
"prenom": str,
"active":bool
...
}
```
"""
args = request.get_json(force=True) # may raise 400 Bad Request
data = request.get_json(force=True) # may raise 400 Bad Request
user: User = User.query.get_or_404(uid)
# L'utilisateur doit avoir le droit dans le département de départ et celui d'arrivée
orig_dept = user.dept
dest_dept = args.get("dept", False)
dest_dept = data.get("dept", False)
if dest_dept is not False:
if dest_dept == "@all":
dest_dept = None
@ -203,11 +164,10 @@ def user_edit(uid: int):
return json_error(404, "user_edit: departement inexistant")
user.dept = dest_dept
ok, msg = _is_allowed_user_edit(args)
if not ok:
return json_error(403, f"user_edit: {msg}")
user.nom = data.get("nom", user.nom)
user.prenom = data.get("prenom", user.prenom)
user.active = scu.to_bool(data.get("active", user.active))
user.from_dict(args)
db.session.add(user)
db.session.commit()
return user.to_dict()
@ -220,15 +180,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 +218,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 +247,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 +273,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 +284,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 +295,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 +313,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 +338,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 +357,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 +385,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,68 +417,8 @@ 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()
return {"OK": True}
# @bp.route("/user/<int:uid>/edt")
# @api_web_bp.route("/user/<int:uid>/edt")
# @login_required
# @scodoc
# @permission_required(Permission.ScoView)
# @as_json
# def user_edt(uid: int):
# """L'emploi du temps de l'utilisateur.
# Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur.
# show_modules_titles affiche le titre complet du module (défaut), sinon juste le code.
# Il faut la permission ScoView + (UsersView ou bien être connecté comme l'utilisateur demandé)
# """
# if g.scodoc_dept is None: # route API non départementale
# if not current_user.has_permission(Permission.UsersView):
# return scu.json_error(403, "accès non autorisé")
# user: User = db.session.get(User, uid)
# if user is None:
# return json_error(404, "user not found")
# # Check permission
# if current_user.id != user.id:
# if g.scodoc_dept:
# allowed_depts = current_user.get_depts_with_permission(Permission.UsersView)
# if (None not in allowed_depts) and (user.dept not in allowed_depts):
# return json_error(404, "user not found")
# show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False))
# # Cherche ics
# if not user.edt_id:
# return json_error(404, "user not configured")
# ics_filename = sco_edt_cal.get_ics_user_edt_filename(user.edt_id)
# if not ics_filename:
# return json_error(404, "no calendar for this user")
# _, calendar = sco_edt_cal.load_calendar(ics_filename)
# # TODO:
# # - Construire mapping edt2modimpl: edt_id -> modimpl
# # pour cela, considérer tous les formsemestres de la période de l'edt
# # (soit on considère l'année scolaire du 1er event, ou celle courante,
# # soit on cherche min, max des dates des events)
# # - Modifier décodage des groupes dans convert_ics pour avoi run mapping
# # de groupe par semestre (retrouvé grâce au modimpl associé à l'event)
# raise NotImplementedError() # TODO XXX WIP
# events_scodoc, _ = sco_edt_cal.convert_ics(
# calendar,
# edt2group=edt2group,
# default_group=default_group,
# edt2modimpl=edt2modimpl,
# )
# edt_dict = sco_edt_cal.translate_calendar(
# events_scodoc, group_ids, show_modules_titles=show_modules_titles
# )
# return edt_dict

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

@ -12,30 +12,23 @@ from typing import Optional
import cracklib # pylint: disable=import-error
import flask
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
import jwt
from app import db, email, log, login
from app.models import Departement, ScoDocModel
from app.models import Departement
from app.models import SHORT_STR_LEN, USERNAME_STR_LEN
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
import app.scodoc.sco_utils as scu
from app.scodoc import sco_etud # a deplacer dans scu
VALID_LOGIN_EXP = re.compile(r"^[a-zA-Z0-9@\\\-_\.]+$")
@ -57,17 +50,16 @@ 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)
(len(user_name) < 2)
or (len(user_name) >= USERNAME_STR_LEN)
or not VALID_LOGIN_EXP.match(user_name)
)
class User(UserMixin, ScoDocModel):
class User(UserMixin, db.Model):
"""ScoDoc users, handled by Flask / SQLAlchemy"""
id = db.Column(db.Integer, primary_key=True)
@ -99,7 +91,7 @@ class User(UserMixin, ScoDocModel):
"""date du dernier login via CAS"""
edt_id = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
password_hash = db.Column(db.Text()) # les hashs modernes peuvent être très longs
password_hash = db.Column(db.String(128))
password_scodoc7 = db.Column(db.String(42))
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
date_modif_passwd = db.Column(db.DateTime, default=datetime.utcnow)
@ -111,8 +103,6 @@ class User(UserMixin, ScoDocModel):
token = db.Column(db.Text(), index=True, unique=True)
token_expiration = db.Column(db.DateTime)
# Define the back reference from User to ModuleImpl
modimpls = db.relationship("ModuleImpl", back_populates="responsable")
roles = db.relationship("Role", secondary="user_role", viewonly=True)
Permission = Permission
@ -126,17 +116,12 @@ class User(UserMixin, ScoDocModel):
)
def __init__(self, **kwargs):
"user_name:str is mandatory"
self.roles = []
self.user_roles = []
# check login:
if not "user_name" in kwargs:
raise ValueError("missing user_name argument")
if not is_valid_user_name(kwargs["user_name"]):
if kwargs.get("user_name") and 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 ""
super().__init__(**kwargs)
super(User, self).__init__(**kwargs)
# Ajoute roles:
if (
not self.roles
@ -245,44 +230,33 @@ class User(UserMixin, ScoDocModel):
return None
return db.session.get(User, user_id)
def sort_key(self) -> tuple:
"sort key"
return (
(self.nom or "").upper(),
(self.prenom or "").upper(),
(self.user_name or "").upper(),
)
def to_dict(self, include_email=True):
"""l'utilisateur comme un dict, avec des champs supplémentaires"""
data = {
"date_expiration": (
self.date_expiration.isoformat() + "Z" if self.date_expiration else None
),
"date_modif_passwd": (
self.date_modif_passwd.isoformat() + "Z"
if self.date_modif_passwd
else None
),
"date_created": (
self.date_created.isoformat() + "Z" if self.date_created else None
),
"date_expiration": self.date_expiration.isoformat() + "Z"
if self.date_expiration
else None,
"date_modif_passwd": self.date_modif_passwd.isoformat() + "Z"
if self.date_modif_passwd
else None,
"date_created": self.date_created.isoformat() + "Z"
if self.date_created
else None,
"dept": self.dept,
"id": self.id,
"active": self.active,
"cas_id": self.cas_id,
"cas_allow_login": self.cas_allow_login,
"cas_allow_scodoc_login": self.cas_allow_scodoc_login,
"cas_last_login": (
self.cas_last_login.isoformat() + "Z" if self.cas_last_login else None
),
"edt_id": self.edt_id,
"cas_last_login": self.cas_last_login.isoformat() + "Z"
if self.cas_last_login
else None,
"status_txt": "actif" if self.active else "fermé",
"last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None,
"nom": self.nom or "",
"prenom": self.prenom or "",
"nom": (self.nom or ""), # sco8
"prenom": (self.prenom or ""), # sco8
"roles_string": self.get_roles_string(), # eg "Ens_RT, Ens_Info"
"user_name": self.user_name,
"user_name": self.user_name, # sco8
# Les champs calculés:
"nom_fmt": self.get_nom_fmt(),
"prenom_fmt": self.get_prenom_fmt(),
@ -296,55 +270,37 @@ class User(UserMixin, ScoDocModel):
data["email_institutionnel"] = self.email_institutionnel or ""
return data
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect.
args: dict with args in application.
returns: dict to store in model's db.
Convert boolean values to bools.
"""
args_dict = args
# Dates
if "date_expiration" in args:
date_expiration = args.get("date_expiration")
if isinstance(date_expiration, str):
args["date_expiration"] = (
datetime.datetime.fromisoformat(date_expiration)
if date_expiration
else None
)
# booléens:
for field in ("active", "cas_allow_login", "cas_allow_scodoc_login"):
if field in args:
args_dict[field] = scu.to_bool(args.get(field))
# chaines ne devant pas être NULLs
for field in ("nom", "prenom"):
if field in args:
args[field] = args[field] or ""
# chaines ne devant pas être vides mais au contraire null (unicité)
if "cas_id" in args:
args["cas_id"] = args["cas_id"] or None
return args_dict
def from_dict(self, data: dict, new_user=False):
"""Set users' attributes from given dict values.
- roles_string : roles, encoded like "Ens_RT, Secr_CJ"
- date_expiration is a dateime object.
Does not check permissions here.
Roles must be encoded as "roles_string", like "Ens_RT, Secr_CJ"
"""
for field in [
"nom",
"prenom",
"dept",
"active",
"email",
"email_institutionnel",
"date_expiration",
"cas_id",
]:
if field in data:
setattr(self, field, data[field] or None)
# required boolean fields
for field in [
"cas_allow_login",
"cas_allow_scodoc_login",
]:
setattr(self, field, scu.to_bool(data.get(field, False)))
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"]):
raise ValueError(f"invalid user_name: {data['user_name']}")
self.user_name = data["user_name"]
if "password" in data:
self.set_password(data["password"])
if invalid_user_name(self.user_name):
raise ValueError(f"invalid user_name: {self.user_name}")
# Roles: roles_string is "Ens_RT, Secr_RT, ..."
if "roles_string" in data:
self.user_roles = []
@ -353,13 +309,11 @@ class User(UserMixin, ScoDocModel):
role, dept = UserRole.role_dept_from_string(r_d)
self.add_role(role, dept)
super().from_dict(data, excluded={"user_name", "roles_string", "roles"})
# Set cas_id using regexp if configured:
exp = ScoDocSiteConfig.get("cas_uid_from_mail_regexp")
if exp and self.email_institutionnel:
cas_id = ScoDocSiteConfig.extract_cas_id(self.email_institutionnel)
if cas_id:
if cas_id is not None:
self.cas_id = cas_id
def get_token(self, expires_in=3600):
@ -487,12 +441,12 @@ class User(UserMixin, ScoDocModel):
"""nomplogin est le nom en majuscules suivi du prénom et du login
e.g. Dupont Pierre (dupont)
"""
nom = scu.format_nom(self.nom) if self.nom else self.user_name.upper()
return f"{nom} {scu.format_prenom(self.prenom)} ({self.user_name})"
nom = sco_etud.format_nom(self.nom) if self.nom else self.user_name.upper()
return f"{nom} {sco_etud.format_prenom(self.prenom)} ({self.user_name})"
@staticmethod
def get_user_from_nomplogin(nomplogin: str) -> Optional["User"]:
"""Returns User instance from the string "Dupont Pierre (dupont)"
def get_user_id_from_nomplogin(nomplogin: str) -> Optional[int]:
"""Returns id from the string "Dupont Pierre (dupont)"
or None if user does not exist
"""
match = re.match(r".*\((.*)\)", nomplogin.strip())
@ -500,96 +454,38 @@ class User(UserMixin, ScoDocModel):
user_name = match.group(1)
u = User.query.filter_by(user_name=user_name).first()
if u:
return u
return u.id
return None
def get_nom_fmt(self):
"""Nom formaté: "Martin" """
if self.nom:
return scu.format_nom(self.nom, uppercase=False)
return sco_etud.format_nom(self.nom, uppercase=False)
else:
return self.user_name
def get_prenom_fmt(self):
"""Prénom formaté (minuscule capitalisées)"""
return scu.format_prenom(self.prenom)
return sco_etud.format_prenom(self.prenom)
def get_nomprenom(self):
"""Nom capitalisé suivi de l'initiale du prénom:
Viennet E.
"""
prenom_abbrv = scu.abbrev_prenom(scu.format_prenom(self.prenom))
prenom_abbrv = scu.abbrev_prenom(sco_etud.format_prenom(self.prenom))
return (self.get_nom_fmt() + " " + prenom_abbrv).strip()
def get_prenomnom(self):
"""L'initiale du prénom suivie du nom: "J.-C. Dupont" """
prenom_abbrv = scu.abbrev_prenom(scu.format_prenom(self.prenom))
prenom_abbrv = scu.abbrev_prenom(sco_etud.format_prenom(self.prenom))
return (prenom_abbrv + " " + self.get_nom_fmt()).strip()
def get_nomcomplet(self):
"Prénom et nom complets"
return scu.format_prenom(self.prenom) + " " + self.get_nom_fmt()
return sco_etud.format_prenom(self.prenom) + " " + self.get_nom_fmt()
# 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"
@ -671,19 +567,8 @@ class Role(db.Model):
"""Create default roles if missing, then, if reset_permissions,
reset their permissions to default values.
"""
Role.reset_roles_permissions(
SCO_ROLES_DEFAULTS, reset_permissions=reset_permissions
)
@staticmethod
def reset_roles_permissions(roles_perms: dict[str, tuple], reset_permissions=True):
"""Ajoute les permissions aux roles
roles_perms : { "role_name" : (permission, ...) }
reset_permissions : si vrai efface permissions déja existantes
Si le role n'existe pas, il est (re) créé.
"""
default_role = "Observateur"
for role_name, permissions in roles_perms.items():
for role_name, permissions in SCO_ROLES_DEFAULTS.items():
role = Role.query.filter_by(name=role_name).first()
if role is None:
role = Role(name=role_name)

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"))
@ -56,7 +54,6 @@ def _login_form():
title=_("Sign In"),
form=form,
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
is_cas_forced=ScoDocSiteConfig.is_cas_forced(),
)
@ -211,3 +208,5 @@ def cas_users_import_config():
title=_("Importation configuration CAS utilisateurs"),
form=form,
)
return

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -21,9 +21,9 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
return ""
ref_comp = ue.formation.referentiel_competence
if ref_comp is None:
return f"""<div class="scobox ue_advanced">
return f"""<div class="ue_advanced">
<div class="warning">Pas de référentiel de compétence associé à cette formation !</div>
<div><a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation',
<div><a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation',
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)
}">associer un référentiel de compétence</a>
</div>
@ -31,36 +31,27 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
H = [
"""
<div class="scobox ue_advanced">
<div class="scobox-title">Parcours du BUT</div>
<div class="ue_advanced">
<h3>Parcours du BUT</h3>
"""
]
# Choix des parcours
ue_pids = [p.id for p in ue.parcours]
H.append(
"""
<div class="help">
Cocher tous les parcours dans lesquels cette UE est utilisée,
même si vous n'offrez pas ce parcours dans votre département.
Sans quoi, les UEs de Tronc Commun ne seront pas reconnues.
Ne cocher aucun parcours est équivalent à tous les cocher.
</div>
<form id="choix_parcours" style="margin-top: 12px;">
"""
)
H.append("""<form id="choix_parcours">""")
ects_differents = {
ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours
} != {None}
for parcour in ref_comp.parcours:
ects_parcour = ue.get_ects(parcour)
ects_parcour_txt = (
f" ({ue.get_ects(parcour):.3g} ects)" if ects_differents else ""
)
H.append(
f"""<label><input type="checkbox" name="{parcour.id}" value="{parcour.id}"
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>"""
)
@ -71,7 +62,7 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
<ul>
<li>
<a class="stdlink" href="{
url_for("notes.ue_parcours_ects",
url_for("notes.ue_parcours_ects",
scodoc_dept=g.scodoc_dept, ue_id=ue.id)
}">définir des ECTS différents dans chaque parcours</a>
</li>

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -9,14 +9,12 @@
import collections
import datetime
import pandas as pd
import numpy as np
from flask import g, has_request_context, url_for
from app import db
from app.comp.moy_mod import ModuleImplResults
from app.comp.res_but import ResultatsSemestreBUT
from app.models import Evaluation, FormSemestre, Identite, ModuleImpl
from app.models import Evaluation, FormSemestre, Identite
from app.models.groups import GroupDescr
from app.models.ues import UniteEns
from app.scodoc import sco_bulletins, sco_utils as scu
@ -106,11 +104,9 @@ class BulletinBUT:
"competence": None, # XXX TODO lien avec référentiel
"moyenne": None,
# Le bonus sport appliqué sur cette UE
"bonus": (
fmt_note(res.bonus_ues[ue.id][etud.id])
if res.bonus_ues is not None and ue.id in res.bonus_ues
else fmt_note(0.0)
),
"bonus": fmt_note(res.bonus_ues[ue.id][etud.id])
if res.bonus_ues is not None and ue.id in res.bonus_ues
else fmt_note(0.0),
"malus": fmt_note(res.malus[ue.id][etud.id]),
"capitalise": None, # "AAAA-MM-JJ" TODO #sco93
"ressources": self.etud_ue_mod_results(etud, ue, res.ressources),
@ -185,16 +181,14 @@ class BulletinBUT:
"is_external": ue_capitalisee.is_external,
"date_capitalisation": ue_capitalisee.event_date,
"formsemestre_id": ue_capitalisee.formsemestre_id,
"bul_orig_url": (
url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
formsemestre_id=ue_capitalisee.formsemestre_id,
)
if ue_capitalisee.formsemestre_id
else None
),
"bul_orig_url": url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
formsemestre_id=ue_capitalisee.formsemestre_id,
)
if ue_capitalisee.formsemestre_id
else None,
"ressources": {}, # sans détail en BUT
"saes": {},
}
@ -231,17 +225,15 @@ class BulletinBUT:
if res.modimpl_inscr_df[modimpl.id][etud.id]: # si inscrit
d[modimpl.module.code] = {
"id": modimpl.id,
"titre": modimpl.module.titre_str(),
"titre": modimpl.module.titre,
"code_apogee": modimpl.module.code_apogee,
"url": (
url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
if has_request_context()
else "na"
),
"url": url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
if has_request_context()
else "na",
"moyenne": {
# # moyenne indicative de module: moyenne des UE,
# # ignorant celles sans notes (nan)
@ -250,115 +242,68 @@ class BulletinBUT:
# "max": fmt_note(moyennes_etuds.max()),
# "moy": fmt_note(moyennes_etuds.mean()),
},
"evaluations": (
self.etud_list_modimpl_evaluations(
etud, modimpl, modimpl_results, version
"evaluations": [
self.etud_eval_results(etud, e)
for e in modimpl.evaluations
if (e.visibulletin or version == "long")
and (e.id in modimpl_results.evaluations_etat)
and (
modimpl_results.evaluations_etat[e.id].is_complete
or self.prefs["bul_show_all_evals"]
)
if version != "short"
else []
),
]
if version != "short"
else [],
}
return d
def etud_list_modimpl_evaluations(
self,
etud: Identite,
modimpl: ModuleImpl,
modimpl_results: ModuleImplResults,
version: str,
) -> list[dict]:
"""Liste des résultats aux évaluations de ce modimpl à montrer pour cet étudiant"""
evaluation: Evaluation
eval_results = []
for evaluation in modimpl.evaluations:
if (
(evaluation.visibulletin or version == "long")
and (evaluation.id in modimpl_results.evaluations_etat)
and (
modimpl_results.evaluations_etat[evaluation.id].is_complete
or self.prefs["bul_show_all_evals"]
)
):
eval_notes = self.res.modimpls_results[modimpl.id].evals_notes[
evaluation.id
]
if (evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE) or (
not np.isnan(eval_notes[etud.id])
):
eval_results.append(
self.etud_eval_results(etud, evaluation, eval_notes)
)
return eval_results
def etud_eval_results(
self, etud: Identite, evaluation: Evaluation, eval_notes: pd.DataFrame
) -> dict:
def etud_eval_results(self, etud, e: Evaluation) -> dict:
"dict resultats d'un étudiant à une évaluation"
# eval_notes est une pd.Series avec toutes les notes des étudiants inscrits
eval_notes = self.res.modimpls_results[e.moduleimpl_id].evals_notes[e.id]
notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna()
modimpls_evals_poids = self.res.modimpls_evals_poids[evaluation.moduleimpl_id]
modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id]
try:
etud_ues_ids = self.res.etud_ues_ids(etud.id)
poids = {
ue.acronyme: modimpls_evals_poids[ue.id][evaluation.id]
ue.acronyme: modimpls_evals_poids[ue.id][e.id]
for ue in self.res.ues
if (ue.type != UE_SPORT) and (ue.id in etud_ues_ids)
}
except KeyError:
poids = collections.defaultdict(lambda: 0.0)
d = {
"id": evaluation.id,
"coef": (
fmt_note(evaluation.coefficient)
if evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE
else None
),
"date_debut": (
evaluation.date_debut.isoformat() if evaluation.date_debut else None
),
"date_fin": (
evaluation.date_fin.isoformat() if evaluation.date_fin else None
),
"description": evaluation.description,
"evaluation_type": evaluation.evaluation_type,
"note": (
{
"value": fmt_note(
eval_notes[etud.id],
note_max=evaluation.note_max,
),
"min": fmt_note(notes_ok.min(), note_max=evaluation.note_max),
"max": fmt_note(notes_ok.max(), note_max=evaluation.note_max),
"moy": fmt_note(notes_ok.mean(), note_max=evaluation.note_max),
}
if not evaluation.is_blocked()
else {}
),
"id": e.id,
"coef": fmt_note(e.coefficient)
if e.evaluation_type == scu.EVALUATION_NORMALE
else None,
"date_debut": e.date_debut.isoformat() if e.date_debut else None,
"date_fin": e.date_fin.isoformat() if e.date_fin else None,
"description": e.description,
"evaluation_type": e.evaluation_type,
"note": {
"value": fmt_note(
eval_notes[etud.id],
note_max=e.note_max,
),
"min": fmt_note(notes_ok.min(), note_max=e.note_max),
"max": fmt_note(notes_ok.max(), note_max=e.note_max),
"moy": fmt_note(notes_ok.mean(), note_max=e.note_max),
},
"poids": poids,
"url": (
url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=evaluation.id,
)
if has_request_context()
else "na"
),
"url": url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.id,
)
if has_request_context()
else "na",
# deprecated (supprimer avant #sco9.7)
"date": (
evaluation.date_debut.isoformat() if evaluation.date_debut else None
),
"heure_debut": (
evaluation.date_debut.time().isoformat("minutes")
if evaluation.date_debut
else None
),
"heure_fin": (
evaluation.date_fin.time().isoformat("minutes")
if evaluation.date_fin
else None
),
"date": e.date_debut.isoformat() if e.date_debut else None,
"heure_debut": e.date_debut.time().isoformat("minutes")
if e.date_debut
else None,
"heure_fin": e.date_fin.time().isoformat("minutes") if e.date_fin else None,
}
return d
@ -398,18 +343,25 @@ class BulletinBUT:
"short" : ne descend pas plus bas que les modules.
- Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai
(bulletins non publiés sur la passerelle).
(bulletins non publiés).
"""
if version not in scu.BULLETINS_VERSIONS_BUT:
raise ScoValueError("bulletin_etud: version de bulletin demandée invalide")
if version not in scu.BULLETINS_VERSIONS:
raise ScoValueError("version de bulletin demandée invalide")
res = self.res
formsemestre = res.formsemestre
etat_inscription = etud.inscription_etat(formsemestre.id)
nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
published = (not formsemestre.bul_hide_xml) or force_publishing
if formsemestre.formation.referentiel_competence is None:
etud_ues_ids = {ue.id for ue in res.ues if res.modimpls_in_ue(ue, etud.id)}
else:
etud_ues_ids = res.etud_ues_ids(etud.id)
d = {
"version": "0",
"type": "BUT",
"date": datetime.datetime.utcnow().isoformat() + "Z",
"publie": not formsemestre.bul_hide_xml,
"etat_inscription": etud.inscription_etat(formsemestre.id),
"etudiant": etud.to_dict_bul(),
"formation": {
"id": formsemestre.formation.id,
@ -418,21 +370,15 @@ class BulletinBUT:
"titre": formsemestre.formation.titre,
},
"formsemestre_id": formsemestre.id,
"etat_inscription": etat_inscription,
"options": sco_preferences.bulletin_option_affichage(
formsemestre, self.prefs
),
}
published = (not formsemestre.bul_hide_xml) or force_publishing
if not published or d["etat_inscription"] is False:
if not published:
return d
nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
if formsemestre.formation.referentiel_competence is None:
etud_ues_ids = {ue.id for ue in res.ues if res.modimpls_in_ue(ue, etud.id)}
else:
etud_ues_ids = res.etud_ues_ids(etud.id)
nbabsnj, nbabsjust, nbabs = formsemestre.get_abs_count(etud.id)
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
etud_groups = sco_groups.get_etud_formsemestre_groups(
etud, formsemestre, only_to_show=True
)
@ -447,7 +393,7 @@ class BulletinBUT:
}
if self.prefs["bul_show_abs"]:
semestre_infos["absences"] = {
"injustifie": nbabsnj,
"injustifie": nbabs - nbabsjust,
"total": nbabs,
"metrique": {
"H.": "Heure(s)",
@ -464,7 +410,7 @@ class BulletinBUT:
semestre_infos.update(
sco_bulletins_json.dict_decision_jury(etud, formsemestre)
)
if d["etat_inscription"] == scu.INSCRIT:
if etat_inscription == scu.INSCRIT:
# moyenne des moyennes générales du semestre
semestre_infos["notes"] = {
"value": fmt_note(res.etud_moy_gen[etud.id]),
@ -553,8 +499,10 @@ class BulletinBUT:
d["etud"]["etat_civil"] = etud.etat_civil
d.update(self.res.sem)
etud_etat = self.res.get_etud_etat(etud.id)
d["filigranne"] = sco_bulletins_pdf.get_filigranne_apc(
etud_etat, self.prefs, etud.id, res=self.res
d["filigranne"] = sco_bulletins_pdf.get_filigranne(
etud_etat,
self.prefs,
decision_sem=d["semestre"].get("decision"),
)
if etud_etat == scu.DEMISSION:
d["demission"] = "(Démission)"
@ -564,7 +512,7 @@ class BulletinBUT:
d["demission"] = ""
# --- Absences
_, d["nbabsjust"], d["nbabs"] = self.res.formsemestre.get_abs_count(etud.id)
d["nbabs"], d["nbabsjust"] = self.res.formsemestre.get_abs_count(etud.id)
# --- Decision Jury
infos, _ = sco_bulletins.etud_descr_situation_semestre(
@ -576,11 +524,12 @@ class BulletinBUT:
show_uevalid=self.prefs["bul_show_uevalid"],
show_mention=self.prefs["bul_show_mention"],
)
d.update(infos)
# --- Rangs
d["rang_nt"] = (
f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
)
d[
"rang_nt"
] = f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
d["rang_txt"] = "Rang " + d["rang_nt"]
d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"]))

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -18,7 +18,7 @@ Ces données sont des objets passés au template.
- `bul: dict` : le bulletin (dict, même structure que le json publié)
- `cursus: EtudCursusBUT`: infos sur le cursus BUT (niveaux validés etc)
- `decision_ues: dict`: `{ acronyme_ue : { 'code' : 'ADM' }}` accès aux décisions
de jury d'UE
de jury d'UE
- `ects_total` : nombre d'ECTS validées dans ce cursus
- `ue_validation_by_niveau : dict` : les validations d'UE de chaque niveau du cursus
"""
@ -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
@ -66,48 +65,11 @@ def bulletin_but(formsemestre_id: int, etudid: int = None, fmt="html"):
)
if not formsemestre.formation.is_apc():
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)
return scu.sendPDFFile(bul_pdf, filename + ".pdf")
return render_template(
"but/bulletin_court_page.j2",
datetime=datetime,
sco=ScoData(formsemestre=formsemestre, etud=etud),
time=time,
version="butcourt",
**args,
)
def bulletin_but_court_pdf_frag(
etud: Identite, formsemestre: FormSemestre, stand_alone=False
) -> bytes:
"""Le code PDF d'un bulletin BUT court, à intégrer dans un document
(pour les classeurs de tous les bulletins)
"""
args = _build_bulletin_but_infos(etud, formsemestre)
return bulletin_but_court_pdf.make_bulletin_but_court_pdf(
args, stand_alone=stand_alone
)
def _build_bulletin_but_infos(
etud: Identite, formsemestre: FormSemestre, fmt="pdf"
) -> dict:
"""Réuni toutes les information pour le contenu d'un bulletin BUT court.
On indique le format ("html" ou "pdf") car il y a moins d'infos en HTML.
"""
bulletins_sem = BulletinBUT(formsemestre)
if fmt == "pdf":
bul: dict = bulletins_sem.bulletin_etud_complet(etud)
filigranne = bul["filigranne"]
else: # la même chose avec un peu moins d'infos
bul: dict = bulletins_sem.bulletin_etud(etud, force_publishing=True)
filigranne = ""
decision_ues = (
{x["acronyme"]: x for x in bul["semestre"]["decision_ue"]}
if "semestre" in bul and "decision_ue" in bul["semestre"]
@ -119,14 +81,6 @@ def _build_bulletin_but_infos(
refcomp = formsemestre.formation.referentiel_competence
if refcomp is None:
raise ScoNoReferentielCompetences(formation=formsemestre.formation)
warn_html = cursus_but.formsemestre_warning_apc_setup(
formsemestre, bulletins_sem.res
)
if warn_html:
raise ScoValueError(
"<b>Formation mal configurée pour le BUT</b>" + warn_html, safe=True
)
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
refcomp, etud
)
@ -141,7 +95,6 @@ def _build_bulletin_but_infos(
"decision_ues": decision_ues,
"ects_total": ects_total,
"etud": etud,
"filigranne": filigranne,
"formsemestre": formsemestre,
"logo": logo,
"prefs": bulletins_sem.prefs,
@ -153,5 +106,16 @@ 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
if fmt == "pdf":
filename = scu.bul_filename(formsemestre, etud, prefix="bul-but")
bul_pdf = bulletin_but_court_pdf.make_bulletin_but_court_pdf(**args)
return scu.sendPDFFile(bul_pdf, filename + ".pdf")
return render_template(
"but/bulletin_court_page.j2",
datetime=datetime,
sco=ScoData(formsemestre=formsemestre, etud=etud),
time=time,
version="butcourt",
**args,
)

View File

@ -1,12 +1,12 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Génération bulletin BUT PDF synthétique en une page
On génère du PDF avec reportLab en utilisant les classes
On génère du PDF avec reportLab en utilisant les classes
ScoDoc BulletinGenerator et GenTable.
"""
@ -31,37 +31,28 @@ from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc.sco_logos import Logo
from app.scodoc.sco_pdf import PDFLOCK, SU
from app.scodoc.sco_preferences import SemPreferences
from app.scodoc import sco_utils as scu
def make_bulletin_but_court_pdf(
args: dict,
stand_alone: bool = True,
bul: dict = None,
cursus: cursus_but.EtudCursusBUT = None,
decision_ues: dict = None,
ects_total: float = 0.0,
etud: Identite = None,
formsemestre: FormSemestre = None,
logo: Logo = None,
prefs: SemPreferences = None,
title: str = "",
ue_validation_by_niveau: dict[tuple[int, str], ScolarFormSemestreValidation] = None,
ues_acronyms: list[str] = None,
) -> bytes:
"""génère le bulletin court BUT en pdf.
Si stand_alone, génère un doc pdf complet (une page ici),
sinon un morceau (fragment) à intégrer dans un autre document.
args donne toutes les infos du contenu du bulletin:
bul: dict = None,
cursus: cursus_but.EtudCursusBUT = None,
decision_ues: dict = None,
ects_total: float = 0.0,
etud: Identite = None,
formsemestre: FormSemestre = None,
filigranne=""
logo: Logo = None,
prefs: SemPreferences = None,
title: str = "",
ue_validation_by_niveau: dict[tuple[int, str], ScolarFormSemestreValidation] = None,
ues_acronyms: list[str] = None,
"""
"génère le bulletin court BUT en pdf"
# A priori ce verrou n'est plus nécessaire avec Flask (multi-process)
# mais...
try:
PDFLOCK.acquire()
bul_generator = BulletinGeneratorBUTCourt(**args)
bul_pdf = bul_generator.generate(fmt="pdf", stand_alone=stand_alone)
bul_generator = BulletinGeneratorBUTCourt(**locals())
bul_pdf = bul_generator.generate(fmt="pdf")
finally:
PDFLOCK.release()
return bul_pdf
@ -88,7 +79,6 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
decision_ues: dict = None,
ects_total: float = 0.0,
etud: Identite = None,
filigranne="",
formsemestre: FormSemestre = None,
logo: Logo = None,
prefs: SemPreferences = None,
@ -97,10 +87,8 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
tuple[int, str], ScolarFormSemestreValidation
] = None,
ues_acronyms: list[str] = None,
diplome_dut120: bool = False,
diplome_dut120_descr: str = "",
):
super().__init__(bul, authuser=current_user, filigranne=filigranne)
super().__init__(bul, authuser=current_user)
self.bul = bul
self.cursus = cursus
self.decision_ues = decision_ues
@ -112,8 +100,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")
@ -198,7 +185,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
"""Génère la partie "titre" du bulletin de notes.
Renvoie une liste d'objets platypus
"""
# comme les bulletins standards, mais avec notre préférence
# comme les bulletins standard, mais avec notre préférence
return super().bul_title_pdf(preference_field=preference_field)
def bul_part_below(self, fmt="pdf") -> list:
@ -246,17 +233,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,
)
@ -351,11 +334,9 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
for mod in self.bul[mod_type]:
row = [mod, bul[mod_type][mod]["titre"]]
row += [
(
bul["ues"][ue][mod_type][mod]["moyenne"]
if mod in bul["ues"][ue][mod_type]
else ""
)
bul["ues"][ue][mod_type][mod]["moyenne"]
if mod in bul["ues"][ue][mod_type]
else ""
for ue in self.ues_acronyms
]
rows.append(row)
@ -416,8 +397,6 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
def boite_identite(self) -> list:
"Les informations sur l'identité et l'inscription de l'étudiant"
parcour = self.formsemestre.etuds_inscriptions[self.etud.id].parcour
return [
Paragraph(
SU(f"""{self.etud.nomprenom}"""),
@ -428,8 +407,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
f"""
<b>{self.bul["demission"]}</b><br/>
Formation: {self.formsemestre.titre_num()}<br/>
{'Parcours ' + parcour.code + '<br/>' if parcour else ''}
Année universitaire: {self.formsemestre.annee_scolaire_str()}<br/>
Année scolaire: {self.formsemestre.annee_scolaire_str()}<br/>
"""
),
style=self.style_base,
@ -530,17 +508,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("%d/%m/%Y")
}, 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

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -9,7 +9,7 @@
La génération du bulletin PDF suit le chemin suivant:
- vue formsemestre_bulletinetud -> sco_bulletins.formsemestre_bulletinetud
bul_dict = bulletin_but.BulletinBUT(formsemestre).bulletin_etud_complet(etud)
- sco_bulletins_generator.make_formsemestre_bulletin_etud()
@ -24,7 +24,7 @@ from reportlab.lib.colors import blue
from reportlab.lib.units import cm, mm
from reportlab.platypus import Paragraph, Spacer
from app.models import Evaluation, ScoDocSiteConfig
from app.models import ScoDocSiteConfig
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc import gen_tables
from app.scodoc.codes_cursus import UE_SPORT
@ -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
@ -270,7 +269,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
date_capitalisation = ue.get("date_capitalisation")
if date_capitalisation:
fields_bmr.append(
f"""Capitalisée le {date_capitalisation.strftime(scu.DATE_FMT)}"""
f"""Capitalisée le {date_capitalisation.strftime("%d/%m/%Y")}"""
)
t = {
"titre": " - ".join(fields_bmr),
@ -423,22 +422,16 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
def evaluations_rows(self, rows, evaluations: list[dict], ue_acros=()):
"lignes des évaluations"
for e in evaluations:
coef = (
e["coef"]
if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE
else "*"
)
note_value = e["note"].get("value", "")
coef = e["coef"] if e["evaluation_type"] == scu.EVALUATION_NORMALE else "*"
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>{
coef if e["evaluation_type"] != Evaluation.EVALUATION_BONUS
else "bonus"
}</i></para>"""
f"<para align=right fontSize={self.small_fontsize}><i>{coef}</i></para>"
),
"_pdf_style": [
(

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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
@ -38,11 +38,14 @@ import datetime
from xml.etree import ElementTree
from xml.etree.ElementTree import Element
from app import db, log
from app import log
from app.but import bulletin_but
from app.models import BulAppreciations, FormSemestre, Identite, UniteEns
from app.models import BulAppreciations, FormSemestre, Identite
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_ue
from app.scodoc import sco_etud
from app.scodoc import sco_photos
from app.scodoc import sco_preferences
from app.scodoc import sco_xml
@ -199,12 +202,12 @@ def bulletin_but_xml_compat(
if e.visibulletin or version == "long":
x_eval = Element(
"evaluation",
date_debut=(
e.date_debut.isoformat() if e.date_debut else ""
),
date_fin=(
e.date_fin.isoformat() if e.date_fin else ""
),
date_debut=e.date_debut.isoformat()
if e.date_debut
else "",
date_fin=e.date_fin.isoformat()
if e.date_debut
else "",
coefficient=str(e.coefficient),
# pas les poids en XML compat
evaluation_type=str(e.evaluation_type),
@ -212,9 +215,9 @@ def bulletin_but_xml_compat(
# notes envoyées sur 20, ceci juste pour garder trace:
note_max_origin=str(e.note_max),
# --- deprecated
jour=(
e.date_debut.isoformat() if e.date_debut else ""
),
jour=e.date_debut.isoformat()
if e.date_debut
else "",
heure_debut=e.heure_debut(),
heure_fin=e.heure_fin(),
)
@ -241,7 +244,7 @@ def bulletin_but_xml_compat(
# --- Absences
if sco_preferences.get_preference("bul_show_abs", formsemestre_id):
_, nbabsjust, nbabs = formsemestre.get_abs_count(etud.id)
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
doc.append(Element("absences", nbabs=str(nbabs), nbabsjust=str(nbabsjust)))
# -------- LA SUITE EST COPIEE SANS MODIF DE sco_bulletins_xml.py ---------
@ -291,18 +294,17 @@ def bulletin_but_xml_compat(
"decisions_ue"
]: # and sco_preferences.get_preference( 'bul_show_uevalid', formsemestre_id): always publish (car utile pour export Apogee)
for ue_id in decision["decisions_ue"].keys():
ue = db.session.get(UniteEns, ue_id)
if ue:
doc.append(
Element(
"decision_ue",
ue_id=str(ue.id),
numero=quote_xml_attr(ue.numero),
acronyme=quote_xml_attr(ue.acronyme),
titre=quote_xml_attr(ue.titre or ""),
code=decision["decisions_ue"][ue_id]["code"],
)
ue = sco_edit_ue.ue_list({"ue_id": ue_id})[0]
doc.append(
Element(
"decision_ue",
ue_id=str(ue["ue_id"]),
numero=quote_xml_attr(ue["numero"]),
acronyme=quote_xml_attr(ue["acronyme"]),
titre=quote_xml_attr(ue["titre"]),
code=decision["decisions_ue"][ue_id]["code"],
)
)
for aut in decision["autorisations"]:
doc.append(

View File

@ -1,92 +0,0 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Code expérimental: si deux référentiel sont presques identiques
(mêmes compétences, niveaux, parcours)
essaie de changer une formation de référentiel.
"""
from app import clear_scodoc_cache, db
from app.models import (
ApcParcours,
ApcReferentielCompetences,
ApcValidationRCUE,
Formation,
FormSemestreInscription,
UniteEns,
)
from app.scodoc.sco_exceptions import ScoValueError
def formation_change_referentiel(
formation: Formation, new_ref: ApcReferentielCompetences
):
"""Try to change ref."""
if not formation.referentiel_competence:
raise ScoValueError("formation non associée à un référentiel")
if not isinstance(new_ref, ApcReferentielCompetences):
raise ScoValueError("nouveau référentiel invalide")
r = formation.referentiel_competence.map_to_other_referentiel(new_ref)
if isinstance(r, str):
raise ScoValueError(f"référentiels incompatibles: {r}")
parcours_map, competences_map, niveaux_map = r
formation.referentiel_competence = new_ref
db.session.add(formation)
# UEs - Niveaux et UEs - parcours
for ue in formation.ues:
if ue.niveau_competence:
ue.niveau_competence_id = niveaux_map[ue.niveau_competence_id]
db.session.add(ue)
if ue.parcours:
new_list = [ApcParcours.query.get(parcours_map[p.id]) for p in ue.parcours]
ue.parcours.clear()
ue.parcours.extend(new_list)
db.session.add(ue)
# Modules / parcours et app_critiques
for module in formation.modules:
if module.parcours:
new_list = [
ApcParcours.query.get(parcours_map[p.id]) for p in module.parcours
]
module.parcours.clear()
module.parcours.extend(new_list)
db.session.add(module)
if module.app_critiques: # efface les apprentissages critiques
module.app_critiques.clear()
db.session.add(module)
# ApcValidationRCUE
for valid_rcue in ApcValidationRCUE.query.join(
UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id
).filter_by(formation_id=formation.id):
if valid_rcue.parcour:
valid_rcue.parcour_id = parcours_map[valid_rcue.parcour.id]
db.session.add(valid_rcue)
for valid_rcue in ApcValidationRCUE.query.join(
UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id
).filter_by(formation_id=formation.id):
if valid_rcue.parcour:
valid_rcue.parcour_id = parcours_map[valid_rcue.parcour.id]
db.session.add(valid_rcue)
# FormSemestre / parcours_formsemestre
for formsemestre in formation.formsemestres:
new_list = [
ApcParcours.query.get(parcours_map[p.id]) for p in formsemestre.parcours
]
formsemestre.parcours.clear()
formsemestre.parcours.extend(new_list)
db.session.add(formsemestre)
# FormSemestreInscription.parcour_id
for inscr in FormSemestreInscription.query.filter_by(
formsemestre_id=formsemestre.id
).filter(FormSemestreInscription.parcour_id != None):
if inscr.parcour_id is not None:
inscr.parcour_id = parcours_map[inscr.parcour_id]
#
db.session.commit()
clear_scodoc_cache()

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -14,39 +14,45 @@ 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
from app.comp.res_compat import NotesTableCompat
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences,
)
from app.models.ues import UEParcours
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
)
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
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 +62,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:
@ -127,15 +119,8 @@ class EtudCursusBUT:
self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
validation_rcue: ApcValidationRCUE
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau()
if niveau is None:
raise ScoValueError(
"""UE d'un RCUE non associée à un niveau de compétence.
Vérifiez la formation et les associations de ses UEs.
"""
)
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(
@ -209,10 +194,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 +288,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(
@ -487,38 +436,15 @@ def formsemestre_warning_apc_setup(
"""
if not formsemestre.formation.is_apc():
return ""
url_formation = url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=formsemestre.formation.id,
semestre_idx=formsemestre.semestre_id,
)
if formsemestre.formation.referentiel_competence is None:
return f"""<div class="formsemestre_status_warning">
La <a class="stdlink" href="{url_formation}">formation
n'est pas associée à un référentiel de compétence.</a>
La <a class="stdlink" href="{
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
}">formation n'est pas associée à un référentiel de compétence.</a>
</div>
"""
H = []
# Le semestre n'a pas de parcours, mais les UE ont des parcours ?
if not formsemestre.parcours:
nb_ues_sans_parcours = len(
formsemestre.formation.query_ues_parcour(None)
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
.all()
)
nb_ues_tot = (
UniteEns.query.filter_by(formation=formsemestre.formation, type=UE_STANDARD)
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
.count()
)
if nb_ues_sans_parcours != nb_ues_tot:
H.append(
"""Le semestre n'est associé à aucun parcours,
mais les UEs de la formation ont des parcours
"""
)
# Vérifie les niveaux de chaque parcours
H = []
for parcour in formsemestre.parcours or [None]:
annee = (formsemestre.semestre_id + 1) // 2
niveaux_ids = {
@ -543,8 +469,7 @@ def formsemestre_warning_apc_setup(
if not H:
return ""
return f"""<div class="formsemestre_status_warning">
Problème dans la
<a class="stdlink" href="{url_formation}">configuration de la formation</a>:
Problème dans la configuration de la formation:
<ul>
<li>{ '</li><li>'.join(H) }</li>
</ul>
@ -557,79 +482,6 @@ def formsemestre_warning_apc_setup(
"""
def formation_semestre_niveaux_warning(formation: Formation, semestre_idx: int) -> str:
"""Vérifie que tous les niveaux de compétences de cette année de formation
ont bien des UEs.
Afin de ne pas générer trop de messages, on ne considère que les parcours
du référentiel de compétences pour lesquels au moins une UE a été associée.
Renvoie fragment de html
"""
annee = (semestre_idx - 1) // 2 + 1 # année BUT
ref_comp: ApcReferentielCompetences = formation.referentiel_competence
if not ref_comp:
return "" # détecté ailleurs...
niveaux_sans_ue_by_parcour = {} # { parcour.code : [ ue ... ] }
parcours_ids = {
uep.parcours_id
for uep in UEParcours.query.join(UniteEns).filter_by(
formation_id=formation.id, type=UE_STANDARD
)
}
for parcour in ref_comp.parcours:
if parcour.id not in parcours_ids:
continue # saute parcours associés à aucune UE (tous semestres)
niveaux_sans_ue = []
niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp)
# print(f"\n# Parcours {parcour.code} : {len(niveaux)} niveaux")
for niveau in niveaux:
ues = [ue for ue in formation.ues if ue.niveau_competence_id == niveau.id]
if not ues:
niveaux_sans_ue.append(niveau)
# print( niveau.competence.titre + " " + str(niveau.ordre) + "\t" + str(ue) )
if niveaux_sans_ue:
niveaux_sans_ue_by_parcour[parcour.code] = niveaux_sans_ue
#
H = []
for parcour_code, niveaux in niveaux_sans_ue_by_parcour.items():
H.append(
f"""<li>Parcours {parcour_code} : {
len(niveaux)} niveaux sans UEs&nbsp;:
<span class="niveau-nom"><span>
{ '</span>, <span>'.join( f'{niveau.competence.titre} {niveau.ordre}'
for niveau in niveaux
)
}
</span>
</li>
"""
)
# Combien de compétences de tronc commun ?
_, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee)
nb_niveaux_tc = len(niveaux_by_parcours["TC"])
nb_ues_tc = len(
formation.query_ues_parcour(None)
.filter(UniteEns.semestre_idx == semestre_idx)
.all()
)
if nb_niveaux_tc != nb_ues_tc:
H.append(
f"""<li>{nb_niveaux_tc} niveaux de compétences de tronc commun,
mais {nb_ues_tc} UEs de tronc commun ! (c'est normal si
vous avez des UEs différenciées par parcours)</li>"""
)
if H:
return f"""<div class="formation_semestre_niveaux_warning">
<div>Problèmes détectés à corriger :</div>
<ul>
{"".join(H)}
</ul>
</div>
"""
return "" # no problem detected
def ue_associee_au_niveau_du_parcours(
ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
) -> UniteEns:

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -10,11 +10,9 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from wtforms import SelectField, SubmitField
from wtforms.validators import DataRequired
class FormationRefCompForm(FlaskForm):
"Choix d'un référentiel"
referentiel_competence = SelectField(
"Choisir parmi les référentiels déjà chargés :"
)
@ -23,7 +21,6 @@ class FormationRefCompForm(FlaskForm):
class RefCompLoadForm(FlaskForm):
"Upload d'un référentiel"
referentiel_standard = SelectField(
"Choisir un référentiel de compétences officiel BUT"
)
@ -50,12 +47,3 @@ class RefCompLoadForm(FlaskForm):
)
return False
return True
class FormationChangeRefCompForm(FlaskForm):
"choix d'un nouveau ref. comp. pour une formation"
object_select = SelectField(
"Choisir le nouveau référentiel", validators=[DataRequired()]
)
submit = SubmitField("Changer le référentiel de la formation")
cancel = SubmitField("Annuler")

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
from xml.etree import ElementTree
@ -23,12 +23,9 @@ from app.models.but_refcomp import (
from app.scodoc.sco_exceptions import ScoFormatError, ScoValueError
def orebut_import_refcomp(
xml_data: str, dept_id: int, orig_filename=None
) -> ApcReferentielCompetences:
def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
"""Importation XML Orébut
peut lever TypeError ou ScoFormatError
L'objet créé est ajouté et commité.
Résultat: instance de ApcReferentielCompetences
"""
# Vérifie que le même fichier n'a pas déjà été chargé:
@ -36,7 +33,7 @@ def orebut_import_refcomp(
scodoc_orig_filename=orig_filename, dept_id=dept_id
).count():
raise ScoValueError(
f"""Un référentiel a déjà été chargé d'un fichier de même nom.
f"""Un référentiel a déjà été chargé d'un fichier de même nom.
({orig_filename})
Supprimez-le ou changez le nom du fichier."""
)
@ -44,7 +41,7 @@ def orebut_import_refcomp(
try:
root = ElementTree.XML(xml_data)
except ElementTree.ParseError as exc:
raise ScoFormatError(f"fichier XML Orébut invalide (2): {exc.args}") from exc
raise ScoFormatError(f"fichier XML Orébut invalide (2): {exc.args}")
if root.tag != "referentiel_competence":
raise ScoFormatError("élément racine 'referentiel_competence' manquant")
args = ApcReferentielCompetences.attr_from_xml(root.attrib)
@ -63,8 +60,7 @@ def orebut_import_refcomp(
# ne devrait plus se produire car pas d'unicité de l'id: donc inutile
db.session.rollback()
raise ScoValueError(
f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({
competence.attrib["id"]})
f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({competence.attrib["id"]})
"""
) from exc
ref.competences.append(c)

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -13,22 +13,22 @@ Utilisation:
cherche les RCUEs de l'année (BUT1, 2, 3)
pour un redoublant, le RCUE peut considérer un formsemestre d'une année antérieure.
on instancie des DecisionsProposees pour les
on instancie des DecisionsProposees pour les
différents éléments (UEs, RCUEs, Année, Diplôme)
Cela donne
Cela donne
- les codes possibles (dans .codes)
- le code actuel si une décision existe déjà (dans code_valide)
- pour les UEs, le rcue s'il y en a un)
2) Validation pour l'utilisateur (form)) => enregistrement code
- on vérifie que le code soumis est bien dans les codes possibles
- on enregistre la décision (dans ScolarFormSemestreValidation pour les UE,
ApcValidationRCUE pour les RCUE, et ApcValidationAnnee pour les années)
- Si RCUE validé, on déclenche d'éventuelles validations:
("La validation des deux UE du niveau d'une compétence emporte la validation
- Si RCUE validé, on déclenche d'éventuelles validations:
("La validation des deux UE du niveau d'une compétence emporte la validation
de l'ensemble des UE du niveau inférieur de cette même compétence.")
Les jurys de semestre BUT impairs entrainent systématiquement la génération d'une
Les jurys de semestre BUT impairs entrainent systématiquement la génération d'une
autorisation d'inscription dans le semestre pair suivant: `ScolarAutorisationInscription`.
Les jurys de semestres pairs non (S2, S4, S6): il y a une décision sur l'année (ETP)
- autorisation en S_2n+1 (S3 ou S5) si: ADM, ADJ, PASD, PAS1CN
@ -39,8 +39,8 @@ Le formulaire permet de choisir des codes d'UE, RCUE et Année (ETP).
Mais normalement, les codes d'UE sont à choisir: les RCUE et l'année s'en déduisent.
Si l'utilisateur coche "décision manuelle", il peut alors choisir les codes RCUE et années.
La soumission du formulaire:
- etud, formation
La soumission du formulaire:
- etud, formation
- UEs: [(formsemestre, ue, code), ...]
- RCUE: [(formsemestre, ue, code), ...] le formsemestre est celui d'indice pair du niveau
(S2, S4 ou S6), il sera regoupé avec celui impair de la même année ou de la suivante.
@ -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
@ -78,14 +77,12 @@ from app.models.but_refcomp import (
ApcNiveau,
ApcParcours,
)
from app.models import Evaluation, ModuleImpl, Scolog, ScolarAutorisationInscription
from app.models import Scolog, ScolarAutorisationInscription
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,
@ -121,7 +117,7 @@ class NoRCUEError(ScoValueError):
{warning_impair}
{warning_pair}
<div><b>UE {ue.acronyme}</b>: niveau {html.escape(str(ue.niveau_competence))}</div>
<div><b>UEs impaires:</b> {html.escape(', '.join(str(u.niveau_competence or "pas de niveau")
<div><b>UEs impaires:</b> {html.escape(', '.join(str(u.niveau_competence or "pas de niveau")
for u in deca.ues_impair))}
</div>
"""
@ -264,11 +260,11 @@ class DecisionsProposeesAnnee(DecisionsProposees):
else []
)
# ---- Niveaux et RCUEs
niveaux_by_parcours = formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
self.annee_but, [self.parcour] if self.parcour else None
)[
1
]
niveaux_by_parcours = (
formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
self.annee_but, [self.parcour] if self.parcour else None
)[1]
)
self.niveaux_competences = niveaux_by_parcours["TC"] + (
niveaux_by_parcours[self.parcour.id] if self.parcour else []
)
@ -277,8 +273,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
= niveaux du tronc commun + niveau du parcours de l'étudiant.
"""
self.rcue_by_niveau = self._compute_rcues_annee()
"""RCUEs de l'année
(peuvent être construits avec des UEs validées antérieurement: redoublants
"""RCUEs de l'année
(peuvent être construits avec des UEs validées antérieurement: redoublants
avec UEs capitalisées, validation "antérieures")
"""
# ---- Décision année et autorisation
@ -362,17 +358,13 @@ class DecisionsProposeesAnnee(DecisionsProposees):
# self.codes = [] # pas de décision annuelle sur semestres impairs
elif self.inscription_etat != scu.INSCRIT:
self.codes = [
(
sco_codes.DEM
if self.inscription_etat == scu.DEMISSION
else sco_codes.DEF
),
sco_codes.DEM
if self.inscription_etat == scu.DEMISSION
else sco_codes.DEF,
# propose aussi d'autres codes, au cas où...
(
sco_codes.DEM
if self.inscription_etat != scu.DEMISSION
else sco_codes.DEF
),
sco_codes.DEM
if self.inscription_etat != scu.DEMISSION
else sco_codes.DEF,
sco_codes.ABAN,
sco_codes.ABL,
sco_codes.EXCLU,
@ -388,24 +380,14 @@ class DecisionsProposeesAnnee(DecisionsProposees):
sco_codes.ADJ,
] + self.codes
explanation += f" et {self.nb_rcues_under_8} < 8"
else: # autres cas: non admis, non passage, non dem, pas la moitié des rcue:
if formsemestre.semestre_id % 2 and self.formsemestre_pair is None:
# Si jury sur un seul semestre impair, ne propose pas redoublement
# et efface décision éventuellement existante
codes = [None]
else:
codes = []
self.codes = (
codes
+ [
sco_codes.RED,
sco_codes.NAR,
sco_codes.PAS1NCI,
sco_codes.ADJ,
sco_codes.PASD, # voir #488 (discutable, conventions locales)
]
+ self.codes
)
else:
self.codes = [
sco_codes.RED,
sco_codes.NAR,
sco_codes.PAS1NCI,
sco_codes.ADJ,
sco_codes.PASD, # voir #488 (discutable, conventions locales)
] + self.codes
explanation += f""" et {self.nb_rcues_under_8
} niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""
@ -417,19 +399,15 @@ class DecisionsProposeesAnnee(DecisionsProposees):
# Si validée par niveau supérieur:
if self.code_valide == sco_codes.ADSUP:
self.codes.insert(0, sco_codes.ADSUP)
self.explanation = f'<div class="deca-expl">{explanation}</div>'
self.explanation = f"<div>{explanation}</div>"
messages = self.descr_pb_coherence()
if messages:
self.explanation += (
'<div class="warning warning-info">'
+ '</div><div class="warning warning-info">'.join(messages)
'<div class="warning">'
+ '</div><div class="warning">'.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(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"""
@ -536,21 +514,19 @@ class DecisionsProposeesAnnee(DecisionsProposees):
"""Les deux formsemestres auquel est inscrit l'étudiant (ni DEM ni DEF)
du niveau auquel appartient formsemestre.
-> S_impair, S_pair (de la même année scolaire)
-> S_impair, S_pair
Si l'origine est impair, S_impair est l'origine et S_pair est None
Si l'origine est paire, S_pair est l'origine, et S_impair l'antérieur
suivi par cet étudiant (ou None).
Note: si l'option "block_moyennes" est activée, ne prend pas en compte le semestre.
"""
if not formsemestre.formation.is_apc(): # garde fou
return None, None
if formsemestre.semestre_id % 2:
idx_autre = formsemestre.semestre_id + 1 # impair, autre = suivant
idx_autre = formsemestre.semestre_id + 1
else:
idx_autre = formsemestre.semestre_id - 1 # pair: autre = précédent
idx_autre = formsemestre.semestre_id - 1
# Cherche l'autre semestre de la même année scolaire:
autre_formsemestre = None
@ -563,8 +539,6 @@ class DecisionsProposeesAnnee(DecisionsProposees):
inscr.formsemestre.formation.referentiel_competence
== formsemestre.formation.referentiel_competence
)
# Non bloqué
and not inscr.formsemestre.block_moyennes
# L'autre semestre
and (inscr.formsemestre.semestre_id == idx_autre)
# de la même année scolaire
@ -607,9 +581,11 @@ class DecisionsProposeesAnnee(DecisionsProposees):
# Ordonne par numéro d'UE
niv_rcue = sorted(
self.rcue_by_niveau.items(),
key=lambda x: (
x[1].ue_1.numero if x[1].ue_1 else x[1].ue_2.numero if x[1].ue_2 else 0
),
key=lambda x: x[1].ue_1.numero
if x[1].ue_1
else x[1].ue_2.numero
if x[1].ue_2
else 0,
)
return {
niveau_id: DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
@ -634,7 +610,6 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def next_semestre_ids(self, code: str) -> set[int]:
"""Les indices des semestres dans lequels l'étudiant est autorisé
à poursuivre après le semestre courant.
code: code jury sur année BUT
"""
# La poursuite d'études dans un semestre pair d'une même année
# est de droit pour tout étudiant.
@ -678,8 +653,6 @@ class DecisionsProposeesAnnee(DecisionsProposees):
Si les code_rcue et le code_annee ne sont pas fournis,
et qu'il n'y en a pas déjà, enregistre ceux par défaut.
Si le code_annee est None, efface le code déjà enregistré.
"""
log("jury_but.DecisionsProposeesAnnee.record_form")
code_annee = self.codes[0] # si pas dans le form, valeur par defaut
@ -724,7 +697,6 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def record(self, code: str, mark_recorded: bool = True) -> bool:
"""Enregistre le code de l'année, et au besoin l'autorisation d'inscription.
Si l'étudiant est DEM ou DEF, ne fait rien.
Si le code est None, efface le code déjà enregistré.
Si mark_recorded est vrai, positionne self.recorded
"""
if self.inscription_etat != scu.INSCRIT:
@ -761,22 +733,20 @@ 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()
return True
def record_autorisation_inscription(self, code: str):
"""Autorisation d'inscription dans semestre suivant.
code: code jury sur année BUT
"""
"""Autorisation d'inscription dans semestre suivant"""
if self.autorisations_recorded:
return
if self.inscription_etat != scu.INSCRIT:
@ -804,33 +774,16 @@ class DecisionsProposeesAnnee(DecisionsProposees):
if self.formsemestre_pair is not None:
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id)
def _get_current_res(self) -> ResultatsSemestreBUT:
"Les res. du semestre d'origine du deca"
return (
def has_notes_en_attente(self) -> bool:
"Vrai si l'étudiant a au moins une note en attente dans le semestre origine de ce deca"
res = (
self.res_pair
if self.formsemestre_pair
and (self.formsemestre.id == self.formsemestre_pair.id)
else self.res_impair
)
def has_notes_en_attente(self) -> bool:
"Vrai si l'étudiant a au moins une note en attente dans le semestre origine de ce deca"
res = self._get_current_res()
return res and self.etud.id in res.get_etudids_attente()
def get_modimpls_attente(self) -> list[ModuleImpl]:
"Liste des ModuleImpl dans lesquels l'étudiant à au moins une note en ATTente"
res = self._get_current_res()
modimpls_results = [
modimpl_result
for modimpl_result in res.modimpls_results.values()
if self.etud.id in modimpl_result.etudids_attente
]
modimpls = [
db.session.get(ModuleImpl, mr.moduleimpl_id) for mr in modimpls_results
]
return sorted(modimpls, key=lambda mi: (mi.module.numero, mi.module.code))
def record_all(self, only_validantes: bool = False) -> bool:
"""Enregistre les codes qui n'ont pas été spécifiés par le formulaire,
et sont donc en mode "automatique".
@ -843,15 +796,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
Return: True si au moins un code modifié et enregistré.
"""
modif = False
if only_validantes:
if self.has_notes_en_attente():
# notes en attente dans formsemestre origine
return False
if Evaluation.get_evaluations_blocked_for_etud(
self.formsemestre, self.etud
):
# évaluation(s) qui seront débloquées dans le futur
return False
# Vérification notes en attente dans formsemestre origine
if only_validantes and self.has_notes_en_attente():
return False
# Toujours valider dans l'ordre UE, RCUE, Année
annee_scolaire = self.formsemestre.annee_scolaire()
@ -893,7 +840,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 +851,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 +900,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 +941,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 +949,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]:
@ -1068,28 +969,24 @@ class DecisionsProposeesAnnee(DecisionsProposees):
if dec_ue.code_valide not in CODES_UE_VALIDES:
if (
dec_ue.ue_status
and dec_ue.ue_status["is_capitalized"]
and dec_ue.ue_status["was_capitalized"]
):
messages.append(
f"Information: l'UE {ue.acronyme} capitalisée est utilisée pour un RCUE cette année"
)
else:
messages.append(
f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est (probablement une validation antérieure)"
f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est !"
)
else:
messages.append(
f"L'UE {ue.acronyme} n'a pas décision (???)"
)
# Voyons si on est dispensé de cette ue ?
res = self.res_impair if ue.semestre_idx % 2 else self.res_pair
if res and (self.etud.id, ue.id) in res.dispense_ues:
messages.append(f"Pas (ré)inscrit à l'UE {ue.acronyme}")
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 +1125,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
@ -1570,11 +1468,9 @@ class DecisionsProposeesUE(DecisionsProposees):
self.validation = None # cache toute validation
self.explanation = "non inscrit (dem. ou déf.)"
self.codes = [
(
sco_codes.DEM
if res.get_etud_etat(etud.id) == scu.DEMISSION
else sco_codes.DEF
)
sco_codes.DEM
if res.get_etud_etat(etud.id) == scu.DEMISSION
else sco_codes.DEF
]
return
@ -1588,7 +1484,7 @@ class DecisionsProposeesUE(DecisionsProposees):
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} ue={self.ue.acronyme} valid={self.code_valide
} codes={self.codes} explanation="{self.explanation}">"""
} codes={self.codes} explanation={self.explanation}>"""
def compute_codes(self):
"""Calcul des .codes attribuables et de l'explanation associée"""
@ -1646,12 +1542,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

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -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
@ -56,21 +55,11 @@ def pvjury_page_but(formsemestre_id: int, fmt="html"):
else:
line_sep = "\n"
rows, titles = pvjury_table_but(formsemestre, line_sep=line_sep)
if fmt.startswith("xls"):
titles.update(
{
"etudid": "etudid",
"code_nip": "nip",
"code_ine": "ine",
"ects_but": "Total ECTS BUT",
"civilite": "Civ.",
"nom": "Nom",
"prenom": "Prénom",
}
)
# Style excel... passages à la ligne sur \n
xls_style_base = sco_excel.excel_make_style()
xls_style_base["alignment"] = Alignment(wrapText=True, vertical="top")
tab = GenTable(
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}",
caption=title,
@ -80,7 +69,7 @@ def pvjury_page_but(formsemestre_id: int, fmt="html"):
html_title=f"""<div style="margin-bottom: 8px;"><span
style="font-size: 120%; font-weight: bold;">{title}</span>
<span style="padding-left: 20px;">
<a href="{url_for("notes.pvjury_page_but",
<a href="{url_for("notes.pvjury_page_but",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, fmt="xlsx")}"
class="stdlink">version excel</a></span></div>
@ -127,7 +116,7 @@ def pvjury_table_but(
"pas de référentiel de compétences associé à la formation de ce semestre !"
)
titles = {
"nom_pv": "Code" if anonymous else "Nom",
"nom": "Code" if anonymous else "Nom",
"cursus": "Cursus",
"ects": "ECTS",
"ues": "UE validées",
@ -155,64 +144,33 @@ def pvjury_table_but(
except ScoValueError:
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
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
else etud.etat_civil_pv(
line_sep=line_sep, with_paragraph=with_paragraph_nom
)
"nom": etud.code_ine or etud.code_nip or etud.id
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
else etud.etat_civil_pv(
line_sep=line_sep, with_paragraph=with_paragraph_nom
),
"_nom_pv_order": etud.sort_key,
"_nom_pv_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_pv_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_pv_target": url_for(
"scolar.fiche_etud",
"_nom_order": etud.sort_key,
"_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_target": url_for(
"scolar.ficheEtud",
scodoc_dept=g.scodoc_dept,
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_but": ects_but_valides,
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {but_ects_valides(etud, referentiel_competence_id ):g}""",
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": (
deca.descr_niveaux_validation(line_sep=line_sep) if deca else "-"
),
"niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
if deca
else "-",
"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 ""
)
),
"diplome": diplome_str,
# pour exports excel seulement:
"civilite": etud.civilite_etat_civil_str,
"nom": etud.nom,
"prenom": etud.prenom_etat_civil or etud.prenom or "",
"etudid": etud.id,
"code_nip": etud.code_nip,
"code_ine": etud.code_ine,
"devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else "",
}
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"])
rows.sort(key=lambda x: x["_nom_order"])
return rows, titles

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -9,15 +9,15 @@
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
) -> tuple[int, list[jury_but.DecisionsProposeesAnnee]]:
formsemestre: FormSemestre, only_adm: bool = True
) -> int:
"""Calcul automatique des décisions de jury sur une "année" BUT.
- N'enregistre jamais de décisions de l'année scolaire précédente, même
@ -27,27 +27,16 @@ 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])
Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code.
"""
if not formsemestre.formation.is_apc():
raise ScoValueError("fonction réservée aux formations BUT")
nb_etud_modif = 0
decas = []
with sco_cache.DeferredSemCacheManager():
for etudid in formsemestre.etuds_inscriptions:
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
else:
decas.append(deca)
nb_etud_modif += deca.record_all(only_validantes=only_adm)
db.session.commit()
ScolarNews.add(
@ -60,29 +49,4 @@ def formsemestre_validation_auto_but(
formsemestre_id=formsemestre.id,
),
)
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
return nb_etud_modif

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -21,6 +21,8 @@ from app.but.jury_but import (
DecisionsProposeesRCUE,
DecisionsProposeesUE,
)
from app.comp import res_sem
from app.comp.res_but import ResultatsSemestreBUT
from app.models import (
ApcNiveau,
FormSemestre,
@ -31,8 +33,11 @@ from app.models import (
ScolarFormSemestreValidation,
ScolarNews,
)
from app.models.config import ScoDocSiteConfig
from app.scodoc import html_sco_header
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
@ -71,9 +76,9 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
f"""
<div class="titre_niveaux">
<b>Niveaux de compétences et unités d'enseignement du BUT{deca.annee_but}</b>
<a style="margin-left: 32px;" class="stdlink" target="_blank" rel="noopener noreferrer"
<a style="margin-left: 32px;" class="stdlink" target="_blank" rel="noopener noreferrer"
href={
url_for("notes.validation_rcues", scodoc_dept=g.scodoc_dept,
url_for("notes.validation_rcues", scodoc_dept=g.scodoc_dept,
etudid=deca.etud.id,
formsemestre_id=formsemestre_2.id if formsemestre_2 else formsemestre_1.id
)
@ -92,7 +97,7 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
<span class="avertissement_redoublement">{formsemestre_2.annee_scolaire_str()
if formsemestre_2 else ""}</span>
</div>
<div class="titre" title="Décisions sur RCUEs enregistrées sur l'ensemble du cursus">RCUE</div>
<div class="titre">RCUE</div>
"""
)
for dec_rcue in deca.get_decisions_rcues_annee():
@ -104,32 +109,23 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
</div>"""
)
ue_impair, ue_pair = rcue.ue_1, rcue.ue_2
# Les UEs à afficher : on regarde si read only et si dispense (non ré-inscription à l'UE)
# tuples (UniteEns, read_only, dispense)
ues_ro_dispense = [
# Les UEs à afficher,
# qui
ues_ro = [
(
ue_impair,
rcue.ue_cur_impair is None,
deca.res_impair
and ue_impair
and (deca.etud.id, ue_impair.id) in deca.res_impair.dispense_ues,
),
(
ue_pair,
rcue.ue_cur_pair is None,
deca.res_pair
and ue_pair
and (deca.etud.id, ue_pair.id) in deca.res_pair.dispense_ues,
),
]
# Ordonne selon les dates des 2 semestres considérés:
if reverse_semestre:
ues_ro_dispense[0], ues_ro_dispense[1] = (
ues_ro_dispense[1],
ues_ro_dispense[0],
)
ues_ro[0], ues_ro[1] = ues_ro[1], ues_ro[0]
# Colonnes d'UE:
for ue, ue_read_only, ue_dispense in ues_ro_dispense:
for ue, ue_read_only in ues_ro:
if ue:
H.append(
_gen_but_niveau_ue(
@ -138,7 +134,6 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
disabled=read_only or ue_read_only,
annee_prec=ue_read_only,
niveau_id=ue.niveau_competence.id,
ue_dispense=ue_dispense,
)
)
else:
@ -177,7 +172,7 @@ def _gen_but_select(
]
)
return f"""<select required name="{name}"
class="but_code {klass}"
class="but_code {klass}"
data-orig_code="{code_valide or (codes[0] if codes else '')}"
data-orig_recorded="{code_valide or ''}"
onchange="change_menu_code(this);"
@ -193,30 +188,21 @@ def _gen_but_niveau_ue(
disabled: bool = False,
annee_prec: bool = False,
niveau_id: int = None,
ue_dispense: bool = False,
) -> str:
if dec_ue.ue_status and dec_ue.ue_status["is_capitalized"]:
moy_ue_str = f"""<span class="ue_cap">{
scu.fmt_note(dec_ue.moy_ue_with_cap)}</span>"""
if ue_dispense:
etat_en_cours = """Non (ré)inscrit à cette UE"""
else:
etat_en_cours = f"""UE en cours
{ "sans notes" if np.isnan(dec_ue.moy_ue)
else
("avec moyenne <b>" + scu.fmt_note(dec_ue.moy_ue) + "</b>")
}
"""
scoplement = f"""<div class="scoplement">
<div>
<b>UE {ue.acronyme} capitalisée </b>
<span>le {dec_ue.ue_status["event_date"].strftime(scu.DATE_FMT)}
<span>le {dec_ue.ue_status["event_date"].strftime("%d/%m/%Y")}
</span>
</div>
<div>
{ etat_en_cours }
<div>UE en cours
{ "sans notes" if np.isnan(dec_ue.moy_ue)
else
("avec moyenne <b>" + scu.fmt_note(dec_ue.moy_ue) + "</b>")
}
</div>
</div>
"""
@ -228,7 +214,7 @@ def _gen_but_niveau_ue(
<div>
<b>UE {ue.acronyme} antérieure </b>
<span>validée {dec_ue.validation.code}
le {dec_ue.validation.event_date.strftime(scu.DATE_FMT)}
le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
</span>
</div>
<div>Non reprise dans l'année en cours</div>
@ -246,7 +232,9 @@ def _gen_but_niveau_ue(
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
if dec_ue.code_valide:
date_str = (
f"""enregistré le {dec_ue.validation.event_date.strftime(scu.DATEATIME_FMT)}"""
f"""enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
à {dec_ue.validation.event_date.strftime("%Hh%M")}
"""
if dec_ue.validation and dec_ue.validation.event_date
else ""
)
@ -256,13 +244,7 @@ def _gen_but_niveau_ue(
</div>
"""
else:
if dec_ue.ue_status and dec_ue.ue_status["was_capitalized"]:
scoplement = """<div class="scoplement">
UE déjà capitalisée avec résultat moins favorable.
</div>
"""
else:
scoplement = ""
scoplement = ""
ue_class = "" # 'recorded' if dec_ue.code_valide is not None else ''
if dec_ue.code_valide is not None and dec_ue.codes:
@ -274,20 +256,20 @@ def _gen_but_niveau_ue(
return f"""<div class="but_niveau_ue {ue_class}
{'annee_prec' if annee_prec else ''}
">
<div title="{ue.titre or ''}">{ue.acronyme}</div>
<div title="{ue.titre}">{ue.acronyme}</div>
<div class="but_note with_scoplement">
<div>{moy_ue_str}</div>
{scoplement}
</div>
<div class="but_code">{
_gen_but_select("code_ue_"+str(ue.id),
_gen_but_select("code_ue_"+str(ue.id),
dec_ue.codes,
dec_ue.code_valide,
disabled=disabled,
klass=f"code_ue ue_rcue_{niveau_id}" if not disabled else ""
)
}</div>
</div>"""
@ -349,6 +331,250 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
"""
def jury_but_semestriel(
formsemestre: FormSemestre,
etud: Identite,
read_only: bool,
navigation_div: str = "",
) -> str:
"""Page: formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)."""
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
parcour, ues = jury_but.list_ue_parcour_etud(formsemestre, etud, res)
inscription_etat = etud.inscription_etat(formsemestre.id)
semestre_terminal = (
formsemestre.semestre_id >= formsemestre.formation.get_cursus().NB_SEM
)
autorisations_passage = ScolarAutorisationInscription.query.filter_by(
etudid=etud.id,
origin_formsemestre_id=formsemestre.id,
).all()
# Par défaut: autorisé à passer dans le semestre suivant si sem. impair,
# ou si décision déjà enregistrée:
est_autorise_a_passer = (formsemestre.semestre_id % 2) or (
formsemestre.semestre_id + 1
) in (a.semestre_id for a in autorisations_passage)
decisions_ues = {
ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat)
for ue in ues
}
for dec_ue in decisions_ues.values():
dec_ue.compute_codes()
if request.method == "POST":
if not read_only:
for key in request.form:
code = request.form[key]
# Codes d'UE
code_match = re.match(r"^code_ue_(\d+)$", key)
if code_match:
ue_id = int(code_match.group(1))
dec_ue = decisions_ues.get(ue_id)
if not dec_ue:
raise ScoValueError(f"UE invalide ue_id={ue_id}")
dec_ue.record(code)
db.session.commit()
flash("codes enregistrés")
if not semestre_terminal:
if request.form.get("autorisation_passage"):
if not formsemestre.semestre_id + 1 in (
a.semestre_id for a in autorisations_passage
):
ScolarAutorisationInscription.delete_autorisation_etud(
etud.id, formsemestre.id
)
ScolarAutorisationInscription.autorise_etud(
etud.id,
formsemestre.formation.formation_code,
formsemestre.id,
formsemestre.semestre_id + 1,
)
db.session.commit()
flash(
f"""autorisation de passage en S{formsemestre.semestre_id + 1
} enregistrée"""
)
else:
if est_autorise_a_passer:
ScolarAutorisationInscription.delete_autorisation_etud(
etud.id, formsemestre.id
)
db.session.commit()
flash(
f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée"
)
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=formsemestre.id,
text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""",
url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
)
return flask.redirect(
url_for(
"notes.formsemestre_validation_but",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
etudid=etud.id,
)
)
# GET
if formsemestre.semestre_id % 2 == 0:
warning = f"""<div class="warning">
Cet étudiant de S{formsemestre.semestre_id} ne peut pas passer
en jury BUT annuel car il lui manque le semestre précédent.
</div>"""
else:
warning = ""
H = [
html_sco_header.sco_header(
page_title=f"Validation BUT S{formsemestre.semestre_id}",
formsemestre_id=formsemestre.id,
etudid=etud.id,
cssstyles=("css/jury_but.css",),
javascripts=("js/jury_but.js",),
),
f"""
<div class="jury_but">
<div>
<div class="bull_head">
<div>
<div class="titre_parcours">Jury BUT S{formsemestre.id}
- Parcours {(parcour.libelle if parcour else False) or "non spécifié"}
</div>
<div class="nom_etud">{etud.nomprenom}</div>
</div>
<div class="bull_photo"><a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
</div>
</div>
<h3>Jury sur un semestre BUT isolé (ne concerne que les UEs)</h3>
{warning}
</div>
<form method="post" class="jury_but_box" id="jury_but">
""",
]
erase_span = ""
if not read_only:
# Requête toutes les validations (pas seulement celles du deca courant),
# au cas où: changement d'architecture, saisie en mode classique, ...
validations = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id
).all()
if validations:
erase_span = f"""<a href="{
url_for("notes.formsemestre_jury_but_erase",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id,
etudid=etud.id, only_one_sem=1)
}" class="stdlink">effacer les décisions enregistrées</a>"""
else:
erase_span = (
"Cet étudiant n'a aucune décision enregistrée pour ce semestre."
)
H.append(
f"""
<div class="but_section_annee">
</div>
<div><b>Unités d'enseignement de S{formsemestre.semestre_id}:</b></div>
"""
)
if not ues:
H.append(
"""<div class="warning">Aucune UE ! Vérifiez votre programme de
formation, et l'association UEs / Niveaux de compétences</div>"""
)
else:
H.append(
"""
<div class="but_annee">
<div class="titre"></div>
<div class="titre"></div>
<div class="titre"></div>
<div class="titre"></div>
"""
)
for ue in ues:
dec_ue = decisions_ues[ue.id]
H.append("""<div class="but_niveau_titre"><div></div></div>""")
H.append(
_gen_but_niveau_ue(
ue,
dec_ue,
disabled=read_only,
)
)
H.append(
"""<div style=""></div>
<div class=""></div>"""
)
H.append("</div>") # but_annee
div_autorisations_passage = (
f"""
<div class="but_autorisations_passage">
<span>Autorisé à passer en&nbsp;:</span>
{ ", ".join( ["S" + str(a.semestre_id or '') for a in autorisations_passage ] )}
</div>
"""
if autorisations_passage
else """<div class="but_autorisations_passage but_explanation">pas d'autorisations de passage enregistrées.</div>"""
)
H.append(div_autorisations_passage)
if read_only:
H.append(
f"""<div class="but_explanation">
{"Vous n'avez pas la permission de modifier ces décisions."
if formsemestre.etat
else "Semestre verrouillé."}
Les champs entourés en vert sont enregistrés.
</div>
"""
)
else:
if formsemestre.semestre_id < formsemestre.formation.get_cursus().NB_SEM:
H.append(
f"""
<div class="but_settings">
<input type="checkbox" name="autorisation_passage" value="1" {
"checked" if est_autorise_a_passer else ""}>
<em>autoriser à passer dans le semestre S{formsemestre.semestre_id+1}</em>
</input>
</div>
"""
)
else:
H.append("""<div class="help">dernier semestre de la formation.</div>""")
H.append(
f"""
<div class="but_buttons">
<span><input type="submit" value="Enregistrer ces décisions"></span>
<span>{erase_span}</span>
</div>
"""
)
H.append(navigation_div)
H.append("</div>")
H.append(
render_template(
"but/documentation_codes_jury.j2",
nom_univ=f"""Export {sco_preferences.get_preference("InstituteName")
or sco_preferences.get_preference("UnivName")
or "Apogée"}""",
codes=ScoDocSiteConfig.get_codes_apo_dict(),
)
)
return "\n".join(H)
# -------------
def infos_fiche_etud_html(etudid: int) -> str:
"""Section html pour fiche etudiant

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

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -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

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -75,7 +75,7 @@ class RegroupementCoherentUE:
else None
)
# Autres validations pour les UEs paire/impaire
# Autres validations pour l'UE paire
self.validation_ue_best_pair = best_autre_ue_validation(
etud.id,
niveau.id,
@ -101,24 +101,14 @@ class RegroupementCoherentUE:
"résultats formsemestre de l'UE si elle est courante, None sinon"
self.ue_status_impair = None
if self.ue_cur_impair:
# UE courante
ue_status = res_impair.get_etud_ue_status(etud.id, self.ue_cur_impair.id)
self.moy_ue_1 = ue_status["moy"] if ue_status else None # avec capitalisée
self.ue_1 = self.ue_cur_impair
self.res_impair = res_impair
self.ue_status_impair = ue_status
elif self.validation_ue_best_impair:
# UE capitalisée
self.moy_ue_1 = self.validation_ue_best_impair.moy_ue
self.ue_1 = self.validation_ue_best_impair.ue
if (
res_impair
and self.validation_ue_best_impair
and self.validation_ue_best_impair.ue
):
self.ue_status_impair = res_impair.get_etud_ue_status(
etud.id, self.validation_ue_best_impair.ue.id
)
else:
self.moy_ue_1, self.ue_1 = None, None
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0

View File

@ -1,13 +1,16 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# 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

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -30,9 +30,7 @@ class StatsMoyenne:
self.max = np.nanmax(vals)
self.size = len(vals)
self.nb_vals = self.size - np.count_nonzero(np.isnan(vals))
except (
TypeError
): # que des NaN dans un array d'objets, ou ce genre de choses exotiques...
except TypeError: # que des NaN dans un array d'objets, ou ce genre de choses exotiques...
self.moy = self.min = self.max = self.size = self.nb_vals = 0
def to_dict(self):

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -667,12 +667,10 @@ class BonusCalais(BonusSportAdditif):
sur 20 obtenus dans chacune des matières optionnelles sont cumulés
dans la limite de 10 points. 6% de ces points cumulés s'ajoutent :
<ul>
<li><b>en BUT</b> à la moyenne de chaque UE;
<li><b>en DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant.
</li>
<li><b>en DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant;
</li>
<li><b>en LP</b>, et en BUT avant 2023-2024, à la moyenne de chaque UE dont
l'acronyme termine par <b>BS</b> (comme UE2.1BS, UE32BS).
<li><b>en BUT et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b>
(ex : UE2.1BS, UE32BS)
</li>
</ul>
"""
@ -694,17 +692,12 @@ class BonusCalais(BonusSportAdditif):
else:
self.classic_use_bonus_ues = True # pour les LP
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
if (
self.formsemestre.annee_scolaire() < 2023
or not self.formsemestre.formation.is_apc()
):
# LP et anciens semestres: ne s'applique qu'aux UE dont l'acronyme termine par BS
ues = self.formsemestre.get_ues(with_sport=False)
ues_sans_bs = [
ue for ue in ues if ue.acronyme[-2:].upper() != "BS"
] # les 2 derniers cars forcés en majus
for ue in ues_sans_bs:
self.bonus_ues[ue.id] = 0.0
ues = self.formsemestre.get_ues(with_sport=False)
ues_sans_bs = [
ue for ue in ues if ue.acronyme[-2:].upper() != "BS"
] # les 2 derniers cars forcés en majus
for ue in ues_sans_bs:
self.bonus_ues[ue.id] = 0.0
class BonusColmar(BonusSportAdditif):

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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
@ -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

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -23,7 +23,6 @@ from app.models import (
)
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu
class ValidationsSemestre(ResultatsCache):
@ -39,7 +38,7 @@ class ValidationsSemestre(ResultatsCache):
super().__init__(formsemestre, sco_cache.ValidationsSemestreCache)
self.decisions_jury = {}
"""Décisions prises dans ce semestre:
"""Décisions prises dans ce semestre:
{ etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }}"""
self.decisions_jury_ues = {}
"""Décisions sur des UEs dans ce semestre:
@ -85,7 +84,7 @@ class ValidationsSemestre(ResultatsCache):
"code": decision.code,
"assidu": decision.assidu,
"compense_formsemestre_id": decision.compense_formsemestre_id,
"event_date": decision.event_date.strftime(scu.DATE_FMT),
"event_date": decision.event_date.strftime("%d/%m/%Y"),
}
self.decisions_jury = decisions_jury
@ -108,7 +107,7 @@ class ValidationsSemestre(ResultatsCache):
decisions_jury_ues[decision.etudid][decision.ue.id] = {
"code": decision.code,
"ects": ects, # 0. si UE non validée
"event_date": decision.event_date.strftime(scu.DATE_FMT),
"event_date": decision.event_date.strftime("%d/%m/%Y"),
}
self.decisions_jury_ues = decisions_jury_ues
@ -146,11 +145,11 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
query = sa.text(
"""
SELECT DISTINCT SFV.*, ue.ue_code
FROM
notes_ue ue,
FROM
notes_ue ue,
notes_formations nf,
notes_formations nf2,
scolar_formsemestre_validation SFV,
notes_formations nf2,
scolar_formsemestre_validation SFV,
notes_formsemestre sem,
notes_formsemestre_inscription ins

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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
@ -35,6 +35,7 @@ moyenne générale d'une UE.
"""
import dataclasses
from dataclasses import dataclass
import numpy as np
import pandas as pd
import sqlalchemy as sa
@ -45,6 +46,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
@ -54,7 +56,6 @@ class EvaluationEtat:
evaluation_id: int
nb_attente: int
nb_notes: int # nb notes d'étudiants inscrits au semestre et au modimpl
is_complete: bool
def to_dict(self):
@ -71,15 +72,7 @@ class ModuleImplResults:
les caches sont gérés par ResultatsSemestre.
"""
def __init__(
self, moduleimpl: ModuleImpl, etudids: list[int], etudids_actifs: set[int]
):
"""
Args:
- etudids : liste des etudids, qui donne l'index du dataframe
(doit être tous les étudiants inscrits au semestre incluant les DEM et DEF)
- etudids_actifs l'ensemble des étudiants inscrits au semestre, non DEM/DEF.
"""
def __init__(self, moduleimpl: ModuleImpl):
self.moduleimpl_id = moduleimpl.id
self.module_id = moduleimpl.module.id
self.etudids = None
@ -112,23 +105,14 @@ 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.load_notes()
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"""
self.etuds_use_rattrapage = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module utilise la note de rattrapage"""
def load_notes(
self, etudids: list[int], etudids_actifs: set[int]
): # ré-écriture de df_load_modimpl_notes
def load_notes(self): # ré-écriture de df_load_modimpl_notes
"""Charge toutes les notes de toutes les évaluations du module.
Args:
- etudids : liste des etudids, qui donne l'index du dataframe
(doit être tous les étudiants inscrits au semestre incluant les DEM et DEF)
- etudids_actifs l'ensemble des étudiants inscrits au semestre, non DEM/DEF.
Dataframe evals_notes
colonnes: le nom de la colonne est l'evaluation_id (int)
index (lignes): etudid (int)
@ -151,12 +135,12 @@ class ModuleImplResults:
qui ont des notes ATT.
"""
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
self.etudids = etudids
self.etudids = self._etudids()
# --- Calcul nombre d'inscrits pour déterminer les évaluations "completes":
# on prend les inscrits au module ET au semestre (donc sans démissionnaires)
inscrits_module = {ins.etud.id for ins in moduleimpl.inscriptions}.intersection(
etudids_actifs
moduleimpl.formsemestre.etudids_actifs
)
self.nb_inscrits_module = len(inscrits_module)
@ -164,24 +148,19 @@ class ModuleImplResults:
evals_notes = pd.DataFrame(index=self.etudids, dtype=float)
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
# ou évaluation déclarée "à prise en compte immédiate"
# ou rattrapage, 2eme session, bonus
# ET pas bloquée par date (is_blocked)
is_blocked = evaluation.is_blocked()
# is_complete ssi tous les inscrits (non dem) au semestre ont une note
# ou évaluation déclarée "à prise en compte immédiate"
# Les évaluations de rattrapage et 2eme session sont toujours complètes
etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem.
is_complete = (
(evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE)
(evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE)
or (evaluation.evaluation_type == scu.EVALUATION_SESSION2)
or (evaluation.publish_incomplete)
or (not etudids_sans_note)
) and not is_blocked
)
self.evaluations_completes.append(is_complete)
self.evaluations_completes_dict[evaluation.id] = is_complete
self.evals_etudids_sans_note[evaluation.id] = etudids_sans_note
@ -189,39 +168,25 @@ class ModuleImplResults:
# NULL en base => ABS (= -999)
eval_df.fillna(scu.NOTES_ABSENCE, inplace=True)
# Ce merge ne garde que les étudiants inscrits au module
# et met à NULL (NaN) les notes non présentes
# et met à NULL les notes non présentes
# (notes non saisies ou etuds non inscrits au module):
evals_notes = evals_notes.merge(
eval_df, how="left", left_index=True, right_index=True
)
# Notes en attente: (ne prend en compte que les inscrits, non démissionnaires)
eval_notes_inscr = evals_notes[str(evaluation.id)][list(inscrits_module)]
# Nombre de notes (non vides, incluant ATT etc) des inscrits:
nb_notes = eval_notes_inscr.notna().sum()
if is_blocked:
eval_etudids_attente = set()
else:
# Etudiants avec notes en attente:
# = ceux avec note ATT
eval_etudids_attente = set(
eval_notes_inscr.iloc[
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
].index
)
if evaluation.publish_incomplete:
# et en "immédiat", tous ceux sans note
eval_etudids_attente |= etudids_sans_note
# Synthèse pour état du module:
eval_etudids_attente = set(
eval_notes_inscr.iloc[
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
].index
)
self.etudids_attente |= eval_etudids_attente
self.evaluations_etat[evaluation.id] = EvaluationEtat(
evaluation_id=evaluation.id,
nb_attente=len(eval_etudids_attente),
nb_notes=int(nb_notes),
is_complete=is_complete,
)
# au moins une note en attente (ATT ou manquante en mode "immédiat") dans ce modimpl:
# au moins une note en ATT dans ce modimpl:
self.en_attente = bool(self.etudids_attente)
# Force columns names to integers (evaluation ids)
@ -254,44 +219,36 @@ class ModuleImplResults:
eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)])
return eval_df
def get_evaluations_coefs(self, modimpl: ModuleImpl) -> np.array:
def _etudids(self):
"""L'index du dataframe est la liste de tous les étudiants inscrits au semestre
(incluant les DEM et DEF)
"""
return [
inscr.etudid
for inscr in db.session.get(
ModuleImpl, self.moduleimpl_id
).formsemestre.inscriptions
]
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
"""Coefficients des évaluations.
Les coefs des évals incomplètes, rattrapage, session 2, bonus sont forcés à zéro.
Les coefs des évals incomplètes et non "normales" (session 2, rattrapage)
sont zéro.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[
(
e.coefficient
if e.evaluation_type == Evaluation.EVALUATION_NORMALE
else 0.0
)
for e in modimpl.evaluations
e.coefficient
if e.evaluation_type == scu.EVALUATION_NORMALE
else 0.0
for e in moduleimpl.evaluations
],
dtype=float,
)
* 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"
@ -309,7 +266,7 @@ class ModuleImplResults:
) / [e.note_max / 20.0 for e in moduleimpl.evaluations]
def get_eval_notes_dict(self, evaluation_id: int) -> dict:
"""Notes d'une évaluation, brutes, sous forme d'un dict
"""Notes d'une évaulation, brutes, sous forme d'un dict
{ etudid : valeur }
avec les valeurs float, ou "ABS" ou EXC
"""
@ -318,55 +275,44 @@ 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):
"""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 e.evaluation_type == scu.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):
"""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
]
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."""
return [
e
for e in modimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_BONUS and not e.is_blocked()
]
def get_evaluations_bonus_idx(self, modimpl: ModuleImpl) -> list[int]:
"""Les indices des évaluations bonus non bloquées"""
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 == scu.EVALUATION_SESSION2
]
if eval_list:
return eval_list[0]
return None
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 +333,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)
@ -401,7 +346,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
# et dans dans evals_poids_etuds
# (rappel: la comparaison est toujours false face à un NaN)
# shape: (nb_etuds, nb_evals, nb_ues)
poids_stacked = np.stack([evals_poids] * nb_etuds) # nb_etuds, nb_evals, nb_ues
poids_stacked = np.stack([evals_poids] * nb_etuds)
evals_poids_etuds = np.where(
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
poids_stacked,
@ -409,61 +354,51 @@ class ModuleImplResultsAPC(ModuleImplResults):
)
# Calcule la moyenne pondérée sur les notes disponibles:
evals_notes_stacked = np.stack([evals_notes_20] * nb_ues, axis=2)
# evals_notes_stacked shape: nb_etuds, nb_evals, nb_ues
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module = np.sum(
evals_poids_etuds * evals_notes_stacked, axis=1
) / 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
)
# 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],
etuds_moy_module_s2,
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
)
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,
modimpl,
evals_poids_df,
evals_notes_stacked,
)
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,58 +406,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,
modimpl: ModuleImpl,
evals_poids_df: pd.DataFrame,
evals_notes_stacked: np.ndarray,
):
"""Ajoute les points des évaluations bonus.
Il peut y avoir un nb quelconque d'évaluations bonus.
Les points sont directement ajoutés (ils peuvent être négatifs).
"""
evals_bonus = self.get_evaluations_bonus(modimpl)
if not evals_bonus:
return etuds_moy_module
poids_stacked = np.stack([evals_poids_df.values] * len(etuds_moy_module))
for evaluation in evals_bonus:
eval_idx = evals_poids_df.index.get_loc(evaluation.id)
etuds_moy_module += (
evals_notes_stacked[:, eval_idx, :] * poids_stacked[:, eval_idx, :]
)
# Clip dans [0,20]
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
return etuds_moy_module
def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
"""Charge poids des évaluations d'un module et retourne un dataframe
@ -536,10 +419,11 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
Résultat: (evals_poids, liste de UEs du semestre sauf le sport)
"""
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
modimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id)
evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all()
ues = modimpl.formsemestre.get_ues(with_sport=False)
ue_ids = [ue.id for ue in ues]
evaluation_ids = [evaluation.id for evaluation in modimpl.evaluations]
evaluation_ids = [evaluation.id for evaluation in evaluations]
evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float)
if (
modimpl.module.module_type == ModuleType.RESSOURCE
@ -550,7 +434,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
).filter_by(moduleimpl_id=moduleimpl_id):
try:
evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids
except KeyError:
except KeyError as exc:
pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre...
# Initialise poids non enregistrés:
@ -571,7 +455,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 +476,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,87 +523,42 @@ 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)
# 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,
etuds_moy_module_s2,
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
)
elif evals_rat:
else:
# 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,
)
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,
)
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,
modimpl: ModuleImpl,
evals_notes_20: np.ndarray,
):
"""Ajoute les points des évaluations bonus.
Il peut y avoir un nb quelconque d'évaluations bonus.
Les points sont directement ajoutés (ils peuvent être négatifs).
"""
evals_bonus_idx = self.get_evaluations_bonus_idx(modimpl)
if not evals_bonus_idx:
return etuds_moy_module
for eval_idx in evals_bonus_idx:
etuds_moy_module += evals_notes_20[:, eval_idx]
# Clip dans [0,20]
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
return etuds_moy_module

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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
@ -89,7 +89,7 @@ def compute_sem_moys_apc_using_ects(
flash(
Markup(
f"""Calcul moyenne générale impossible: ECTS des UE manquants !<br>
(formation: <a href="{url_for("notes.ue_table",
(formation: <a href="{url_for("notes.ue_table",
scodoc_dept=g.scodoc_dept, formation_id=formation_id)}">{formation.get_titre_version()}</a>)"""
)
)
@ -100,7 +100,7 @@ def compute_sem_moys_apc_using_ects(
def comp_ranks_series(notes: pd.Series) -> tuple[pd.Series, pd.Series]:
"""Calcul rangs à partir d'une série ("vecteur") de notes (index etudid, valeur
"""Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur
numérique) en tenant compte des ex-aequos.
Result: couple (tuple)

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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
@ -99,11 +99,9 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
# sur toutes les UE)
default_poids = {
mod.id: (
1.0
if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT)
else 0.0
)
mod.id: 1.0
if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT)
else 0.0
for mod in modules
}
@ -150,12 +148,10 @@ def df_load_modimpl_coefs(
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
# sur toutes les UE)
default_poids = {
modimpl.id: (
1.0
if (modimpl.module.module_type == ModuleType.STANDARD)
and (modimpl.module.ue.type == UE_SPORT)
else 0.0
)
modimpl.id: 1.0
if (modimpl.module.module_type == ModuleType.STANDARD)
and (modimpl.module.ue.type == UE_SPORT)
else 0.0
for modimpl in formsemestre.modimpls_sorted
}
@ -183,9 +179,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.
@ -206,11 +200,10 @@ def notes_sem_load_cube(
modimpls_results = {}
modimpls_evals_poids = {}
modimpls_notes = []
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)
mod_results = moy_mod.ModuleImplResultsAPC(modimpl)
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

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -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)
@ -274,7 +273,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
return s.index[s.notna()]
def etud_parcours_ues_ids(self, etudid: int) -> set[int]:
"""Ensemble des id des UEs que l'étudiant doit valider dans ce semestre compte tenu
"""Ensemble les id des UEs que l'étudiant doit valider dans ce semestre compte tenu
du parcours dans lequel il est inscrit.
Se base sur le parcours dans ce semestre, et le référentiel de compétences.
Note: il n'est pas nécessairement inscrit à toutes ces UEs.
@ -308,7 +307,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
return ues_ids
def etud_has_decision(self, etudid, include_rcues=True) -> bool:
def etud_has_decision(self, etudid) -> bool:
"""True s'il y a une décision (quelconque) de jury
émanant de ce formsemestre pour cet étudiant.
prend aussi en compte les autorisations de passage.
@ -319,12 +318,9 @@ class ResultatsSemestreBUT(NotesTableCompat):
or ApcValidationAnnee.query.filter_by(
formsemestre_id=self.formsemestre.id, etudid=etudid
).count()
or (
include_rcues
and ApcValidationRCUE.query.filter_by(
formsemestre_id=self.formsemestre.id, etudid=etudid
).count()
)
or ApcValidationRCUE.query.filter_by(
formsemestre_id=self.formsemestre.id, etudid=etudid
).count()
)
def get_validations_annee(self) -> dict[int, ApcValidationAnnee]:

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -234,7 +234,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
raise ScoValueError(
f"""<div class="scovalueerror"><p>Coefficient de l'UE capitalisée {ue.acronyme}
impossible à déterminer pour l'étudiant <a href="{
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}" class="discretelink">{etud.nom_disp()}</a></p>
<p>Il faut <a href="{
url_for("notes.formsemestre_edit_uecoefs", scodoc_dept=g.scodoc_dept,
@ -242,8 +242,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
)
}">saisir le coefficient de cette UE avant de continuer</a></p>
</div>
""",
safe=True,
"""
)
@ -257,9 +256,8 @@ def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple[np.ndarray, dict]
"""
modimpls_results = {}
modimpls_notes = []
etudids, etudids_actifs = formsemestre.etudids_actifs()
for modimpl in formsemestre.modimpls_sorted:
mod_results = moy_mod.ModuleImplResultsClassic(modimpl, etudids, etudids_actifs)
mod_results = moy_mod.ModuleImplResultsClassic(modimpl)
etuds_moy_module = mod_results.compute_module_moy()
modimpls_results[modimpl.id] = mod_results
modimpls_notes.append(etuds_moy_module)

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -9,13 +9,12 @@
from collections import Counter, defaultdict
from collections.abc import Generator
import datetime
from functools import cached_property
from operator import attrgetter
import numpy as np
import pandas as pd
import sqlalchemy as sa
from flask import g, url_for
from app import db
@ -23,19 +22,14 @@ from app.comp import res_sem
from app.comp.res_cache import ResultatsCache
from app.comp.jury import ValidationsSemestre
from app.comp.moy_mod import ModuleImplResults
from app.models import (
Evaluation,
FormSemestre,
FormSemestreUECoef,
Identite,
ModuleImpl,
ModuleImplInscription,
ScolarAutorisationInscription,
UniteEns,
)
from app.models import FormSemestre, FormSemestreUECoef
from app.models import Identite
from app.models import ModuleImpl, ModuleImplInscription
from app.models import ScolarAutorisationInscription
from app.models.ues import UniteEns
from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError, ScoTemporaryError
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu
@ -150,7 +144,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.
@ -198,86 +192,16 @@ class ResultatsSemestre(ResultatsCache):
*[mr.etudids_attente for mr in self.modimpls_results.values()]
)
# Etat des évaluations
def get_evaluation_etat(self, evaluation: Evaluation) -> dict:
"""État d'une évaluation
{
"coefficient" : float, # 0 si None
"description" : str, # de l'évaluation, "" si None
"etat" {
"blocked" : bool, # vrai si prise en compte bloquée
"evalcomplete" : bool,
"last_modif" : datetime.datetime | None, # saisie de note la plus récente
"nb_notes" : int, # nb notes d'étudiants inscrits
"nb_attente" : int, # nb de notes en ATTente (même si bloquée)
},
"evaluation_id" : int,
"jour" : datetime.datetime, # e.date_debut or datetime.datetime(1900, 1, 1)
"publish_incomplete" : bool,
}
"""
mod_results = self.modimpls_results.get(evaluation.moduleimpl_id)
if mod_results is None:
raise ScoTemporaryError() # argh !
etat = mod_results.evaluations_etat.get(evaluation.id)
if etat is None:
raise ScoTemporaryError() # argh !
# Date de dernière saisie de note
cursor = db.session.execute(
sa.text(
"SELECT MAX(date) FROM notes_notes WHERE evaluation_id = :evaluation_id"
),
{"evaluation_id": evaluation.id},
)
date_modif = cursor.one_or_none()
last_modif = date_modif[0] if date_modif else None
return {
"coefficient": evaluation.coefficient,
"description": evaluation.description,
"etat": {
"blocked": evaluation.is_blocked(),
"evalcomplete": etat.is_complete,
"nb_attente": etat.nb_attente,
"nb_notes": etat.nb_notes,
"last_modif": last_modif,
},
"evaluation_id": evaluation.id,
"jour": evaluation.date_debut or datetime.datetime(1900, 1, 1),
"publish_incomplete": evaluation.publish_incomplete,
}
def get_mod_evaluation_etat_list(self, modimpl: ModuleImpl) -> list[dict]:
"""Liste des états des évaluations de ce module
[ evaluation_etat, ... ] (voir get_evaluation_etat)
trié par (numero desc, date_debut desc)
"""
# nouvelle version 2024-02-02
return list(
reversed(
[
self.get_evaluation_etat(evaluation)
for evaluation in modimpl.evaluations
]
)
)
# modernisation de get_mod_evaluation_etat_list
# utilisé par:
# sco_evaluations.do_evaluation_etat_in_mod
# e["etat"]["evalcomplete"]
# e["etat"]["nb_notes"]
# e["etat"]["last_modif"]
#
# sco_formsemestre_status.formsemestre_description_table
# "jour" (qui est e.date_debut or datetime.date(1900, 1, 1))
# "description"
# "coefficient"
# e["etat"]["evalcomplete"]
# publish_incomplete
#
# sco_formsemestre_status.formsemestre_tableau_modules
# e["etat"]["nb_notes"]
#
# # Etat des évaluations
# # (se substitue à do_evaluation_etat, sans les moyennes par groupes)
# def get_evaluations_etats(evaluation_id: int) -> dict:
# """Renvoie dict avec les clés:
# last_modif
# nb_evals_completes
# nb_evals_en_cours
# nb_evals_vides
# attente
# """
# --- JURY...
def get_formsemestre_validations(self) -> ValidationsSemestre:
@ -436,28 +360,11 @@ class ResultatsSemestre(ResultatsCache):
ue_cap_dict["compense_formsemestre_id"] = None
return ue_cap_dict
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict | None:
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict:
"""L'état de l'UE pour cet étudiant.
Result: dict, ou None si l'UE n'existe pas ou n'est pas dans ce semestre.
{
"is_capitalized": # vrai si la version capitalisée est celle prise en compte (meilleure)
"was_capitalized":# si elle a été capitalisée (meilleure ou pas)
"is_external": # si UE externe
"coef_ue": 0.0,
"cur_moy_ue": 0.0, # moyenne de l'UE courante
"moy": 0.0, # moyenne prise en compte
"event_date": # date de la capiltalisation éventuelle (ou None)
"ue": ue_dict, # l'UE, comme un dict
"formsemestre_id": None,
"capitalized_ue_id": None, # l'id de l'UE capitalisée, ou None
"ects_pot": 0.0, # deprecated (les ECTS liés à cette UE)
"ects": 0.0, # les ECTS acquis grace à cette UE
"ects_ue": # les ECTS liés à cette UE
}
Result: dict, ou None si l'UE n'est pas dans ce semestre.
"""
ue: UniteEns = db.session.get(UniteEns, ue_id)
if not ue:
return None
ue_dict = ue.to_dict()
if ue.type == UE_SPORT:
@ -476,7 +383,7 @@ class ResultatsSemestre(ResultatsCache):
"ects": 0.0,
"ects_ue": ue.ects,
}
if not ue_id in self.etud_moy_ue or not etudid in self.etud_moy_ue[ue_id]:
if not ue_id in self.etud_moy_ue:
return None
if not self.validations:
self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
@ -518,8 +425,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:
@ -534,13 +440,11 @@ class ResultatsSemestre(ResultatsCache):
"is_external": ue_cap["is_external"] if is_capitalized else ue.is_external,
"coef_ue": coef_ue,
"ects_pot": ue.ects or 0.0,
"ects": (
self.validations.decisions_jury_ues.get(etudid, {})
.get(ue.id, {})
.get("ects", 0.0)
if self.validations.decisions_jury_ues
else 0.0
),
"ects": self.validations.decisions_jury_ues.get(etudid, {})
.get(ue.id, {})
.get("ects", 0.0)
if self.validations.decisions_jury_ues
else 0.0,
"ects_ue": ue.ects,
"cur_moy_ue": cur_moy_ue,
"moy": moy_ue,

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -58,6 +58,7 @@ class NotesTableCompat(ResultatsSemestre):
self.moy_moy = "NA"
self.moy_gen_rangs_by_group = {} # { group_id : (Series, Series) }
self.ue_rangs_by_group = {} # { ue_id : {group_id : (Series, Series)}}
self.expr_diagnostics = ""
self.parcours = self.formsemestre.formation.get_cursus()
self._modimpls_dict_by_ue = {} # local cache
@ -216,9 +217,9 @@ class NotesTableCompat(ResultatsSemestre):
# Rangs / UEs:
for ue in ues:
group_moys_ue = self.etud_moy_ue[ue.id][group_members]
self.ue_rangs_by_group.setdefault(ue.id, {})[group.id] = (
moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
)
self.ue_rangs_by_group.setdefault(ue.id, {})[
group.id
] = moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
def get_etud_rang(self, etudid: int) -> str:
"""Le rang (classement) de l'étudiant dans le semestre.
@ -289,10 +290,9 @@ class NotesTableCompat(ResultatsSemestre):
]
return etudids
def etud_has_decision(self, etudid, include_rcues=True) -> bool:
def etud_has_decision(self, etudid) -> bool:
"""True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre.
prend aussi en compte les autorisations de passage.
Si include_rcues, prend en compte les validation d'RCUEs en BUT (pas d'effet en classic).
Sous-classée en BUT pour les RCUEs et années.
"""
return bool(
@ -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.
@ -407,7 +407,7 @@ class NotesTableCompat(ResultatsSemestre):
de ce module.
Évaluation "complete" ssi toutes notes saisies ou en attente.
"""
modimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id)
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
modimpl_results = self.modimpls_results.get(moduleimpl_id)
if not modimpl_results:
return [] # safeguard
@ -422,37 +422,30 @@ class NotesTableCompat(ResultatsSemestre):
)
return evaluations
def get_evaluations_etats(self) -> dict[int, dict]:
""" "état" de chaque évaluation du semestre
{
evaluation_id : {
"evalcomplete" : bool,
"last_modif" : datetime | None
"nb_notes" : int,
}, ...
}
"""
# utilisé par do_evaluation_etat_in_sem
evaluations_etats = {}
for modimpl in self.formsemestre.modimpls_sorted:
for evaluation in modimpl.evaluations:
evaluation_etat = self.get_evaluation_etat(evaluation)
evaluations_etats[evaluation.id] = evaluation_etat["etat"]
return evaluations_etats
def get_evaluations_etats(self) -> list[dict]:
"""Liste de toutes les évaluations du semestre
[ {...evaluation et son etat...} ]"""
# TODO: à moderniser (voir dans ResultatsSemestre)
# utilisé par
# do_evaluation_etat_in_sem
# ancienne version < 2024-02-02
# def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
# """Liste des états des évaluations de ce module
# ordonnée selon (numero desc, date_debut desc)
# """
# # à moderniser: lent, recharge des données que l'on a déjà...
# # remplacemé par ResultatsSemestre.get_mod_evaluation_etat_list
# #
# return [
# e
# for e in self.get_evaluations_etats()
# if e["moduleimpl_id"] == moduleimpl_id
# ]
from app.scodoc import sco_evaluations
if not hasattr(self, "_evaluations_etats"):
self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem(
self.formsemestre.id
)
return self._evaluations_etats
def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
"""Liste des états des évaluations de ce module"""
# XXX TODO à moderniser: lent, recharge des données que l'on a déjà...
return [
e
for e in self.get_evaluations_etats()
if e["moduleimpl_id"] == moduleimpl_id
]
def get_moduleimpls_attente(self):
"""Liste des modimpls du semestre ayant des notes en attente"""

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

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

@ -1,17 +1,16 @@
# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
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 +19,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(
@ -92,12 +83,9 @@ Adresses d'origine:
\n\n"""
+ msg.body
)
now = datetime.datetime.now()
formatted_time = now.strftime("%Y-%m-%d %H:%M:%S") + ".{:03d}".format(
now.microsecond // 1000
)
current_app.logger.info(
f"""[{formatted_time}] email sent to{
f"""email sent to{
' (mode test)' if email_test_mode_address else ''
}: {msg.recipients}
from sender {msg.sender}

View File

@ -6,7 +6,6 @@ from flask import Blueprint
from app.scodoc import sco_etud
from app.auth.models import User
from app.models import Departement
import app.scodoc.sco_utils as scu
bp = Blueprint("entreprises", __name__)
@ -16,12 +15,12 @@ SIRET_PROVISOIRE_START = "xx"
@bp.app_template_filter()
def format_prenom(s):
return scu.format_prenom(s)
return sco_etud.format_prenom(s)
@bp.app_template_filter()
def format_nom(s):
return scu.format_nom(s)
return sco_etud.format_nom(s)
@bp.app_template_filter()
@ -59,4 +58,3 @@ def check_taxe_now(taxes):
from app.entreprises import routes
from app.entreprises.activate import activate_module

View File

@ -1,31 +0,0 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Activation du module entreprises
L'affichage du module est contrôlé par la config ScoDocConfig.enable_entreprises
Au moment de l'activation, il est en général utile de proposer de configurer les
permissions de rôles standards: AdminEntreprise UtilisateurEntreprise ObservateurEntreprise
Voir associations dans sco_roles_default
"""
from app.auth.models import Role
from app.models import ScoDocSiteConfig
from app.scodoc.sco_roles_default import SCO_ROLES_ENTREPRISES_DEFAULT
def activate_module(
enable: bool = True, set_default_roles_permission: bool = False
) -> bool:
"""Active le module et en option donne les permissions aux rôles standards.
True si l'état d'activation a changé.
"""
change = ScoDocSiteConfig.enable_entreprises(enable)
if enable and set_default_roles_permission:
Role.reset_roles_permissions(SCO_ROLES_ENTREPRISES_DEFAULT)
return change

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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

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)
@ -1584,8 +1580,8 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
)
)
elif request.method == "GET":
form.etudiant.data = f"""{scu.format_nom(etudiant.nom)} {
scu.format_prenom(etudiant.prenom)}"""
form.etudiant.data = f"""{sco_etud.format_nom(etudiant.nom)} {
sco_etud.format_prenom(etudiant.prenom)}"""
form.etudid.data = etudiant.id
form.type_offre.data = stage_apprentissage.type_offre
form.date_debut.data = stage_apprentissage.date_debut
@ -1703,7 +1699,7 @@ def json_etudiants():
list = []
for etudiant in etudiants:
content = {}
value = f"{scu.format_nom(etudiant.nom)} {scu.format_prenom(etudiant.prenom)}"
value = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}"
if etudiant.inscription_courante() is not None:
content = {
"id": f"{etudiant.id}",
@ -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

@ -1,176 +0,0 @@
# -*- 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 ajout d'une "assiduité" sur un étudiant
Formulaire ajout d'un justificatif sur un étudiant
"""
from flask_wtf import FlaskForm
from flask_wtf.file import MultipleFileField
from wtforms import (
BooleanField,
SelectField,
StringField,
SubmitField,
RadioField,
TextAreaField,
validators,
)
from wtforms.validators import DataRequired
from app.scodoc import sco_utils as scu
class AjoutAssiOrJustForm(FlaskForm):
"""Elements communs aux deux formulaires ajout
assiduité et justificatif
"""
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}
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 (si plusieurs jours)",
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})
class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
"Formulaire de saisie d'une assiduité pour un étudiant"
description = TextAreaField(
"Description",
render_kw={
"id": "description",
"cols": 75,
"rows": 4,
"maxlength": 500,
},
)
assi_etat = RadioField(
"Signaler:",
choices=[("absent", "absence"), ("retard", "retard"), ("present", "présence")],
default="absent",
validators=[
validators.DataRequired("spécifiez le type d'évènement à signaler"),
],
)
modimpl = SelectField(
"Module",
choices={}, # will be populated dynamically
)
est_just = BooleanField("Justifiée")
class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
"Formulaire de saisie d'un justificatif pour un étudiant"
raison = TextAreaField(
"Raison",
render_kw={
"id": "raison",
"cols": 75,
"rows": 4,
"maxlength": 500,
},
)
etat = SelectField(
"État du justificatif",
choices=[], # sera rempli dynamiquement
validators=[DataRequired(message="This field is required.")],
)
fichiers = MultipleFileField(label="Ajouter des fichiers")

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

@ -17,7 +17,7 @@ def UEParcoursECTSForm(ue: UniteEns) -> FlaskForm:
pass
parcours: list[ApcParcours] = ue.formation.referentiel_competence.parcours
# Initialise un champ de saisie par parcours
# Initialise un champs de saisie par parcours
for parcour in parcours:
ects = ue.get_ects(parcour, only_parcours=True)
setattr(

View File

@ -4,7 +4,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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
@ -43,7 +43,6 @@ def gen_formsemestre_change_formation_form(
formations: list[Formation],
) -> FormSemestreChangeFormationForm:
"Create our dynamical form"
# see https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition
class F(FormSemestreChangeFormationForm):
pass

View File

@ -4,7 +4,7 @@ Formulaire configuration des codes Apo et EDT des modimps d'un formsemestre
from flask_wtf import FlaskForm
from wtforms import validators
from wtforms.fields.simple import StringField, SubmitField
from wtforms.fields.simple import BooleanField, StringField, SubmitField
from app.models import FormSemestre, ModuleImpl

View File

@ -1,17 +0,0 @@
"""
Formulaire activation module entreprises
"""
from flask_wtf import FlaskForm
from wtforms.fields.simple import BooleanField, SubmitField
from app.models import ScoDocSiteConfig
class ActivateEntreprisesForm(FlaskForm):
"Formulaire activation module entreprises"
set_default_roles_permission = BooleanField(
"(re)mettre les rôles 'Entreprise' à leurs valeurs par défaut"
)
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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
@ -29,16 +29,56 @@
Formulaire configuration Module Assiduités
"""
import datetime
import re
from flask_wtf import FlaskForm
from wtforms import DecimalField, SubmitField, ValidationError
from wtforms.fields.simple import StringField
from wtforms.validators import Optional, Length
from wtforms.validators import Optional
from wtforms.widgets import TimeInput
class TimeField(StringField):
"""HTML5 time input.
tiré de : https://gist.github.com/tachyondecay/6016d32f65a996d0d94f
"""
widget = TimeInput()
def __init__(self, label=None, validators=None, fmt="%H:%M:%S", **kwargs):
super(TimeField, self).__init__(label, validators, **kwargs)
self.fmt = fmt
self.data = None
def _value(self):
if self.raw_data:
return " ".join(self.raw_data)
if self.data and isinstance(self.data, str):
self.data = datetime.time(*map(int, self.data.split(":")))
return self.data and self.data.strftime(self.fmt) or ""
def process_formdata(self, valuelist):
if valuelist:
time_str = " ".join(valuelist)
try:
components = time_str.split(":")
hour = 0
minutes = 0
seconds = 0
if len(components) in range(2, 4):
hour = int(components[0])
minutes = int(components[1])
if len(components) == 3:
seconds = int(components[2])
else:
raise ValueError
self.data = datetime.time(hour, minutes, seconds)
except ValueError as exc:
self.data = None
raise ValueError(self.gettext("Not a valid time string")) from exc
def check_tick_time(form, field):
"""Le tick_time doit être entre 0 et 60 minutes"""
if field.data < 1 or field.data > 59:
@ -58,57 +98,14 @@ def check_ics_path(form, field):
raise ValidationError("Le chemin vers les ics doit utiliser {edt_id}")
def check_ics_field(form, field):
"""Vérifie que c'est un nom de champ crédible: un mot alphanumérique"""
if not re.match(r"^[a-zA-Z\-_0-9]+$", field.data):
raise ValidationError("nom de champ ics invalide")
def check_ics_regexp(form, field):
"""Vérifie que field est une expresssion régulière"""
value = field.data.strip()
# check that it compiles
try:
_ = re.compile(value)
except re.error as exc:
raise ValidationError("expression invalide") from exc
return True
class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduité"
assi_morning_time = StringField(
"Début de la journée",
default="",
validators=[Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_morning_time",
},
)
assi_lunch_time = StringField(
"Heure de midi (date pivot entre matin et après-midi)",
default="",
validators=[Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_lunch_time",
},
)
assi_afternoon_time = StringField(
"Fin de la journée",
validators=[Length(max=5)],
default="",
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_afternoon_time",
},
)
assi_tick_time = DecimalField(
morning_time = TimeField("Début de la journée")
lunch_time = TimeField("Heure de midi (date pivot entre Matin et Après Midi)")
afternoon_time = TimeField("Fin de la journée")
tick_time = DecimalField(
"Granularité de la timeline (temps en minutes)",
places=0,
validators=[check_tick_time],
@ -122,69 +119,6 @@ class ConfigAssiduitesForm(FlaskForm):
Si ce champ n'est pas renseigné, les emplois du temps ne seront pas utilisés.""",
validators=[Optional(), check_ics_path],
)
edt_ics_user_path = StringField(
label="Chemin vers les ics des utilisateurs (enseignants)",
description="""Optionnel. Chemin absolu unix sur le serveur vers le fichier ics donnant l'emploi
du temps d'un enseignant. La balise <tt>{edt_id}</tt> sera remplacée par l'edt_id du
de l'utilisateur.
Dans certains cas (XXX), ScoDoc peut générer ces fichiers et les écrira suivant
ce chemin (avec edt_id).
""",
validators=[Optional(), check_ics_path],
)
edt_ics_title_field = StringField(
label="Champ contenant le titre",
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
validators=[Optional(), check_ics_field],
)
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.
Exemple: <tt>Matière : \w+ - ([\w\.\s']+)</tt>
""",
validators=[Optional(), check_ics_regexp],
)
edt_ics_group_field = StringField(
label="Champ contenant le groupe",
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
validators=[Optional(), check_ics_field],
)
edt_ics_group_regexp = StringField(
label="Extraction du groupe",
description=r"""expression régulière python dont le premier groupe doit
correspondre à l'identifiant de groupe de l'emploi du temps.
Exemple: <tt>.*- ([\w\s]+)$</tt>
""",
validators=[Optional(), check_ics_regexp],
)
edt_ics_mod_field = StringField(
label="Champ contenant le module",
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
validators=[Optional(), check_ics_field],
)
edt_ics_mod_regexp = StringField(
label="Extraction du module",
description=r"""expression régulière python dont le premier groupe doit
correspondre à l'identifiant (code) du module de l'emploi du temps.
Exemple: <tt>Matière : ([A-Z][A-Z0-9]+)</tt>
""",
validators=[Optional(), check_ics_regexp],
)
edt_ics_uid_field = StringField(
label="Champ contenant les enseignants",
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
validators=[Optional(), check_ics_field],
)
edt_ics_uid_regexp = StringField(
label="Extraction des enseignants",
description=r"""expression régulière python permettant d'extraire les
identifiants des enseignants associés à l'évènement.
(contrairement aux autres champs, il peut y avoir plusieurs enseignants par évènement.)
Exemple: <tt>[0-9]+</tt>
""",
validators=[Optional(), check_ics_regexp],
)
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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
@ -82,7 +82,7 @@ class ConfigCASForm(FlaskForm):
cas_attribute_id = StringField(
label="Attribut CAS utilisé comme id (laissez vide pour prendre l'id par défaut)",
description="""Le champ CAS qui sera considéré comme l'id unique des
description="""Le champs CAS qui sera considéré comme l'id unique des
comptes utilisateurs.""",
)

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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
@ -48,15 +48,13 @@ class BonusConfigurationForm(FlaskForm):
for (name, displayed_name) in ScoDocSiteConfig.get_bonus_sport_class_list()
],
)
submit_bonus = SubmitField("Enregistrer ce bonus")
submit_bonus = SubmitField("Valider")
cancel_bonus = SubmitField("Annuler", render_kw={"formnovalidate": True})
class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration avancée"
disable_passerelle = BooleanField( # disable car par défaut activée
"""cacher les fonctions liées à une passerelle de publication des résultats vers les étudiants ("œil"). N'affecte pas l'API, juste la présentation."""
)
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
month_debut_annee_scolaire = SelectField(
label="Mois de début des années scolaires",
description="""Date pivot. En France métropolitaine, août.
@ -79,13 +77,8 @@ class ScoDocConfigurationForm(FlaskForm):
Attention: si ce champ peut aussi être défini dans chaque département.""",
validators=[Optional(), Email()],
)
user_require_email_institutionnel = BooleanField(
"imposer la saisie du mail institutionnel dans le formulaire de création utilisateur"
)
disable_bul_pdf = BooleanField(
"interdire les exports des bulletins en PDF (déconseillé)"
)
submit_scodoc = SubmitField("Enregistrer ces paramètres")
disable_bul_pdf = BooleanField("empêcher les exports des bulletins en PDF")
submit_scodoc = SubmitField("Valider")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
@ -100,12 +93,10 @@ def configuration():
form_scodoc = ScoDocConfigurationForm(
data={
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
"disable_passerelle": ScoDocSiteConfig.is_passerelle_disabled(),
"month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(),
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
"email_from_addr": ScoDocSiteConfig.get("email_from_addr"),
"disable_bul_pdf": ScoDocSiteConfig.is_bul_pdf_disabled(),
"user_require_email_institutionnel": ScoDocSiteConfig.is_user_require_email_institutionnel_enabled(),
}
)
if request.method == "POST" and (
@ -126,12 +117,12 @@ def configuration():
flash("Fonction bonus inchangée.")
return redirect(url_for("scodoc.index"))
elif form_scodoc.submit_scodoc.data and form_scodoc.validate():
if ScoDocSiteConfig.disable_passerelle(
disabled=form_scodoc.data["disable_passerelle"]
if ScoDocSiteConfig.enable_entreprises(
enabled=form_scodoc.data["enable_entreprises"]
):
flash(
"Fonction passerelle "
+ ("cachées" if form_scodoc.data["disable_passerelle"] else "montrées")
"Module entreprise "
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
)
if ScoDocSiteConfig.set_month_debut_annee_scolaire(
int(form_scodoc.data["month_debut_annee_scolaire"])
@ -158,23 +149,10 @@ def configuration():
"Exports PDF "
+ ("désactivés" if form_scodoc.data["disable_bul_pdf"] else "réactivés")
)
if ScoDocSiteConfig.set(
"user_require_email_institutionnel",
"on" if form_scodoc.data["user_require_email_institutionnel"] else "",
):
flash(
(
"impose"
if form_scodoc.data["user_require_email_institutionnel"]
else "n'impose pas"
)
+ " la saisie du mail institutionnel des utilisateurs"
)
return redirect(url_for("scodoc.index"))
return render_template(
"configuration.j2",
is_entreprises_enabled=ScoDocSiteConfig.is_entreprises_enabled(),
form_bonus=form_bonus,
form_scodoc=form_scodoc,
scu=scu,

View File

@ -1,49 +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 configuration RGPD
"""
from flask_wtf import FlaskForm
from wtforms import SubmitField
from wtforms.fields.simple import TextAreaField
class ConfigRGPDForm(FlaskForm):
"Formulaire paramétrage RGPD"
rgpd_coordonnees_dpo = TextAreaField(
label="Optionnel: coordonnées du DPO",
description="""Le délégué à la protection des données (DPO) est chargé de mettre en œuvre
la conformité au règlement européen sur la protection des données (RGPD) au sein de lorganisme.
Indiquer ici les coordonnées (format libre) qui seront affichées aux utilisateurs de ScoDoc.
""",
render_kw={"rows": 5, "cols": 72},
)
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

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

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 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

View File

@ -1,65 +0,0 @@
##############################################################################
#
# 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 options génération table poursuite études (PE)
"""
from flask_wtf import FlaskForm
from wtforms import BooleanField, HiddenField, SubmitField
class ParametrageClasseurPE(FlaskForm):
"Formulaire paramétrage génération classeur PE"
# cohorte_restreinte = BooleanField(
# "Restreindre aux étudiants inscrits dans le semestre (sans interclassement de promotion) (à venir)"
# )
moyennes_tags = BooleanField(
"Générer les moyennes sur les tags de modules personnalisés (cf. programme de formation)",
default=True,
render_kw={"checked": ""},
)
moyennes_ue_res_sae = BooleanField(
"Générer les moyennes des ressources et des SAEs",
default=True,
render_kw={"checked": ""},
)
moyennes_ues_rcues = BooleanField(
"Générer les moyennes par RCUEs (compétences) et leurs synthèses HTML étudiant par étudiant",
default=True,
render_kw={"checked": ""},
)
min_max_moy = BooleanField("Afficher les colonnes min/max/moy")
# synthese_individuelle_etud = BooleanField(
# "Générer (suppose les RCUES)"
# )
publipostage = BooleanField(
"Nomme les moyennes pour publipostage",
# default=False,
# render_kw={"checked": ""},
)
submit = SubmitField("Générer les classeurs poursuites d'études")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

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
@ -25,17 +23,8 @@ convention = {
metadata_obj = sqlalchemy.MetaData(naming_convention=convention)
class ScoDocModel(db.Model):
"""Superclass for our models. Add some useful methods for editing, cloning, etc.
- clone() : clone object and add copy to session, do not commit.
- create_from_dict() : create instance from given dict, applying conversions.
- convert_dict_fields() : convert dict values, called before instance creation.
By default, do nothing.
- from_dict() : update object using data from dict. data is first converted.
- edit() : update from wtf form.
"""
__abstract__ = True # declare an abstract class for SQLAlchemy
class ScoDocModel:
"Mixin class for our models. Add somme useful methods for editing, cloning, etc."
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
@ -51,28 +40,21 @@ class ScoDocModel(db.Model):
return copy
@classmethod
def create_from_dict(cls, data: dict) -> "ScoDocModel":
def create_from_dict(cls, data: dict):
"""Create a new instance of the model with attributes given in dict.
The instance is added to the session (but not flushed nor committed).
Use only relevant attributes for the given model and ignore others.
Use only relevant arributes for the given model and ignore others.
"""
if data:
args = cls.convert_dict_fields(cls.filter_model_attributes(data))
if args:
obj = cls(**args)
else:
obj = cls()
else:
obj = cls()
args = cls.convert_dict_fields(cls.filter_model_attributes(data))
obj = cls(**args)
db.session.add(obj)
return obj
@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 'id' to excluded."""
excluded = excluded or set()
excluded.add("id") # always exclude id
By default, excluded == { 'id' }"""
excluded = {"id"} if excluded is None else set()
# Les attributs du modèle qui sont des variables: (élimine les __ et les alias comme adm_id)
my_attributes = [
a
@ -88,7 +70,7 @@ class ScoDocModel(db.Model):
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields from the given dict to model's attributes values. No side effect.
"""Convert fields in the given dict. No side effect.
By default, do nothing, but is overloaded by some subclasses.
args: dict with args in application.
returns: dict to store in model's db.
@ -96,63 +78,13 @@ class ScoDocModel(db.Model):
# virtual, by default, do nothing
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.
"""
args_dict = self.convert_dict_fields(
self.filter_model_attributes(args, excluded=excluded)
)
modified = False
def from_dict(self, args: dict):
"Update object's fields given in dict. Add to session but don't commit."
args_dict = self.convert_dict_fields(self.filter_model_attributes(args))
for key, value in args_dict.items():
if hasattr(self, key) and value != getattr(self, key):
if hasattr(self, key):
setattr(self, key, value)
modified = True
db.session.add(self)
return modified
def edit_from_form(self, form) -> bool:
"""Generic edit method for updating model instance.
True if modification.
"""
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
@ -198,6 +130,7 @@ from app.models.notes import (
NotesNotesLog,
)
from app.models.validations import (
ScolarEvent,
ScolarFormSemestreValidation,
ScolarAutorisationInscription,
)
@ -211,13 +144,8 @@ 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
from app.models.assiduites import Assiduite, Justificatif
from app.models.scolar_event import ScolarEvent

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

@ -1,38 +1,25 @@
# -*- coding: UTF-8 -*
"""Gestion de l'assiduité (assiduités + justificatifs)"""
"""Gestion de l'assiduité (assiduités + justificatifs)
"""
from datetime import datetime
from flask_login import current_user
from flask_sqlalchemy.query import Query
from app import db, log, g, set_sco_dept
from app.models import (
ModuleImpl,
Module,
Scolog,
FormSemestre,
FormSemestreInscription,
ScoDocModel,
)
from app import db, log
from app.models import ModuleImpl, Scolog, FormSemestre, FormSemestreInscription
from app.models.etudiants import Identite
from app.auth.models import User
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,
EtatJustificatif,
localize_datetime,
is_assiduites_module_forced,
NonWorkDays,
)
from flask_sqlalchemy.query import Query
class Assiduite(ScoDocModel):
class Assiduite(db.Model):
"""
Représente une assiduité:
- une plage horaire lié à un état et un étudiant
@ -90,14 +77,10 @@ class Assiduite(ScoDocModel):
lazy="select",
)
# Argument "restrict" obligatoire car on override la fonction "to_dict" de ScoDocModel
# pylint: disable-next=unused-argument
def to_dict(self, format_api=True, restrict: bool | None = None) -> dict:
"""Retourne la représentation json de l'assiduité
restrict n'est pas utilisé ici.
"""
def to_dict(self, format_api=True) -> dict:
"""Retourne la représentation json de l'assiduité"""
etat = self.etat
user: User | None = None
user: User = None
if format_api:
# format api utilise les noms "present,absent,retard" au lieu des int
etat = EtatAssiduite.inverse().get(self.etat).name
@ -115,9 +98,9 @@ class Assiduite(ScoDocModel):
"entry_date": self.entry_date,
"user_id": None if user is None else user.id, # l'uid
"user_name": None if user is None else user.user_name, # le login
"user_nom_complet": (
None if user is None else user.get_nomcomplet()
), # "Marie Dupont"
"user_nom_complet": None
if user is None
else user.get_nomcomplet(), # "Marie Dupont"
"est_just": self.est_just,
"external_data": self.external_data,
}
@ -152,56 +135,16 @@ class Assiduite(ScoDocModel):
external_data: dict = None,
notify_mail=False,
) -> "Assiduite":
"""Créer une nouvelle assiduité pour l'étudiant.
Les datetime doivent être en timezone serveur.
Raises ScoValueError en cas de conflit ou erreur.
"""
"""Créer une nouvelle assiduité pour l'étudiant"""
if date_debut.tzinfo is None:
log(
f"Warning: create_assiduite: date_debut without timezone ({date_debut})"
)
if date_fin.tzinfo is None:
log(f"Warning: create_assiduite: date_fin without timezone ({date_fin})")
# Vérification jours non travaillés
# -> vérifie si la date de début ou la date de fin est sur un jour non travaillé
# On récupère les formsemestres des dates de début et de fin
formsemestre_date_debut: FormSemestre = get_formsemestre_from_data(
{
"etudid": etud.id,
"date_debut": date_debut,
"date_fin": date_debut,
}
)
formsemestre_date_fin: FormSemestre = get_formsemestre_from_data(
{
"etudid": etud.id,
"date_debut": date_fin,
"date_fin": date_fin,
}
)
if date_debut.weekday() in NonWorkDays.get_all_non_work_days(
formsemestre_id=formsemestre_date_debut
):
raise ScoValueError("La date de début n'est pas un jour travaillé")
if date_fin.weekday() in NonWorkDays.get_all_non_work_days(
formsemestre_id=formsemestre_date_fin
):
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):
log(
f"""create_assiduite: period_conflicting etudid={etud.id} date_debut={
date_debut} date_fin={date_fin}"""
)
raise ScoValueError(
"Duplication: la période rentre en conflit avec une plage enregistrée"
)
@ -251,8 +194,7 @@ class Assiduite(ScoDocModel):
user_id=user_id,
)
db.session.add(nouv_assiduite)
db.session.flush()
log(f"create_assiduite: {etud.id} id={nouv_assiduite.id} {nouv_assiduite}")
log(f"create_assiduite: {etud.id} {nouv_assiduite}")
Scolog.logdb(
method="create_assiduite",
etudid=etud.id,
@ -262,166 +204,8 @@ class Assiduite(ScoDocModel):
sco_abs_notification.abs_notify(etud.id, nouv_assiduite.date_debut)
return nouv_assiduite
def set_moduleimpl(self, moduleimpl_id: int | str):
"""Mise à jour du moduleimpl_id
Les valeurs du champ "moduleimpl_id" possibles sont :
- <int> (un id classique)
- <str> ("autre" ou "<id>")
- "" (pas de moduleimpl_id)
Si la valeur est "autre" il faut:
- mettre à None assiduité.moduleimpl_id
- mettre à jour assiduite.external_data["module"] = "autre"
En fonction de la configuration du semestre (option force_module) la valeur "" peut-être
considérée comme invalide.
- Il faudra donc vérifier que ce n'est pas le cas avant de mettre à jour l'assiduité
"""
moduleimpl: ModuleImpl = None
if moduleimpl_id == "autre":
# Configuration de external_data pour Module Autre
# Si self.external_data None alors on créé un dictionnaire {"module": "autre"}
# Sinon on met à jour external_data["module"] à "autre"
if self.external_data is None:
self.external_data = {"module": "autre"}
else:
self.external_data["module"] = "autre"
# Dans tous les cas une fois fait, assiduite.moduleimpl_id doit être None
self.moduleimpl_id = None
# Ici pas de vérification du force module car on l'a mis dans "external_data"
return
if moduleimpl_id != "":
try:
moduleimpl_id = int(moduleimpl_id)
except ValueError as exc:
raise ScoValueError("Module non reconnu") from exc
moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
# ici moduleimpl est None si non spécifié
# Vérification ModuleImpl not None (raise ScoValueError)
if moduleimpl is None:
self._check_force_module()
# Ici uniquement si on est autorisé à ne pas avoir de module
self.moduleimpl_id = None
return
# Vérification Inscription ModuleImpl (raise ScoValueError)
if moduleimpl.est_inscrit(self.etudiant):
self.moduleimpl_id = moduleimpl.id
else:
raise ScoValueError("L'étudiant n'est pas inscrit au module")
def supprime(self):
"Supprime l'assiduité. Log et commit."
# Obligatoire car import circulaire sinon
# pylint: disable-next=import-outside-toplevel
from app.scodoc import sco_assiduites as scass
if g.scodoc_dept is None and self.etudiant.dept_id is not None:
# route sans département
set_sco_dept(self.etudiant.departement.acronym)
obj_dict: dict = self.to_dict()
# Suppression de l'objet et LOG
log(f"delete_assidutite: {self.etudiant.id} {self}")
Scolog.logdb(
method="delete_assiduite",
etudid=self.etudiant.id,
msg=f"Assiduité: {self}",
)
db.session.delete(self)
db.session.commit()
# Invalidation du cache
scass.simple_invalidate_cache(obj_dict)
def get_formsemestre(self) -> FormSemestre:
"""Le formsemestre associé.
Attention: en cas d'inscription multiple prend arbitrairement l'un des semestres.
A utiliser avec précaution !
"""
return get_formsemestre_from_data(self.to_dict())
def get_module(self, traduire: bool = False) -> Module | str:
"""
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
"""
if self.moduleimpl_id is not None:
modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id)
mod: Module = Module.query.get(modimpl.module_id)
if traduire:
return f"{mod.code} {mod.titre}"
return mod
if self.external_data is not None and "module" in self.external_data:
return (
"Autre module (pas dans la liste)"
if self.external_data["module"] == "Autre"
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
def get_saisie(self) -> str:
"""
retourne le texte "saisie le <date> par <User>"
"""
date: str = self.entry_date.strftime(scu.DATEATIME_FMT)
utilisateur: str = ""
if self.user is not None:
self.user: User
utilisateur = f"par {self.user.get_prenomnom()}"
return f"saisie le {date} {utilisateur}"
def _check_force_module(self):
"""Vérification si module forcé:
Si le module est requis, raise ScoValueError
sinon ne fait rien.
"""
# cherche le formsemestre affecté pour utiliser ses préférences
formsemestre: FormSemestre = get_formsemestre_from_data(
{
"etudid": self.etudid,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
}
)
formsemestre_id = formsemestre.id if formsemestre else None
# si pas de formsemestre, utilisera les prefs globales du département
dept_id = self.etudiant.dept_id
force = is_assiduites_module_forced(
formsemestre_id=formsemestre_id, dept_id=dept_id
)
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):
class Justificatif(db.Model):
"""
Représente un justificatif:
- une plage horaire lié à un état et un étudiant
@ -453,8 +237,6 @@ class Justificatif(ScoDocModel):
)
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
"date de création de l'élément: date de saisie"
# pourrait devenir date de dépôt au secrétariat, si différente
user_id = db.Column(
db.Integer,
@ -473,35 +255,23 @@ class Justificatif(ScoDocModel):
etudiant = db.relationship(
"Identite", back_populates="justificatifs", lazy="joined"
)
# En revanche, user est rarement accédé:
user = db.relationship(
"User",
backref=db.backref(
"justificatifs", lazy="select", order_by="Justificatif.entry_date"
),
lazy="select",
)
external_data = db.Column(db.JSON, nullable=True)
@classmethod
def get_justificatif(cls, justif_id: int) -> "Justificatif":
"""Justificatif ou 404, cherche uniquement dans le département courant"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
return query.first_or_404()
def to_dict(self, format_api: bool = False, restrict: bool = False) -> dict:
"""L'objet en dictionnaire sérialisable.
Si restrict, ne donne par la raison et les fichiers et external_data
"""
def to_dict(self, format_api: bool = False) -> dict:
"""transformation de l'objet en dictionnaire sérialisable"""
etat = self.etat
user: User = self.user if self.user_id is not None else None
username = self.user_id
if format_api:
etat = EtatJustificatif.inverse().get(self.etat).name
if self.user_id is not None:
user: User = db.session.get(User, self.user_id)
if user is None:
username = "Non renseigné"
else:
username = user.get_prenomnom()
data = {
"justif_id": self.justif_id,
@ -510,49 +280,30 @@ class Justificatif(ScoDocModel):
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"raison": None if restrict else self.raison,
"fichier": None if restrict else self.fichier,
"raison": self.raison,
"fichier": self.fichier,
"entry_date": self.entry_date,
"user_id": None if user is None else user.id, # l'uid
"user_name": None if user is None else user.user_name, # le login
"user_nom_complet": None if user is None else user.get_nomcomplet(),
"external_data": None if restrict else self.external_data,
"user_id": username,
"external_data": self.external_data,
}
return data
def __repr__(self) -> str:
def __str__(self) -> str:
"chaine pour journaux et debug (lisible par humain français)"
try:
etat_str = EtatJustificatif(self.etat).name
except ValueError:
etat_str = "Invalide"
return f"""Justificatif id={self.id} {etat_str} de {
return f"""Justificatif {etat_str} de {
self.date_debut.strftime("%d/%m/%Y %Hh%M")
} à {
self.date_fin.strftime("%d/%m/%Y %Hh%M")
}"""
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields. Called by ScoDocModel's create_from_dict, edit and from_dict
Raises ScoValueError si paramètres incorrects.
"""
if not isinstance(args["date_debut"], datetime) or not isinstance(
args["date_fin"], datetime
):
raise ScoValueError("type date incorrect")
if args["date_fin"] <= args["date_debut"]:
raise ScoValueError("dates incompatibles")
if args["entry_date"] and not isinstance(args["entry_date"], datetime):
raise ScoValueError("type entry_date incorrect")
return args
@classmethod
def create_justificatif(
cls,
etudiant: Identite,
# On a besoin des arguments mais on utilise "locals" pour les récupérer
# pylint: disable=unused-argument
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
@ -561,163 +312,38 @@ class Justificatif(ScoDocModel):
user_id: int = None,
external_data: dict = None,
) -> "Justificatif":
"""Créer un nouveau justificatif pour l'étudiant.
Raises ScoValueError si paramètres incorrects.
"""
nouv_justificatif = cls.create_from_dict(locals())
log(f"create_justificatif: etudid={etudiant.id} {nouv_justificatif}")
"""Créer un nouveau justificatif pour l'étudiant"""
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
raison=raison,
entry_date=entry_date,
user_id=user_id,
external_data=external_data,
)
db.session.add(nouv_justificatif)
log(f"create_justificatif: {etud.id} {nouv_justificatif}")
Scolog.logdb(
method="create_justificatif",
etudid=etudiant.id,
etudid=etud.id,
msg=f"justificatif: {nouv_justificatif}",
)
db.session.commit()
return nouv_justificatif
def supprime(self):
"Supprime le justificatif. Log et commit."
# Obligatoire car import circulaire sinon
# pylint: disable-next=import-outside-toplevel
from app.scodoc import sco_assiduites as scass
# Récupération de l'archive du justificatif
archive_name: str = self.fichier
if archive_name is not None:
# Si elle existe : on essaye de la supprimer
archiver: JustificatifArchiver = JustificatifArchiver()
try:
archiver.delete_justificatif(self.etudiant, archive_name)
except ValueError:
pass
if g.scodoc_dept is None and self.etudiant.dept_id is not None:
# route sans département
set_sco_dept(self.etudiant.departement.acronym)
# On invalide le cache
scass.simple_invalidate_cache(self.to_dict())
# Suppression de l'objet et LOG
log(f"delete_justificatif: {self.etudiant.id} {self}")
Scolog.logdb(
method="delete_justificatif",
etudid=self.etudiant.id,
msg=f"Justificatif: {self}",
)
db.session.delete(self)
db.session.commit()
# On actualise les assiduités justifiées de l'étudiant concerné
self.dejustifier_assiduites()
def get_fichiers(self) -> tuple[list[str], int]:
"""Renvoie la liste des noms de fichiers justicatifs
accessibles par l'utilisateur courant et le nombre total
de fichiers.
(ces fichiers sont dans l'archive associée)
"""
if self.fichier is None:
return [], 0
archive_name: str = self.fichier
archiver: JustificatifArchiver = JustificatifArchiver()
filenames = archiver.list_justificatifs(archive_name, self.etudiant)
accessible_filenames = []
#
for filename in filenames:
if int(filename[1]) == current_user.id or current_user.has_permission(
Permission.AbsJustifView
):
accessible_filenames.append(filename[0])
return accessible_filenames, len(filenames)
def justifier_assiduites(
self,
) -> list[int]:
"""Justifie les assiduités sur la période de validité du justificatif"""
log(f"justifier_assiduites: {self}")
assiduites_justifiees: list[int] = []
if self.etat != EtatJustificatif.VALIDE:
return []
# On récupère les assiduités de l'étudiant sur la période donnée
assiduites: Query = self.etudiant.assiduites.filter(
Assiduite.date_debut >= self.date_debut,
Assiduite.date_fin <= self.date_fin,
Assiduite.etat != EtatAssiduite.PRESENT,
)
# Pour chaque assiduité, on la justifie
for assi in assiduites:
assi.est_just = True
assiduites_justifiees.append(assi.assiduite_id)
db.session.add(assi)
db.session.commit()
return assiduites_justifiees
def dejustifier_assiduites(self) -> list[int]:
"""
Déjustifie les assiduités sur la période du justificatif
"""
assiduites_dejustifiees: list[int] = []
# On récupère les assiduités de l'étudiant sur la période donnée
assiduites: Query = self.etudiant.assiduites.filter(
Assiduite.date_debut >= self.date_debut,
Assiduite.date_fin <= self.date_fin,
Assiduite.etat != EtatAssiduite.PRESENT,
)
assi: Assiduite
for assi in assiduites:
# On récupère les justificatifs qui justifient l'assiduité `assi`
assi_justifs: list[int] = get_justifs_from_date(
self.etudiant.etudid,
assi.date_debut,
assi.date_fin,
long=False,
valid=True,
)
# Si il n'y a pas d'autre justificatif valide, on déjustifie l'assiduité
if len(assi_justifs) == 0 or (
len(assi_justifs) == 1 and assi_justifs[0] == self.justif_id
):
assi.est_just = False
assiduites_dejustifiees.append(assi.assiduite_id)
db.session.add(assi)
db.session.commit()
return assiduites_dejustifiees
def get_assiduites(self) -> Query:
"""
get_assiduites Récupère les assiduités qui sont concernées par le justificatif
(Concernée Justifiée, mais qui sont sur la même période)
Ne prends pas en compte les Présences
Returns:
Query: Les assiduités concernées
"""
assiduites_query = Assiduite.query.filter(
Assiduite.etudid == self.etudid,
Assiduite.date_debut >= self.date_debut,
Assiduite.date_fin <= self.date_fin,
Assiduite.etat != EtatAssiduite.PRESENT,
)
return assiduites_query
def is_period_conflicting(
date_debut: datetime,
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,14 +351,74 @@ 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
def compute_assiduites_justified(
etudid: int, justificatifs: list[Justificatif] = None, reset: bool = False
) -> list[int]:
"""
compute_assiduites_justified_faster
Args:
etudid (int): l'identifiant de l'étudiant
justificatifs (list[Justificatif]): La liste des justificatifs qui seront utilisés
reset (bool, optional): remet à false les assiduites non justifiés. Defaults to False.
Returns:
list[int]: la liste des assiduités qui ont été justifiées.
"""
# Si on ne donne pas de justificatifs on prendra par défaut tous les justificatifs de l'étudiant
if justificatifs is None:
justificatifs: list[Justificatif] = Justificatif.query.filter_by(
etudid=etudid
).all()
# On ne prend que les justificatifs valides
justificatifs = [j for j in justificatifs if j.etat == EtatJustificatif.VALIDE]
# On récupère les assiduités de l'étudiant
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
assiduites_justifiees: list[int] = []
for assi in assiduites:
# On ne justifie pas les Présences
if assi.etat == EtatAssiduite.PRESENT:
continue
# On récupère les justificatifs qui justifient l'assiduité `assi`
assi_justificatifs = Justificatif.query.filter(
Justificatif.etudid == assi.etudid,
Justificatif.date_debut <= assi.date_debut,
Justificatif.date_fin >= assi.date_fin,
Justificatif.etat == EtatJustificatif.VALIDE,
).all()
# Si au moins un justificatif possède une période qui couvre l'assiduité
if any(
assi.date_debut >= j.date_debut and assi.date_fin <= j.date_fin
for j in justificatifs + assi_justificatifs
):
# On justifie l'assiduité
# On ajoute l'id de l'assiduité à la liste des assiduités justifiées
assi.est_just = True
assiduites_justifiees.append(assi.assiduite_id)
db.session.add(assi)
elif reset:
# Si le paramètre reset est Vrai alors les assiduités non justifiées
# sont remise en "non justifiée"
assi.est_just = False
db.session.add(assi)
# On valide la session
db.session.commit()
# On renvoie la liste des assiduite_id des assiduités justifiées
return assiduites_justifiees
def get_assiduites_justif(assiduite_id: int, long: bool) -> list[int | dict]:
"""
get_assiduites_justif Récupération des justificatifs d'une assiduité
@ -743,8 +429,7 @@ def get_assiduites_justif(assiduite_id: int, long: bool) -> list[int | dict]:
des identifiants des justificatifs
Returns:
list[int | dict]: La liste des justificatifs (par défaut uniquement
les identifiants, sinon les dict si long est vrai)
list[int | dict]: La liste des justificatifs (par défaut uniquement les identifiants, sinon les Dict si long est vrai)
"""
assi: Assiduite = Assiduite.query.get_or_404(assiduite_id)
return get_justifs_from_date(assi.etudid, assi.date_debut, assi.date_fin, long)
@ -774,8 +459,7 @@ def get_justifs_from_date(
Defaults to False.
Returns:
list[int | dict]: La liste des justificatifs (par défaut uniquement
les identifiants, sinon les dict si long est vrai)
list[int | dict]: La liste des justificatifs (par défaut uniquement les identifiants, sinon les Dict si long est vrai)
"""
# On récupère les justificatifs d'un étudiant couvrant la période donnée
justifs: Query = Justificatif.query.filter(
@ -788,20 +472,16 @@ def get_justifs_from_date(
if valid:
justifs = justifs.filter(Justificatif.etat == EtatJustificatif.VALIDE)
# On renvoie la liste des id des justificatifs si long est Faux,
# sinon on renvoie les dicts des justificatifs
if long:
return [j.to_dict(True) for j in justifs]
return [j.justif_id for j in justifs]
# On renvoie la liste des id des justificatifs si long est Faux, sinon on renvoie les dicts des justificatifs
return [j.justif_id if not long else j.to_dict(True) for j in justifs]
def get_formsemestre_from_data(data: dict[str, datetime | int]) -> FormSemestre:
"""
get_formsemestre_from_data récupère un formsemestre en fonction des données passées
Si l'étudiant est inscrit à plusieurs formsemestre, prend le premier.
Args:
data (dict[str, datetime | int]): Une représentation simplifiée d'une
assiduité ou d'un justificatif
data (dict[str, datetime | int]): Une réprésentation simplifiée d'une assiduité ou d'un justificatif
data = {
"etudid" : int,
@ -824,29 +504,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

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 models : Référentiel Compétence BUT 2021
@ -8,19 +8,16 @@
from datetime import datetime
import functools
from operator import attrgetter
import yaml
from flask import g
from flask_sqlalchemy.query import Query
from sqlalchemy.orm import class_mapper
import sqlalchemy
from app import db, log
from app import db
from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
REFCOMP_EQUIVALENCE_FILENAME = "ressources/referentiels/equivalences.yaml"
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
@ -107,11 +104,6 @@ class ApcReferentielCompetences(db.Model, XMLModel):
def __repr__(self):
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
def get_title(self) -> str:
"Titre affichable"
# utilise type_titre (B.U.T.), spécialité, version
return f"{self.type_titre} {self.specialite} {self.get_version()}"
def get_version(self) -> str:
"La version, normalement sous forme de date iso yyy-mm-dd"
if not self.version_orebut:
@ -132,11 +124,9 @@ class ApcReferentielCompetences(db.Model, XMLModel):
"type_departement": self.type_departement,
"type_titre": self.type_titre,
"version_orebut": self.version_orebut,
"scodoc_date_loaded": (
self.scodoc_date_loaded.isoformat() + "Z"
if self.scodoc_date_loaded
else ""
),
"scodoc_date_loaded": self.scodoc_date_loaded.isoformat() + "Z"
if self.scodoc_date_loaded
else "",
"scodoc_orig_filename": self.scodoc_orig_filename,
"competences": {
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
@ -244,100 +234,6 @@ class ApcReferentielCompetences(db.Model, XMLModel):
return parcours_info
def equivalents(self) -> set["ApcReferentielCompetences"]:
"""Ensemble des référentiels du même département
qui peuvent être considérés comme "équivalents", au sens
une formation de ce référentiel pourrait changer vers un équivalent,
en ignorant les apprentissages critiques.
Pour cela, il faut avoir le même type, etc et les mêmes compétences,
niveaux et parcours (voir map_to_other_referentiel).
"""
candidats = ApcReferentielCompetences.query.filter_by(
dept_id=self.dept_id
).filter(ApcReferentielCompetences.id != self.id)
return {
referentiel
for referentiel in candidats
if not isinstance(self.map_to_other_referentiel(referentiel), str)
}
def map_to_other_referentiel(
self, other: "ApcReferentielCompetences"
) -> str | tuple[dict[int, int], dict[int, int], dict[int, int]]:
"""Build mapping between this referentiel and ref2.
If successful, returns 3 dicts mapping self ids to other ids.
Else return a string, error message.
"""
if self.type_structure != other.type_structure:
return "type_structure mismatch"
if self.type_departement != other.type_departement:
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}
parcours_by_code_2 = {
eq_parcours.get(p.code, p.code): p for p in other.parcours
}
if parcours_by_code_1.keys() != parcours_by_code_2.keys():
return "parcours mismatch"
parcours_map = {
parcours_by_code_1[eq_parcours.get(code, code)]
.id: parcours_by_code_2[eq_parcours.get(code, code)]
.id
for code in parcours_by_code_1
}
# mêmes compétences ?
competence_by_code_1 = {c.titre: c for c in self.competences}
competence_by_code_2 = {c.titre: c for c in other.competences}
if competence_by_code_1.keys() != competence_by_code_2.keys():
return "competences mismatch"
competences_map = {
competence_by_code_1[titre].id: competence_by_code_2[titre].id
for titre in competence_by_code_1
}
# mêmes niveaux (dans chaque compétence) ?
niveaux_map = {}
for titre in competence_by_code_1:
c1 = competence_by_code_1[titre]
c2 = competence_by_code_2[titre]
niveau_by_attr_1 = {(n.annee, n.ordre, n.libelle): n for n in c1.niveaux}
niveau_by_attr_2 = {(n.annee, n.ordre, n.libelle): n for n in c2.niveaux}
if niveau_by_attr_1.keys() != niveau_by_attr_2.keys():
return f"niveaux mismatch in comp. '{titre}'"
niveaux_map.update(
{
niveau_by_attr_1[a].id: niveau_by_attr_2[a].id
for a in niveau_by_attr_1
}
)
return parcours_map, competences_map, niveaux_map
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:
doc = yaml.safe_load(f.read())
except FileNotFoundError:
log(f"_load_config_equivalences: {REFCOMP_EQUIVALENCE_FILENAME} not found")
return {}
except yaml.parser.ParserError as exc:
raise ScoValueError(
f"erreur dans le fichier {REFCOMP_EQUIVALENCE_FILENAME}"
) from exc
return doc.get(self.specialite, {})
class ApcCompetence(db.Model, XMLModel):
"Compétence"
@ -478,11 +374,9 @@ class ApcNiveau(db.Model, XMLModel):
"libelle": self.libelle,
"annee": self.annee,
"ordre": self.ordre,
"app_critiques": (
{x.code: x.to_dict() for x in self.app_critiques}
if with_app_critiques
else {}
),
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
if with_app_critiques
else {},
}
def to_dict_bul(self):
@ -570,9 +464,9 @@ class ApcNiveau(db.Model, XMLModel):
return []
if competence is None:
parcour_niveaux: list[ApcParcoursNiveauCompetence] = (
annee_parcour.niveaux_competences
)
parcour_niveaux: list[
ApcParcoursNiveauCompetence
] = annee_parcour.niveaux_competences
niveaux: list[ApcNiveau] = [
pn.competence.niveaux.filter_by(ordre=pn.niveau).first()
for pn in parcour_niveaux
@ -604,7 +498,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,21 +2,17 @@
"""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.formations import Formation
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
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,33 +57,30 @@ 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}>"""
def __str__(self):
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
self.code} enregistrée le {self.date.strftime(scu.DATE_FMT)}"""
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
def html(self) -> str:
"description en HTML"
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
<b>{self.code}</b>
<em>enregistrée le {self.date.strftime(scu.DATEATIME_FMT)}</em>"""
<em>enregistrée le {self.date.strftime("%d/%m/%Y")}
à {self.date.strftime("%Hh%M")}</em>"""
def annee(self) -> str:
"""l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """
niveau = self.niveau()
return niveau.annee if niveau else None
def niveau(self) -> ApcNiveau | None:
def niveau(self) -> ApcNiveau:
"""Le niveau de compétence associé à cet RCUE."""
# Par convention, il est donné par la seconde UE
# à défaut (si l'UE a été désacciée entre temps), la première
# et à défaut, renvoie None
return self.ue2.niveau_competence or self.ue1.niveau_competence
return self.ue2.niveau_competence
def to_dict(self):
"as a dict"
@ -118,14 +111,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 +143,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}>"""
@ -177,7 +162,7 @@ class ApcValidationAnnee(ScoDocModel):
def html(self) -> str:
"Affichage html"
date_str = (
f"""le {self.date.strftime(scu.DATEATIME_FMT)}"""
f"""le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}"""
if self.date
else "(sans date)"
)
@ -215,9 +200,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"]
)
@ -226,127 +219,15 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
decisions["descr_decisions_rcue"] = ""
decisions["descr_decisions_niveaux"] = ""
# --- Année: prend la validation pour l'année scolaire et l'ordre de ce semestre
if sco_preferences.get_preference("bul_but_code_annuel", formsemestre.id):
annee_but = (formsemestre.semestre_id + 1) // 2
validation = ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
ordre=annee_but,
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
).first()
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
else:
decisions["decision_annee"] = None
annee_but = (formsemestre.semestre_id + 1) // 2
validation = ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
ordre=annee_but,
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
).first()
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
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

@ -92,11 +92,9 @@ class ScoDocSiteConfig(db.Model):
"INSTITUTION_CITY": str,
"DEFAULT_PDF_FOOTER_TEMPLATE": str,
"enable_entreprises": bool,
"disable_passerelle": bool, # remplace pref. bul_display_publication
"month_debut_annee_scolaire": int,
"month_debut_periode2": int,
"disable_bul_pdf": bool,
"user_require_email_institutionnel": bool,
# CAS
"cas_enable": bool,
"cas_server": str,
@ -233,52 +231,49 @@ class ScoDocSiteConfig(db.Model):
cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first()
return cfg is not None and cfg.value
@classmethod
def is_cas_forced(cls) -> bool:
"""True si CAS forcé"""
cfg = ScoDocSiteConfig.query.filter_by(name="cas_force").first()
return cfg is not None and cfg.value
@classmethod
def is_entreprises_enabled(cls) -> bool:
"""True si on doit activer le module entreprise"""
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
return cfg is not None and cfg.value
@classmethod
def is_passerelle_disabled(cls):
"""True si on doit cacher les fonctions passerelle ("oeil")."""
cfg = ScoDocSiteConfig.query.filter_by(name="disable_passerelle").first()
return cfg is not None and cfg.value
@classmethod
def is_user_require_email_institutionnel_enabled(cls) -> bool:
"""True si impose saisie email_institutionnel"""
cfg = ScoDocSiteConfig.query.filter_by(
name="user_require_email_institutionnel"
).first()
return cfg is not None and cfg.value
@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
@classmethod
def enable_entreprises(cls, enabled: bool = True) -> bool:
def enable_entreprises(cls, enabled=True) -> bool:
"""Active (ou déactive) le module entreprises. True si changement."""
return cls.set("enable_entreprises", "on" if enabled else "")
@classmethod
def disable_passerelle(cls, disabled: bool = True) -> bool:
"""Désactive (ou active) les fonctions liées à la présence d'une passerelle. True si changement."""
return cls.set("disable_passerelle", "on" if disabled else "")
if enabled != ScoDocSiteConfig.is_entreprises_enabled():
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
if cfg is None:
cfg = ScoDocSiteConfig(
name="enable_entreprises", value="on" if enabled else ""
)
else:
cfg.value = "on" if enabled else ""
db.session.add(cfg)
db.session.commit()
return True
return False
@classmethod
def disable_bul_pdf(cls, enabled=True) -> bool:
"""Interdit (ou autorise) les exports PDF. True si changement."""
return cls.set("disable_bul_pdf", "on" if enabled else "")
"""Interedit (ou autorise) les exports PDF. True si changement."""
if enabled != ScoDocSiteConfig.is_bul_pdf_disabled():
cfg = ScoDocSiteConfig.query.filter_by(name="disable_bul_pdf").first()
if cfg is None:
cfg = ScoDocSiteConfig(
name="disable_bul_pdf", value="on" if enabled else ""
)
else:
cfg.value = "on" if enabled else ""
db.session.add(cfg)
db.session.commit()
return True
return False
@classmethod
def get(cls, name: str, default: str = "") -> str:
@ -297,10 +292,9 @@ class ScoDocSiteConfig(db.Model):
if cfg is None:
cfg = ScoDocSiteConfig(name=name, value=value_str)
else:
cfg.value = value_str
cfg.value = str(value or "")
current_app.logger.info(
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}{
'...' if len(cfg.value)>32 else ''}'"""
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}...'"""
)
db.session.add(cfg)
db.session.commit()
@ -309,7 +303,7 @@ class ScoDocSiteConfig(db.Model):
@classmethod
def _get_int_field(cls, name: str, default=None) -> int:
"""Valeur d'un champ integer"""
"""Valeur d'un champs integer"""
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if (cfg is None) or cfg.value is None:
return default
@ -323,7 +317,7 @@ class ScoDocSiteConfig(db.Model):
default=None,
range_values: tuple = (),
) -> bool:
"""Set champ integer. True si changement."""
"""Set champs integer. True si changement."""
if value != cls._get_int_field(name, default=default):
if not isinstance(value, int) or (
range_values and (value < range_values[0]) or (value > range_values[1])

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

@ -15,15 +15,14 @@ from sqlalchemy import desc, text
from app import db, log
from app import models
from app.models.departements import Departement
from app.models.scolar_event import ScolarEvent
from app.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat
from app.scodoc.sco_exceptions import ScoGenError, ScoInvalidParamError, ScoValueError
from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
import app.scodoc.sco_utils as scu
class Identite(models.ScoDocModel):
class Identite(db.Model, models.ScoDocModel):
"""étudiant"""
__tablename__ = "identite"
@ -101,12 +100,7 @@ class Identite(models.ScoDocModel):
adresses = db.relationship(
"Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic"
)
annotations = db.relationship(
"EtudAnnotation",
backref="etudiant",
cascade="all, delete-orphan",
lazy="dynamic",
)
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
#
dispense_ues = db.relationship(
@ -124,9 +118,6 @@ class Identite(models.ScoDocModel):
"Justificatif", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
)
# Champs "protégés" par ViewEtudData (RGPD)
protected_attrs = {"boursier", "nationalite"}
def __repr__(self):
return (
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
@ -179,15 +170,9 @@ class Identite(models.ScoDocModel):
def html_link_fiche(self) -> str:
"lien vers la fiche"
return (
f"""<a class="etudlink" href="{self.url_fiche()}">{self.nom_prenom()}</a>"""
)
def url_fiche(self) -> str:
"url de la fiche étudiant"
return url_for(
"scolar.fiche_etud", scodoc_dept=self.departement.acronym, etudid=self.id
)
return f"""<a class="stdlink" href="{
url_for("scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id)
}">{self.nomprenom}</a>"""
@classmethod
def from_request(cls, etudid=None, code_nip=None) -> "Identite":
@ -199,28 +184,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":
@ -230,48 +200,19 @@ class Identite(models.ScoDocModel):
return cls.create_from_dict(args)
@classmethod
def create_from_dict(cls, args) -> "Identite":
def create_from_dict(cls, data) -> "Identite":
"""Crée un étudiant à partir d'un dict, avec admission et adresse vides.
If required dept_id or dept are not specified, set it to the current dept.
args: dict with args in application.
Les clés adresses et admission ne SONT PAS utilisées.
(added to session but not flushed nor commited)
"""
if not "dept_id" in args:
if "dept" in args:
departement = Departement.query.filter_by(acronym=args["dept"]).first()
if departement:
args["dept_id"] = departement.id
if not "dept_id" in args:
args["dept_id"] = g.scodoc_dept_id
etud: Identite = super().create_from_dict(args)
if args.get("admission_id", None) is None:
etud: Identite = super(cls, cls).create_from_dict(data)
if (data.get("admission_id", None) is None) and (
data.get("admission", None) is None
):
etud.admission = Admission()
etud.adresses.append(Adresse(typeadresse="domicile"))
db.session.flush()
event = ScolarEvent(etud=etud, event_type="CREATION")
db.session.add(event)
log(f"Identite.create {etud}")
return etud
def from_dict(self, args, **kwargs) -> bool:
"""Check arguments, then modify.
Add to session but don't commit.
True if modification.
"""
check_etud_duplicate_code(args, "code_nip")
check_etud_duplicate_code(args, "code_ine")
return super().from_dict(args, **kwargs)
@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."""
return super().filter_model_attributes(
data,
excluded=(excluded or set()) | {"adresses", "admission", "departement"},
)
@property
def civilite_str(self) -> str:
"""returns civilité usuelle: 'M.' ou 'Mme' ou '' (pour le genre neutre,
@ -314,24 +255,18 @@ class Identite(models.ScoDocModel):
else:
return self.nom
@property
@cached_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.
"""
nom = self.nom_usuel or self.nom
prenom = self.prenom_str
if reverse:
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}"
fields = (nom, prenom)
else:
fields = (self.civilite_str, prenom, nom)
return " ".join([x for x in fields if x])
@property
def prenom_str(self):
@ -347,10 +282,12 @@ class Identite(models.ScoDocModel):
@property
def etat_civil(self) -> str:
"M. PRÉNOM NOM, utilisant les données état civil si présentes, usuelles sinon."
return f"""{self.civilite_etat_civil_str} {
self.prenom_etat_civil or self.prenom or ''} {
self.nom or ''}""".strip()
"M. Prénom NOM, utilisant les données état civil si présentes, usuelles sinon."
if self.prenom_etat_civil:
civ = {"M": "M.", "F": "Mme", "X": ""}[self.civilite_etat_civil]
return f"{civ} {self.prenom_etat_civil} {self.nom}"
else:
return self.nomprenom
@property
def nom_short(self):
@ -358,81 +295,34 @@ class Identite(models.ScoDocModel):
return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}."
@cached_property
def sort_key(self) -> str:
def sort_key(self) -> tuple:
"clé pour tris par ordre alphabétique"
# Note: scodoc7 utilisait sco_etud.etud_sort_key, à mettre à jour
# si on modifie cette méthode.
return scu.sanitize_string(
(self.nom_usuel or self.nom or "") + ";" + (self.prenom or ""),
remove_spaces=False,
).lower()
return (
scu.sanitize_string(
self.nom_usuel or self.nom or "", remove_spaces=False
).lower(),
scu.sanitize_string(self.prenom or "", remove_spaces=False).lower(),
)
def get_first_email(self, field="email") -> str:
"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(
self, annee_scolaire: int
) -> dict[int, list["ModuleImpl"]]:
"""Pour chaque semestre de l'année indiquée dans lequel l'étudiant
est inscrit à des moduleimpls, liste ceux ci.
{ formsemestre_id : [ modimpl, ... ] }
annee_scolaire est un nombre: eg 2023
"""
date_debut_annee = scu.date_debut_annee_scolaire(annee_scolaire)
date_fin_annee = scu.date_fin_annee_scolaire(annee_scolaire)
modimpls = (
ModuleImpl.query.join(ModuleImplInscription)
.join(FormSemestre)
.filter(
(FormSemestre.date_debut <= date_fin_annee)
& (FormSemestre.date_fin >= date_debut_annee)
)
.join(Identite)
.filter_by(id=self.id)
)
# Tri, par semestre puis par module, suivant le type de formation:
formsemestres = sorted(
{m.formsemestre for m in modimpls}, key=lambda s: s.sort_key()
)
modimpls_by_formsemestre = {}
for formsemestre in formsemestres:
modimpls_sem = [m for m in modimpls if m.formsemestre_id == formsemestre.id]
if formsemestre.formation.is_apc():
modimpls_sem.sort(key=lambda m: m.module.sort_key_apc())
else:
modimpls_sem.sort(
key=lambda m: (m.module.ue.numero or 0, m.module.numero or 0)
)
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.
If required dept_id is not specified, set it to the current dept.
args: dict with args in application.
returns: dict to store in model's db.
"""
# Les champs qui sont toujours stockés en majuscules:
@ -451,6 +341,8 @@ class Identite(models.ScoDocModel):
"code_ine",
}
args_dict = {}
if not "dept_id" in args:
args["dept_id"] = g.scodoc_dept_id
for key, value in args.items():
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
# compat scodoc7 (mauvaise idée de l'époque)
@ -463,14 +355,14 @@ class Identite(models.ScoDocModel):
elif key == "civilite_etat_civil":
value = input_civilite_etat_civil(value)
elif key == "boursier":
value = scu.to_bool(value)
value = bool(value)
elif key == "date_naissance":
value = ndb.DateDMYtoISO(value)
args_dict[key] = value
return args_dict
def to_dict_short(self) -> dict:
"""Les champs essentiels (aucune donnée perso protégée)"""
"""Les champs essentiels"""
return {
"id": self.id,
"civilite": self.civilite,
@ -485,11 +377,9 @@ class Identite(models.ScoDocModel):
"prenom_etat_civil": self.prenom_etat_civil,
}
def to_dict_scodoc7(self, restrict=False, with_inscriptions=False) -> dict:
def to_dict_scodoc7(self) -> dict:
"""Représentation dictionnaire,
compatible ScoDoc7 mais sans infos admission.
Si restrict, cache les infos "personnelles" si pas permission ViewEtudData
Si with_inscriptions, inclut les champs "inscription"
compatible ScoDoc7 mais sans infos admission
"""
e_dict = self.__dict__.copy() # dict(self.__dict__)
e_dict.pop("_sa_instance_state", None)
@ -500,9 +390,7 @@ class Identite(models.ScoDocModel):
e_dict["nomprenom"] = self.nomprenom
adresse = self.adresses.first()
if adresse:
e_dict.update(adresse.to_dict(restrict=restrict))
if with_inscriptions:
e_dict.update(self.inscription_descr())
e_dict.update(adresse.to_dict())
return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty
def to_dict_bul(self, include_urls=True):
@ -517,11 +405,9 @@ class Identite(models.ScoDocModel):
"civilite": self.civilite,
"code_ine": self.code_ine or "",
"code_nip": self.code_nip or "",
"date_naissance": (
self.date_naissance.strftime(scu.DATE_FMT)
if self.date_naissance
else ""
),
"date_naissance": self.date_naissance.strftime("%d/%m/%Y")
if self.date_naissance
else "",
"dept_acronym": self.departement.acronym,
"dept_id": self.dept_id,
"dept_naissance": self.dept_naissance or "",
@ -539,7 +425,7 @@ class Identite(models.ScoDocModel):
if include_urls and has_request_context():
# test request context so we can use this func in tests under the flask shell
d["fiche_url"] = url_for(
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=self.id
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id
)
d["photo_url"] = sco_photos.get_etud_photo_url(self.id)
adresse = self.adresses.first()
@ -548,37 +434,22 @@ class Identite(models.ScoDocModel):
d["id"] = self.id # a été écrasé par l'id de adresse
return d
def to_dict_api(self, restrict=False, with_annotations=False) -> dict:
"""Représentation dictionnaire pour export API, avec adresses et admission.
Si restrict, supprime les infos "personnelles" (boursier)
"""
def to_dict_api(self) -> dict:
"""Représentation dictionnaire pour export API, avec adresses et admission."""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
admission = self.admission
e["admission"] = admission.to_dict() if admission is not None else None
e["adresses"] = [adr.to_dict(restrict=restrict) for adr in self.adresses]
e["adresses"] = [adr.to_dict() for adr in self.adresses]
e["dept_acronym"] = self.departement.acronym
e.pop("departement", None)
e["sort_key"] = self.sort_key
if with_annotations:
e["annotations"] = (
[
annot.to_dict()
for annot in EtudAnnotation.query.filter_by(
etudid=self.id
).order_by(desc(EtudAnnotation.date))
]
if not restrict
else []
)
if restrict:
# Met à None les attributs protégés:
for attr in self.protected_attrs:
e[attr] = None
return e
def inscriptions(self) -> list["FormSemestreInscription"]:
"Liste des inscriptions à des formsemestres, triée, la plus récente en tête"
from app.models.formsemestre import FormSemestre, FormSemestreInscription
return (
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
.filter(
@ -588,7 +459,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).
"""
@ -604,6 +475,8 @@ class Identite(models.ScoDocModel):
(il est rare qu'il y en ai plus d'une, mais c'est possible).
Triées par date de début de semestre décroissante (le plus récent en premier).
"""
from app.models.formsemestre import FormSemestre, FormSemestreInscription
return (
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
.filter(
@ -626,9 +499,7 @@ class Identite(models.ScoDocModel):
return r[0] if r else None
def inscription_descr(self) -> dict:
"""Description de l'état d'inscription
avec champs compatibles templates ScoDoc7
"""
"""Description de l'état d'inscription"""
inscription_courante = self.inscription_courante()
if inscription_courante:
titre_sem = inscription_courante.formsemestre.titre_mois()
@ -639,7 +510,7 @@ class Identite(models.ScoDocModel):
else:
inscr_txt = "Inscrit en"
result = {
return {
"etat_in_cursem": inscription_courante.etat,
"inscription_courante": inscription_courante,
"inscription": titre_sem,
@ -662,20 +533,15 @@ class Identite(models.ScoDocModel):
inscription = "ancien"
situation = "ancien élève"
else:
inscription = "non inscrit"
inscription = ("non inscrit",)
situation = inscription
result = {
return {
"etat_in_cursem": "?",
"inscription_courante": None,
"inscription": inscription,
"inscription_str": inscription,
"situation": situation,
}
# aliases pour compat templates ScoDoc7
result["etatincursem"] = result["etat_in_cursem"]
result["inscriptionstr"] = result["inscription_str"]
return result
def inscription_etat(self, formsemestre_id: int) -> str:
"""État de l'inscription de cet étudiant au semestre:
@ -772,7 +638,7 @@ class Identite(models.ScoDocModel):
"""
if with_paragraph:
return f"""{self.etat_civil}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
self.date_naissance.strftime(scu.DATE_FMT) if self.date_naissance else ""}{
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
line_sep}à {self.lieu_naissance or ""}"""
return self.etat_civil
@ -796,58 +662,6 @@ class Identite(models.ScoDocModel):
)
def check_etud_duplicate_code(args, code_name, edit=True):
"""Vérifie que le code n'est pas dupliqué.
Raises ScoGenError si problème.
"""
etudid = args.get("etudid", None)
if not args.get(code_name, None):
return
etuds = Identite.query.filter_by(
**{code_name: str(args[code_name]), "dept_id": g.scodoc_dept_id}
).all()
duplicate = False
if edit:
duplicate = (len(etuds) > 1) or ((len(etuds) == 1) and etuds[0].id != etudid)
else:
duplicate = len(etuds) > 0
if duplicate:
listh = [] # liste des doubles
for etud in etuds:
listh.append(f"Autre étudiant: {etud.html_link_fiche()}")
if etudid:
submit_label = "retour à la fiche étudiant"
dest_endpoint = "scolar.fiche_etud"
parameters = {"etudid": etudid}
else:
if "tf_submitted" in args:
del args["tf_submitted"]
submit_label = "Continuer"
dest_endpoint = "scolar.etudident_create_form"
parameters = args
else:
submit_label = "Annuler"
dest_endpoint = "notes.index_html"
parameters = {}
err_page = f"""<h3>Code étudiant ({code_name}) dupliqué !</h3>
<p class="help">Le {code_name} {args[code_name]} est déjà utilisé: un seul étudiant peut avoir
ce code. Vérifier votre valeur ou supprimer l'autre étudiant avec cette valeur.
</p>
<ul><li>
{ '</li><li>'.join(listh) }
</li></ul>
<p>
<a href="{ url_for(dest_endpoint, scodoc_dept=g.scodoc_dept, **parameters) }
">{submit_label}</a>
</p>
"""
log(f"*** error: code {code_name} duplique: {args[code_name]}")
raise ScoGenError(err_page, safe=True)
def make_etud_args(
etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True
) -> dict:
@ -928,7 +742,7 @@ def pivot_year(y) -> int:
return y
class Adresse(models.ScoDocModel):
class Adresse(db.Model, models.ScoDocModel):
"""Adresse d'un étudiant
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule)
"""
@ -955,29 +769,16 @@ class Adresse(models.ScoDocModel):
)
description = db.Column(db.Text)
# Champs "protégés" par ViewEtudData (RGPD)
protected_attrs = {
"emailperso",
"domicile",
"codepostaldomicile",
"villedomicile",
"telephone",
"telephonemobile",
"fax",
}
def to_dict(self, convert_nulls_to_str=False, restrict=False):
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
def to_dict(self, convert_nulls_to_str=False):
"""Représentation dictionnaire,"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
if convert_nulls_to_str:
e = {k: v or "" for k, v in e.items()}
if restrict:
e = {k: v for (k, v) in e.items() if k not in self.protected_attrs}
return {k: e[k] or "" for k in e}
return e
class Admission(models.ScoDocModel):
class Admission(db.Model, models.ScoDocModel):
"""Informations liées à l'admission d'un étudiant"""
__tablename__ = "admissions"
@ -1028,16 +829,12 @@ class Admission(models.ScoDocModel):
# classement (1..Ngr) par le jury dans le groupe APB
apb_classement_gr = db.Column(db.Integer)
# Tous les champs sont "protégés" par ViewEtudData (RGPD)
# sauf:
not_protected_attrs = {"bac", "specialite", "anne_bac"}
def get_bac(self) -> Baccalaureat:
"Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères."
return Baccalaureat(self.bac, specialite=self.specialite)
def to_dict(self, no_nulls=False, restrict=False):
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
def to_dict(self, no_nulls=False):
"""Représentation dictionnaire,"""
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
if no_nulls:
@ -1052,8 +849,6 @@ class Admission(models.ScoDocModel):
d[key] = 0
elif isinstance(col_type, sqlalchemy.Boolean):
d[key] = False
if restrict:
d = {k: v for (k, v) in d.items() if k in self.not_protected_attrs}
return d
@classmethod
@ -1080,9 +875,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 +888,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,25 +909,13 @@ itemsuivi_tags_assoc = db.Table(
)
class EtudAnnotation(models.ScoDocModel):
class EtudAnnotation(db.Model):
"""Annotation sur un étudiant"""
__tablename__ = "etud_annotations"
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
etudid = db.Column(db.Integer, db.ForeignKey(Identite.id))
etudid = db.Column(db.Integer) # sans contrainte (compat ScoDoc 7))
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__)
e.pop("_sa_instance_state", None)
return e
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription

View File

@ -5,15 +5,16 @@
import datetime
from operator import attrgetter
from flask import abort, g, url_for
from flask import g, url_for
from flask_login import current_user
import sqlalchemy as sa
from app import db, log
from app import models
from app.models.etudiants import Identite
from app.models.events import ScolarNews
from app.models.moduleimpls import ModuleImpl
from app.models.notes import NotesNotes
from app.models.ues import UniteEns
from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
@ -24,8 +25,10 @@ MAX_EVALUATION_DURATION = datetime.timedelta(days=365)
NOON = datetime.time(12, 00)
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
VALID_EVALUATION_TYPES = {0, 1, 2}
class Evaluation(models.ScoDocModel):
class Evaluation(db.Model):
"""Evaluation (contrôle, examen, ...)"""
__tablename__ = "notes_evaluation"
@ -37,9 +40,9 @@ class Evaluation(models.ScoDocModel):
)
date_debut = db.Column(db.DateTime(timezone=True), nullable=True)
date_fin = db.Column(db.DateTime(timezone=True), nullable=True)
description = db.Column(db.Text, nullable=False)
note_max = db.Column(db.Float, nullable=False)
coefficient = db.Column(db.Float, nullable=False)
description = db.Column(db.Text)
note_max = db.Column(db.Float)
coefficient = db.Column(db.Float)
visibulletin = db.Column(
db.Boolean, nullable=False, default=True, server_default="true"
)
@ -47,41 +50,15 @@ class Evaluation(models.ScoDocModel):
publish_incomplete = db.Column(
db.Boolean, nullable=False, default=False, server_default="false"
)
"prise en compte immédiate"
# type d'evaluation: 0 normale, 1 rattrapage, 2 "2eme session"
evaluation_type = db.Column(
db.Integer, nullable=False, default=0, server_default="0"
)
"type d'evaluation: 0 normale, 1 rattrapage, 2 2eme session, 3 bonus"
blocked_until = db.Column(db.DateTime(timezone=True), nullable=True)
"date de prise en compte"
BLOCKED_FOREVER = datetime.datetime(2666, 12, 31, tzinfo=scu.TIME_ZONE)
# ordre de presentation (par défaut, le plus petit numero
# est la plus ancienne eval):
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
EVALUATION_BONUS = 3
VALID_EVALUATION_TYPES = {
EVALUATION_NORMALE,
EVALUATION_RATTRAPAGE,
EVALUATION_SESSION2,
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 ''} "{
@ -90,22 +67,20 @@ class Evaluation(models.ScoDocModel):
@classmethod
def create(
cls,
moduleimpl: "ModuleImpl" = None,
moduleimpl: ModuleImpl = None,
date_debut: datetime.datetime = None,
date_fin: datetime.datetime = None,
description=None,
note_max=None,
blocked_until=None,
coefficient=None,
visibulletin=None,
publish_incomplete=None,
evaluation_type=None,
numero=None,
**kw, # ceci pour absorber les éventuel arguments excedentaires
) -> "Evaluation":
):
"""Create an evaluation. Check permission and all arguments.
Ne crée pas les poids vers les UEs.
Add to session, do not commit.
"""
if not moduleimpl.can_edit_evaluation(current_user):
raise AccessDenied(
@ -114,15 +89,13 @@ class Evaluation(models.ScoDocModel):
args = locals()
del args["cls"]
del args["kw"]
check_and_convert_evaluation_args(args, moduleimpl)
check_convert_evaluation_args(moduleimpl, args)
# Check numeros
Evaluation.moduleimpl_evaluation_renumber(moduleimpl, only_if_unumbered=True)
if not "numero" in args or args["numero"] is None:
args["numero"] = cls.get_new_numero(moduleimpl, args["date_debut"])
#
evaluation = Evaluation(**args)
db.session.add(evaluation)
db.session.flush()
sco_cache.invalidate_formsemestre(formsemestre_id=moduleimpl.formsemestre_id)
url = url_for(
"notes.moduleimpl_status",
@ -141,7 +114,7 @@ class Evaluation(models.ScoDocModel):
@classmethod
def get_new_numero(
cls, moduleimpl: "ModuleImpl", date_debut: datetime.datetime
cls, moduleimpl: ModuleImpl, date_debut: datetime.datetime
) -> int:
"""Get a new numero for an evaluation in this moduleimpl
If necessary, renumber existing evals to make room for a new one.
@ -172,7 +145,7 @@ class Evaluation(models.ScoDocModel):
"delete evaluation (commit) (check permission)"
from app.scodoc import sco_evaluation_db
modimpl: "ModuleImpl" = self.moduleimpl
modimpl: ModuleImpl = self.moduleimpl
if not modimpl.can_edit_evaluation(current_user):
raise AccessDenied(
f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
@ -213,24 +186,18 @@ class Evaluation(models.ScoDocModel):
# ScoDoc7 output_formators
e_dict["evaluation_id"] = self.id
e_dict["date_debut"] = self.date_debut.isoformat() if self.date_debut else None
e_dict["date_fin"] = self.date_fin.isoformat() if self.date_fin else None
e_dict["date_fin"] = self.date_debut.isoformat() if self.date_fin else None
e_dict["numero"] = self.numero or 0
e_dict["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
# Deprecated
e_dict["jour"] = (
self.date_debut.strftime(scu.DATE_FMT) if self.date_debut else ""
)
e_dict["jour"] = self.date_debut.strftime("%d/%m/%Y") if self.date_debut else ""
return evaluation_enrich_dict(self, e_dict)
def to_dict_api(self) -> dict:
"Représentation dict pour API JSON"
return {
"blocked": self.is_blocked(),
"blocked_until": (
self.blocked_until.isoformat() if self.blocked_until else ""
),
"coefficient": self.coefficient,
"date_debut": self.date_debut.isoformat() if self.date_debut else "",
"date_fin": self.date_fin.isoformat() if self.date_fin else "",
@ -245,9 +212,9 @@ class Evaluation(models.ScoDocModel):
"visibulletin": self.visibulletin,
# Deprecated (supprimer avant #sco9.7)
"date": self.date_debut.date().isoformat() if self.date_debut else "",
"heure_debut": (
self.date_debut.time().isoformat() if self.date_debut else ""
),
"heure_debut": self.date_debut.time().isoformat()
if self.date_debut
else "",
"heure_fin": self.date_fin.time().isoformat() if self.date_fin else "",
}
@ -267,28 +234,14 @@ class Evaluation(models.ScoDocModel):
return e_dict
@classmethod
def get_evaluation(
cls, evaluation_id: int | str, dept_id: int = None, accept_none=False
) -> "Evaluation":
"""Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant.
Si accept_none, return None si l'id est invalide ou n'existe pas.
"""
from app.models import FormSemestre
if not isinstance(evaluation_id, int):
try:
evaluation_id = int(evaluation_id)
except (TypeError, ValueError):
abort(404, "evaluation_id invalide")
if g.scodoc_dept:
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
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()
def from_dict(self, data):
"""Set evaluation attributes from given dict values."""
check_convert_evaluation_args(self.moduleimpl, data)
if data.get("numero") is None:
data["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1
for k in self.__dict__.keys():
if k != "_sa_instance_state" and k != "id" and k in data:
setattr(self, k, data[k])
@classmethod
def get_max_numero(cls, moduleimpl_id: int) -> int:
@ -304,7 +257,7 @@ class Evaluation(models.ScoDocModel):
@classmethod
def moduleimpl_evaluation_renumber(
cls, moduleimpl: "ModuleImpl", only_if_unumbered=False
cls, moduleimpl: ModuleImpl, only_if_unumbered=False
):
"""Renumber evaluations in this moduleimpl, according to their date. (numero=0: oldest one)
Needed because previous versions of ScoDoc did not have eval numeros
@ -314,9 +267,7 @@ class Evaluation(models.ScoDocModel):
evaluations = moduleimpl.evaluations.order_by(
Evaluation.date_debut, Evaluation.numero
).all()
numeros_distincts = {e.numero for e in evaluations if e.numero is not None}
# pas de None, pas de dupliqués
all_numbered = len(numeros_distincts) == len(evaluations)
all_numbered = all(e.numero is not None for e in evaluations)
if all_numbered and only_if_unumbered:
return # all ok
@ -327,15 +278,14 @@ class Evaluation(models.ScoDocModel):
db.session.add(e)
i += 1
db.session.commit()
sco_cache.invalidate_formsemestre(moduleimpl.formsemestre_id)
def descr_heure(self) -> str:
"Description de la plage horaire pour affichages ('de 13h00 à 14h00')"
if self.date_debut and (not self.date_fin or self.date_fin == self.date_debut):
return f"""à {self.date_debut.strftime(scu.TIME_FMT)}"""
return f"""à {self.date_debut.strftime("%Hh%M")}"""
elif self.date_debut and self.date_fin:
return f"""de {self.date_debut.strftime(scu.TIME_FMT)
} à {self.date_fin.strftime(scu.TIME_FMT)}"""
return f"""de {self.date_debut.strftime("%Hh%M")
} à {self.date_fin.strftime("%Hh%M")}"""
else:
return ""
@ -362,15 +312,13 @@ class Evaluation(models.ScoDocModel):
def _h(dt: datetime.datetime) -> str:
if dt.minute:
return dt.strftime(scu.TIME_FMT)
return dt.strftime("%Hh%M")
return f"{dt.hour}h"
if self.date_fin is None:
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)}"
)
@ -390,6 +338,19 @@ class Evaluation(models.ScoDocModel):
Chaine vide si non renseignée."""
return self.date_fin.time().isoformat("minutes") if self.date_fin else ""
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain commit
"""
d = dict(self.__dict__)
d.pop("id") # get rid of id
d.pop("_sa_instance_state") # get rid of SQLAlchemy special attr
for k in not_copying:
d.pop(k)
copy = self.__class__(**d)
db.session.add(copy)
return copy
def is_matin(self) -> bool:
"Evaluation commençant le matin (faux si pas de date)"
if not self.date_debut:
@ -402,14 +363,6 @@ class Evaluation(models.ScoDocModel):
return False
return self.date_debut.time() >= NOON
def is_blocked(self, now=None) -> bool:
"True si prise en compte bloquée"
if self.blocked_until is None:
return False
if now is None:
now = datetime.datetime.now(scu.TIME_ZONE)
return self.blocked_until > now
def set_default_poids(self) -> bool:
"""Initialize les poids vers les UE à leurs valeurs par défaut
C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
@ -434,28 +387,22 @@ 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
L = []
for ue_id, poids in ue_poids_dict.items():
ue = db.session.get(UniteEns, ue_id)
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:
@ -480,8 +427,8 @@ class Evaluation(models.ScoDocModel):
def get_ue_poids_str(self) -> str:
"""string describing poids, for excel cells and pdfs
Note: les poids nuls ou non initialisés (poids par défaut),
ne sont pas affichés.
Note: si les poids ne sont pas initialisés (poids par défaut),
ils ne sont pas affichés.
"""
# restreint aux UE du semestre dans lequel est cette évaluation
# au cas où le module ait changé de semestre et qu'il reste des poids
@ -492,7 +439,7 @@ class Evaluation(models.ScoDocModel):
for p in sorted(
self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme)
)
if evaluation_semestre_idx == p.ue.semestre_idx and (p.poids or 0) > 0
if evaluation_semestre_idx == p.ue.semestre_idx
]
)
@ -502,29 +449,6 @@ class Evaluation(models.ScoDocModel):
"""
return NotesNotes.query.filter_by(etudid=etud.id, evaluation_id=self.id).first()
@classmethod
def get_evaluations_blocked_for_etud(
cls, formsemestre, etud: Identite
) -> list["Evaluation"]:
"""Liste des évaluations de ce semestre avec note pour cet étudiant et date blocage
et date blocage < FOREVER.
Si non vide, une note apparaitra dans le futur pour cet étudiant: il faut
donc interdire la saisie du jury.
"""
now = datetime.datetime.now(scu.TIME_ZONE)
return (
Evaluation.query.filter(
Evaluation.blocked_until != None, # pylint: disable=C0121
Evaluation.blocked_until >= now,
)
.join(ModuleImpl)
.filter_by(formsemestre_id=formsemestre.id)
.join(ModuleImplInscription)
.filter_by(etudid=etud.id)
.join(NotesNotes)
.all()
)
class EvaluationUEPoids(db.Model):
"""Poids des évaluations (BUT)
@ -550,7 +474,7 @@ class EvaluationUEPoids(db.Model):
backref=db.backref("ue_poids", cascade="all, delete-orphan"),
)
ue = db.relationship(
"UniteEns",
UniteEns,
backref=db.backref("evaluation_ue_poids", cascade="all, delete-orphan"),
)
@ -562,8 +486,8 @@ class EvaluationUEPoids(db.Model):
def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
"""add or convert some fields in an evaluation dict"""
# For ScoDoc7 compat
e_dict["heure_debut"] = e.date_debut.strftime(scu.TIME_FMT) if e.date_debut else ""
e_dict["heure_fin"] = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else ""
e_dict["heure_debut"] = e.date_debut.strftime("%Hh%M") if e.date_debut else ""
e_dict["heure_fin"] = e.date_fin.strftime("%Hh%M") if e.date_fin else ""
e_dict["jour_iso"] = e.date_debut.isoformat() if e.date_debut else ""
# Calcule durée en minutes
e_dict["descrheure"] = e.descr_heure()
@ -582,7 +506,7 @@ def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
return e_dict
def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
def check_convert_evaluation_args(moduleimpl: ModuleImpl, data: dict):
"""Check coefficient, dates and duration, raises exception if invalid.
Convert date and time strings to date and time objects.
@ -597,7 +521,7 @@ def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
# --- evaluation_type
try:
data["evaluation_type"] = int(data.get("evaluation_type", 0) or 0)
if not data["evaluation_type"] in Evaluation.VALID_EVALUATION_TYPES:
if not data["evaluation_type"] in VALID_EVALUATION_TYPES:
raise ScoValueError("invalid evaluation_type value")
except ValueError as exc:
raise ScoValueError("invalid evaluation_type value") from exc
@ -622,7 +546,7 @@ def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
if coef < 0:
raise ScoValueError("invalid coefficient value (must be positive or null)")
data["coefficient"] = coef
# --- date de l'évaluation dans le semestre ?
# --- date de l'évaluation
formsemestre = moduleimpl.formsemestre
date_debut = data.get("date_debut", None)
if date_debut:
@ -637,7 +561,7 @@ def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
):
raise ScoValueError(
f"""La date de début de l'évaluation ({
data["date_debut"].strftime(scu.DATE_FMT)
data["date_debut"].strftime("%d/%m/%Y")
}) n'est pas dans le semestre !""",
dest_url="javascript:history.back();",
)
@ -652,19 +576,27 @@ def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
):
raise ScoValueError(
f"""La date de fin de l'évaluation ({
data["date_fin"].strftime(scu.DATE_FMT)
data["date_fin"].strftime("%d/%m/%Y")
}) n'est pas dans le semestre !""",
dest_url="javascript:history.back();",
)
if date_debut and date_fin:
duration = data["date_fin"] - data["date_debut"]
if duration.total_seconds() < 0 or duration > MAX_EVALUATION_DURATION:
raise ScoValueError(
"Heures de l'évaluation incohérentes !",
dest_url="javascript:history.back();",
)
if "blocked_until" in data:
data["blocked_until"] = data["blocked_until"] or None
raise ScoValueError("Heures de l'évaluation incohérentes !")
# # --- heures
# heure_debut = data.get("heure_debut", None)
# if heure_debut and not isinstance(heure_debut, datetime.time):
# if date_format == "dmy":
# data["heure_debut"] = heure_to_time(heure_debut)
# else: # ISO
# data["heure_debut"] = datetime.time.fromisoformat(heure_debut)
# heure_fin = data.get("heure_fin", None)
# if heure_fin and not isinstance(heure_fin, datetime.time):
# if date_format == "dmy":
# data["heure_fin"] = heure_to_time(heure_fin)
# else: # ISO
# data["heure_fin"] = datetime.time.fromisoformat(heure_fin)
def heure_to_time(heure: str) -> datetime.time:
@ -674,6 +606,19 @@ def heure_to_time(heure: str) -> datetime.time:
return datetime.time(int(h), int(m))
def _time_duration_HhM(heure_debut: str, heure_fin: str) -> int:
"""duree (nb entier de minutes) entre deux heures a notre format
ie 12h23
"""
if heure_debut and heure_fin:
h0, m0 = [int(x) for x in heure_debut.split("h")]
h1, m1 = [int(x) for x in heure_fin.split("h")]
d = (h1 - h0) * 60 + (m1 - m0)
return d
else:
return None
def _moduleimpl_evaluation_insert_before(
evaluations: list[Evaluation], next_eval: Evaluation
) -> int:
@ -694,6 +639,3 @@ def _moduleimpl_evaluation_insert_before(
db.session.add(e)
db.session.commit()
return n
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription

View File

@ -12,12 +12,13 @@ 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
from app.models.moduleimpls import ModuleImpl
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 +28,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 +46,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 +77,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(
@ -150,7 +133,7 @@ class ScolarNews(db.Model):
return query.order_by(cls.date.desc()).limit(n).all()
@classmethod
def add(cls, typ, obj=None, text="", url=None, max_frequency=600, dept_id=None):
def add(cls, typ, obj=None, text="", url=None, max_frequency=600):
"""Enregistre une nouvelle
Si max_frequency, ne génère pas 2 nouvelles "identiques"
à moins de max_frequency secondes d'intervalle (10 minutes par défaut).
@ -158,11 +141,10 @@ class ScolarNews(db.Model):
même (obj, typ, user).
La nouvelle enregistrée est aussi envoyée par mail.
"""
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
if max_frequency:
last_news = (
cls.query.filter_by(
dept_id=dept_id,
dept_id=g.scodoc_dept_id,
authenticated_user=current_user.user_name,
type=typ,
object=obj,
@ -181,7 +163,7 @@ class ScolarNews(db.Model):
return
news = ScolarNews(
dept_id=dept_id,
dept_id=g.scodoc_dept_id,
authenticated_user=current_user.user_name,
type=typ,
object=obj,
@ -198,7 +180,6 @@ class ScolarNews(db.Model):
None si inexistant
"""
from app.models.formsemestre import FormSemestre
from app.models.moduleimpls import ModuleImpl
formsemestre_id = None
if self.type == self.NEWS_INSCR:
@ -250,9 +231,7 @@ class ScolarNews(db.Model):
)
# Transforme les URL en URL absolues
base = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)[
: -len("/index_html")
]
base = scu.ScoURL()
txt = re.sub('href=/.*?"', 'href="' + base + "/", txt)
# Transforme les liens HTML en texte brut: '<a href="url">texte</a>' devient 'texte: url'
@ -269,12 +248,11 @@ class ScolarNews(db.Model):
news_list = cls.last_news(n=n)
if not news_list:
return ""
dept_news_url = url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
H = [
f"""<div class="scobox news"><div class="scobox-title"><a href="{
dept_news_url
f"""<div class="news"><span class="newstitle"><a href="{
url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
}">Dernières opérations</a>
</div><ul class="newslist">"""
</span><ul class="newslist">"""
]
for news in news_list:
@ -282,22 +260,16 @@ class ScolarNews(db.Model):
f"""<li class="newslist"><span class="newsdate">{news.formatted_date()}</span><span
class="newstext">{news}</span></li>"""
)
H.append(
f"""<li class="newslist">
<span class="newstext"><a href="{dept_news_url}" class="stdlink">...</a>
</span>
</li>"""
)
H.append("</ul></div>")
H.append("</ul>")
# Informations générales
H.append(
f"""<div>
Pour en savoir plus sur ScoDoc voir
<a class="stdlink" href="{scu.SCO_ANNONCES_WEBSITE}">scodoc.org</a>
f"""<div><a class="discretelink" href="{scu.SCO_ANNONCES_WEBSITE}">
Pour en savoir plus sur ScoDoc voir le site scodoc.org</a>.
</div>
"""
)
H.append("</div>")
return "\n".join(H)

View File

@ -1,13 +1,11 @@
"""ScoDoc 9 models : Formations
"""
from flask import abort, g
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 +21,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"
@ -66,21 +64,6 @@ class Formation(ScoDocModel):
"titre complet pour affichage"
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
@classmethod
def get_formation(cls, formation_id: int | str, dept_id: int = None) -> "Formation":
"""Formation ou 404, cherche uniquement dans le département spécifié
ou le courant (g.scodoc_dept)"""
if not isinstance(formation_id, int):
try:
formation_id = int(formation_id)
except (TypeError, ValueError):
abort(404, "formation_id invalide")
if g.scodoc_dept:
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
if dept_id is not None:
return cls.query.filter_by(id=formation_id, dept_id=dept_id).first_or_404()
return cls.query.filter_by(id=formation_id).first_or_404()
def to_dict(self, with_refcomp_attrs=False, with_departement=True):
"""As a dict.
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.
@ -297,7 +280,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 +296,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

@ -1,7 +1,7 @@
# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -10,22 +10,19 @@
"""ScoDoc models: formsemestre
"""
from collections import defaultdict
import datetime
from functools import cached_property
from itertools import chain
from operator import attrgetter
from flask_login import current_user
from flask import abort, flash, g, url_for
from flask import flash, g, url_for
from sqlalchemy.sql import text
from sqlalchemy import func
import app.scodoc.sco_utils as scu
from app import db, log
from app.auth.models import User
from app import models
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models.but_refcomp import (
ApcParcours,
@ -33,17 +30,11 @@ from app.models.but_refcomp import (
parcours_formsemestre,
)
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 (
ModuleImpl,
ModuleImplInscription,
notes_modules_enseignants,
)
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
from app.models.modules import Module
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
@ -53,10 +44,12 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric
from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.sco_utils import translate_assiduites_metric
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
class FormSemestre(models.ScoDocModel):
class FormSemestre(db.Model):
"""Mise en oeuvre d'un semestre de formation"""
__tablename__ = "notes_formsemestre"
@ -70,7 +63,7 @@ class FormSemestre(models.ScoDocModel):
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
titre = db.Column(db.Text(), nullable=False)
date_debut = db.Column(db.Date(), nullable=False)
date_fin = db.Column(db.Date(), nullable=False) # jour inclus
date_fin = db.Column(db.Date(), nullable=False)
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
@ -86,7 +79,7 @@ class FormSemestre(models.ScoDocModel):
bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
"ne publie pas le bulletin sur l'API"
"ne publie pas le bulletin XML ou JSON"
block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
@ -95,10 +88,6 @@ class FormSemestre(models.ScoDocModel):
db.Boolean(), nullable=False, default=False, server_default="false"
)
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
mode_calcul_moyennes = db.Column(
db.Integer, nullable=False, default=0, server_default="0"
)
"pour usage futur"
gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
@ -123,11 +112,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)
@ -193,15 +180,9 @@ class FormSemestre(models.ScoDocModel):
@classmethod
def get_formsemestre(
cls, formsemestre_id: int | str, dept_id: int = None
cls, formsemestre_id: int, dept_id: int = None
) -> "FormSemestre":
"""FormSemestre ou 404, cherche uniquement dans le département spécifié
ou le courant (g.scodoc_dept)"""
if not isinstance(formsemestre_id, int):
try:
formsemestre_id = int(formsemestre_id)
except (TypeError, ValueError):
abort(404, "formsemestre_id invalide")
""" "FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant"""
if g.scodoc_dept:
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
if dept_id is not None:
@ -210,72 +191,8 @@ 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
"""clé pour tris par ordre alphabétique
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
return (self.date_debut, self.semestre_id)
@ -291,12 +208,12 @@ class FormSemestre(models.ScoDocModel):
d["formsemestre_id"] = self.id
d["titre_num"] = self.titre_num()
if self.date_debut:
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
d["date_debut_iso"] = self.date_debut.isoformat()
else:
d["date_debut"] = d["date_debut_iso"] = ""
if self.date_fin:
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
d["date_fin_iso"] = self.date_fin.isoformat()
else:
d["date_fin"] = d["date_fin_iso"] = ""
@ -314,20 +231,19 @@ class FormSemestre(models.ScoDocModel):
def to_dict_api(self):
"""
Un dict avec les informations sur le semestre destinées à l'api
Un dict avec les informations sur le semestre destiné à l'api
"""
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
d.pop("groups_auto_assignment_data", None)
d["annee_scolaire"] = self.annee_scolaire()
d["bul_hide_xml"] = self.bul_hide_xml
if self.date_debut:
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
d["date_debut_iso"] = self.date_debut.isoformat()
else:
d["date_debut"] = d["date_debut_iso"] = ""
if self.date_fin:
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
d["date_fin_iso"] = self.date_fin.isoformat()
else:
d["date_fin"] = d["date_fin_iso"] = ""
@ -353,15 +269,10 @@ class FormSemestre(models.ScoDocModel):
return default_partition.groups.first()
raise ScoValueError("Le semestre n'a pas de groupe par défaut")
def get_edt_ids(self) -> list[str]:
"""Les ids pour l'emploi du temps: à défaut, les codes étape Apogée.
Les edt_id de formsemestres ne sont pas normalisés afin de contrôler
précisément l'accès au fichier ics.
"""
def get_edt_id(self) -> str:
"l'id pour l'emploi du temps: à défaut, le 1er code étape Apogée"
return (
scu.split_id(self.edt_id)
or [e.etape_apo.strip() for e in self.etapes if e.etape_apo]
or []
self.edt_id or "" or (self.etapes[0].etape_apo if len(self.etapes) else "")
)
def get_infos_dict(self) -> dict:
@ -466,80 +377,6 @@ class FormSemestre(models.ScoDocModel):
_cache[key] = ues
return ues
@classmethod
def get_user_formsemestres_annee_by_dept(
cls, user: User
) -> tuple[
defaultdict[int, list["FormSemestre"]], defaultdict[int, list[ModuleImpl]]
]:
"""Liste des formsemestres de l'année scolaire
dans lesquels user intervient (comme resp., resp. de module ou enseignant),
ainsi que la liste des modimpls concernés dans chaque formsemestre
Attention: les semestres et modimpls peuvent être de différents départements !
Résultat:
{ dept_id : [ formsemestre, ... ] },
{ formsemestre_id : [ modimpl, ... ]}
"""
debut_annee_scolaire = scu.date_debut_annee_scolaire()
fin_annee_scolaire = scu.date_fin_annee_scolaire()
query = FormSemestre.query.filter(
FormSemestre.date_fin >= debut_annee_scolaire,
FormSemestre.date_debut < fin_annee_scolaire,
)
# responsable ?
formsemestres_resp = query.join(notes_formsemestre_responsables).filter_by(
responsable_id=user.id
)
# Responsable d'un modimpl ?
modimpls_resp = (
ModuleImpl.query.filter_by(responsable_id=user.id)
.join(FormSemestre)
.filter(
FormSemestre.date_fin >= debut_annee_scolaire,
FormSemestre.date_debut < fin_annee_scolaire,
)
)
# Enseignant dans un modimpl ?
modimpls_ens = (
ModuleImpl.query.join(notes_modules_enseignants)
.filter_by(ens_id=user.id)
.join(FormSemestre)
.filter(
FormSemestre.date_fin >= debut_annee_scolaire,
FormSemestre.date_debut < fin_annee_scolaire,
)
)
# Liste les modimpls, uniques
modimpls = modimpls_resp.all()
ids = {modimpl.id for modimpl in modimpls}
for modimpl in modimpls_ens:
if modimpl.id not in ids:
modimpls.append(modimpl)
ids.add(modimpl.id)
# Liste les formsemestres et modimpls associés
modimpls_by_formsemestre = defaultdict(lambda: [])
formsemestres = formsemestres_resp.all()
ids = {formsemestre.id for formsemestre in formsemestres}
for modimpl in chain(modimpls_resp, modimpls_ens):
if modimpl.formsemestre_id not in ids:
formsemestres.append(modimpl.formsemestre)
ids.add(modimpl.formsemestre_id)
modimpls_by_formsemestre[modimpl.formsemestre_id].append(modimpl)
# Tris et organisation par département
formsemestres_by_dept = defaultdict(lambda: [])
formsemestres.sort(key=lambda x: (x.departement.acronym,) + x.sort_key())
for formsemestre in formsemestres:
formsemestres_by_dept[formsemestre.dept_id].append(formsemestre)
modimpls = modimpls_by_formsemestre[formsemestre.id]
if formsemestre.formation.is_apc():
key = lambda x: x.module.sort_key_apc()
else:
key = lambda x: x.module.sort_key()
modimpls.sort(key=key)
return formsemestres_by_dept, modimpls_by_formsemestre
def get_evaluations(self) -> list[Evaluation]:
"Liste de toutes les évaluations du semestre, triées par module/numero"
return (
@ -550,7 +387,7 @@ class FormSemestre(models.ScoDocModel):
Module.numero,
Module.code,
Evaluation.numero,
Evaluation.date_debut,
Evaluation.date_debut.desc(),
)
.all()
)
@ -560,7 +397,6 @@ class FormSemestre(models.ScoDocModel):
"""Liste des modimpls du semestre (y compris bonus)
- triée par type/numéro/code en APC
- triée par numéros d'UE/matières/modules pour les formations standard.
Hors APC, élimine les modules de type ressources et SAEs.
"""
modimpls = self.modimpls.all()
if self.formation.is_apc():
@ -572,14 +408,6 @@ class FormSemestre(models.ScoDocModel):
)
)
else:
modimpls = [
mi
for mi in modimpls
if (
mi.module.module_type
not in (scu.ModuleType.RESSOURCE, scu.ModuleType.SAE)
)
]
modimpls.sort(
key=lambda m: (
m.module.ue.numero or 0,
@ -677,41 +505,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 (
@ -726,7 +519,7 @@ class FormSemestre(models.ScoDocModel):
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
jour_pivot_annee=1,
jour_pivot_periode=1,
) -> tuple[int, int]:
):
"""Calcule la session associée à un formsemestre commençant en date_debut
sous la forme (année, période)
année: première année de l'année scolaire
@ -776,27 +569,7 @@ class FormSemestre(models.ScoDocModel):
mois_pivot_periode=ScoDocSiteConfig.get_month_debut_periode2(),
)
@classmethod
def get_dept_formsemestres_courants(
cls, dept: Departement, date_courante: datetime.datetime | None = None
) -> db.Query:
"""Liste (query) ordonnée des formsemestres courants, c'est
à dire contenant la date courant (si None, la date actuelle)"""
date_courante = date_courante or db.func.current_date()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,
FormSemestre.date_debut <= date_courante,
FormSemestre.date_fin >= date_courante,
)
return formsemestres.order_by(
FormSemestre.date_debut.desc(),
FormSemestre.modalite,
FormSemestre.semestre_id,
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 +582,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 +618,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(
@ -858,7 +631,7 @@ class FormSemestre(models.ScoDocModel):
def can_change_groups(self, user: User = None) -> bool:
"""Vrai si l'utilisateur (par def. current) peut changer les groupes dans
ce semestre: vérifie permission et verrouillage (mais pas si la partition est éditable).
ce semestre: vérifie permission et verrouillage.
"""
if not self.etat:
return False # semestre verrouillé
@ -867,7 +640,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.
"""
@ -978,9 +751,9 @@ class FormSemestre(models.ScoDocModel):
descr_sem += " " + self.modalite
return descr_sem
def get_abs_count(self, etudid) -> tuple[int, int, int]:
def get_abs_count(self, etudid):
"""Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs non just, nb abs justifiées, nb abs total)
tuple (nb abs, nb abs justifiées)
Utilise un cache.
"""
from app.scodoc import sco_assiduites
@ -995,12 +768,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 +777,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]:
@ -1045,16 +811,12 @@ class FormSemestre(models.ScoDocModel):
partitions += [p for p in self.partitions if p.partition_name is None]
return partitions
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
et donne l'ensemble des inscrits non DEM ni DEF.
"""
return [inscr.etudid for inscr in self.inscriptions], {
ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT
}
@cached_property
def etudids_actifs(self) -> set:
"Set des etudids inscrits non démissionnaires et non défaillants"
return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT}
@property
@cached_property
def etuds_inscriptions(self) -> dict:
"""Map { etudid : inscription } (incluant DEM et DEF)"""
return {ins.etud.id: ins for ins in self.inscriptions}
@ -1278,33 +1040,6 @@ class FormSemestre(models.ScoDocModel):
nb_recorded += 1
return nb_recorded
def change_formation(self, formation_dest: Formation):
"""Associe ce formsemestre à une autre formation.
Ce n'est possible que si la formation destination possède des modules de
même code que ceux utilisés dans la formation d'origine du formsemestre.
S'il manque un module, l'opération est annulée.
Commit (or rollback) session.
"""
ok = True
for mi in self.modimpls:
dest_modules = formation_dest.modules.filter_by(code=mi.module.code).all()
match len(dest_modules):
case 1:
mi.module = dest_modules[0]
db.session.add(mi)
case 0:
print(f"Argh ! no module found with code={mi.module.code}")
ok = False
case _:
print(f"Arg ! several modules found with code={mi.module.code}")
ok = False
if ok:
self.formation_id = formation_dest.id
db.session.commit()
else:
db.session.rollback()
# Association id des utilisateurs responsables (aka directeurs des etudes) du semestre
notes_formsemestre_responsables = db.Table(
@ -1334,18 +1069,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 +1225,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

@ -1,7 +1,7 @@
# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -11,15 +11,14 @@ from operator import attrgetter
from sqlalchemy.exc import IntegrityError
from app import db, log
from app.models import ScoDocModel, GROUPNAME_STR_LEN, SHORT_STR_LEN
from app.models import Scolog, GROUPNAME_STR_LEN, SHORT_STR_LEN
from app.models.etudiants import Identite
from app.models.events import Scolog
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
class Partition(ScoDocModel):
class Partition(db.Model):
"""Partition: découpage d'une promotion en groupes"""
__table_args__ = (db.UniqueConstraint("formsemestre_id", "partition_name"),)
@ -54,7 +53,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 +92,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
@ -210,7 +204,7 @@ class Partition(ScoDocModel):
return group
class GroupDescr(ScoDocModel):
class GroupDescr(db.Model):
"""Description d'un groupe d'une partition"""
__tablename__ = "group_descr"
@ -226,11 +220,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",
@ -252,20 +241,15 @@ class GroupDescr(ScoDocModel):
def to_dict(self, with_partition=True) -> dict:
"""as a dict, with or without partition"""
if with_partition:
partition_dict = self.partition.to_dict(with_groups=False)
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
if with_partition:
d["partition"] = partition_dict
d["partition"] = self.partition.to_dict(with_groups=False)
return d
def get_edt_ids(self) -> list[str]:
"les ids normalisés pour l'emploi du temps: à défaut, le nom scodoc du groupe"
return [
scu.normalize_edt_id(x)
for x in scu.split_id(self.edt_id) or [self.group_name] or []
]
def get_edt_id(self) -> str:
"l'id pour l'emploi du temps: à défaut, le nom scodoc du groupe"
return self.edt_id or self.group_name or ""
def get_nb_inscrits(self) -> int:
"""Nombre inscrits à ce group et au formsemestre.
@ -298,18 +282,18 @@ class GroupDescr(ScoDocModel):
return False
return True
def set_name(self, group_name: str, dest_url: str = None):
def set_name(
self, group_name: str, edt_id: str | bool = False, dest_url: str = None
):
"""Set group name, and optionally edt_id.
Check permission (partition must be groups_editable)
and invalidate caches. Commit session.
Check permission and invalidate caches. Commit session.
dest_url is used for error messages.
"""
if not self.partition.formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
if self.group_name is None:
raise ValueError("can't set a name to default group")
if not self.partition.groups_editable:
raise AccessDenied("Partition non éditable")
if group_name:
group_name = group_name.strip()
if not group_name:
@ -322,32 +306,27 @@ class GroupDescr(ScoDocModel):
)
self.group_name = group_name
if edt_id is not False:
if isinstance(edt_id, str):
edt_id = edt_id.strip() or None
self.edt_id = edt_id
db.session.add(self)
db.session.commit()
sco_cache.invalidate_formsemestre(
formsemestre_id=self.partition.formsemestre_id
)
def set_edt_id(self, edt_id: str):
"Set edt_id. Check permission. Commit session."
if not self.partition.formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
if isinstance(edt_id, str):
edt_id = edt_id.strip() or None
self.edt_id = edt_id
db.session.add(self)
db.session.commit()
def remove_etud(self, etud: "Identite"):
"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

@ -2,24 +2,20 @@
"""ScoDoc models: moduleimpls
"""
import pandas as pd
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
from app.models import APO_CODE_STR_LEN, ScoDocModel
from app.models import APO_CODE_STR_LEN
from app.models.etudiants import Identite
from app.models.evaluations import Evaluation
from app.models.modules import Module
from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
class ModuleImpl(ScoDocModel):
class ModuleImpl(db.Model):
"""Mise en oeuvre d'un module pour une annee/semestre"""
__tablename__ = "notes_moduleimpl"
@ -38,29 +34,18 @@ class ModuleImpl(ScoDocModel):
index=True,
nullable=False,
)
responsable_id = db.Column(
"responsable_id", db.Integer, db.ForeignKey("user.id", ondelete="SET NULL")
)
responsable = db.relationship("User", back_populates="modimpls")
responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id"))
# formule de calcul moyenne:
computation_expr = db.Column(db.Text())
evaluations = db.relationship(
"Evaluation",
lazy="dynamic",
backref="moduleimpl",
order_by=(Evaluation.numero, Evaluation.date_debut),
)
"évaluations, triées par numéro et dates croissants, donc la plus ancienne d'abord."
evaluations = db.relationship("Evaluation", lazy="dynamic", backref="moduleimpl")
enseignants = db.relationship(
"User",
secondary="notes_modules_enseignants",
lazy="dynamic",
backref="moduleimpl",
viewonly=True,
)
"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)}>"
@ -73,17 +58,16 @@ class ModuleImpl(ScoDocModel):
return {x.strip() for x in self.code_apogee.split(",") if x}
return self.module.get_codes_apogee()
def get_edt_ids(self) -> list[str]:
"les ids pour l'emploi du temps: à défaut, les codes Apogée"
return [
scu.normalize_edt_id(x)
for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee)
] or self.module.get_edt_ids()
def get_edt_id(self) -> str:
"l'id pour l'emploi du temps: à défaut, le 1er code Apogée"
return (
self.edt_id
or (self.code_apogee.split(",")[0] if self.code_apogee else "")
or self.module.get_edt_id()
)
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
@ -92,58 +76,24 @@ class ModuleImpl(ScoDocModel):
df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids)
return evaluations_poids
@classmethod
def get_modimpl(cls, moduleimpl_id: int | str, dept_id: int = None) -> "ModuleImpl":
"""ModuleImpl ou 404, cherche uniquement dans le département spécifié ou le courant."""
from app.models.formsemestre import FormSemestre
if not isinstance(moduleimpl_id, int):
try:
moduleimpl_id = int(moduleimpl_id)
except (TypeError, ValueError):
abort(404, "moduleimpl_id invalide")
if g.scodoc_dept:
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
query = cls.query.filter_by(id=moduleimpl_id)
if dept_id is not None:
query = query.join(FormSemestre).filter_by(dept_id=dept_id)
return query.first_or_404()
def invalidate_evaluations_poids(self):
"""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,
)
@ -213,7 +163,7 @@ class ModuleImpl(ScoDocModel):
return allow_ens and user.id in (ens.id for ens in self.enseignants)
return True
def can_change_responsable(self, user: User, raise_exc=False) -> bool:
def can_change_ens_by(self, user: User, raise_exc=False) -> bool:
"""Check if user can modify module resp.
If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.
= Admin, et dir des etud. (si option l'y autorise)
@ -234,81 +184,21 @@ class ModuleImpl(ScoDocModel):
raise AccessDenied(f"Modification impossible pour {user}")
return False
def can_change_ens(self, user: User | None = None, raise_exc=True) -> bool:
"""check if user can modify ens list (raise exception if not)"
if user is None, current user.
def est_inscrit(self, etud: Identite) -> bool:
"""
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
# admin, resp. module ou resp. semestre
if (
user.id != self.responsable_id
and not user.has_permission(Permission.EditFormSemestre)
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
Vérifie si l'étudiant est bien inscrit au moduleimpl
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.
Retourne Vrai si c'est le cas, faux sinon
"""
user = current_user if user is None else user
if not self.formsemestre.etat:
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")
return False
# -- check access
# resp. module ou ou perm. EtudInscrit ou resp. semestre
if (
user.id != self.responsable_id
and not user.has_permission(Permission.EtudInscrit)
and user.id not in (u.id for u in self.formsemestre.responsables)
):
if raise_exc:
raise AccessDenied(f"Modification impossible pour {user}")
return False
return True
def est_inscrit(self, etud: Identite):
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre).
(lent, pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df).
Retourne ModuleImplInscription si inscrit au module, False 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

@ -1,24 +1,17 @@
"""ScoDoc 9 models : Modules
"""
from flask import current_app, g
from flask import current_app
from app import db
from app import models
from app.models import APO_CODE_STR_LEN
from app.models.but_refcomp import (
ApcParcours,
ApcReferentielCompetences,
app_critiques_modules,
parcours_modules,
)
from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import ModuleType
class Module(models.ScoDocModel):
class Module(db.Model):
"""Module"""
__tablename__ = "notes_modules"
@ -29,7 +22,6 @@ class Module(models.ScoDocModel):
abbrev = db.Column(db.Text()) # nom court
# certains départements ont des codes infiniment longs: donc Text !
code = db.Column(db.Text(), nullable=False)
"code module, chaine non nullable"
heures_cours = db.Column(db.Float)
heures_td = db.Column(db.Float)
heures_tp = db.Column(db.Float)
@ -75,8 +67,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)
@ -85,100 +75,6 @@ class Module(models.ScoDocModel):
return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name
} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"""
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect.
returns: dict to store in model's db.
"""
# s'assure que ects etc est non ''
fs_empty_stored_as_nulls = {
"coefficient",
"ects",
"heures_cours",
"heures_td",
"heures_tp",
}
args_dict = {}
for key, value in args.items():
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
if key in fs_empty_stored_as_nulls and value == "":
value = None
args_dict[key] = value
return args_dict
@classmethod
def filter_model_attributes(cls, args: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
Add 'id' to excluded."""
# on ne peut pas affecter directement parcours
return super().filter_model_attributes(args, (excluded or set()) | {"parcours"})
def from_dict(self, args: dict, excluded: set[str] | None = None) -> bool:
"""Update object's fields given in dict. Add to session but don't commit.
True if modification.
- can't change ue nor formation
- can change matiere_id, iff new matiere in same ue
- can change parcours: parcours list of ApcParcour id or instances.
"""
# Vérifie les changements de matiere
new_matiere_id = args.get("matiere_id", self.matiere_id)
if new_matiere_id != self.matiere_id:
# exists ?
from app.models import Matiere
matiere = db.session.get(Matiere, new_matiere_id)
if matiere is None or matiere.ue_id != self.ue_id:
raise ScoValueError("invalid matiere")
modified = super().from_dict(
args, excluded=(excluded or set()) | {"formation_id", "ue_id"}
)
existing_parcours = {p.id for p in self.parcours}
new_parcours = args.get("parcours", []) or []
if existing_parcours != set(new_parcours):
self._set_parcours_from_list(new_parcours)
return True
return modified
@classmethod
def create_from_dict(cls, data: dict) -> "Module":
"""Create from given dict, add parcours.
Flush session."""
module = super().create_from_dict(data)
db.session.flush()
module._set_parcours_from_list(data.get("parcours", []) or [])
return module
def _set_parcours_from_list(self, parcours: list[ApcParcours | int]):
"""Ajoute ces parcours à la liste des parcours du module.
Chaque élément est soit un objet parcours soit un id.
S'assure que chaque parcours est dans le référentiel de compétence
associé à la formation du module.
"""
for p in parcours:
if isinstance(p, ApcParcours):
parcour: ApcParcours = p
if p.referentiel_id != self.formation.referentiel_competence.id:
raise ScoValueError("Parcours hors référentiel du module")
else:
try:
pid = int(p)
except ValueError as exc:
raise ScoValueError("id de parcours invalide") from exc
query = (
ApcParcours.query.filter_by(id=pid)
.join(ApcReferentielCompetences)
.filter_by(id=self.formation.referentiel_competence.id)
)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
parcour: ApcParcours = query.first()
if parcour is None:
raise ScoValueError("Parcours invalide")
self.parcours.append(parcour)
def clone(self):
"""Create a new copy of this module."""
mod = Module(
@ -210,29 +106,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:
@ -276,10 +159,6 @@ class Module(models.ScoDocModel):
"Identifiant du module à afficher : abbrev ou titre ou code"
return self.abbrev or self.titre or self.code
def sort_key(self) -> tuple:
"""Clé de tri pour formations classiques"""
return self.numero or 0, self.code
def sort_key_apc(self) -> tuple:
"""Clé de tri pour avoir
présentation par type (res, sae), parcours, type, numéro
@ -400,33 +279,19 @@ 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:
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
def get_edt_ids(self) -> list[str]:
"les ids pour l'emploi du temps: à défaut, le 1er code Apogée"
return [
scu.normalize_edt_id(x)
for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) or []
]
def get_edt_id(self) -> str:
"l'id pour l'emploi du temps: à défaut, le 1er code Apogée"
return (
self.edt_id
or (self.code_apogee.split(",")[0] if self.code_apogee else "")
or ""
)
def get_parcours(self) -> list[ApcParcours]:
"""Les parcours utilisant ce module.
@ -441,14 +306,6 @@ class Module(models.ScoDocModel):
return []
return self.parcours
def add_tag(self, tag: "NotesTag"):
"""Add tag to module. Check if already has it."""
if tag.id in {t.id for t in self.tags}:
return
self.tags.append(tag)
db.session.add(self)
db.session.flush()
class ModuleUECoef(db.Model):
"""Coefficients des modules vers les UE (APC, BUT)
@ -511,19 +368,6 @@ class NotesTag(db.Model):
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
title = db.Column(db.Text(), nullable=False)
@classmethod
def get_or_create(cls, title: str, dept_id: int | None = None) -> "NotesTag":
"""Get tag, or create it if it doesn't yet exists.
If dept_id unspecified, use current dept.
"""
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
tag = NotesTag.query.filter_by(dept_id=dept_id, title=title).first()
if tag is None:
tag = NotesTag(dept_id=dept_id, title=title)
db.session.add(tag)
db.session.flush()
return tag
# Association tag <-> module
notes_modules_tags = db.Table(

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}>"

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