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 # Symlinks static ScoDoc
app/static/links/[0-9]*.*[0-9] 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 # 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> 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 ### Lignes de commandes
Voir [le guide de configuration](https://scodoc.org/GuideConfig). Voir [https://scodoc.org/GuideConfig](le guide de configuration).
## Organisation des fichiers ## 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é. Le répertoire `/opt/scodoc-data` doit être régulièrement sauvegardé.
Principaux contenus: Principaux contenus:
```
/opt/scodoc-data /opt/scodoc-data
/opt/scodoc-data/log # Fichiers de log ScoDoc /opt/scodoc-data/log # Fichiers de log ScoDoc
/opt/scodoc-data/config # Fichiers de configuration /opt/scodoc-data/config # Fichiers de configuration
.../config/logos # Logos de l'établissement .../config/logos # Logos de l'établissement
.../config/depts # un fichier par département .../config/depts # un fichier par département
/opt/scodoc-data/photos # Photos des étudiants /opt/scodoc-data/photos # Photos des étudiants
/opt/scodoc-data/archives # Archives: PV de jury, maquettes Apogée, fichiers étudiants /opt/scodoc-data/archives # Archives: PV de jury, maquettes Apogée, fichiers étudiants
```
## Pour les développeurs ## Pour les développeurs
### Installation du code ### 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. 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: sudo su
chown -R scodoc:scodoc /opt/scodoc 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: Il faut ensuite installer l'environnement et le fichier de configuration:
```bash
# Le plus simple est de piquer le virtualenv configuré par l'installeur: # Le plus simple est de piquer le virtualenv configuré par l'installeur:
mv /opt/off-scodoc/venv /opt/scodoc mv /opt/off-scodoc/venv /opt/scodoc
```
Et la config: 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 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 n'est pas toujours le plus judicieux: vous pouvez modifier son contenu, par
exemple pour travailler en mode "développement" avec `FLASK_ENV=development`. 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`. Les tests unitaires utilisent normalement la base postgresql `SCODOC_TEST`.
Avant le premier lancement, créer cette base ainsi: Avant le premier lancement, créer cette base ainsi:
```bash
./tools/create_database.sh SCODOC_TEST ./tools/create_database.sh SCODOC_TEST
export FLASK_ENV=test export FLASK_ENV=test
flask db upgrade flask db upgrade
```
Cette commande n'est nécessaire que la première fois (le contenu de la base 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 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. 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 Certains tests ont besoin d'un département déjà créé, qui n'est pas créé par les
scripts de tests: scripts de tests:
Lancer au préalable: 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: Puis dérouler les tests unitaires:
```bash
pytest tests/unit pytest tests/unit
```
Ou avec couverture (`pip install pytest-cov`) 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 #### 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 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 Il suffit de positionner une variable d'environnement indiquant la BD utilisée
par les tests: 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 (si elle n'existe pas, voir plus loin pour la créer) puis de les lancer
normalement, par exemple: 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 Il est en général nécessaire d'affecter ensuite un mot de passe à (au moins) un
utilisateur: utilisateur:
```bash
flask user-password admin flask user-password admin
```
**Attention:** les tests unitaires **effacent** complètement le contenu de la **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 ! base de données (tous les départements, et les utilisateurs) avant de commencer !
#### Modification du schéma de la base #### Modification du schéma de la base
On utilise SQLAlchemy avec Alembic et Flask-Migrate. On utilise SQLAlchemy avec Alembic et Flask-Migrate.
```bash
flask db migrate -m "message explicatif....." flask db migrate -m "message explicatif....."
flask db upgrade flask db upgrade
```
Ne pas oublier de d'ajouter le script de migration à git (`git add migrations/...`). 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` **Mémo**: séquence re-création d'une base (vérifiez votre `.env`
ou variables d'environnement pour interroger la bonne base !). 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: dropdb SCODOC_DEV
flask import-scodoc7-users tools/create_database.sh SCODOC_DEV # créé base SQL
flask import-scodoc7-dept STID SCOSTID 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 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 migration, utiliser les commandes `flask db history`et `flask db stamp`pour se
positionner à la bonne étape. positionner à la bonne étape.
@ -159,23 +163,23 @@ positionner à la bonne étape.
### Profiling ### Profiling
Sur une machine de DEV, lancer 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`). 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: Pour la visualisation, [snakeviz](https://jiffyclub.github.io/snakeviz/) est bien:
```bash
pip install snakeviz pip install snakeviz
```
puis 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 ## Paquet Debian 12
Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus 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). upgrade de scodoc9).
La préparation d'une release se fait à l'aide du script 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 Flask
from flask import abort, flash, has_request_context from flask import abort, flash, has_request_context
from flask import render_template from flask import render_template
# from flask.json import JSONEncoder
from flask.logging import default_handler from flask.logging import default_handler
from flask_bootstrap import Bootstrap
from flask_caching import Cache from flask_caching import Cache
from flask_json import FlaskJSON, json_response from flask_json import FlaskJSON, json_response
from flask_login import LoginManager, current_user from flask_login import LoginManager, current_user
@ -30,7 +34,6 @@ from flask_sqlalchemy import SQLAlchemy
from jinja2 import select_autoescape from jinja2 import select_autoescape
import sqlalchemy as sa import sqlalchemy as sa
import werkzeug.debug import werkzeug.debug
from wtforms.fields import HiddenField
from flask_cas import CAS from flask_cas import CAS
@ -56,6 +59,8 @@ login.login_view = "auth.login"
login.login_message = "Identifiez-vous pour accéder à cette page." login.login_message = "Identifiez-vous pour accéder à cette page."
mail = Mail() mail = Mail()
bootstrap = Bootstrap()
# moment = Moment()
CACHE_TYPE = os.environ.get("CACHE_TYPE") CACHE_TYPE = os.environ.get("CACHE_TYPE")
cache = Cache( cache = Cache(
@ -86,9 +91,8 @@ def handle_invalid_csrf(exc):
return render_template("error_csrf.j2", exc=exc), 404 return render_template("error_csrf.j2", exc=exc), 404
# def handle_pdf_format_error(exc): def handle_pdf_format_error(exc):
# return "ay ay ay" return "ay ay ay"
handle_pdf_format_error = handle_sco_value_error
def internal_server_error(exc): def internal_server_error(exc):
@ -300,6 +304,7 @@ def create_app(config_class=DevConfig):
login.init_app(app) login.init_app(app)
mail.init_app(app) mail.init_app(app)
app.extensions["mail"].debug = 0 # disable copy of mails to stderr app.extensions["mail"].debug = 0 # disable copy of mails to stderr
bootstrap.init_app(app)
cache.init_app(app) cache.init_app(app)
sco_cache.CACHE = cache sco_cache.CACHE = cache
if CACHE_TYPE: # non default 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_bp
from app.api import api_web_bp from app.api import api_web_bp
# Jinja2 configuration
# Enable autoescaping of all templates, including .j2 # Enable autoescaping of all templates, including .j2
app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True) 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 # https://scodoc.fr/ScoDoc
app.register_blueprint(scodoc_bp) app.register_blueprint(scodoc_bp)
@ -551,8 +549,8 @@ def truncate_database():
CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$ CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$
DECLARE DECLARE
statements CURSOR FOR statements CURSOR FOR
SELECT sequence_name SELECT sequence_name
FROM information_schema.sequences FROM information_schema.sequences
ORDER BY sequence_name ; ORDER BY sequence_name ;
BEGIN BEGIN
FOR stmt IN statements LOOP FOR stmt IN statements LOOP
@ -637,12 +635,14 @@ def critical_error(msg):
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
log(f"\n*** CRITICAL ERROR: {msg}") log(f"\n*** CRITICAL ERROR: {msg}")
subject = f"CRITICAL ERROR: {msg}".strip()[:68] send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
send_scodoc_alarm(subject, msg)
clear_scodoc_cache() clear_scodoc_cache()
raise ScoValueError( raise ScoValueError(
f""" 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} {msg}
""" """

View File

@ -1,17 +1,11 @@
"""api.__init__ """api.__init__
""" """
from functools import wraps
from flask_json import as_json from flask_json import as_json
from flask import Blueprint from flask import Blueprint
from flask import current_app, g, request from flask import request, g
from flask_login import current_user
from app import db from app import db
from app.decorators import permission_required
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoException from app.scodoc.sco_exceptions import AccessDenied, ScoException
from app.scodoc.sco_permissions import Permission
api_bp = Blueprint("api", __name__) api_bp = Blueprint("api", __name__)
api_web_bp = Blueprint("apiweb", __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 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_bp.errorhandler(ScoException)
@api_web_bp.errorhandler(ScoException) @api_web_bp.errorhandler(ScoException)
@api_bp.errorhandler(404) @api_bp.errorhandler(404)
@ -76,35 +48,20 @@ def requested_format(default_format="json", allowed_formats=None):
@as_json @as_json
def get_model_api_object( def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None):
model_cls: db.Model,
model_id: int,
join_cls: db.Model = None,
restrict: bool | None = None,
):
""" """
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]" 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 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) query = model_cls.query.filter_by(id=model_id)
if g.scodoc_dept and join_cls is not None: if g.scodoc_dept and join_cls is not None:
query = query.join(join_cls).filter_by(dept_id=g.scodoc_dept_id) 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 unique.to_dict(format_api=True)
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)
from app.api import tokens from app.api import tokens

View File

@ -1,34 +1,30 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
"""ScoDoc 9 API : Assiduités""" """ScoDoc 9 API : Assiduités
"""
from datetime import datetime from datetime import datetime
from flask import g, request from flask import g, request
from flask_json import as_json from flask_json import as_json
from flask_login import current_user, login_required 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 from app import db, log, set_sco_dept
import app.scodoc.sco_assiduites as scass import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.api import api_bp as bp 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_web_bp, get_model_api_object, tools
from app.api import api_permission_required as permission_required from app.decorators import permission_required, scodoc
from app.decorators import scodoc
from app.models import ( from app.models import (
Assiduite, Assiduite,
Evaluation,
FormSemestre, FormSemestre,
Identite, Identite,
ModuleImpl, ModuleImpl,
Scolog, Scolog,
) )
from flask_sqlalchemy.query import Query
from app.models.assiduites import ( from app.models.assiduites import (
get_assiduites_justif, get_assiduites_justif,
get_justifs_from_date, get_justifs_from_date,
@ -43,13 +39,10 @@ from app.scodoc.sco_utils import json_error
@api_web_bp.route("/assiduite/<int:assiduite_id>") @api_web_bp.route("/assiduite/<int:assiduite_id>")
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json
def assiduite(assiduite_id: int = None): def assiduite(assiduite_id: int = None):
"""Retourne un objet assiduité à partir de son id """Retourne un objet assiduité à partir de son id
Exemple de résultat: Exemple de résultat:
```json
{ {
"assiduite_id": 1, "assiduite_id": 1,
"etudid": 2, "etudid": 2,
@ -58,17 +51,11 @@ def assiduite(assiduite_id: int = None):
"date_fin": "2022-10-31T10:00+01:00", "date_fin": "2022-10-31T10:00+01:00",
"etat": "retard", "etat": "retard",
"desc": "une description", "desc": "une description",
"user_id": 1 or null, "user_id: 1 or null,
"user_name" : login scodoc or null, "user_name" : login scodoc or null
"user_nom_complet": "Marie Dupont", "user_nom_complet": "Marie Dupont"
"est_just": False or True, "est_just": False or True,
} }
```
SAMPLES
-------
/assiduite/1;
""" """
return get_model_api_object(Assiduite, assiduite_id, Identite) return get_model_api_object(Assiduite, assiduite_id, Identite)
@ -86,26 +73,18 @@ def assiduite(assiduite_id: int = None):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def assiduite_justificatifs(assiduite_id: int = None, long: bool = False): 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: Exemple de résultat:
```json
[ [
1, 1,
2, 2,
3, 3,
... ...
] ]
```
SAMPLES
-------
/assiduite/1/justificatifs;
/assiduite/1/justificatifs/long;
""" """
return get_assiduites_justif(assiduite_id, long) return get_assiduites_justif(assiduite_id, True)
# etudid # etudid
@ -136,42 +115,52 @@ def assiduite_justificatifs(assiduite_id: int = None, long: bool = False):
@scodoc @scodoc
@as_json @as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def assiduites_count( def count_assiduites(
etudid: int = None, nip: str = None, ine: str = None, with_query: bool = False 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, 404,
message="étudiant inconnu", message="étudiant inconnu",
) )
set_sco_dept(etud.departement.acronym)
# Les filtres qui seront appliqués au comptage (type, date, etudid...) # Les filtres qui seront appliqués au comptage (type, date, etudid...)
filtered: dict[str, object] = {} filtered: dict[str, object] = {}
@ -228,35 +216,40 @@ def assiduites_count(
def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False): def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False):
""" """
Retourne toutes les assiduités d'un étudiant Retourne toutes les assiduités d'un étudiant
chemin : /assiduites/<int:etudid>
QUERY Un filtrage peut être donné avec une query
----- 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>
PARAMS Les différents filtres :
----- Etat (etat de l'étudiant -> absent, present ou retard):
user_id:l'id de l'auteur de l'assiduité query?etat=[- liste des états séparé par une virgule -]
est_just:si l'assiduité est justifiée (fait aussi filtre abs/retard) ex: .../query?etat=present,retard
moduleimpl_id:l'id du module concerné par l'assiduité Date debut
date_debut:date de début de l'assiduité (supérieur ou égal) (date de début de l'assiduité, sont affichés les assiduités
date_fin:date de fin de l'assiduité (inférieur ou égal) dont la date de début est supérieur ou égale à la valeur donnée):
etat:etat de l'étudiant &rightarrow; absent, present ou retard query?date_debut=[- date au format iso -]
formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité ex: query?date_debut=2022-11-03T08:00+01:00
with_justif:ajoute les justificatifs liés à l'assiduité 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, 404,
message="étudiant inconnu", message="étudiant inconnu",
) )
# Récupération des assiduités de l'étudiant # Récupération des assiduités de l'étudiant
assiduites_query: Query = etud.assiduites 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 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}) @bp.route("/assiduites/group/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/group/query", defaults={"with_query": True}) @api_web_bp.route("/assiduites/group/query", defaults={"with_query": True})
@login_required @login_required
@ -404,34 +294,38 @@ def assiduites_group(with_query: bool = False):
Retourne toutes les assiduités d'un groupe d'étudiants Retourne toutes les assiduités d'un groupe d'étudiants
chemin : /assiduites/group/query?etudids=1,2,3 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 Les différents filtres :
----- Etat (etat de l'étudiant -> absent, present ou retard):
user_id:<int:user_id> query?etat=[- liste des états séparé par une virgule -]
est_just:<bool:est_just> ex: .../query?etat=present,retard
moduleimpl_id:<int:moduleimpl_id> Date debut
date_debut:<string:date_debut_iso> (date de début de l'assiduité, sont affichés les assiduités
date_fin:<string:date_fin_iso> dont la date de début est supérieur ou égale à la valeur donnée):
etat:<array[string]:etat> query?date_debut=[- date au format iso -]
etudids:<array[int]:etudids> ex: query?date_debut=2022-11-03T08:00+01:00
formsemestre_id:<int:formsemestre_id> Date fin
with_justif:<bool:with_justif> (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: try:
etuds = [int(etu) for etu in etuds] etuds = [int(etu) for etu in etuds]
except ValueError: 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 # Vérification que tous les étudiants sont du même département
query = Identite.query.filter(Identite.id.in_(etuds)) query = Identite.query.filter(Identite.id.in_(etuds))
@ -491,34 +385,7 @@ def assiduites_group(with_query: bool = False):
@as_json @as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False): def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne toutes les assiduités du formsemestre """Retourne toutes les assiduités du formsemestre"""
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
PARAMS
-----
user_id:l'id de l'auteur de l'assiduité
est_just:si l'assiduité est justifiée (fait aussi filtre abs/retard)
moduleimpl_id:l'id du module concerné par l'assiduité
date_debut:date de début de l'assiduité (supérieur ou égal)
date_fin:date de fin de l'assiduité (inférieur ou égal)
etat:etat de l'étudiant &rightarrow; absent, present ou retard
formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité
SAMPLES
-------
/assiduites/formsemestre/1;
/assiduites/formsemestre/1/query?etat=retard;
/assiduites/formsemestre/1/query?moduleimpl_id=1;
"""
# Récupération du formsemestre à partir du formsemestre_id # Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None formsemestre: FormSemestre = None
@ -565,42 +432,10 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
@scodoc @scodoc
@as_json @as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def assiduites_formsemestre_count( def count_assiduites_formsemestre(
formsemestre_id: int = None, with_query: bool = False formsemestre_id: int = None, with_query: bool = False
): ):
"""Comptage des assiduités du formsemestre """Comptage des assiduités du formsemestre"""
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
formsemestre_id:<int:formsemestre_id>
metric:<array[string]:metric>
split:<bool:split>
PARAMS
-----
user_id:l'id de l'auteur de l'assiduité
est_just:si l'assiduité est justifiée (fait aussi filtre abs/retard)
moduleimpl_id:l'id du module concerné par l'assiduité
date_debut:date de début de l'assiduité (supérieur ou égal)
date_fin:date de fin de l'assiduité (inférieur ou égal)
etat:etat de l'étudiant &rightarrow; absent, present ou retard
formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité
metric: la/les métriques de comptage (journee, demi, heure, compte)
split: divise le comptage par état
SAMPLES
-------
/assiduites/formsemestre/1/count;
/assiduites/formsemestre/1/count/query?etat=retard;
/assiduites/formsemestre/1/count/query?etat=present,retard&metric=compte,heure;
"""
# Récupération du formsemestre à partir du formsemestre_id # Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None formsemestre: FormSemestre = None
@ -609,8 +444,6 @@ def assiduites_formsemestre_count(
if formsemestre is None: if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas") 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 # Récupération des étudiants du formsemestre
etuds = formsemestre.etuds.all() etuds = formsemestre.etuds.all()
etuds_id = [etud.id for etud in etuds] 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): def assiduite_create(etudid: int = None, nip=None, ine=None):
""" """
Enregistrement d'assiduités pour un étudiant (etudid) Enregistrement d'assiduités pour un étudiant (etudid)
La requête doit avoir un content type "application/json":
DATA
----
```json
[ [
{ {
"date_debut": str, "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 # Récupération de l'étudiant
@ -729,10 +553,7 @@ def assiduite_create(etudid: int = None, nip=None, ine=None):
def assiduites_create(): def assiduites_create():
""" """
Création d'une assiduité ou plusieurs assiduites Création d'une assiduité ou plusieurs assiduites
La requête doit avoir un content type "application/json":
DATA
----
```json
[ [
{ {
"date_debut": str, "date_debut": str,
@ -745,17 +566,12 @@ def assiduites_create():
"date_fin": str, "date_fin": str,
"etat": str, "etat": str,
"etudid":int, "etudid":int,
"moduleimpl_id": int, "moduleimpl_id": int,
"desc":str, "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, etud: Identite,
) -> tuple[int, object]: ) -> 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. 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 Suppression d'une assiduité à partir de son id
DATA Forme des données envoyées :
----
```json
[ [
<assiduite_id:int>, <assiduite_id:int>,
... ...
] ]
```
SAMPLES
-------
/assiduite/delete;[2,2,3]
""" """
# Récupération des ids envoyés dans la liste # 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): def assiduite_edit(assiduite_id: int):
""" """
Edition d'une assiduité à partir de son id Edition d'une assiduité à partir de son id
La requête doit avoir un content type "application/json":
DATA
----
```json
{ {
"etat"?: str, "etat"?: str,
"moduleimpl_id"?: int "moduleimpl_id"?: int
"desc"?: str "desc"?: str
"est_just"?: bool "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 # Récupération de l'assiduité à modifier
assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first() assiduite_unique: Assiduite = Assiduite.query.filter_by(
if assiduite_unique is None: id=assiduite_id
return json_error(404, "Assiduité non existante") ).first_or_404()
# Récupération des valeurs à modifier # Récupération des valeurs à modifier
data = request.get_json(force=True) data = request.get_json(force=True)
# Préparation du retour
errors: list[str] = []
# Code 200 si modification réussie # Code 200 si modification réussie
# Code 404 si raté + message d'erreur # Code 404 si raté + message d'erreur
code, obj = _edit_one(assiduite_unique, data) code, obj = _edit_one(assiduite_unique, data)
@ -1054,10 +857,7 @@ def assiduite_edit(assiduite_id: int):
msg=f"assiduite: modif {assiduite_unique}", msg=f"assiduite: modif {assiduite_unique}",
) )
db.session.commit() db.session.commit()
try: scass.simple_invalidate_cache(assiduite_unique.to_dict())
scass.simple_invalidate_cache(assiduite_unique.to_dict())
except ObjectDeletedError:
return json_error(404, "Assiduité supprimée / inexistante")
return {"OK": True} return {"OK": True}
@ -1071,10 +871,7 @@ def assiduite_edit(assiduite_id: int):
def assiduites_edit(): def assiduites_edit():
""" """
Edition de plusieurs assiduités Edition de plusieurs assiduités
La requête doit avoir un content type "application/json":
DATA
----
```json
[ [
{ {
"assiduite_id" : int, "assiduite_id" : int,
@ -1084,13 +881,6 @@ def assiduites_edit():
"est_just"?: bool "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) 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: if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide") errors.append("param 'moduleimpl_id': invalide")
else: 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") errors.append("param 'moduleimpl_id': etud non inscrit")
else: else:
# Mise à jour du moduleimpl # Mise à jour du moduleimpl
@ -1214,9 +1006,7 @@ def _edit_one(assiduite_unique: Assiduite, data: dict) -> tuple[int, str]:
if formsemestre: if formsemestre:
force = scu.is_assiduites_module_forced(formsemestre_id=formsemestre.id) force = scu.is_assiduites_module_forced(formsemestre_id=formsemestre.id)
else: else:
force = scu.is_assiduites_module_forced( force = scu.is_assiduites_module_forced(dept_id=etud.dept_id)
dept_id=assiduite_unique.etudiant.dept_id
)
external_data = ( external_data = (
external_data external_data
@ -1224,9 +1014,7 @@ def _edit_one(assiduite_unique: Assiduite, data: dict) -> tuple[int, str]:
else assiduite_unique.external_data else assiduite_unique.external_data
) )
if force and not ( if force and not external_data.get("module", False):
external_data is not None and external_data.get("module", False) != ""
):
errors.append( errors.append(
"param 'moduleimpl_id' : le moduleimpl_id ne peut pas être nul" "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() annee: int = scu.annee_scolaire()
assiduites_query: Query = assiduites_query.filter( assiduites_query: Query = assiduites_query.filter(
Assiduite.date_debut >= scu.date_debut_annee_scolaire(annee), Assiduite.date_debut >= scu.date_debut_anne_scolaire(annee),
Assiduite.date_fin <= scu.date_fin_annee_scolaire(annee), Assiduite.date_fin <= scu.date_fin_anne_scolaire(annee),
) )
return assiduites_query return assiduites_query

View File

@ -1,16 +1,11 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
""" """
API : billets d'absences API : billets d'absences
CATEGORY
--------
Billets d'absence
""" """
from flask import g, request from flask import g, request
@ -34,7 +29,7 @@ from app.scodoc.sco_permissions import Permission
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def billets_absence_etudiant(etudid: int): 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) billets = sco_abs_billets.query_billets_etud(etudid)
return [billet.to_dict() for billet in billets] return [billet.to_dict() for billet in billets]
@ -46,20 +41,7 @@ def billets_absence_etudiant(etudid: int):
@permission_required(Permission.AbsAddBillet) @permission_required(Permission.AbsAddBillet)
@as_json @as_json
def billets_absence_create(): def billets_absence_create():
"""Ajout d'un billet d'absence. Renvoie le billet créé en json. """Ajout d'un billet d'absence"""
DATA
----
```json
{
"etudid" : int,
"abs_begin" : date_iso,
"abs_end" : date_iso,
"description" : string,
"justified" : bool
}
```
"""
data = request.get_json(force=True) # may raise 400 Bad Request data = request.get_json(force=True) # may raise 400 Bad Request
etudid = data.get("etudid") etudid = data.get("etudid")
abs_begin = data.get("abs_begin") abs_begin = data.get("abs_begin")

View File

@ -1,19 +1,14 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
""" """
ScoDoc 9 API : accès aux départements 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). mais évidemment pas sur l'API web (/ScoDoc/<dept>/api).
CATEGORY
--------
Département
""" """
from datetime import datetime from datetime import datetime
@ -21,15 +16,26 @@ from flask import request
from flask_json import as_json from flask_json import as_json
from flask_login import login_required from flask_login import login_required
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_bp as bp, API_CLIENT_ERROR
from app.api import api_permission_required as permission_required from app.scodoc.sco_utils import json_error
from app.decorators import scodoc from app.decorators import scodoc, permission_required
from app.models import Departement, FormSemestre from app.models import Departement, FormSemestre
from app.models import departements from app.models import departements
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
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") @bp.route("/departements")
@ -38,7 +44,7 @@ from app.scodoc.sco_utils import json_error
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def departements_list(): 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] return [dept.to_dict(with_dept_name=True) for dept in Departement.query]
@ -48,7 +54,7 @@ def departements_list():
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def departements_ids(): 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] return [dept.id for dept in Departement.query]
@ -57,12 +63,11 @@ def departements_ids():
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def departement_by_acronym(acronym: str): def departement(acronym: str):
""" """
Info sur un département. Accès par acronyme. Info sur un département. Accès par acronyme.
Exemple de résultat : Exemple de résultat :
```json
{ {
"id": 1, "id": 1,
"acronym": "TAPI", "acronym": "TAPI",
@ -71,7 +76,6 @@ def departement_by_acronym(acronym: str):
"visible": true, "visible": true,
"date_creation": "Fri, 15 Apr 2022 12:19:28 GMT" "date_creation": "Fri, 15 Apr 2022 12:19:28 GMT"
} }
```
""" """
dept = Departement.query.filter_by(acronym=acronym).first_or_404() dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return dept.to_dict(with_dept_name=True) return dept.to_dict(with_dept_name=True)
@ -98,15 +102,11 @@ def departement_by_id(dept_id: int):
def departement_create(): def departement_create():
""" """
Création d'un département. Création d'un département.
Le content type doit être `application/json`. The request content type should be "application/json":
DATA
----
```json
{ {
"acronym": str, "acronym": str,
"visible": bool, "visible":bool,
} }
```
""" """
data = request.get_json(force=True) # may raise 400 Bad Request data = request.get_json(force=True) # may raise 400 Bad Request
acronym = str(data.get("acronym", "")) acronym = str(data.get("acronym", ""))
@ -117,9 +117,6 @@ def departement_create():
dept = departements.create_dept(acronym, visible=visible) dept = departements.create_dept(acronym, visible=visible)
except ScoValueError as exc: except ScoValueError as exc:
return json_error(500, exc.args[0] if exc.args else "") return json_error(500, exc.args[0] if exc.args else "")
log(f"departement_create {dept.acronym}")
return dept.to_dict() return dept.to_dict()
@ -130,12 +127,10 @@ def departement_create():
@as_json @as_json
def departement_edit(acronym): def departement_edit(acronym):
""" """
Édition d'un département: seul le champ `visible` peut être modifié. Edition d'un département: seul visible peut être modifié
The request content type should be "application/json":
DATA
----
{ {
"visible": bool, "visible":bool,
} }
""" """
dept = Departement.query.filter_by(acronym=acronym).first_or_404() dept = Departement.query.filter_by(acronym=acronym).first_or_404()
@ -147,7 +142,6 @@ def departement_edit(acronym):
dept.visible = visible dept.visible = visible
db.session.add(dept) db.session.add(dept)
db.session.commit() db.session.commit()
log(f"departement_edit {dept.acronym}")
return dept.to_dict() return dept.to_dict()
@ -157,13 +151,11 @@ def departement_edit(acronym):
@permission_required(Permission.ScoSuperAdmin) @permission_required(Permission.ScoSuperAdmin)
def departement_delete(acronym): 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() dept = Departement.query.filter_by(acronym=acronym).first_or_404()
acronym = dept.acronym
db.session.delete(dept) db.session.delete(dept)
db.session.commit() db.session.commit()
log(f"departement_delete {acronym}")
return {"OK": True} return {"OK": True}
@ -172,16 +164,13 @@ def departement_delete(acronym):
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @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 : Exemple de résultat :
```json
[ [
{ {
"civilite": "M", "civilite": "M",
@ -196,7 +185,6 @@ def departement_etudiants(acronym: str):
}, },
... ...
] ]
```
""" """
dept = Departement.query.filter_by(acronym=acronym).first_or_404() dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return [etud.to_dict_short() for etud in dept.etudiants] return [etud.to_dict_short() for etud in dept.etudiants]
@ -207,7 +195,7 @@ def departement_etudiants(acronym: str):
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @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é. 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 @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def departement_formsemestres_ids(acronym: str): def dept_formsemestres_ids(acronym: str):
"""Liste des ids de tous les formsemestres du département.""" """liste des ids formsemestre du département"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404() dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return [formsemestre.id for formsemestre in dept.formsemestres] return [formsemestre.id for formsemestre in dept.formsemestres]
@ -231,39 +219,100 @@ def departement_formsemestres_ids(acronym: str):
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def departement_formsemestres_ids_by_id(dept_id: int): def dept_formsemestres_ids_by_id(dept_id: int):
"""Liste des ids de tous les formsemestres du département.""" """liste des ids formsemestre du département"""
dept = Departement.query.get_or_404(dept_id) dept = Departement.query.get_or_404(dept_id)
return [formsemestre.id for formsemestre in dept.formsemestres] return [formsemestre.id for formsemestre in dept.formsemestres]
@bp.route("/departement/<string:acronym>/formsemestres_courants") @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") @bp.route("/departement/id/<int:dept_id>/formsemestres_courants")
@login_required @login_required
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @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) Liste des semestres actifs d'un département d'id donné
contenant la date courante, ou à défaut celle indiquée en argument
(au format ISO).
QUERY
-----
date_courante:<string:date_courante>
""" """
dept = ( # Le département, spécifié par un id ou un acronyme
Departement.query.filter_by(acronym=acronym).first_or_404() dept = Departement.query.get_or_404(dept_id)
if acronym
else Departement.query.get_or_404(dept_id)
)
date_courante = request.args.get("date_courante") date_courante = request.args.get("date_courante")
date_courante = datetime.fromisoformat(date_courante) if date_courante else None if date_courante:
return [ test_date = datetime.fromisoformat(date_courante)
formsemestre.to_dict_api() else:
for formsemestre in FormSemestre.get_dept_formsemestres_courants( test_date = app.db.func.now()
dept, date_courante # 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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
""" """
API : accès aux étudiants API : accès aux étudiants
CATEGORY
--------
Étudiants
""" """
from datetime import datetime from datetime import datetime
from operator import attrgetter from operator import attrgetter
from flask import g, request, Response from flask import g, request
from flask_json import as_json from flask_json import as_json
from flask_login import current_user from flask_login import current_user
from flask_login import login_required from flask_login import login_required
@ -22,28 +18,24 @@ from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import VARCHAR from sqlalchemy.dialects.postgresql import VARCHAR
import app import app
from app import db, log
from app.api import api_bp as bp, api_web_bp from app.api import api_bp as bp, api_web_bp
from app.api import tools from app.api import tools
from app.api import api_permission_required as permission_required
from app.but import bulletin_but_court from app.but import bulletin_but_court
from app.decorators import scodoc from app.decorators import scodoc, permission_required
from app.models import ( from app.models import (
Admission, Admission,
Departement, Departement,
EtudAnnotation,
FormSemestreInscription, FormSemestreInscription,
FormSemestre, FormSemestre,
Identite, Identite,
ScolarNews,
) )
from app.scodoc import sco_bulletins from app.scodoc import sco_bulletins
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc import sco_etud
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_photos
from app.scodoc.sco_utils import json_error, suppress_accents 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 import app.scodoc.sco_utils as scu
# Un exemple: # 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", defaults={"long": False})
@bp.route("/etudiants/courants/long", defaults={"long": True}) @bp.route("/etudiants/courants/long", defaults={"long": True})
@api_web_bp.route("/etudiants/courants", defaults={"long": False}) @api_web_bp.route("/etudiants/courants", defaults={"long": False})
@ -93,20 +59,14 @@ def _get_etud_by_code(
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def etudiants_courants(long: bool = False): def etudiants_courants(long=False):
""" """
La liste des étudiants des semestres "courants". La liste des étudiants des semestres "courants" (tous départements)
Considère tous les départements dans lesquels l'utilisateur a la (date du jour comprise dans la période couverte par le sem.)
permission `ScoView` (donc tous si le dépt. du rôle est `None`), dans lesquels l'utilisateur a la permission ScoView
et les formsemestres contenant la date courante, (donc tous si le dept du rôle est None).
ou à défaut celle indiquée en argument (au format ISO).
QUERY
-----
date_courante:<string:date_courante>
Exemple de résultat : Exemple de résultat :
```json
[ [
{ {
"id": 1234, "id": 1234,
@ -119,7 +79,6 @@ def etudiants_courants(long: bool = False):
} }
... ...
] ]
```
En format "long": voir documentation. En format "long": voir documentation.
@ -142,10 +101,7 @@ def etudiants_courants(long: bool = False):
or_(Departement.acronym == acronym for acronym in allowed_depts) or_(Departement.acronym == acronym for acronym in allowed_depts)
) )
if long: if long:
restrict = not current_user.has_permission(Permission.ViewEtudData) data = [etud.to_dict_api() for etud in etuds]
data = [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in etuds
]
else: else:
data = [etud.to_dict_short() for etud in etuds] data = [etud.to_dict_short() for etud in etuds]
return data 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é. Retourne les informations de l'étudiant correspondant, ou 404 si non trouvé.
PARAMS
------
etudid : l'etudid de l'étudiant etudid : l'etudid de l'étudiant
nip : le code nip de l'étudiant nip : le code nip de l'étudiant
ine : le code ine 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. 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. 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, 404,
message="étudiant inconnu", 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") @bp.route("/etudiant/etudid/<int:etudid>/photo")
@ -195,18 +148,11 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
@login_required @login_required
@scodoc @scodoc
@permission_required(Permission.ScoView) @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. Retourne la photo de l'étudiant
Le paramètre `size` peut prendre la valeur `small` (taille réduite, hauteur correspondant ou un placeholder si non existant.
environ 90 pixels) ou `orig` (défaut, image de la taille originale).
QUERY
-----
size:<string:size>
PARAMS
------
etudid : l'etudid de l'étudiant etudid : l'etudid de l'étudiant
nip : le code nip de l'étudiant nip : le code nip de l'étudiant
ine : le code ine 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 @scodoc
@permission_required(Permission.EtudChangeAdr) @permission_required(Permission.EtudChangeAdr)
@as_json @as_json
def etudiant_set_photo_image(etudid: int = None): def set_photo_image(etudid: int = None):
"""Enregistre la photo de l'étudiant.""" """Enregistre la photo de l'étudiant."""
allowed_depts = current_user.get_depts_with_permission(Permission.EtudChangeAdr) allowed_depts = current_user.get_depts_with_permission(Permission.EtudChangeAdr)
query = Identite.query.filter_by(id=etudid) query = Identite.query.filter_by(id=etudid)
@ -279,12 +225,9 @@ def etudiant_set_photo_image(etudid: int = None):
@as_json @as_json
def etudiants(etudid: int = None, nip: str = None, ine: str = None): def etudiants(etudid: int = None, nip: str = None, ine: str = None):
""" """
Info sur le ou les étudiants correspondants. Info sur le ou les étudiants correspondant. Comme /etudiant mais renvoie
toujours une liste.
Comme `/etudiant` mais renvoie toujours une liste.
Si non trouvé, liste vide, pas d'erreur. Si non trouvé, liste vide, pas d'erreur.
Dans 99% des cas, la liste contient un seul étudiant, mais si l'étudiant a 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.). é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( query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts) or_(Departement.acronym == acronym for acronym in allowed_depts)
) )
restrict = not current_user.has_permission(Permission.ViewEtudData) return [etud.to_dict_api() for etud in query]
return [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in query
]
@bp.route("/etudiants/name/<string:start>") @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) @permission_required(Permission.ScoView)
@as_json @as_json
def etudiants_by_name(start: str = "", min_len=3, limit=32): def etudiants_by_name(start: str = "", min_len=3, limit=32):
"""Liste des étudiants dont le nom débute par `start`. """Liste des étudiants dont le nom débute par start.
Si start fait moins de min_len=3 caractères, liste vide.
Si `start` fait moins de `min_len=3` caractères, liste vide.
La casse et les accents sont ignorés. La casse et les accents sont ignorés.
""" """
if len(start) < min_len: 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) 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: # 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() for etud in sorted(etuds, key=attrgetter("sort_key"))]
return [
etud.to_dict_api(restrict=restrict)
for etud in sorted(etuds, key=attrgetter("sort_key"))
]
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres") @bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@ -354,13 +289,13 @@ def etudiants_by_name(start: str = "", min_len=3, limit=32):
@as_json @as_json
def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None): 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. 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`. 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: if etudid is not None:
q_etud = Identite.query.filter_by(id=etudid) q_etud = Identite.query.filter_by(id=etudid)
@ -421,43 +356,48 @@ def bulletin(
code_type: str = "etudid", code_type: str = "etudid",
code: str = None, code: str = None,
formsemestre_id: int = None, formsemestre_id: int = None,
version: str = "selectedevals", version: str = "long",
pdf: bool = False, pdf: bool = False,
with_img_signatures_pdf: bool = True, with_img_signatures_pdf: bool = True,
): ):
""" """
Retourne le bulletin d'un étudiant dans un formsemestre. Retourne le bulletin d'un étudiant dans un formsemestre.
PARAMS
------
formsemestre_id : l'id d'un formsemestre formsemestre_id : l'id d'un formsemestre
code_type : "etudid", "nip" ou "ine" code_type : "etudid", "nip" ou "ine"
code : valeur du code INE, NIP ou etudid, selon code_type. 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). pdf : si spécifié, bulletin au format PDF (et non JSON).
Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin
""" """
if version == "pdf": if version == "pdf":
version = "long" version = "long"
pdf = True pdf = True
formsemestre = FormSemestre.get_formsemestre(formsemestre_id) if version not in scu.BULLETINS_VERSIONS_BUT:
if version not in (
scu.BULLETINS_VERSIONS_BUT
if formsemestre.formation.is_apc()
else scu.BULLETINS_VERSIONS
):
return json_error(404, "version invalide") return json_error(404, "version invalide")
if formsemestre.bul_hide_xml and pdf: # return f"{code_type}={code}, version={version}, pdf={pdf}"
return json_error(403, "bulletin non disponible") formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
# note: la version json est réduite si bul_hide_xml
dept = Departement.query.filter_by(id=formsemestre.dept_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: if g.scodoc_dept and dept.acronym != g.scodoc_dept:
return json_error(404, "formsemestre inexistant") return json_error(404, "formsemestre inexistant")
app.set_sco_dept(dept.acronym) app.set_sco_dept(dept.acronym)
ok, etud = _get_etud_by_code(code_type, code, dept) if code_type == "nip":
if not ok: query = Identite.query.filter_by(code_nip=code, dept_id=dept.id)
return etud # json error 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 version == "butcourt":
if pdf: if pdf:
@ -478,9 +418,9 @@ def bulletin(
) )
@bp.route("/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups") @bp.route(
@api_web_bp.route( "/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups",
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups" methods=["GET"],
) )
@scodoc @scodoc
@permission_required(Permission.ScoView) @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é Retourne la liste des groupes auxquels appartient l'étudiant dans le formsemestre indiqué
PARAMS
------
formsemestre_id : l'id d'un formsemestre formsemestre_id : l'id d'un formsemestre
etudid : l'etudid d'un étudiant etudid : l'etudid d'un étudiant
Exemple de résultat : Exemple de résultat :
```json
[ [
{ {
"partition_id": 1, "partition_id": 1,
@ -520,8 +457,8 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
"group_name": "A" "group_name": "A"
} }
] ]
```
""" """
query = FormSemestre.query.filter_by(id=formsemestre_id) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id) 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) data = sco_groups.get_etud_groups(etud.id, formsemestre.id)
return data 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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
""" """
ScoDoc 9 API : accès aux évaluations ScoDoc 9 API : accès aux évaluations
CATEGORY
--------
Évaluations
""" """
from flask import g, request from flask import g, request
from flask_json import as_json from flask_json import as_json
@ -18,8 +14,7 @@ from flask_login import current_user, login_required
import app import app
from app import log, db from app import log, db
from app.api import api_bp as bp, api_web_bp 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, permission_required
from app.decorators import scodoc
from app.models import Evaluation, ModuleImpl, FormSemestre from app.models import Evaluation, ModuleImpl, FormSemestre
from app.scodoc import sco_evaluation_db, sco_saisie_notes from app.scodoc import sco_evaluation_db, sco_saisie_notes
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError 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): def get_evaluation(evaluation_id: int):
"""Description d'une évaluation. """Description d'une évaluation.
DATA
----
```json
{ {
'coefficient': 1.0, 'coefficient': 1.0,
'date_debut': '2016-01-04T08:30:00', 'date_debut': '2016-01-04T08:30:00',
'date_fin': '2016-01-04T12:30:00', 'date_fin': '2016-01-04T12:30:00',
'description': 'TP Température', 'description': 'TP NI9219 Température',
'evaluation_type': 0, 'evaluation_type': 0,
'id': 15797, 'id': 15797,
'moduleimpl_id': 1234, 'moduleimpl_id': 1234,
'note_max': 20.0, 'note_max': 20.0,
'numero': 3, 'numero': 3,
'poids': { 'poids': {
'UE1.1': 1.0, 'UE1.1': 1.0,
'UE1.2': 1.0, 'UE1.2': 1.0,
'UE1.3': 1.0 'UE1.3': 1.0
}, },
'publish_incomplete': False, 'publish_incomplete': False,
'visibulletin': True 'visibulletin': True
} }
```
""" """
query = Evaluation.query.filter_by(id=evaluation_id) query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -76,18 +67,22 @@ def get_evaluation(evaluation_id: int):
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @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 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) query = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id)
return [evaluation.to_dict_api() for evaluation in modimpl.evaluations] 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") @bp.route("/evaluation/<int:evaluation_id>/notes")
@ -98,36 +93,30 @@ def moduleimpl_evaluations(moduleimpl_id: int):
@as_json @as_json
def evaluation_notes(evaluation_id: int): def evaluation_notes(evaluation_id: int):
""" """
Retourne la liste des notes de l'évaluation. Retourne la liste des notes de l'évaluation
PARAMS
------
evaluation_id : l'id de l'évaluation evaluation_id : l'id de l'évaluation
Exemple de résultat : Exemple de résultat :
```json {
{ "11": {
"11": {
"etudid": 11, "etudid": 11,
"evaluation_id": 1, "evaluation_id": 1,
"value": 15.0, "value": 15.0,
"note_max" : 20.0,
"comment": "", "comment": "",
"date": "2024-07-19T19:08:44+02:00", "date": "Wed, 20 Apr 2022 06:49:05 GMT",
"uid": 2 "uid": 2
}, },
"12": { "12": {
"etudid": 12, "etudid": 12,
"evaluation_id": 1, "evaluation_id": 1,
"value": "ABS", "value": 12.0,
"note_max" : 20.0,
"comment": "", "comment": "",
"date": "2024-07-19T19:08:44+02:00", "date": "Wed, 20 Apr 2022 06:49:06 GMT",
"uid": 2 "uid": 2
}, },
... ...
} }
```
""" """
query = Evaluation.query.filter_by(id=evaluation_id) query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -159,20 +148,15 @@ def evaluation_notes(evaluation_id: int):
@scodoc @scodoc
@permission_required(Permission.EnsView) @permission_required(Permission.EnsView)
@as_json @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. """Écriture de notes dans une évaluation.
The request content type should be "application/json",
DATA and contains:
----
```json
{ {
'notes' : [ [etudid, value], ... ], 'notes' : [ [etudid, value], ... ],
'comment' : optional string 'comment' : optional string
} }
``` Result:
Résultat:
- nb_changed: nombre de notes changées - nb_changed: nombre de notes changées
- nb_suppress: nombre de notes effacées - nb_suppress: nombre de notes effacées
- etudids_with_decision: liste des etudiants dont la note a changé - 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 @as_json
def evaluation_create(moduleimpl_id: int): def evaluation_create(moduleimpl_id: int):
"""Création d'une évaluation. """Création d'une évaluation.
The request content type should be "application/json",
DATA and contains:
----
{ {
"description" : str, "description" : str,
"evaluation_type" : int, // {0,1,2} default 0 (normale) "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 "coefficient" : float, // si non spécifié, 1.0
"poids" : { ue_id : poids } // optionnel "poids" : { ue_id : poids } // optionnel
} }
Result: l'évaluation créée.
Résultat: l'évaluation créée.
""" """
moduleimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id) moduleimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id)
if not moduleimpl.can_edit_evaluation(current_user): if not moduleimpl.can_edit_evaluation(current_user):
@ -273,7 +255,7 @@ def evaluation_create(moduleimpl_id: int):
@as_json @as_json
def evaluation_delete(evaluation_id: int): def evaluation_delete(evaluation_id: int):
"""Suppression d'une évaluation. """Suppression d'une évaluation.
Efface aussi toutes ses notes. Efface aussi toutes ses notes
""" """
query = Evaluation.query.filter_by(id=evaluation_id) query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept: if g.scodoc_dept:

View File

@ -1,15 +1,11 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
""" """
ScoDoc 9 API : accès aux formations ScoDoc 9 API : accès aux formations
CATEGORY
--------
Formations
""" """
from flask import flash, g, request from flask import flash, g, request
@ -19,15 +15,12 @@ from flask_login import login_required
import app import app
from app import db, log from app import db, log
from app.api import api_bp as bp, api_web_bp 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.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required
from app.models import ( from app.models import (
ApcNiveau, ApcNiveau,
ApcParcours, ApcParcours,
Formation, Formation,
Module,
UniteEns, UniteEns,
) )
from app.scodoc import sco_formations from app.scodoc import sco_formations
@ -42,8 +35,7 @@ from app.scodoc.sco_permissions import Permission
@as_json @as_json
def formations(): def formations():
""" """
Retourne la liste de toutes les formations (tous départements, Retourne la liste de toutes les formations (tous départements)
sauf si route départementale).
""" """
query = Formation.query query = Formation.query
if g.scodoc_dept: if g.scodoc_dept:
@ -60,10 +52,9 @@ def formations():
@as_json @as_json
def formations_ids(): def formations_ids():
""" """
Retourne la liste de toutes les id de formations Retourne la liste de toutes les id de formations (tous départements)
(tous départements, ou du département indiqué dans la route)
Exemple de résultat : `[ 17, 99, 32 ]`. Exemple de résultat : [ 17, 99, 32 ]
""" """
query = Formation.query query = Formation.query
if g.scodoc_dept: if g.scodoc_dept:
@ -79,26 +70,24 @@ def formations_ids():
@as_json @as_json
def formation_by_id(formation_id: int): 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 : Exemple de résultat :
{
```json "id": 1,
{ "acronyme": "BUT R&amp;T",
"id": 1, "titre_officiel": "Bachelor technologique réseaux et télécommunications",
"acronyme": "BUT R&amp;T", "formation_code": "V1RET",
"titre_officiel": "Bachelor technologique réseaux et télécommunications", "code_specialite": null,
"formation_code": "V1RET", "dept_id": 1,
"code_specialite": null, "titre": "BUT R&amp;T",
"dept_id": 1, "version": 1,
"titre": "BUT R&amp;T", "type_parcours": 700,
"version": 1, "referentiel_competence_id": null,
"type_parcours": 700, "formation_id": 1
"referentiel_competence_id": null, }
"formation_id": 1
}
```
""" """
query = Formation.query.filter_by(id=formation_id) query = Formation.query.filter_by(id=formation_id)
if g.scodoc_dept: 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 Retourne la formation, avec UE, matières, modules
PARAMS
------
formation_id : l'id d'une formation 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 : 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", "id": 1,
"numero": 1, "acronyme": "BUT R&amp;T",
"titre": "Administrer les r\u00e9seaux et l\u2019Internet", "titre_officiel": "Bachelor technologique r\u00e9seaux et t\u00e9l\u00e9communications",
"type": 0, "formation_code": "V1RET",
"ue_code": "UCOD11", "code_specialite": null,
"ects": 12.0, "dept_id": 1,
"is_external": false, "titre": "BUT R&amp;T",
"code_apogee": "", "version": 1,
"coefficient": 0.0, "type_parcours": 700,
"semestre_idx": 1, "referentiel_competence_id": null,
"color": "#B80004", "formation_id": 1,
"reference": 1, "ue": [
"matiere": [
{ {
"titre": "Administrer les r\u00e9seaux et l\u2019Internet", "acronyme": "RT1.1",
"numero": 1, "numero": 1,
"module": [ "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", "titre": "Administrer les r\u00e9seaux et l\u2019Internet",
"abbrev": "Init aux r\u00e9seaux informatiques", "numero": 1,
"code": "R101", "module": [
"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", "titre": "Initiation aux r\u00e9seaux informatiques",
"coef": "12.0" "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", "titre": "Se sensibiliser \u00e0 l&apos;hygi\u00e8ne informatique...",
"coef": "4.0" "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) query = Formation.query.filter_by(id=formation_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -248,8 +232,11 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
@as_json @as_json
def referentiel_competences(formation_id: int): def referentiel_competences(formation_id: int):
""" """
Retourne le référentiel de compétences de la formation Retourne le référentiel de compétences
ou null si pas de référentiel associé.
formation_id : l'id d'une formation
return null si pas de référentiel associé.
""" """
query = Formation.query.filter_by(id=formation_id) query = Formation.query.filter_by(id=formation_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -260,22 +247,16 @@ def referentiel_competences(formation_id: int):
return formation.referentiel_competence.to_dict() return formation.referentiel_competence.to_dict()
@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("/formation/ue/<int:ue_id>/set_parcours", methods=["POST"]) @api_web_bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
@login_required @login_required
@scodoc @scodoc
@permission_required(Permission.EditFormation) @permission_required(Permission.EditFormation)
@as_json @as_json
def ue_set_parcours(ue_id: int): def set_ue_parcours(ue_id: int):
"""Associe UE et parcours BUT. """Associe UE et parcours BUT.
La liste des ids de parcours est passée en argument JSON. La liste des ids de parcours est passée en argument JSON.
JSON arg: [parcour_id1, parcour_id2, ...]
DATA
----
```json
[ parcour_id1, parcour_id2, ... ]
```
""" """
query = UniteEns.query.filter_by(id=ue_id) query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -288,7 +269,7 @@ def ue_set_parcours(ue_id: int):
parcours = [ parcours = [
ApcParcours.query.get_or_404(int(parcour_id)) for parcour_id in parcours_ids 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) ok, error_message = ue.set_parcours(parcours)
if not ok: if not ok:
return json_error(404, error_message) return json_error(404, error_message)
@ -296,19 +277,19 @@ def ue_set_parcours(ue_id: int):
@bp.route( @bp.route(
"/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>", "/assoc_ue_niveau/<int:ue_id>/<int:niveau_id>",
methods=["POST"], methods=["POST"],
) )
@api_web_bp.route( @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"], methods=["POST"],
) )
@login_required @login_required
@scodoc @scodoc
@permission_required(Permission.EditFormation) @permission_required(Permission.EditFormation)
@as_json @as_json
def ue_assoc_niveau(ue_id: int, niveau_id: int): def assoc_ue_niveau(ue_id: int, niveau_id: int):
"""Associe l'UE au niveau de compétence.""" """Associe l'UE au niveau de compétence"""
query = UniteEns.query.filter_by(id=ue_id) query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id) 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( @bp.route(
"/formation/ue/<int:ue_id>/desassoc_niveau", "/desassoc_ue_niveau/<int:ue_id>",
methods=["POST"], methods=["POST"],
) )
@api_web_bp.route( @api_web_bp.route(
"/formation/ue/<int:ue_id>/desassoc_niveau", "/desassoc_ue_niveau/<int:ue_id>",
methods=["POST"], methods=["POST"],
) )
@login_required @login_required
@scodoc @scodoc
@permission_required(Permission.EditFormation) @permission_required(Permission.EditFormation)
@as_json @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 """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) query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id) query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404() ue: UniteEns = query.first_or_404()
ok, error_message = ue.set_niveau_competence(None) ue.niveau_competence = None
if not ok: db.session.add(ue)
if g.scodoc_dept: # "usage web" db.session.commit()
flash(error_message, "error") log(f"desassoc_ue_niveau: {ue}")
return json_error(404, error_message) if g.scodoc_dept:
if g.scodoc_dept: # "usage web" # "usage web"
flash(f"UE {ue.acronyme} dé-associée") flash(f"UE {ue.acronyme} dé-associée")
return {"status": 0} 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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
""" """
ScoDoc 9 API : accès aux formsemestres ScoDoc 9 API : accès aux formsemestres
CATEGORY
--------
FormSemestre
""" """
from operator import attrgetter, itemgetter from operator import attrgetter, itemgetter
from flask import g, make_response, request from flask import g, make_response, request
from flask_json import as_json from flask_json import as_json
from flask_login import current_user, login_required from flask_login import login_required
import sqlalchemy as sa
import app 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_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.api import api_permission_required as permission_required from app.decorators import scodoc, permission_required
from app.decorators import scodoc
from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import json_error
from app.comp import res_sem from app.comp import res_sem
from app.comp.moy_mod import ModuleImplResults 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_permissions import Permission
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
import app.scodoc.sco_utils as scu 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>") @bp.route("/formsemestre/<int:formsemestre_id>")
@ -61,37 +54,35 @@ def formsemestre_infos(formsemestre_id: int):
formsemestre_id : l'id du formsemestre formsemestre_id : l'id du formsemestre
Exemple de résultat : Exemple de résultat :
```json {
{ "block_moyennes": false,
"block_moyennes": false, "bul_bgcolor": "white",
"bul_bgcolor": "white", "bul_hide_xml": false,
"bul_hide_xml": false, "date_debut_iso": "2021-09-01",
"date_debut_iso": "2021-09-01", "date_debut": "01/09/2021",
"date_debut": "01/09/2021", "date_fin_iso": "2022-08-31",
"date_fin_iso": "2022-08-31", "date_fin": "31/08/2022",
"date_fin": "31/08/2022", "dept_id": 1,
"dept_id": 1, "elt_annee_apo": null,
"elt_annee_apo": null, "elt_sem_apo": null,
"elt_passage_apo" : null, "ens_can_edit_eval": false,
"elt_sem_apo": null, "etat": true,
"ens_can_edit_eval": false, "formation_id": 1,
"etat": true, "formsemestre_id": 1,
"formation_id": 1, "gestion_compensation": false,
"formsemestre_id": 1, "gestion_semestrielle": false,
"gestion_compensation": false, "id": 1,
"gestion_semestrielle": false, "modalite": "FI",
"id": 1, "resp_can_change_ens": true,
"modalite": "FI", "resp_can_edit": false,
"resp_can_change_ens": true, "responsables": [1, 99], // uids
"resp_can_edit": false, "scodoc7_id": null,
"responsables": [1, 99], // uids "semestre_id": 1,
"scodoc7_id": null, "titre_formation" : "BUT GEA",
"semestre_id": 1, "titre_num": "BUT GEA semestre 1",
"titre_formation" : "BUT GEA", "titre": "BUT GEA",
"titre_num": "BUT GEA semestre 1", }
"titre": "BUT GEA",
}
```
""" """
query = FormSemestre.query.filter_by(id=formsemestre_id) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -108,28 +99,15 @@ def formsemestre_infos(formsemestre_id: int):
@as_json @as_json
def formsemestres_query(): def formsemestres_query():
""" """
Retourne les formsemestres filtrés par étape Apogée ou année scolaire Retourne les formsemestres filtrés par
ou département (acronyme ou id) ou état ou code étudiant. é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 etape_apo : un code étape apogée
annee_scolaire : année de début de l'année scolaire annee_scolaire : année de début de l'année scolaire
dept_acronym : acronyme du département (eg "RT") dept_acronym : acronyme du département (eg "RT")
dept_id : id du département dept_id : id du département
ine ou nip: code d'un étudiant: ramène alors tous les semestres auxquels il est inscrit. ine ou nip: code d'un étudiant: ramène alors tous les semestres auxquels il est inscrit.
etat: 0 si verrouillé, 1 sinon 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") etape_apo = request.args.get("etape_apo")
annee_scolaire = request.args.get("annee_scolaire") annee_scolaire = request.args.get("annee_scolaire")
@ -146,8 +124,8 @@ def formsemestres_query():
annee_scolaire_int = int(annee_scolaire) annee_scolaire_int = int(annee_scolaire)
except ValueError: except ValueError:
return json_error(API_CLIENT_ERROR, "invalid annee_scolaire: not int") return json_error(API_CLIENT_ERROR, "invalid annee_scolaire: not int")
debut_annee = scu.date_debut_annee_scolaire(annee_scolaire_int) debut_annee = scu.date_debut_anne_scolaire(annee_scolaire_int)
fin_annee = scu.date_fin_annee_scolaire(annee_scolaire_int) fin_annee = scu.date_fin_anne_scolaire(annee_scolaire_int)
formsemestres = formsemestres.filter( formsemestres = formsemestres.filter(
FormSemestre.date_fin >= debut_annee, FormSemestre.date_debut <= fin_annee 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")
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>") @bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins") @api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
@ -418,12 +181,9 @@ def formsemestre_set_elt_passage_apo():
@as_json @as_json
def bulletins(formsemestre_id: int, version: str = "long"): def bulletins(formsemestre_id: int, version: str = "long"):
""" """
Retourne les bulletins d'un formsemestre. Retourne les bulletins d'un formsemestre donné
PARAMS formsemestre_id : l'id d'un formesemestre
------
formsemestre_id : int
version : string ("long", "short", "selectedevals")
Exemple de résultat : liste, voir https://scodoc.org/ScoDoc9API/#bulletin 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 Retourne la liste des UEs, ressources et SAEs d'un semestre
formsemestre_id : l'id d'un formsemestre
Exemple de résultat : 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
},
... ...
}, ],
... "ressources": [
], {
"modules" : [ ... les modules qui ne sont ni des SAEs ni des 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) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -579,16 +338,7 @@ def formsemestre_programme(formsemestre_id: int):
def formsemestre_etudiants( def formsemestre_etudiants(
formsemestre_id: int, with_query: bool = False, long: bool = False formsemestre_id: int, with_query: bool = False, long: bool = False
): ):
"""Étudiants d'un formsemestre. """Étudiants d'un formsemestre."""
Si l'état est spécifié, ne renvoie que les inscrits (`I`), les
démissionnaires (`D`) ou les défaillants (`DEF`)
QUERY
-----
etat:<string:etat>
"""
query = FormSemestre.query.filter_by(id=formsemestre_id) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id) query = query.filter_by(dept_id=g.scodoc_dept_id)
@ -610,8 +360,7 @@ def formsemestre_etudiants(
inscriptions = formsemestre.inscriptions inscriptions = formsemestre.inscriptions
if long: if long:
restrict = not current_user.has_permission(Permission.ViewEtudData) etuds = [ins.etud.to_dict_api() for ins in inscriptions]
etuds = [ins.etud.to_dict_api(restrict=restrict) for ins in inscriptions]
else: else:
etuds = [ins.etud.to_dict_short() for ins in inscriptions] etuds = [ins.etud.to_dict_short() for ins in inscriptions]
# Ajout des groupes de chaque étudiants # Ajout des groupes de chaque étudiants
@ -630,13 +379,13 @@ def formsemestre_etudiants(
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def formsemestre_etat_evaluations(formsemestre_id: int): def etat_evals(formsemestre_id: int):
""" """
Informations sur l'état des évaluations d'un formsemestre. 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 "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) app.set_sco_dept(formsemestre.departement.acronym)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) 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: for modimpl_id in nt.modimpls_results:
modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl_id] modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl_id]
modimpl: ModuleImpl = ModuleImpl.query.get_or_404(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 = [] list_eval = []
for evaluation_id in modimpl_results.evaluations_etat: 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 date_mediane = notes_sorted[len(notes_sorted) // 2].date
eval_dict["saisie_notes"] = { eval_dict["saisie_notes"] = {
"datetime_debut": ( "datetime_debut": date_debut.isoformat()
date_debut.isoformat() if date_debut is not None else None if date_debut is not None
), else None,
"datetime_fin": date_fin.isoformat() if date_fin is not None else None, "datetime_fin": date_fin.isoformat() if date_fin is not None else None,
"datetime_mediane": ( "datetime_mediane": date_mediane.isoformat()
date_mediane.isoformat() if date_mediane is not None else None if date_mediane is not None
), else None,
} }
list_eval.append(eval_dict) list_eval.append(eval_dict)
@ -739,16 +490,8 @@ def formsemestre_etat_evaluations(formsemestre_id: int):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def formsemestre_resultat(formsemestre_id: int): 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. 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) format_spec = request.args.get("format", None)
if format_spec is not None and format_spec != "raw": 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) formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
app.set_sco_dept(formsemestre.departement.acronym) app.set_sco_dept(formsemestre.departement.acronym)
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) 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( table = TableRecap(
res, res, convert_values=convert_values, include_evaluations=False, mode_jury=False
convert_values=convert_values,
include_evaluations=False,
mode_jury=False,
row_class=RowRecapAPI,
) )
# Supprime les champs inutiles (mise en forme)
rows = table.to_list() 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 return rows
@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>/groups_get_auto_assignment") @api_web_bp.route("/formsemestre/<int:formsemestre_id>/get_groups_auto_assignment")
@login_required @login_required
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def groups_get_auto_assignment(formsemestre_id: int): def get_groups_auto_assignment(formsemestre_id: int):
"""Rend les données stockées par `groups_save_auto_assignment`.""" """rend les données"""
query = FormSemestre.query.filter_by(id=formsemestre_id) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id) query = query.filter_by(dept_id=g.scodoc_dept_id)
@ -806,27 +535,22 @@ def groups_get_auto_assignment(formsemestre_id: int):
@bp.route( @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( @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 @login_required
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def groups_save_auto_assignment(formsemestre_id: int): def save_groups_auto_assignment(formsemestre_id: int):
"""Enregistre les données, associées à ce formsemestre. """enregistre les données"""
Usage réservé aux fonctions de gestion des groupes, ne pas utiliser ailleurs.
"""
query = FormSemestre.query.filter_by(id=formsemestre_id) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id) query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_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: if len(request.data) > GROUPS_AUTO_ASSIGNMENT_DATA_MAX:
return json_error(413, "data too large") return json_error(413, "data too large")
formsemestre.groups_auto_assignment_data = request.data formsemestre.groups_auto_assignment_data = request.data
@ -841,23 +565,9 @@ def groups_save_auto_assignment(formsemestre_id: int):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def formsemestre_edt(formsemestre_id: int): def formsemestre_edt(formsemestre_id: int):
"""L'emploi du temps du semestre. """l'emploi du temps du semestre"""
Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur.
Expérimental, ne pas utiliser hors ScoDoc.
QUERY
-----
group_ids : string (optionnel) filtre sur les groupes ScoDoc.
show_modules_titles: show_modules_titles affiche le titre complet du module (défaut), sinon juste le code.
"""
query = FormSemestre.query.filter_by(id=formsemestre_id) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id) query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id) formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
group_ids = request.args.getlist("group_ids", int) return sco_edt_cal.formsemestre_edt_dict(formsemestre)
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
)

View File

@ -1,16 +1,11 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
""" """
ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions. ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions
CATEGORY
--------
Jury
""" """
import datetime import datetime
@ -22,8 +17,7 @@ from flask_login import current_user, login_required
import app import app
from app import db, log 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_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, permission_required
from app.decorators import scodoc
from app.scodoc.sco_exceptions import ScoException from app.scodoc.sco_exceptions import ScoException
from app.but import jury_but_results from app.but import jury_but_results
from app.models import ( from app.models import (
@ -38,7 +32,6 @@ from app.models import (
ScolarNews, ScolarNews,
Scolog, Scolog,
UniteEns, UniteEns,
ValidationDUT120,
) )
from app.scodoc import codes_cursus from app.scodoc import codes_cursus
from app.scodoc import sco_cache from app.scodoc import sco_cache
@ -56,11 +49,6 @@ def decisions_jury(formsemestre_id: int):
"""Décisions du jury des étudiants du formsemestre.""" """Décisions du jury des étudiants du formsemestre."""
# APC, pair: # APC, pair:
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id) formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
if formsemestre is None:
return json_error(
404,
message="formsemestre inconnu",
)
if formsemestre.formation.is_apc(): if formsemestre.formation.is_apc():
app.set_sco_dept(formsemestre.departement.acronym) app.set_sco_dept(formsemestre.departement.acronym)
rows = jury_but_results.get_jury_but_results(formsemestre) rows = jury_but_results.get_jury_but_results(formsemestre)
@ -69,16 +57,16 @@ def decisions_jury(formsemestre_id: int):
raise ScoException("non implemente") 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" "génère news sur effacement décision"
# n'utilise pas g.scodoc_dept, pas toujours dispo en mode API # n'utilise pas g.scodoc_dept, pas toujours dispo en mode API
url = url_for( 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( ScolarNews.add(
typ=ScolarNews.NEWS_JURY, typ=ScolarNews.NEWS_JURY,
obj=etud.id, 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, url=url,
) )
@ -96,7 +84,7 @@ def _news_delete_jury_etud(etud: Identite, detail: str = ""):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def validation_ue_delete(etudid: int, validation_id: int): def validation_ue_delete(etudid: int, validation_id: int):
"Efface cette validation d'UE." "Efface cette validation"
return _validation_ue_delete(etudid, validation_id) return _validation_ue_delete(etudid, validation_id)
@ -113,7 +101,7 @@ def validation_ue_delete(etudid: int, validation_id: int):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def validation_formsemestre_delete(etudid: int, validation_id: int): 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) # c'est la même chose (formations classiques)
return _validation_ue_delete(etudid, validation_id) return _validation_ue_delete(etudid, validation_id)
@ -165,7 +153,7 @@ def _validation_ue_delete(etudid: int, validation_id: int):
@permission_required(Permission.EtudInscrit) @permission_required(Permission.EtudInscrit)
@as_json @as_json
def autorisation_inscription_delete(etudid: int, validation_id: int): def autorisation_inscription_delete(etudid: int, validation_id: int):
"Efface cette autorisation d'inscription." "Efface cette validation"
etud = tools.get_etud(etudid) etud = tools.get_etud(etudid)
if etud is None: if etud is None:
return "étudiant inconnu", 404 return "étudiant inconnu", 404
@ -194,12 +182,8 @@ def autorisation_inscription_delete(etudid: int, validation_id: int):
@as_json @as_json
def validation_rcue_record(etudid: int): def validation_rcue_record(etudid: int):
"""Enregistre une validation de RCUE. """Enregistre une validation de RCUE.
Si une validation existe déjà pour ce RCUE, la remplace. Si une validation existe déjà pour ce RCUE, la remplace.
The request content type should be "application/json":
DATA
----
```json
{ {
"code" : str, "code" : str,
"ue1_id" : int, "ue1_id" : int,
@ -209,7 +193,6 @@ def validation_rcue_record(etudid: int):
"date" : date_iso, // si non spécifié, now() "date" : date_iso, // si non spécifié, now()
"parcours_id" :int, "parcours_id" :int,
} }
```
""" """
etud = tools.get_etud(etudid) etud = tools.get_etud(etudid)
if etud is None: if etud is None:
@ -301,12 +284,13 @@ def validation_rcue_record(etudid: int):
db.session.add(validation) db.session.add(validation)
# invalider bulletins (les autres résultats ne dépendent pas des RCUEs): # invalider bulletins (les autres résultats ne dépendent pas des RCUEs):
sco_cache.invalidate_formsemestre_etud(etud) sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
Scolog.logdb( Scolog.logdb(
method="validation_rcue_record", method="validation_rcue_record",
etudid=etudid, etudid=etudid,
msg=f"Enregistrement {validation}", msg=f"Enregistrement {validation}",
commit=True,
) )
db.session.commit()
log(f"{operation} {validation}") log(f"{operation} {validation}")
return validation.to_dict() return validation.to_dict()
@ -324,18 +308,18 @@ def validation_rcue_record(etudid: int):
@permission_required(Permission.EtudInscrit) @permission_required(Permission.EtudInscrit)
@as_json @as_json
def validation_rcue_delete(etudid: int, validation_id: int): def validation_rcue_delete(etudid: int, validation_id: int):
"Efface cette validation de RCUE." "Efface cette validation"
etud = tools.get_etud(etudid) etud = tools.get_etud(etudid)
if etud is None: if etud is None:
return "étudiant inconnu", 404 return "étudiant inconnu", 404
validation = ApcValidationRCUE.query.filter_by( validation = ApcValidationRCUE.query.filter_by(
id=validation_id, etudid=etudid id=validation_id, etudid=etudid
).first_or_404() ).first_or_404()
log(f"delete validation_ue_delete: etuid={etudid} {validation}") log(f"validation_ue_delete: etuid={etudid} {validation}")
db.session.delete(validation) db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud) sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit() db.session.commit()
_news_delete_jury_etud(etud, detail="UE") _news_delete_jury_etud(etud)
return "ok" return "ok"
@ -352,45 +336,16 @@ def validation_rcue_delete(etudid: int, validation_id: int):
@permission_required(Permission.EtudInscrit) @permission_required(Permission.EtudInscrit)
@as_json @as_json
def validation_annee_but_delete(etudid: int, validation_id: int): 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) etud = tools.get_etud(etudid)
if etud is None: if etud is None:
return "étudiant inconnu", 404 return "étudiant inconnu", 404
validation = ApcValidationAnnee.query.filter_by( validation = ApcValidationAnnee.query.filter_by(
id=validation_id, etudid=etudid id=validation_id, etudid=etudid
).first_or_404() ).first_or_404()
ordre = validation.ordre log(f"validation_annee_but: etuid={etudid} {validation}")
log(f"delete validation_annee_but: etuid={etudid} {validation}")
db.session.delete(validation) db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud) sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit() db.session.commit()
_news_delete_jury_etud(etud, detail=f"année BUT{ordre}") _news_delete_jury_etud(etud)
return "ok"
@bp.route(
"/etudiant/<int:etudid>/jury/validation_dut120/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_dut120/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def validation_dut120_delete(etudid: int, validation_id: int):
"Efface cette validation de DUT120."
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ValidationDUT120.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
log(f"delete validation_dut120: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud, detail="diplôme DUT120")
return "ok" return "ok"

View File

@ -3,26 +3,31 @@
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
"""ScoDoc 9 API : Justificatifs""" """ScoDoc 9 API : Justificatifs
"""
from datetime import datetime from datetime import datetime
from flask_json import as_json from flask_json import as_json
from flask import g, request from flask import g, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
from werkzeug.exceptions import NotFound
import app.scodoc.sco_assiduites as scass import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu 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_bp as bp
from app.api import api_web_bp from app.api import api_web_bp
from app.api import get_model_api_object, tools from app.api import get_model_api_object, tools
from app.api import api_permission_required as permission_required from app.decorators import permission_required, scodoc
from app.decorators import scodoc from app.models import (
from app.models import Identite, Justificatif, Departement, FormSemestre, Scolog Identite,
Justificatif,
Departement,
FormSemestre,
FormSemestreInscription,
)
from app.models.assiduites import ( from app.models.assiduites import (
compute_assiduites_justified,
get_formsemestre_from_data, get_formsemestre_from_data,
) )
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
@ -38,11 +43,9 @@ from app.scodoc.sco_groups import get_group_members
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def justificatif(justif_id: int = None): 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: Exemple de résultat:
```json
{ {
"justif_id": 1, "justif_id": 1,
"etudid": 2, "etudid": 2,
@ -50,24 +53,14 @@ def justificatif(justif_id: int = None):
"date_fin": "2022-10-31T10:00+01:00", "date_fin": "2022-10-31T10:00+01:00",
"etat": "valide", "etat": "valide",
"fichier": "archive_id", "fichier": "archive_id",
"raison": "une raison", // VIDE si pas le droit "raison": "une raison",
"entry_date": "2022-10-31T08:00+01:00", "entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null, "user_id": 1 or null,
} }
```
SAMPLES
-------
/justificatif/1;
""" """
return get_model_api_object( return get_model_api_object(Justificatif, justif_id, Identite)
Justificatif,
justif_id,
Identite,
restrict=not current_user.has_permission(Permission.AbsJustifView),
)
# etudid # etudid
@ -99,32 +92,28 @@ def justificatif(justif_id: int = None):
def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = False): def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = False):
""" """
Retourne toutes les assiduités d'un étudiant Retourne toutes les assiduités d'un étudiant
chemin : /justificatifs/<int:etudid>
QUERY Un filtrage peut être donné avec une query
----- 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;
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 # Récupération de l'étudiant
etud: Identite = tools.get_etud(etudid, nip, ine) 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 # Mise en forme des données puis retour en JSON
data_set: list[dict] = [] data_set: list[dict] = []
restrict = not current_user.has_permission(Permission.AbsJustifView)
for just in justificatifs_query.all(): 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) data_set.append(data)
return data_set return data_set
@ -163,41 +151,10 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
@as_json @as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def justificatifs_dept(dept_id: int = None, with_query: bool = False): 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 # Récupération du département et des étudiants du département
dept: Departement = Departement.query.get(dept_id) dept: Departement = Departement.query.get_or_404(dept_id)
if dept is None:
return json_error(404, "Assiduité non existante")
etuds: list[int] = [etud.id for etud in dept.etudiants] etuds: list[int] = [etud.id for etud in dept.etudiants]
# Récupération des justificatifs des étudiants du département # 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) justificatifs_query: Query = _filter_manager(request, justificatifs_query)
# Mise en forme des données et retour JSON # Mise en forme des données et retour JSON
restrict = not current_user.has_permission(Permission.AbsJustifView)
data_set: list[dict] = [] data_set: list[dict] = []
for just in justificatifs_query: for just in justificatifs_query:
data_set.append(_set_sems(just, restrict=restrict)) data_set.append(_set_sems(just))
return data_set 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 _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 dict: La représentation de l'assiduité en dictionnaire
""" """
# Conversion du justificatif 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é # Récupération du formsemestre de l'assiduité
formsemestre: FormSemestre = get_formsemestre_from_data(justi.to_dict()) formsemestre: FormSemestre = get_formsemestre_from_data(justi.to_dict())
@ -263,34 +219,7 @@ def _set_sems(justi: Justificatif, restrict: bool) -> dict:
@as_json @as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False): def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne tous les justificatifs du formsemestre """Retourne tous les justificatifs du formsemestre"""
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
order:<bool:order>
courant:<bool:courant>
group_id:<int:group_id>
PARAMS
-----
user_id:l'id de l'auteur du justificatif
date_debut:date de début du justificatif (supérieur ou égal)
date_fin:date de fin du justificatif (inférieur ou égal)
etat:etat du justificatif &rightarrow; valide, non_valide, attente, modifie
order:retourne les justificatifs dans l'ordre décroissant (non vide = True)
courant:retourne les justificatifs de l'année courante (bool : v/t ou f)
group_id:<int:group_id>
SAMPLES
-------
/justificatifs/formsemestre/1;
"""
# Récupération du formsemestre # Récupération du formsemestre
formsemestre: FormSemestre = None 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) justificatifs_query: Query = _filter_manager(request, justificatifs_query)
# Retour des justificatifs en JSON # Retour des justificatifs en JSON
restrict = not current_user.has_permission(Permission.AbsJustifView)
data_set: list[dict] = [] data_set: list[dict] = []
for justi in justificatifs_query.all(): 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) data_set.append(data)
return data_set 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): def justif_create(etudid: int = None, nip=None, ine=None):
""" """
Création d'un justificatif pour l'étudiant (etudid) Création d'un justificatif pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
DATA
----
```json
[ [
{ {
"date_debut": str, "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, 404,
message="étudiant inconnu", message="étudiant inconnu",
) )
set_sco_dept(etud.departement.acronym)
# Récupération des justificatifs à créer # Récupération des justificatifs à créer
create_list: list[object] = request.get_json(force=True) 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] = [] errors: list[dict] = []
success: list[dict] = [] success: list[dict] = []
justifs: list[Justificatif] = []
# énumération des justificatifs # énumération des justificatifs
for i, data in enumerate(create_list): 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}) errors.append({"indice": i, "message": obj})
else: else:
success.append({"indice": i, "message": obj}) success.append({"indice": i, "message": obj})
justi.justifier_assiduites() justifs.append(justi)
scass.simple_invalidate_cache(data, etud.id) 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} return {"errors": errors, "success": success}
@ -413,10 +336,6 @@ def _create_one(
errors.append("param 'etat': invalide") errors.append("param 'etat': invalide")
etat: scu.EtatJustificatif = scu.EtatJustificatif.get(etat) 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 # cas 2 : date_debut
date_debut: str = data.get("date_debut", None) date_debut: str = data.get("date_debut", None)
@ -455,7 +374,7 @@ def _create_one(
date_debut=deb, date_debut=deb,
date_fin=fin, date_fin=fin,
etat=etat, etat=etat,
etudiant=etud, etud=etud,
raison=raison, raison=raison,
user_id=current_user.id, user_id=current_user.id,
external_data=external_data, external_data=external_data,
@ -490,27 +409,20 @@ def _create_one(
def justif_edit(justif_id: int): def justif_edit(justif_id: int):
""" """
Edition d'un justificatif à partir de son id Edition d'un justificatif à partir de son id
La requête doit avoir un content type "application/json":
DATA
----
```json
{ {
"etat"?: str, "etat"?: str,
"raison"?: str "raison"?: str
"date_debut"?: str "date_debut"?: str
"date_fin"?: str "date_fin"?: str
} }
```
SAMPLES
-------
/justificatif/1/edit;{""etat"":""valide""}
/justificatif/1/edit;{""raison"":""MEDIC""}
""" """
# Récupération du justificatif à modifier # 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] = [] errors: list[str] = []
data = request.get_json(force=True) data = request.get_json(force=True)
@ -525,10 +437,7 @@ def justif_edit(justif_id: int):
if etat is None: if etat is None:
errors.append("param 'etat': invalide") errors.append("param 'etat': invalide")
else: else:
if current_user.has_permission(Permission.JustifValidate): justificatif_unique.etat = etat
justificatif_unique.etat = etat
else:
errors.append("param 'etat': non autorisé (Permission.JustifValidate)")
# Cas 2 : raison # Cas 2 : raison
raison: str = data.get("raison", False) raison: str = data.get("raison", False)
@ -579,13 +488,7 @@ def justif_edit(justif_id: int):
return json_error(404, err) return json_error(404, err)
# Mise à jour du justificatif # Mise à jour du justificatif
justificatif_unique.dejustifier_assiduites()
db.session.add(justificatif_unique) db.session.add(justificatif_unique)
Scolog.logdb(
method="edit_justificatif",
etudid=justificatif_unique.etudiant.id,
msg=f"justificatif modif: {justificatif_unique}",
)
db.session.commit() db.session.commit()
# Génération du dictionnaire de retour # Génération du dictionnaire de retour
@ -595,7 +498,11 @@ def justif_edit(justif_id: int):
retour = { retour = {
"couverture": { "couverture": {
"avant": avant_ids, "avant": avant_ids,
"apres": justificatif_unique.justifier_assiduites(), "après": compute_assiduites_justified(
justificatif_unique.etudid,
[justificatif_unique],
True,
),
} }
} }
# Invalide le cache # Invalide le cache
@ -613,18 +520,13 @@ def justif_delete():
""" """
Suppression d'un justificatif à partir de son id Suppression d'un justificatif à partir de son id
DATA Forme des données envoyées :
----
```json
[ [
<justif_id:int>, <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 message : OK si réussi, message d'erreur sinon
""" """
# Récupération du justificatif à supprimer # Récupération du justificatif à supprimer
try: justificatif_unique: Justificatif = Justificatif.query.filter_by(
justificatif_unique = Justificatif.get_justificatif(justif_id) id=justif_id
except NotFound: ).first()
if justificatif_unique is None:
return (404, "Justificatif non existant") return (404, "Justificatif non existant")
# Récupération de l'archive du justificatif # Récupération de l'archive du justificatif
archive_name: str = justificatif_unique.fichier archive_name: str = justificatif_unique.fichier
@ -677,15 +581,14 @@ def _delete_one(justif_id: int) -> tuple[int, str]:
# On invalide le cache # On invalide le cache
scass.simple_invalidate_cache(justificatif_unique.to_dict()) 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 # On supprime le justificatif
db.session.delete(justificatif_unique) 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") return (200, "OK")
@ -700,8 +603,6 @@ def _delete_one(justif_id: int) -> tuple[int, str]:
def justif_import(justif_id: int = None): def justif_import(justif_id: int = None):
""" """
Importation d'un fichier (création d'archive) 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é # 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") return json_error(404, "Il n'y a pas de fichier joint")
# On récupère le justificatif auquel on va importer le fichier # 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 # Récupération de l'archive si elle existe
archive_name: str = justificatif_unique.fichier archive_name: str = justificatif_unique.fichier
@ -737,34 +641,26 @@ def justif_import(justif_id: int = None):
db.session.commit() db.session.commit()
return {"filename": fname} return {"filename": fname}
except ScoValueError as exc: except ScoValueError as err:
# Si cela ne fonctionne pas on renvoie une erreur # 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"]) @bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@api_web_bp.route( @api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
"/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"]
)
@scodoc @scodoc
@login_required @login_required
@permission_required(Permission.ScoView) @permission_required(Permission.AbsChange)
def justif_export(justif_id: int | None = None, filename: str | None = None): 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é # 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:
# Vérification des permissions query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
if not ( justificatif_unique: Justificatif = query.first_or_404()
current_user.has_permission(Permission.AbsJustifView)
or justificatif_unique.user_id == current_user.id
):
return json_error(401, "non autorisé à voir ce fichier")
# On récupère l'archive concernée # On récupère l'archive concernée
archive_name: str = justificatif_unique.fichier 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): def justif_remove(justif_id: int = None):
""" """
Supression d'un fichier ou d'une archive 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"?: [ "filenames"?: [
<filename:str>, <filename:str>,
... ...
] ]
} }
```
""" """
# On récupère le dictionnaire # On récupère le dictionnaire
data: dict = request.get_json(force=True) data: dict = request.get_json(force=True)
# On récupère le justificatif concerné # 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 # On récupère l'archive
archive_name: str = justificatif_unique.fichier archive_name: str = justificatif_unique.fichier
@ -871,15 +764,13 @@ def justif_remove(justif_id: int = None):
def justif_list(justif_id: int = None): def justif_list(justif_id: int = None):
""" """
Liste les fichiers du justificatif Liste les fichiers du justificatif
SAMPLES
-------
/justificatif/1/list;
""" """
# Récupération du justificatif concerné # 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 # Récupération de l'archive avec l'archiver
archive_name: str = justificatif_unique.fichier archive_name: str = justificatif_unique.fichier
@ -918,15 +809,13 @@ def justif_list(justif_id: int = None):
def justif_justifies(justif_id: int = None): def justif_justifies(justif_id: int = None):
""" """
Liste assiduite_id justifiées par le justificatif Liste assiduite_id justifiées par le justificatif
SAMPLES
-------
/justificatif/1/justifies;
""" """
# On récupère le justificatif concerné # 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 # On récupère la liste des assiduités justifiées par le justificatif
assiduites_list: list[int] = scass.justifies(justificatif_unique) 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): def _filter_manager(requested, justificatifs_query: Query):
""" """
Retourne les justificatifs entrés filtrés en fonction de la request 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 # cas 1 : etat justificatif
etat: str = requested.args.get("etat") etat: str = requested.args.get("etat")
@ -975,7 +863,7 @@ def _filter_manager(requested, justificatifs_query: Query):
formsemestre: FormSemestre = None formsemestre: FormSemestre = None
try: try:
formsemestre_id = int(formsemestre_id) 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 = scass.filter_by_formsemestre(
justificatifs_query, Justificatif, formsemestre justificatifs_query, Justificatif, formsemestre
) )
@ -994,8 +882,8 @@ def _filter_manager(requested, justificatifs_query: Query):
annee: int = scu.annee_scolaire() annee: int = scu.annee_scolaire()
justificatifs_query: Query = justificatifs_query.filter( justificatifs_query: Query = justificatifs_query.filter(
Justificatif.date_debut >= scu.date_debut_annee_scolaire(annee), Justificatif.date_debut >= scu.date_debut_anne_scolaire(annee),
Justificatif.date_fin <= scu.date_fin_annee_scolaire(annee), Justificatif.date_fin <= scu.date_fin_anne_scolaire(annee),
) )
# cas 8 : group_id filtre les justificatifs d'un groupe d'étudiant # 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: except ValueError:
group_id = None 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 return justificatifs_query

View File

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

View File

@ -1,26 +1,23 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
""" """
ScoDoc 9 API : accès aux moduleimpl ScoDoc 9 API : accès aux moduleimpl
CATEGORY
--------
ModuleImpl
""" """
from flask import g
from flask_json import as_json from flask_json import as_json
from flask_login import login_required from flask_login import login_required
import app
from app.api import api_bp as bp, api_web_bp from app.api import api_bp as bp, api_web_bp
from app.api import api_permission_required as permission_required from app.decorators import scodoc, permission_required
from app.decorators import scodoc from app.models import (
from app.models import ModuleImpl FormSemestre,
from app.scodoc import sco_liste_notes ModuleImpl,
)
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
@ -32,45 +29,43 @@ from app.scodoc.sco_permissions import Permission
@as_json @as_json
def moduleimpl(moduleimpl_id: int): def moduleimpl(moduleimpl_id: int):
""" """
Retourne le moduleimpl. Retourne un moduleimpl en fonction de son id
PARAMS
------
moduleimpl_id : l'id d'un moduleimpl moduleimpl_id : l'id d'un moduleimpl
Exemple de résultat : 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, "id": 1,
"ects": null, "formsemestre_id": 1,
"abbrev": "Init aux réseaux informatiques", "module_id": 1,
"ue_id": 1, "responsable_id": 2,
"code": "R101", "moduleimpl_id": 1,
"formation_id": 1, "ens": [],
"heures_cours": 0, "module": {
"matiere_id": 1, "heures_tp": 0,
"heures_td": 0, "code_apogee": "",
"semestre_id": 1, "titre": "Initiation aux réseaux informatiques",
"numero": 10, "coefficient": 1,
"module_id": 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) return modimpl.to_dict(convert_objects=True)
@ -81,55 +76,19 @@ def moduleimpl(moduleimpl_id: int):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def moduleimpl_inscriptions(moduleimpl_id: int): def moduleimpl_inscriptions(moduleimpl_id: int):
"""Liste des inscriptions à ce moduleimpl. """Liste des inscriptions à ce moduleimpl
Exemple de résultat : Exemple de résultat :
[
```json {
[ "id": 1,
{ "etudid": 666,
"id": 1, "moduleimpl_id": 1234,
"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] 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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
""" """
ScoDoc 9 API : partitions ScoDoc 9 API : partitions
CATEGORY
--------
Groupes et Partitions
""" """
from operator import attrgetter from operator import attrgetter
@ -23,8 +18,7 @@ from sqlalchemy.exc import IntegrityError
import app import app
from app import db, log from app import db, log
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR 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, permission_required
from app.decorators import scodoc
from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import json_error
from app.models import FormSemestre, FormSemestreInscription, Identite from app.models import FormSemestre, FormSemestreInscription, Identite
from app.models import GroupDescr, Partition, Scolog from app.models import GroupDescr, Partition, Scolog
@ -46,8 +40,7 @@ def partition_info(partition_id: int):
"""Info sur une partition. """Info sur une partition.
Exemple de résultat : Exemple de résultat :
```
```json
{ {
'bul_show_rank': False, 'bul_show_rank': False,
'formsemestre_id': 39, 'formsemestre_id': 39,
@ -77,11 +70,10 @@ def partition_info(partition_id: int):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def formsemestre_partitions(formsemestre_id: int): 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 : { partition_id : {
"bul_show_rank": False, "bul_show_rank": False,
@ -95,7 +87,7 @@ def formsemestre_partitions(formsemestre_id: int):
}, },
... ...
} }
```
""" """
query = FormSemestre.query.filter_by(id=formsemestre_id) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -115,18 +107,13 @@ def formsemestre_partitions(formsemestre_id: int):
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def group_etudiants(group_id: int): def etud_in_group(group_id: int):
""" """
Retourne la liste des étudiants dans un groupe Retourne la liste des étudiants dans un groupe
(inscrits au groupe et inscrits au semestre). (inscrits au groupe et inscrits au semestre).
PARAMS
------
group_id : l'id d'un groupe group_id : l'id d'un groupe
Exemple de résultat : Exemple de résultat :
```json
[ [
{ {
'civilite': 'M', 'civilite': 'M',
@ -139,7 +126,6 @@ def group_etudiants(group_id: int):
}, },
... ...
] ]
```
""" """
query = GroupDescr.query.filter_by(id=group_id) query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -164,14 +150,8 @@ def group_etudiants(group_id: int):
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def group_etudiants_query(group_id: int): def etud_in_group_query(group_id: int):
"""Étudiants du groupe, filtrés par état (aucun, `I`, `D`, `DEF`) """Étudiants du groupe, filtrés par état"""
QUERY
-----
etat : string
"""
etat = request.args.get("etat") etat = request.args.get("etat")
if etat not in {None, scu.INSCRIT, scu.DEMISSION, scu.DEF}: if etat not in {None, scu.INSCRIT, scu.DEMISSION, scu.DEF}:
return json_error(API_CLIENT_ERROR, "etat: valeur invalide") return json_error(API_CLIENT_ERROR, "etat: valeur invalide")
@ -198,8 +178,8 @@ def group_etudiants_query(group_id: int):
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def group_set_etudiant(group_id: int, etudid: int): def set_etud_group(etudid: int, group_id: int):
"""Affecte l'étudiant au groupe indiqué.""" """Affecte l'étudiant au groupe indiqué"""
etud = Identite.query.get_or_404(etudid) etud = Identite.query.get_or_404(etudid)
query = GroupDescr.query.filter_by(id=group_id) query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -261,8 +241,7 @@ def group_remove_etud(group_id: int, etudid: int):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def partition_remove_etud(partition_id: int, etudid: int): 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) (NB: en principe, un étudiant ne doit être que dans 0 ou 1 groupe d'une partition)
""" """
etud = Identite.query.get_or_404(etudid) etud = Identite.query.get_or_404(etudid)
@ -307,15 +286,12 @@ def partition_remove_etud(partition_id: int, etudid: int):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def group_create(partition_id: int): # partition-group-create 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 The request content type should be "application/json":
----
```json
{ {
"group_name" : nom_du_groupe, "group_name" : nom_du_groupe,
} }
```
""" """
query = Partition.query.filter_by(id=partition_id) query = Partition.query.filter_by(id=partition_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -327,26 +303,15 @@ def group_create(partition_id: int): # partition-group-create
return json_error(403, "partition non editable") return json_error(403, "partition non editable")
if not partition.formsemestre.can_change_groups(): if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée") return json_error(401, "opération non autorisée")
data = request.get_json(force=True) # may raise 400 Bad Request
args = request.get_json(force=True) # may raise 400 Bad Request group_name = data.get("group_name")
group_name = args.get("group_name") if group_name is None:
if not isinstance(group_name, str):
return json_error(API_CLIENT_ERROR, "missing group name or invalid data format") 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, group_name):
if not GroupDescr.check_name(partition, args["group_name"]):
return json_error(API_CLIENT_ERROR, "invalid group_name") return json_error(API_CLIENT_ERROR, "invalid group_name")
group_name = group_name.strip()
# le numero est optionnel group = GroupDescr(group_name=group_name, partition_id=partition_id)
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")
db.session.add(group) db.session.add(group)
db.session.commit() db.session.commit()
log(f"created group {group}") log(f"created group {group}")
@ -362,7 +327,7 @@ def group_create(partition_id: int): # partition-group-create
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def group_delete(group_id: int): def group_delete(group_id: int):
"""Suppression d'un groupe.""" """Suppression d'un groupe"""
query = GroupDescr.query.filter_by(id=group_id) query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept: if g.scodoc_dept:
query = ( query = (
@ -391,7 +356,7 @@ def group_delete(group_id: int):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def group_edit(group_id: int): def group_edit(group_id: int):
"""Édition d'un groupe.""" """Edit a group"""
query = GroupDescr.query.filter_by(id=group_id) query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept: if g.scodoc_dept:
query = ( query = (
@ -404,54 +369,21 @@ def group_edit(group_id: int):
return json_error(403, "partition non editable") return json_error(403, "partition non editable")
if not group.partition.formsemestre.can_change_groups(): if not group.partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée") return json_error(401, "opération non autorisée")
data = request.get_json(force=True) # may raise 400 Bad Request
args = request.get_json(force=True) # may raise 400 Bad Request group_name = data.get("group_name")
if "group_name" in args: if group_name is not None:
if not isinstance(args["group_name"], str): group_name = group_name.strip()
return json_error(API_CLIENT_ERROR, "invalid data format for group_name") if not GroupDescr.check_name(group.partition, group_name, existing=True):
args["group_name"] = args["group_name"].strip() if args["group_name"] else ""
if not GroupDescr.check_name(
group.partition, args["group_name"], existing=True
):
return json_error(API_CLIENT_ERROR, "invalid group_name") return json_error(API_CLIENT_ERROR, "invalid group_name")
group.group_name = group_name
group.from_dict(args) db.session.add(group)
db.session.add(group) db.session.commit()
db.session.commit() log(f"modified {group}")
log(f"modified {group}")
app.set_sco_dept(group.partition.formsemestre.departement.acronym) app.set_sco_dept(group.partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id) sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return group.to_dict(with_partition=True) 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"]) @bp.route("/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"])
@api_web_bp.route( @api_web_bp.route(
"/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"] "/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) @permission_required(Permission.ScoView)
@as_json @as_json
def partition_create(formsemestre_id: int): def partition_create(formsemestre_id: int):
"""Création d'une partition dans un semestre. """Création d'une partition dans un semestre
DATA The request content type should be "application/json":
----
```json
{ {
"partition_name": str, "partition_name": str,
"numero": int, "numero":int,
"bul_show_rank": bool, "bul_show_rank":bool,
"show_in_lists": bool, "show_in_lists":bool,
"groups_editable": bool "groups_editable":bool
} }
```
""" """
query = FormSemestre.query.filter_by(id=formsemestre_id) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -528,14 +457,9 @@ def partition_create(formsemestre_id: int):
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def formsemestre_set_partitions_order(formsemestre_id: int): def formsemestre_order_partitions(formsemestre_id: int):
"""Modifie l'ordre des partitions du formsemestre. """Modifie l'ordre des partitions du formsemestre
JSON args: [partition_id1, partition_id2, ...]
DATA
----
```json
[ partition_id1, partition_id2, ... ]
```
""" """
query = FormSemestre.query.filter_by(id=formsemestre_id) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -546,7 +470,7 @@ def formsemestre_set_partitions_order(formsemestre_id: int):
if not formsemestre.can_change_groups(): if not formsemestre.can_change_groups():
return json_error(401, "opération non autorisée") return json_error(401, "opération non autorisée")
partition_ids = request.get_json(force=True) # may raise 400 Bad Request 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 isinstance(x, int) for x in partition_ids
): ):
return json_error( return json_error(
@ -560,7 +484,6 @@ def formsemestre_set_partitions_order(formsemestre_id: int):
db.session.commit() db.session.commit()
app.set_sco_dept(formsemestre.departement.acronym) app.set_sco_dept(formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(formsemestre_id) sco_cache.invalidate_formsemestre(formsemestre_id)
log(f"formsemestre_set_partitions_order({partition_ids})")
return [ return [
partition.to_dict() partition.to_dict()
for partition in formsemestre.partitions.order_by(Partition.numero) 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) @permission_required(Permission.ScoView)
@as_json @as_json
def partition_order_groups(partition_id: int): def partition_order_groups(partition_id: int):
"""Modifie l'ordre des groupes de la partition. """Modifie l'ordre des groupes de la partition
JSON args: [group_id1, group_id2, ...]
DATA
----
```json
[ group_id1, group_id2, ... ]
```
""" """
query = Partition.query.filter_by(id=partition_id) query = Partition.query.filter_by(id=partition_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -592,7 +510,7 @@ def partition_order_groups(partition_id: int):
if not partition.formsemestre.can_change_groups(): if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée") return json_error(401, "opération non autorisée")
group_ids = request.get_json(force=True) # may raise 400 Bad Request 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 isinstance(x, int) for x in group_ids
): ):
return json_error( return json_error(
@ -617,13 +535,10 @@ def partition_order_groups(partition_id: int):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def partition_edit(partition_id: int): 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. The request content type should be "application/json"
All fields are optional:
DATA
----
```json
{ {
"partition_name": str, "partition_name": str,
"numero":int, "numero":int,
@ -631,7 +546,6 @@ def partition_edit(partition_id: int):
"show_in_lists":bool, "show_in_lists":bool,
"groups_editable":bool "groups_editable":bool
} }
```
""" """
query = Partition.query.filter_by(id=partition_id) query = Partition.query.filter_by(id=partition_id)
if g.scodoc_dept: if g.scodoc_dept:
@ -695,9 +609,9 @@ def partition_edit(partition_id: int):
def partition_delete(partition_id: int): def partition_delete(partition_id: int):
"""Suppression d'une partition (et de tous ses groupes). """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. 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. sont désinscrits des parcours.
""" """
query = Partition.query.filter_by(id=partition_id) query = Partition.query.filter_by(id=partition_id)

View File

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

View File

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

View File

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

View File

@ -1,31 +1,28 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
""" """
ScoDoc 9 API : accès aux utilisateurs ScoDoc 9 API : accès aux utilisateurs
CATEGORY
--------
Utilisateurs
""" """
from flask import g, request from flask import g, request
from flask_json import as_json from flask_json import as_json
from flask_login import current_user, login_required from flask_login import current_user, login_required
from app import db, log from app import db, log
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR 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 User, Role, UserRole
from app.auth.models import is_valid_password 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.models import Departement
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error from app.scodoc import sco_utils as scu
@bp.route("/user/<int:uid>") @bp.route("/user/<int:uid>")
@ -36,7 +33,7 @@ from app.scodoc.sco_utils import json_error
@as_json @as_json
def user_info(uid: int): def user_info(uid: int):
""" """
Info sur un compte utilisateur ScoDoc. Info sur un compte utilisateur scodoc
""" """
user: User = db.session.get(User, uid) user: User = db.session.get(User, uid)
if user is None: if user is None:
@ -57,22 +54,11 @@ def user_info(uid: int):
@as_json @as_json
def users_info_query(): def users_info_query():
"""Utilisateurs, filtrés par dept, active ou début nom """Utilisateurs, filtrés par dept, active ou début nom
Exemple:
```
/users/query?departement=dept_acronym&active=1&starts_with=<string:nom> /users/query?departement=dept_acronym&active=1&starts_with=<string:nom>
```
Seuls les utilisateurs "accessibles" (selon les permissions) sont retournés. 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 Si accès via API web, le département de l'URL est ignoré, seules
les permissions de l'utilisateur sont prises en compte. les permissions de l'utilisateur sont prises en compte.
QUERY
-----
active: bool
departement: string
starts_with: string
""" """
query = User.query query = User.query
active = request.args.get("active") active = request.args.get("active")
@ -99,20 +85,6 @@ def users_info_query():
return [user.to_dict() for user in 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"]) @bp.route("/user/create", methods=["POST"])
@api_web_bp.route("/user/create", methods=["POST"]) @api_web_bp.route("/user/create", methods=["POST"])
@login_required @login_required
@ -121,28 +93,23 @@ def _is_allowed_user_edit(args: dict) -> tuple[bool, str]:
@as_json @as_json
def user_create(): def user_create():
"""Création d'un utilisateur """Création d'un utilisateur
The request content type should be "application/json":
DATA
----
```json
{ {
"active":bool (default True), "user_name": str,
"dept": str or null, "dept": str or null,
"nom": str, "nom": str,
"prenom": str, "prenom": str,
"user_name": str, "active":bool (default True)
...
} }
```
""" """
args = request.get_json(force=True) # may raise 400 Bad Request data = request.get_json(force=True) # may raise 400 Bad Request
user_name = args.get("user_name") user_name = data.get("user_name")
if not user_name: if not user_name:
return json_error(404, "empty user_name") return json_error(404, "empty user_name")
user = User.query.filter_by(user_name=user_name).first() user = User.query.filter_by(user_name=user_name).first()
if user: if user:
return json_error(404, f"user_create: user {user} already exists\n") return json_error(404, f"user_create: user {user} already exists\n")
dept = args.get("dept") dept = data.get("dept")
if dept == "@all": if dept == "@all":
dept = None dept = None
allowed_depts = current_user.get_depts_with_permission(Permission.UsersAdmin) 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 Departement.query.filter_by(acronym=dept).first() is None
): ):
return json_error(404, "user_create: departement inexistant") return json_error(404, "user_create: departement inexistant")
args["dept"] = dept nom = data.get("nom")
ok, msg = _is_allowed_user_edit(args) prenom = data.get("prenom")
if not ok: active = scu.to_bool(data.get("active", True))
return json_error(403, f"user_create: {msg}") user = User(user_name=user_name, active=active, dept=dept, nom=nom, prenom=prenom)
user = User(user_name=user_name)
user.from_dict(args, new_user=True)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user.to_dict() return user.to_dict()
@ -170,24 +135,20 @@ def user_create():
@permission_required(Permission.UsersAdmin) @permission_required(Permission.UsersAdmin)
@as_json @as_json
def user_edit(uid: int): def user_edit(uid: int):
"""Modification d'un utilisateur. """Modification d'un utilisateur
Champs modifiables: Champs modifiables:
```json
{ {
"dept": str or null, "dept": str or null,
"nom": str, "nom": str,
"prenom": str, "prenom": str,
"active":bool "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) 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 # L'utilisateur doit avoir le droit dans le département de départ et celui d'arrivée
orig_dept = user.dept 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 is not False:
if dest_dept == "@all": if dest_dept == "@all":
dest_dept = None dest_dept = None
@ -203,11 +164,10 @@ def user_edit(uid: int):
return json_error(404, "user_edit: departement inexistant") return json_error(404, "user_edit: departement inexistant")
user.dept = dest_dept user.dept = dest_dept
ok, msg = _is_allowed_user_edit(args) user.nom = data.get("nom", user.nom)
if not ok: user.prenom = data.get("prenom", user.prenom)
return json_error(403, f"user_edit: {msg}") user.active = scu.to_bool(data.get("active", user.active))
user.from_dict(args)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user.to_dict() return user.to_dict()
@ -220,15 +180,11 @@ def user_edit(uid: int):
@permission_required(Permission.UsersAdmin) @permission_required(Permission.UsersAdmin)
@as_json @as_json
def user_password(uid: int): def user_password(uid: int):
"""Modification du mot de passe d'un utilisateur. """Modification du mot de passe d'un utilisateur
Champs modifiables: Champs modifiables:
```json
{ {
"password": str "password": str
} }
```.
Si le mot de passe ne convient pas, erreur 400. Si le mot de passe ne convient pas, erreur 400.
""" """
data = request.get_json(force=True) # may raise 400 Bad Request data = request.get_json(force=True) # may raise 400 Bad Request
@ -262,7 +218,7 @@ def user_password(uid: int):
@permission_required(Permission.ScoSuperAdmin) @permission_required(Permission.ScoSuperAdmin)
@as_json @as_json
def user_role_add(uid: int, role_name: str, dept: str = None): 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) user: User = User.query.get_or_404(uid)
role: Role = Role.query.filter_by(name=role_name).first_or_404() role: Role = Role.query.filter_by(name=role_name).first_or_404()
if dept is not None: # check 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) @permission_required(Permission.ScoSuperAdmin)
@as_json @as_json
def user_role_remove(uid: int, role_name: str, dept: str = None): 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) user: User = User.query.get_or_404(uid)
role: Role = Role.query.filter_by(name=role_name).first_or_404() role: Role = Role.query.filter_by(name=role_name).first_or_404()
if dept is not None: # check if dept is not None: # check
@ -317,8 +273,8 @@ def user_role_remove(uid: int, role_name: str, dept: str = None):
@scodoc @scodoc
@permission_required(Permission.UsersView) @permission_required(Permission.UsersView)
@as_json @as_json
def permissions_list(): def list_permissions():
"""Liste des noms de permissions définies.""" """Liste des noms de permissions définies"""
return list(Permission.permission_by_name.keys()) return list(Permission.permission_by_name.keys())
@ -328,7 +284,7 @@ def permissions_list():
@scodoc @scodoc
@permission_required(Permission.UsersView) @permission_required(Permission.UsersView)
@as_json @as_json
def role_get(role_name: str): def list_role(role_name: str):
"""Un rôle""" """Un rôle"""
return Role.query.filter_by(name=role_name).first_or_404().to_dict() return Role.query.filter_by(name=role_name).first_or_404().to_dict()
@ -339,8 +295,8 @@ def role_get(role_name: str):
@scodoc @scodoc
@permission_required(Permission.UsersView) @permission_required(Permission.UsersView)
@as_json @as_json
def roles_list(): def list_roles():
"""Tous les rôles définis.""" """Tous les rôles définis"""
return [role.to_dict() for role in Role.query] return [role.to_dict() for role in Role.query]
@ -357,7 +313,7 @@ def roles_list():
@permission_required(Permission.ScoSuperAdmin) @permission_required(Permission.ScoSuperAdmin)
@as_json @as_json
def role_permission_add(role_name: str, perm_name: str): 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() role: Role = Role.query.filter_by(name=role_name).first_or_404()
permission = Permission.get_by_name(perm_name) permission = Permission.get_by_name(perm_name)
if permission is None: if permission is None:
@ -382,7 +338,7 @@ def role_permission_add(role_name: str, perm_name: str):
@permission_required(Permission.ScoSuperAdmin) @permission_required(Permission.ScoSuperAdmin)
@as_json @as_json
def role_permission_remove(role_name: str, perm_name: str): 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() role: Role = Role.query.filter_by(name=role_name).first_or_404()
permission = Permission.get_by_name(perm_name) permission = Permission.get_by_name(perm_name)
if permission is None: if permission is None:
@ -401,15 +357,10 @@ def role_permission_remove(role_name: str, perm_name: str):
@permission_required(Permission.ScoSuperAdmin) @permission_required(Permission.ScoSuperAdmin)
@as_json @as_json
def role_create(role_name: str): def role_create(role_name: str):
"""Création d'un nouveau rôle avec les permissions données. """Create a new role with permissions.
DATA
----
```json
{ {
"permissions" : [ 'ScoView', ... ] "permissions" : [ 'ScoView', ... ]
} }
```
""" """
role: Role = Role.query.filter_by(name=role_name).first() role: Role = Role.query.filter_by(name=role_name).first()
if role: if role:
@ -434,16 +385,11 @@ def role_create(role_name: str):
@permission_required(Permission.ScoSuperAdmin) @permission_required(Permission.ScoSuperAdmin)
@as_json @as_json
def role_edit(role_name: str): def role_edit(role_name: str):
"""Édition d'un rôle. On peut spécifier un nom et/ou des permissions. """Edit a role. On peut spécifier un nom et/ou des permissions.
DATA
----
```json
{ {
"name" : name "name" : name
"permissions" : [ 'ScoView', ... ] "permissions" : [ 'ScoView', ... ]
} }
```
""" """
role: Role = Role.query.filter_by(name=role_name).first_or_404() role: Role = Role.query.filter_by(name=role_name).first_or_404()
data = request.get_json(force=True) # may raise 400 Bad Request 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) @permission_required(Permission.ScoSuperAdmin)
@as_json @as_json
def role_delete(role_name: str): 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() role: Role = Role.query.filter_by(name=role_name).first_or_404()
db.session.delete(role) db.session.delete(role)
db.session.commit() db.session.commit()
return {"OK": True} 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: if user.cas_allow_login:
current_app.logger.info(f"CAS: login {user.user_name}") current_app.logger.info(f"CAS: login {user.user_name}")
if login_user(user): if login_user(user):
flask.session["scodoc_cas_login_date"] = ( flask.session[
datetime.datetime.now().isoformat() "scodoc_cas_login_date"
) ] = datetime.datetime.now().isoformat()
user.cas_last_login = datetime.datetime.utcnow() user.cas_last_login = datetime.datetime.utcnow()
if flask.session.get("CAS_EDT_ID"): if flask.session.get("CAS_EDT_ID"):
# essaie de récupérer l'edt_id s'il est présent # 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` # via l'expression `cas_edt_id_from_xml_regexp`
# voir flask_cas.routing # voir flask_cas.routing
edt_id = flask.session.get("CAS_EDT_ID") edt_id = flask.session.get("CAS_EDT_ID")
current_app.logger.info( current_app.logger.info(f"""after_cas_login: storing edt_id for {
f"""after_cas_login: storing edt_id for { user.user_name}: '{edt_id}'""")
user.user_name}: '{edt_id}'"""
)
user.edt_id = edt_id user.edt_id = edt_id
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
@ -57,17 +55,12 @@ def after_cas_login():
current_app.logger.info( current_app.logger.info(
f"CAS login denied for {user.user_name} (not allowed to use CAS)" 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( current_app.logger.info(
f"""CAS login denied for { f"""CAS login denied for {
user.user_name if user else "" user.user_name if user else ""
} cas_id={cas_id} (unknown or inactive)""" } 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: else:
current_app.logger.info( current_app.logger.info(
f"""CAS attribute '{ScoDocSiteConfig.get("cas_attribute_id")}' not found ! 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 cracklib # pylint: disable=import-error
import flask
from flask import current_app, g from flask import current_app, g
from flask_login import UserMixin, AnonymousUserMixin 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 from werkzeug.security import generate_password_hash, check_password_hash
import jwt import jwt
from app import db, email, log, login 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 import SHORT_STR_LEN, USERNAME_STR_LEN
from app.models.config import ScoDocSiteConfig from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
import app.scodoc.sco_utils as scu 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@\\\-_\.]+$") VALID_LOGIN_EXP = re.compile(r"^[a-zA-Z0-9@\\\-_\.]+$")
@ -57,17 +50,16 @@ def is_valid_password(cleartxt) -> bool:
return False return False
def is_valid_user_name(user_name: str) -> bool: def invalid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is valid" "Check that user_name (aka login) is invalid"
return ( return (
user_name (len(user_name) < 2)
and (len(user_name) >= 2) or (len(user_name) >= USERNAME_STR_LEN)
and (len(user_name) < USERNAME_STR_LEN) or not VALID_LOGIN_EXP.match(user_name)
and VALID_LOGIN_EXP.match(user_name)
) )
class User(UserMixin, ScoDocModel): class User(UserMixin, db.Model):
"""ScoDoc users, handled by Flask / SQLAlchemy""" """ScoDoc users, handled by Flask / SQLAlchemy"""
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -99,7 +91,7 @@ class User(UserMixin, ScoDocModel):
"""date du dernier login via CAS""" """date du dernier login via CAS"""
edt_id = db.Column(db.Text(), index=True, nullable=True) edt_id = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)" "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)) password_scodoc7 = db.Column(db.String(42))
last_seen = db.Column(db.DateTime, default=datetime.utcnow) last_seen = db.Column(db.DateTime, default=datetime.utcnow)
date_modif_passwd = 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 = db.Column(db.Text(), index=True, unique=True)
token_expiration = db.Column(db.DateTime) 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) roles = db.relationship("Role", secondary="user_role", viewonly=True)
Permission = Permission Permission = Permission
@ -126,17 +116,12 @@ class User(UserMixin, ScoDocModel):
) )
def __init__(self, **kwargs): def __init__(self, **kwargs):
"user_name:str is mandatory"
self.roles = [] self.roles = []
self.user_roles = [] self.user_roles = []
# check login: # check login:
if not "user_name" in kwargs: if kwargs.get("user_name") and invalid_user_name(kwargs["user_name"]):
raise ValueError("missing user_name argument")
if not is_valid_user_name(kwargs["user_name"]):
raise ValueError(f"invalid user_name: {kwargs['user_name']}") raise ValueError(f"invalid user_name: {kwargs['user_name']}")
kwargs["nom"] = kwargs.get("nom", "") or "" super(User, self).__init__(**kwargs)
kwargs["prenom"] = kwargs.get("prenom", "") or ""
super().__init__(**kwargs)
# Ajoute roles: # Ajoute roles:
if ( if (
not self.roles not self.roles
@ -245,44 +230,33 @@ class User(UserMixin, ScoDocModel):
return None return None
return db.session.get(User, user_id) 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): def to_dict(self, include_email=True):
"""l'utilisateur comme un dict, avec des champs supplémentaires""" """l'utilisateur comme un dict, avec des champs supplémentaires"""
data = { data = {
"date_expiration": ( "date_expiration": self.date_expiration.isoformat() + "Z"
self.date_expiration.isoformat() + "Z" if self.date_expiration else None if self.date_expiration
), else None,
"date_modif_passwd": ( "date_modif_passwd": self.date_modif_passwd.isoformat() + "Z"
self.date_modif_passwd.isoformat() + "Z" if self.date_modif_passwd
if self.date_modif_passwd else None,
else None "date_created": self.date_created.isoformat() + "Z"
), if self.date_created
"date_created": ( else None,
self.date_created.isoformat() + "Z" if self.date_created else None
),
"dept": self.dept, "dept": self.dept,
"id": self.id, "id": self.id,
"active": self.active, "active": self.active,
"cas_id": self.cas_id, "cas_id": self.cas_id,
"cas_allow_login": self.cas_allow_login, "cas_allow_login": self.cas_allow_login,
"cas_allow_scodoc_login": self.cas_allow_scodoc_login, "cas_allow_scodoc_login": self.cas_allow_scodoc_login,
"cas_last_login": ( "cas_last_login": self.cas_last_login.isoformat() + "Z"
self.cas_last_login.isoformat() + "Z" if self.cas_last_login else None if self.cas_last_login
), else None,
"edt_id": self.edt_id,
"status_txt": "actif" if self.active else "fermé", "status_txt": "actif" if self.active else "fermé",
"last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None, "last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None,
"nom": self.nom or "", "nom": (self.nom or ""), # sco8
"prenom": self.prenom or "", "prenom": (self.prenom or ""), # sco8
"roles_string": self.get_roles_string(), # eg "Ens_RT, Ens_Info" "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: # Les champs calculés:
"nom_fmt": self.get_nom_fmt(), "nom_fmt": self.get_nom_fmt(),
"prenom_fmt": self.get_prenom_fmt(), "prenom_fmt": self.get_prenom_fmt(),
@ -296,55 +270,37 @@ class User(UserMixin, ScoDocModel):
data["email_institutionnel"] = self.email_institutionnel or "" data["email_institutionnel"] = self.email_institutionnel or ""
return data 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): def from_dict(self, data: dict, new_user=False):
"""Set users' attributes from given dict values. """Set users' attributes from given dict values.
- roles_string : roles, encoded like "Ens_RT, Secr_CJ" Roles must be encoded as "roles_string", like "Ens_RT, Secr_CJ"
- date_expiration is a dateime object.
Does not check permissions here.
""" """
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 new_user:
if "user_name" in data: if "user_name" in data:
# never change name of existing users # 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"] self.user_name = data["user_name"]
if "password" in data: if "password" in data:
self.set_password(data["password"]) 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, ..." # Roles: roles_string is "Ens_RT, Secr_RT, ..."
if "roles_string" in data: if "roles_string" in data:
self.user_roles = [] self.user_roles = []
@ -353,13 +309,11 @@ class User(UserMixin, ScoDocModel):
role, dept = UserRole.role_dept_from_string(r_d) role, dept = UserRole.role_dept_from_string(r_d)
self.add_role(role, dept) self.add_role(role, dept)
super().from_dict(data, excluded={"user_name", "roles_string", "roles"})
# Set cas_id using regexp if configured: # Set cas_id using regexp if configured:
exp = ScoDocSiteConfig.get("cas_uid_from_mail_regexp") exp = ScoDocSiteConfig.get("cas_uid_from_mail_regexp")
if exp and self.email_institutionnel: if exp and self.email_institutionnel:
cas_id = ScoDocSiteConfig.extract_cas_id(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 self.cas_id = cas_id
def get_token(self, expires_in=3600): 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 """nomplogin est le nom en majuscules suivi du prénom et du login
e.g. Dupont Pierre (dupont) e.g. Dupont Pierre (dupont)
""" """
nom = scu.format_nom(self.nom) if self.nom else self.user_name.upper() nom = sco_etud.format_nom(self.nom) if self.nom else self.user_name.upper()
return f"{nom} {scu.format_prenom(self.prenom)} ({self.user_name})" return f"{nom} {sco_etud.format_prenom(self.prenom)} ({self.user_name})"
@staticmethod @staticmethod
def get_user_from_nomplogin(nomplogin: str) -> Optional["User"]: def get_user_id_from_nomplogin(nomplogin: str) -> Optional[int]:
"""Returns User instance from the string "Dupont Pierre (dupont)" """Returns id from the string "Dupont Pierre (dupont)"
or None if user does not exist or None if user does not exist
""" """
match = re.match(r".*\((.*)\)", nomplogin.strip()) match = re.match(r".*\((.*)\)", nomplogin.strip())
@ -500,96 +454,38 @@ class User(UserMixin, ScoDocModel):
user_name = match.group(1) user_name = match.group(1)
u = User.query.filter_by(user_name=user_name).first() u = User.query.filter_by(user_name=user_name).first()
if u: if u:
return u return u.id
return None return None
def get_nom_fmt(self): def get_nom_fmt(self):
"""Nom formaté: "Martin" """ """Nom formaté: "Martin" """
if self.nom: if self.nom:
return scu.format_nom(self.nom, uppercase=False) return sco_etud.format_nom(self.nom, uppercase=False)
else: else:
return self.user_name return self.user_name
def get_prenom_fmt(self): def get_prenom_fmt(self):
"""Prénom formaté (minuscule capitalisées)""" """Prénom formaté (minuscule capitalisées)"""
return scu.format_prenom(self.prenom) return sco_etud.format_prenom(self.prenom)
def get_nomprenom(self): def get_nomprenom(self):
"""Nom capitalisé suivi de l'initiale du prénom: """Nom capitalisé suivi de l'initiale du prénom:
Viennet E. 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() return (self.get_nom_fmt() + " " + prenom_abbrv).strip()
def get_prenomnom(self): def get_prenomnom(self):
"""L'initiale du prénom suivie du nom: "J.-C. Dupont" """ """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() return (prenom_abbrv + " " + self.get_nom_fmt()).strip()
def get_nomcomplet(self): def get_nomcomplet(self):
"Prénom et nom complets" "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) # 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): class AnonymousUser(AnonymousUserMixin):
"Notre utilisateur anonyme" "Notre utilisateur anonyme"
@ -671,19 +567,8 @@ class Role(db.Model):
"""Create default roles if missing, then, if reset_permissions, """Create default roles if missing, then, if reset_permissions,
reset their permissions to default values. 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" 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() role = Role.query.filter_by(name=role_name).first()
if role is None: if role is None:
role = Role(name=role_name) role = Role(name=role_name)

View File

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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -21,9 +21,9 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
return "" return ""
ref_comp = ue.formation.referentiel_competence ref_comp = ue.formation.referentiel_competence
if ref_comp is None: 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 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) scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)
}">associer un référentiel de compétence</a> }">associer un référentiel de compétence</a>
</div> </div>
@ -31,36 +31,27 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
H = [ H = [
""" """
<div class="scobox ue_advanced"> <div class="ue_advanced">
<div class="scobox-title">Parcours du BUT</div> <h3>Parcours du BUT</h3>
""" """
] ]
# Choix des parcours # Choix des parcours
ue_pids = [p.id for p in ue.parcours] ue_pids = [p.id for p in ue.parcours]
H.append( H.append("""<form id="choix_parcours">""")
"""
<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;">
"""
)
ects_differents = { ects_differents = {
ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours
} != {None} } != {None}
for parcour in ref_comp.parcours: for parcour in ref_comp.parcours:
ects_parcour = ue.get_ects(parcour)
ects_parcour_txt = ( ects_parcour_txt = (
f" ({ue.get_ects(parcour):.3g} ects)" if ects_differents else "" f" ({ue.get_ects(parcour):.3g} ects)" if ects_differents else ""
) )
H.append( 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 ""} {'checked' if parcour.id in ue_pids else ""}
onclick="set_ue_parcour(this);" 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)}" scodoc_dept=g.scodoc_dept, ue_id=ue.id)}"
>{parcour.code}{ects_parcour_txt}</label>""" >{parcour.code}{ects_parcour_txt}</label>"""
) )
@ -71,7 +62,7 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
<ul> <ul>
<li> <li>
<a class="stdlink" href="{ <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) scodoc_dept=g.scodoc_dept, ue_id=ue.id)
}">définir des ECTS différents dans chaque parcours</a> }">définir des ECTS différents dans chaque parcours</a>
</li> </li>

View File

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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # 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é) - `bul: dict` : le bulletin (dict, même structure que le json publié)
- `cursus: EtudCursusBUT`: infos sur le cursus BUT (niveaux validés etc) - `cursus: EtudCursusBUT`: infos sur le cursus BUT (niveaux validés etc)
- `decision_ues: dict`: `{ acronyme_ue : { 'code' : 'ADM' }}` accès aux décisions - `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 - `ects_total` : nombre d'ECTS validées dans ce cursus
- `ue_validation_by_niveau : dict` : les validations d'UE de chaque niveau du 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_exceptions import ScoNoReferentielCompetences, ScoValueError
from app.scodoc.sco_logos import find_logo from app.scodoc.sco_logos import find_logo
from app.scodoc.sco_permissions import Permission 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 import app.scodoc.sco_utils as scu
from app.views import notes_bp as bp from app.views import notes_bp as bp
from app.views import ScoData 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(): if not formsemestre.formation.is_apc():
raise ScoValueError("formation non BUT") 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) bulletins_sem = BulletinBUT(formsemestre)
if fmt == "pdf": if fmt == "pdf":
bul: dict = bulletins_sem.bulletin_etud_complet(etud) bul: dict = bulletins_sem.bulletin_etud_complet(etud)
filigranne = bul["filigranne"]
else: # la même chose avec un peu moins d'infos else: # la même chose avec un peu moins d'infos
bul: dict = bulletins_sem.bulletin_etud(etud, force_publishing=True) bul: dict = bulletins_sem.bulletin_etud(etud, force_publishing=True)
filigranne = ""
decision_ues = ( decision_ues = (
{x["acronyme"]: x for x in bul["semestre"]["decision_ue"]} {x["acronyme"]: x for x in bul["semestre"]["decision_ue"]}
if "semestre" in bul and "decision_ue" in bul["semestre"] if "semestre" in bul and "decision_ue" in bul["semestre"]
@ -119,14 +81,6 @@ def _build_bulletin_but_infos(
refcomp = formsemestre.formation.referentiel_competence refcomp = formsemestre.formation.referentiel_competence
if refcomp is None: if refcomp is None:
raise ScoNoReferentielCompetences(formation=formsemestre.formation) 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( ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
refcomp, etud refcomp, etud
) )
@ -141,7 +95,6 @@ def _build_bulletin_but_infos(
"decision_ues": decision_ues, "decision_ues": decision_ues,
"ects_total": ects_total, "ects_total": ects_total,
"etud": etud, "etud": etud,
"filigranne": filigranne,
"formsemestre": formsemestre, "formsemestre": formsemestre,
"logo": logo, "logo": logo,
"prefs": bulletins_sem.prefs, "prefs": bulletins_sem.prefs,
@ -153,5 +106,16 @@ def _build_bulletin_but_infos(
if ue.type == UE_STANDARD and ue.acronyme in ue_acronyms if ue.type == UE_STANDARD and ue.acronyme in ue_acronyms
], ],
} }
add_dut120_infos(formsemestre, etud.id, args) if fmt == "pdf":
return args 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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
"""Génération bulletin BUT PDF synthétique en une page """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. 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_logos import Logo
from app.scodoc.sco_pdf import PDFLOCK, SU from app.scodoc.sco_pdf import PDFLOCK, SU
from app.scodoc.sco_preferences import SemPreferences from app.scodoc.sco_preferences import SemPreferences
from app.scodoc import sco_utils as scu
def make_bulletin_but_court_pdf( def make_bulletin_but_court_pdf(
args: dict, bul: dict = None,
stand_alone: bool = True, 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: ) -> bytes:
"""génère le bulletin court BUT en pdf. "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,
"""
# A priori ce verrou n'est plus nécessaire avec Flask (multi-process) # A priori ce verrou n'est plus nécessaire avec Flask (multi-process)
# mais... # mais...
try: try:
PDFLOCK.acquire() PDFLOCK.acquire()
bul_generator = BulletinGeneratorBUTCourt(**args) bul_generator = BulletinGeneratorBUTCourt(**locals())
bul_pdf = bul_generator.generate(fmt="pdf", stand_alone=stand_alone) bul_pdf = bul_generator.generate(fmt="pdf")
finally: finally:
PDFLOCK.release() PDFLOCK.release()
return bul_pdf return bul_pdf
@ -88,7 +79,6 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
decision_ues: dict = None, decision_ues: dict = None,
ects_total: float = 0.0, ects_total: float = 0.0,
etud: Identite = None, etud: Identite = None,
filigranne="",
formsemestre: FormSemestre = None, formsemestre: FormSemestre = None,
logo: Logo = None, logo: Logo = None,
prefs: SemPreferences = None, prefs: SemPreferences = None,
@ -97,10 +87,8 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
tuple[int, str], ScolarFormSemestreValidation tuple[int, str], ScolarFormSemestreValidation
] = None, ] = None,
ues_acronyms: list[str] = 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.bul = bul
self.cursus = cursus self.cursus = cursus
self.decision_ues = decision_ues self.decision_ues = decision_ues
@ -112,8 +100,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
self.title = title self.title = title
self.ue_validation_by_niveau = ue_validation_by_niveau self.ue_validation_by_niveau = ue_validation_by_niveau
self.ues_acronyms = ues_acronyms # sans UEs sport 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) self.nb_ues = len(self.ues_acronyms)
# Styles PDF # Styles PDF
self.style_base = styles.ParagraphStyle("style_base") self.style_base = styles.ParagraphStyle("style_base")
@ -198,7 +185,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
"""Génère la partie "titre" du bulletin de notes. """Génère la partie "titre" du bulletin de notes.
Renvoie une liste d'objets platypus 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) return super().bul_title_pdf(preference_field=preference_field)
def bul_part_below(self, fmt="pdf") -> list: def bul_part_below(self, fmt="pdf") -> list:
@ -246,17 +233,13 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
) )
table_abs_ues.hAlign = "RIGHT" table_abs_ues.hAlign = "RIGHT"
# Ligne (en bas) avec table cursus et boite jury # 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_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), colWidths=(self.width_page_avail - 84 * mm, 84 * mm),
style=style_table_2cols, style=style_table_2cols,
) )
@ -351,11 +334,9 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
for mod in self.bul[mod_type]: for mod in self.bul[mod_type]:
row = [mod, bul[mod_type][mod]["titre"]] row = [mod, bul[mod_type][mod]["titre"]]
row += [ row += [
( bul["ues"][ue][mod_type][mod]["moyenne"]
bul["ues"][ue][mod_type][mod]["moyenne"] if mod in bul["ues"][ue][mod_type]
if mod in bul["ues"][ue][mod_type] else ""
else ""
)
for ue in self.ues_acronyms for ue in self.ues_acronyms
] ]
rows.append(row) rows.append(row)
@ -416,8 +397,6 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
def boite_identite(self) -> list: def boite_identite(self) -> list:
"Les informations sur l'identité et l'inscription de l'étudiant" "Les informations sur l'identité et l'inscription de l'étudiant"
parcour = self.formsemestre.etuds_inscriptions[self.etud.id].parcour
return [ return [
Paragraph( Paragraph(
SU(f"""{self.etud.nomprenom}"""), SU(f"""{self.etud.nomprenom}"""),
@ -428,8 +407,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
f""" f"""
<b>{self.bul["demission"]}</b><br/> <b>{self.bul["demission"]}</b><br/>
Formation: {self.formsemestre.titre_num()}<br/> Formation: {self.formsemestre.titre_num()}<br/>
{'Parcours ' + parcour.code + '<br/>' if parcour else ''} Année scolaire: {self.formsemestre.annee_scolaire_str()}<br/>
Année universitaire: {self.formsemestre.annee_scolaire_str()}<br/>
""" """
), ),
style=self.style_base, style=self.style_base,
@ -530,17 +508,14 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
def boite_decisions_jury(self): def boite_decisions_jury(self):
"""La boite en bas à droite avec jury""" """La boite en bas à droite avec jury"""
txt = f"""ECTS acquis en BUT : <b>{self.ects_total:g}</b><br/>""" txt = f"""ECTS acquis en BUT : <b>{self.ects_total:g}</b><br/>"""
if self.bul["semestre"].get("decision_annee", None): if self.bul["semestre"].get("decision_annee", None):
txt += f""" 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>. <b>{self.bul["semestre"]["decision_annee"]["code"]}</b>.
<br/> <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): if self.bul["semestre"].get("autorisation_inscription", None):
txt += ( txt += (
"<br/>Autorisé à s'inscrire en <b>" "<br/>Autorisé à s'inscrire en <b>"

View File

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

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # 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 import ElementTree
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
from app import db, log from app import log
from app.but import bulletin_but 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.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc import codes_cursus 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_photos
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_xml from app.scodoc import sco_xml
@ -199,12 +202,12 @@ def bulletin_but_xml_compat(
if e.visibulletin or version == "long": if e.visibulletin or version == "long":
x_eval = Element( x_eval = Element(
"evaluation", "evaluation",
date_debut=( date_debut=e.date_debut.isoformat()
e.date_debut.isoformat() if e.date_debut else "" if e.date_debut
), else "",
date_fin=( date_fin=e.date_fin.isoformat()
e.date_fin.isoformat() if e.date_fin else "" if e.date_debut
), else "",
coefficient=str(e.coefficient), coefficient=str(e.coefficient),
# pas les poids en XML compat # pas les poids en XML compat
evaluation_type=str(e.evaluation_type), 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: # notes envoyées sur 20, ceci juste pour garder trace:
note_max_origin=str(e.note_max), note_max_origin=str(e.note_max),
# --- deprecated # --- deprecated
jour=( jour=e.date_debut.isoformat()
e.date_debut.isoformat() if e.date_debut else "" if e.date_debut
), else "",
heure_debut=e.heure_debut(), heure_debut=e.heure_debut(),
heure_fin=e.heure_fin(), heure_fin=e.heure_fin(),
) )
@ -241,7 +244,7 @@ def bulletin_but_xml_compat(
# --- Absences # --- Absences
if sco_preferences.get_preference("bul_show_abs", formsemestre_id): 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))) doc.append(Element("absences", nbabs=str(nbabs), nbabsjust=str(nbabsjust)))
# -------- LA SUITE EST COPIEE SANS MODIF DE sco_bulletins_xml.py --------- # -------- LA SUITE EST COPIEE SANS MODIF DE sco_bulletins_xml.py ---------
@ -291,18 +294,17 @@ def bulletin_but_xml_compat(
"decisions_ue" "decisions_ue"
]: # and sco_preferences.get_preference( 'bul_show_uevalid', formsemestre_id): always publish (car utile pour export Apogee) ]: # 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(): for ue_id in decision["decisions_ue"].keys():
ue = db.session.get(UniteEns, ue_id) ue = sco_edit_ue.ue_list({"ue_id": ue_id})[0]
if ue: doc.append(
doc.append( Element(
Element( "decision_ue",
"decision_ue", ue_id=str(ue["ue_id"]),
ue_id=str(ue.id), numero=quote_xml_attr(ue["numero"]),
numero=quote_xml_attr(ue.numero), acronyme=quote_xml_attr(ue["acronyme"]),
acronyme=quote_xml_attr(ue.acronyme), titre=quote_xml_attr(ue["titre"]),
titre=quote_xml_attr(ue.titre or ""), code=decision["decisions_ue"][ue_id]["code"],
code=decision["decisions_ue"][ue_id]["code"],
)
) )
)
for aut in decision["autorisations"]: for aut in decision["autorisations"]:
doc.append( 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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -14,39 +14,45 @@ Classe raccordant avec ScoDoc 7:
""" """
import collections import collections
from collections.abc import Iterable
from operator import attrgetter from operator import attrgetter
from flask import g, url_for from flask import g, url_for
from flask_sqlalchemy.query import Query
from app import db, log from app import db, log
from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models.but_refcomp import ( from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence, ApcCompetence,
ApcNiveau, ApcNiveau,
ApcParcours, ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences, ApcReferentielCompetences,
) )
from app.models.ues import UEParcours from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
)
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.formations import Formation from app.models.formations import Formation
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus as sco_codes from app.scodoc import codes_cursus as sco_codes
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
from app.scodoc import sco_cursus_dut from app.scodoc import sco_cursus_dut
class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic): 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) super().__init__(etud, formsemestre_id, res)
# Ajustements pour le BUT # Ajustements pour le BUT
self.can_compensate_with_prev = False # jamais de compensation à la mode DUT self.can_compensate_with_prev = False # jamais de compensation à la mode DUT
@ -56,22 +62,8 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
return False return False
def parcours_validated(self): def parcours_validated(self):
"True si le parcours (ici diplôme BUT) est validé" "True si le parcours est validé"
return but_parcours_validated( return False # XXX TODO
self.etud.id, self.cur_sem.formation.referentiel_competence_id
)
def but_parcours_validated(etudid: int, referentiel_competence_id: int) -> bool:
"""Détermine si le parcours BUT est validé:
ne regarde que si une validation BUT3 est enregistrée
"""
return any(
sco_codes.code_annee_validant(v.code)
for v in ApcValidationAnnee.query.filter_by(
etudid=etudid, ordre=3, referentiel_competence_id=referentiel_competence_id
)
)
class EtudCursusBUT: class EtudCursusBUT:
@ -127,15 +119,8 @@ class EtudCursusBUT:
self.validation_par_competence_et_annee = {} self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }""" """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
validation_rcue: ApcValidationRCUE
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau() 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: if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {} self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get( previous_validation = self.validation_par_competence_et_annee.get(
@ -209,10 +194,6 @@ class EtudCursusBUT:
# slow, utile pour affichage fiche # slow, utile pour affichage fiche
return annee in [n.annee for n in self.competences[competence_id].niveaux] 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]]: def load_validation_by_niveau(self) -> dict[int, list[ApcValidationRCUE]]:
"""Cherche les validations de jury enregistrées pour chaque niveau """Cherche les validations de jury enregistrées pour chaque niveau
Résultat: { niveau_id : [ ApcValidationRCUE ] } Résultat: { niveau_id : [ ApcValidationRCUE ] }
@ -307,136 +288,104 @@ class FormSemestreCursusBUT:
) )
return niveaux_by_annee return niveaux_by_annee
# def get_etud_validation_par_competence_et_annee(self, etud: Identite): def get_etud_validation_par_competence_et_annee(self, etud: Identite):
# """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }""" """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
# validation_par_competence_et_annee = {} validation_par_competence_et_annee = {}
# for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# # On s'assurer qu'elle concerne notre cursus ! # On s'assurer qu'elle concerne notre cursus !
# ue = validation_rcue.ue2 ue = validation_rcue.ue2
# if ue.id not in self.ue_ids: if ue.id not in self.ue_ids:
# if ( if (
# ue.formation.referentiel_competences_id ue.formation.referentiel_competences_id
# == self.referentiel_competences_id == self.referentiel_competences_id
# ): ):
# self.ue_ids = ue.id self.ue_ids = ue.id
# else: else:
# continue # skip this validation continue # skip this validation
# niveau = validation_rcue.niveau() niveau = validation_rcue.niveau()
# if not niveau.competence.id in validation_par_competence_et_annee: if not niveau.competence.id in validation_par_competence_et_annee:
# validation_par_competence_et_annee[niveau.competence.id] = {} validation_par_competence_et_annee[niveau.competence.id] = {}
# previous_validation = validation_par_competence_et_annee.get( previous_validation = validation_par_competence_et_annee.get(
# niveau.competence.id niveau.competence.id
# ).get(validation_rcue.annee()) ).get(validation_rcue.annee())
# # prend la "meilleure" validation # prend la "meilleure" validation
# if (not previous_validation) or ( if (not previous_validation) or (
# sco_codes.BUT_CODES_ORDER[validation_rcue.code] sco_codes.BUT_CODES_ORDER[validation_rcue.code]
# > sco_codes.BUT_CODES_ORDER[previous_validation["code"]] > sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
# ): ):
# self.validation_par_competence_et_annee[niveau.competence.id][ self.validation_par_competence_et_annee[niveau.competence.id][
# niveau.annee niveau.annee
# ] = validation_rcue ] = validation_rcue
# return validation_par_competence_et_annee return validation_par_competence_et_annee
# def list_etud_inscriptions(self, etud: Identite): def list_etud_inscriptions(self, etud: Identite):
# "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)" "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
# self.niveaux_by_annee = {} self.niveaux_by_annee = {}
# "{ annee : liste des niveaux à valider }" "{ annee : liste des niveaux à valider }"
# self.niveaux: dict[int, ApcNiveau] = {} self.niveaux: dict[int, ApcNiveau] = {}
# "cache les niveaux" "cache les niveaux"
# for annee in (1, 2, 3): for annee in (1, 2, 3):
# niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours( niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
# annee, [self.parcour] if self.parcour else None # XXX WIP annee, [self.parcour] if self.parcour else None # XXX WIP
# )[1] )[1]
# # groupe les niveaux de tronc commun et ceux spécifiques au parcour # groupe les niveaux de tronc commun et ceux spécifiques au parcour
# self.niveaux_by_annee[annee] = niveaux_d["TC"] + ( self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
# niveaux_d[self.parcour.id] if self.parcour else [] niveaux_d[self.parcour.id] if self.parcour else []
# ) )
# self.niveaux.update( self.niveaux.update(
# {niveau.id: niveau for niveau in self.niveaux_by_annee[annee]} {niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
# ) )
# self.validation_par_competence_et_annee = {} self.validation_par_competence_et_annee = {}
# """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }""" """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
# for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# niveau = validation_rcue.niveau() niveau = validation_rcue.niveau()
# if not niveau.competence.id in self.validation_par_competence_et_annee: if not niveau.competence.id in self.validation_par_competence_et_annee:
# self.validation_par_competence_et_annee[niveau.competence.id] = {} self.validation_par_competence_et_annee[niveau.competence.id] = {}
# previous_validation = self.validation_par_competence_et_annee.get( previous_validation = self.validation_par_competence_et_annee.get(
# niveau.competence.id niveau.competence.id
# ).get(validation_rcue.annee()) ).get(validation_rcue.annee())
# # prend la "meilleure" validation # prend la "meilleure" validation
# if (not previous_validation) or ( if (not previous_validation) or (
# sco_codes.BUT_CODES_ORDER[validation_rcue.code] sco_codes.BUT_CODES_ORDER[validation_rcue.code]
# > sco_codes.BUT_CODES_ORDER[previous_validation["code"]] > sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
# ): ):
# self.validation_par_competence_et_annee[niveau.competence.id][ self.validation_par_competence_et_annee[niveau.competence.id][
# niveau.annee niveau.annee
# ] = validation_rcue ] = validation_rcue
# self.competences = { self.competences = {
# competence.id: competence competence.id: competence
# for competence in ( for competence in (
# self.parcour.query_competences() self.parcour.query_competences()
# if self.parcour if self.parcour
# else self.formation.referentiel_competence.get_competences_tronc_commun() else self.formation.referentiel_competence.get_competences_tronc_commun()
# ) )
# } }
# "cache { competence_id : competence }" "cache { competence_id : competence }"
def but_ects_valides( def but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float:
etud: Identite,
referentiel_competence_id: int,
annees_but: None | Iterable[str] = None,
) -> int:
"""Nombre d'ECTS validés par etud dans le BUT de référentiel indiqué. """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, 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. 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 = ( validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.filter(ScolarFormSemestreValidation.ue_id != None) .filter(ScolarFormSemestreValidation.ue_id != None)
.join(UniteEns) .join(UniteEns)
.join(ApcNiveau) .join(ApcNiveau)
) .join(ApcCompetence)
# restreint à certaines années (utile pour les ECTS du DUT120) .filter_by(referentiel_id=referentiel_competence_id)
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
) )
# Tri (nb: fait en python pour gérer les validations externes qui n'ont pas de formsemestre) ects_dict = {}
return sorted( for v in validations:
validations, key = (v.ue.semestre_idx, v.ue.niveau_competence.id)
key=lambda v: ( if v.code in CODES_UE_VALIDES:
(v.formsemestre.semestre_id, v.ue.numero, v.ue.acronyme) ects_dict[key] = v.ue.ects
if v.formsemestre
else (v.ue.semestre_idx or -2, v.ue.numero, v.ue.acronyme) return sum(ects_dict.values()) if ects_dict else 0.0
),
)
def etud_ues_de_but1_non_validees( def etud_ues_de_but1_non_validees(
@ -487,38 +436,15 @@ def formsemestre_warning_apc_setup(
""" """
if not formsemestre.formation.is_apc(): if not formsemestre.formation.is_apc():
return "" 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: if formsemestre.formation.referentiel_competence is None:
return f"""<div class="formsemestre_status_warning"> return f"""<div class="formsemestre_status_warning">
La <a class="stdlink" href="{url_formation}">formation La <a class="stdlink" href="{
n'est pas associée à un référentiel de compétence.</a> 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> </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 # Vérifie les niveaux de chaque parcours
H = []
for parcour in formsemestre.parcours or [None]: for parcour in formsemestre.parcours or [None]:
annee = (formsemestre.semestre_id + 1) // 2 annee = (formsemestre.semestre_id + 1) // 2
niveaux_ids = { niveaux_ids = {
@ -543,8 +469,7 @@ def formsemestre_warning_apc_setup(
if not H: if not H:
return "" return ""
return f"""<div class="formsemestre_status_warning"> return f"""<div class="formsemestre_status_warning">
Problème dans la Problème dans la configuration de la formation:
<a class="stdlink" href="{url_formation}">configuration de la formation</a>:
<ul> <ul>
<li>{ '</li><li>'.join(H) }</li> <li>{ '</li><li>'.join(H) }</li>
</ul> </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( def ue_associee_au_niveau_du_parcours(
ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S" ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
) -> UniteEns: ) -> UniteEns:

View File

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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -10,11 +10,9 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed from flask_wtf.file import FileField, FileAllowed
from wtforms import SelectField, SubmitField from wtforms import SelectField, SubmitField
from wtforms.validators import DataRequired
class FormationRefCompForm(FlaskForm): class FormationRefCompForm(FlaskForm):
"Choix d'un référentiel"
referentiel_competence = SelectField( referentiel_competence = SelectField(
"Choisir parmi les référentiels déjà chargés :" "Choisir parmi les référentiels déjà chargés :"
) )
@ -23,7 +21,6 @@ class FormationRefCompForm(FlaskForm):
class RefCompLoadForm(FlaskForm): class RefCompLoadForm(FlaskForm):
"Upload d'un référentiel"
referentiel_standard = SelectField( referentiel_standard = SelectField(
"Choisir un référentiel de compétences officiel BUT" "Choisir un référentiel de compétences officiel BUT"
) )
@ -50,12 +47,3 @@ class RefCompLoadForm(FlaskForm):
) )
return False return False
return True 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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
from xml.etree import ElementTree from xml.etree import ElementTree
@ -23,12 +23,9 @@ from app.models.but_refcomp import (
from app.scodoc.sco_exceptions import ScoFormatError, ScoValueError from app.scodoc.sco_exceptions import ScoFormatError, ScoValueError
def orebut_import_refcomp( def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
xml_data: str, dept_id: int, orig_filename=None
) -> ApcReferentielCompetences:
"""Importation XML Orébut """Importation XML Orébut
peut lever TypeError ou ScoFormatError peut lever TypeError ou ScoFormatError
L'objet créé est ajouté et commité.
Résultat: instance de ApcReferentielCompetences Résultat: instance de ApcReferentielCompetences
""" """
# Vérifie que le même fichier n'a pas déjà été chargé: # 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 scodoc_orig_filename=orig_filename, dept_id=dept_id
).count(): ).count():
raise ScoValueError( 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}) ({orig_filename})
Supprimez-le ou changez le nom du fichier.""" Supprimez-le ou changez le nom du fichier."""
) )
@ -44,7 +41,7 @@ def orebut_import_refcomp(
try: try:
root = ElementTree.XML(xml_data) root = ElementTree.XML(xml_data)
except ElementTree.ParseError as exc: 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": if root.tag != "referentiel_competence":
raise ScoFormatError("élément racine 'referentiel_competence' manquant") raise ScoFormatError("élément racine 'referentiel_competence' manquant")
args = ApcReferentielCompetences.attr_from_xml(root.attrib) 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 # ne devrait plus se produire car pas d'unicité de l'id: donc inutile
db.session.rollback() db.session.rollback()
raise ScoValueError( raise ScoValueError(
f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({ f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({competence.attrib["id"]})
competence.attrib["id"]})
""" """
) from exc ) from exc
ref.competences.append(c) ref.competences.append(c)

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -13,22 +13,22 @@ Utilisation:
cherche les RCUEs de l'année (BUT1, 2, 3) 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. 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) différents éléments (UEs, RCUEs, Année, Diplôme)
Cela donne Cela donne
- les codes possibles (dans .codes) - les codes possibles (dans .codes)
- le code actuel si une décision existe déjà (dans code_valide) - le code actuel si une décision existe déjà (dans code_valide)
- pour les UEs, le rcue s'il y en a un) - pour les UEs, le rcue s'il y en a un)
2) Validation pour l'utilisateur (form)) => enregistrement code 2) Validation pour l'utilisateur (form)) => enregistrement code
- on vérifie que le code soumis est bien dans les codes possibles - on vérifie que le code soumis est bien dans les codes possibles
- on enregistre la décision (dans ScolarFormSemestreValidation pour les UE, - on enregistre la décision (dans ScolarFormSemestreValidation pour les UE,
ApcValidationRCUE pour les RCUE, et ApcValidationAnnee pour les années) ApcValidationRCUE pour les RCUE, et ApcValidationAnnee pour les années)
- Si RCUE validé, on déclenche d'éventuelles validations: - Si RCUE validé, on déclenche d'éventuelles validations:
("La validation des deux UE du niveau d'une compétence emporte la validation ("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.") 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`. 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) 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 - 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. 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. Si l'utilisateur coche "décision manuelle", il peut alors choisir les codes RCUE et années.
La soumission du formulaire: La soumission du formulaire:
- etud, formation - etud, formation
- UEs: [(formsemestre, ue, code), ...] - UEs: [(formsemestre, ue, code), ...]
- RCUE: [(formsemestre, ue, code), ...] le formsemestre est celui d'indice pair du niveau - 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. (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 import numpy as np
from flask import flash, g, url_for from flask import flash, g, url_for
import sqlalchemy as sa
from app import db from app import db
from app import log from app import log
@ -78,14 +77,12 @@ from app.models.but_refcomp import (
ApcNiveau, ApcNiveau,
ApcParcours, ApcParcours,
) )
from app.models import Evaluation, ModuleImpl, Scolog, ScolarAutorisationInscription from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import ( from app.models.but_validations import (
ApcValidationAnnee, ApcValidationAnnee,
ApcValidationRCUE, ApcValidationRCUE,
ValidationDUT120,
) )
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation 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 import codes_cursus as sco_codes
from app.scodoc.codes_cursus import ( from app.scodoc.codes_cursus import (
code_rcue_validant, code_rcue_validant,
code_ue_validant,
BUT_CODES_ORDER, BUT_CODES_ORDER,
CODES_RCUE_VALIDES, CODES_RCUE_VALIDES,
CODES_UE_VALIDES, CODES_UE_VALIDES,
@ -121,7 +117,7 @@ class NoRCUEError(ScoValueError):
{warning_impair} {warning_impair}
{warning_pair} {warning_pair}
<div><b>UE {ue.acronyme}</b>: niveau {html.escape(str(ue.niveau_competence))}</div> <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))} for u in deca.ues_impair))}
</div> </div>
""" """
@ -264,11 +260,11 @@ class DecisionsProposeesAnnee(DecisionsProposees):
else [] else []
) )
# ---- Niveaux et RCUEs # ---- Niveaux et RCUEs
niveaux_by_parcours = formsemestre.formation.referentiel_competence.get_niveaux_by_parcours( niveaux_by_parcours = (
self.annee_but, [self.parcour] if self.parcour else None formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
)[ self.annee_but, [self.parcour] if self.parcour else None
1 )[1]
] )
self.niveaux_competences = niveaux_by_parcours["TC"] + ( self.niveaux_competences = niveaux_by_parcours["TC"] + (
niveaux_by_parcours[self.parcour.id] if self.parcour else [] 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. = niveaux du tronc commun + niveau du parcours de l'étudiant.
""" """
self.rcue_by_niveau = self._compute_rcues_annee() self.rcue_by_niveau = self._compute_rcues_annee()
"""RCUEs de l'année """RCUEs de l'année
(peuvent être construits avec des UEs validées antérieurement: redoublants (peuvent être construits avec des UEs validées antérieurement: redoublants
avec UEs capitalisées, validation "antérieures") avec UEs capitalisées, validation "antérieures")
""" """
# ---- Décision année et autorisation # ---- Décision année et autorisation
@ -362,17 +358,13 @@ class DecisionsProposeesAnnee(DecisionsProposees):
# self.codes = [] # pas de décision annuelle sur semestres impairs # self.codes = [] # pas de décision annuelle sur semestres impairs
elif self.inscription_etat != scu.INSCRIT: elif self.inscription_etat != scu.INSCRIT:
self.codes = [ self.codes = [
( sco_codes.DEM
sco_codes.DEM if self.inscription_etat == scu.DEMISSION
if self.inscription_etat == scu.DEMISSION else sco_codes.DEF,
else sco_codes.DEF
),
# propose aussi d'autres codes, au cas où... # propose aussi d'autres codes, au cas où...
( sco_codes.DEM
sco_codes.DEM if self.inscription_etat != scu.DEMISSION
if self.inscription_etat != scu.DEMISSION else sco_codes.DEF,
else sco_codes.DEF
),
sco_codes.ABAN, sco_codes.ABAN,
sco_codes.ABL, sco_codes.ABL,
sco_codes.EXCLU, sco_codes.EXCLU,
@ -388,24 +380,14 @@ class DecisionsProposeesAnnee(DecisionsProposees):
sco_codes.ADJ, sco_codes.ADJ,
] + self.codes ] + self.codes
explanation += f" et {self.nb_rcues_under_8} < 8" explanation += f" et {self.nb_rcues_under_8} < 8"
else: # autres cas: non admis, non passage, non dem, pas la moitié des rcue: else:
if formsemestre.semestre_id % 2 and self.formsemestre_pair is None: self.codes = [
# Si jury sur un seul semestre impair, ne propose pas redoublement sco_codes.RED,
# et efface décision éventuellement existante sco_codes.NAR,
codes = [None] sco_codes.PAS1NCI,
else: sco_codes.ADJ,
codes = [] sco_codes.PASD, # voir #488 (discutable, conventions locales)
self.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
)
explanation += f""" et {self.nb_rcues_under_8 explanation += f""" et {self.nb_rcues_under_8
} niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 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: # Si validée par niveau supérieur:
if self.code_valide == sco_codes.ADSUP: if self.code_valide == sco_codes.ADSUP:
self.codes.insert(0, 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() messages = self.descr_pb_coherence()
if messages: if messages:
self.explanation += ( self.explanation += (
'<div class="warning warning-info">' '<div class="warning">'
+ '</div><div class="warning warning-info">'.join(messages) + '</div><div class="warning">'.join(messages)
+ "</div>" + "</div>"
) )
self.codes = [self.codes[0]] + sorted(self.codes[1:])
# Présente les codes unifiés, avec le code proposé en tête et les autres triés
codes_set = set(self.codes)
codes_set.remove(self.codes[0])
self.codes = [self.codes[0]] + sorted(x or "" for x in codes_set)
def passage_de_droit_en_but3(self) -> tuple[bool, str]: def passage_de_droit_en_but3(self) -> tuple[bool, str]:
"""Vérifie si les conditions supplémentaires de passage BUT2 vers BUT3 sont satisfaites""" """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) """Les deux formsemestres auquel est inscrit l'étudiant (ni DEM ni DEF)
du niveau auquel appartient formsemestre. 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 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 Si l'origine est paire, S_pair est l'origine, et S_impair l'antérieur
suivi par cet étudiant (ou None). 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 if not formsemestre.formation.is_apc(): # garde fou
return None, None return None, None
if formsemestre.semestre_id % 2: if formsemestre.semestre_id % 2:
idx_autre = formsemestre.semestre_id + 1 # impair, autre = suivant idx_autre = formsemestre.semestre_id + 1
else: 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: # Cherche l'autre semestre de la même année scolaire:
autre_formsemestre = None autre_formsemestre = None
@ -563,8 +539,6 @@ class DecisionsProposeesAnnee(DecisionsProposees):
inscr.formsemestre.formation.referentiel_competence inscr.formsemestre.formation.referentiel_competence
== formsemestre.formation.referentiel_competence == formsemestre.formation.referentiel_competence
) )
# Non bloqué
and not inscr.formsemestre.block_moyennes
# L'autre semestre # L'autre semestre
and (inscr.formsemestre.semestre_id == idx_autre) and (inscr.formsemestre.semestre_id == idx_autre)
# de la même année scolaire # de la même année scolaire
@ -607,9 +581,11 @@ class DecisionsProposeesAnnee(DecisionsProposees):
# Ordonne par numéro d'UE # Ordonne par numéro d'UE
niv_rcue = sorted( niv_rcue = sorted(
self.rcue_by_niveau.items(), self.rcue_by_niveau.items(),
key=lambda x: ( key=lambda x: x[1].ue_1.numero
x[1].ue_1.numero if x[1].ue_1 else x[1].ue_2.numero if x[1].ue_2 else 0 if x[1].ue_1
), else x[1].ue_2.numero
if x[1].ue_2
else 0,
) )
return { return {
niveau_id: DecisionsProposeesRCUE(self, rcue, self.inscription_etat) niveau_id: DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
@ -634,7 +610,6 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def next_semestre_ids(self, code: str) -> set[int]: def next_semestre_ids(self, code: str) -> set[int]:
"""Les indices des semestres dans lequels l'étudiant est autorisé """Les indices des semestres dans lequels l'étudiant est autorisé
à poursuivre après le semestre courant. à 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 # La poursuite d'études dans un semestre pair d'une même année
# est de droit pour tout étudiant. # 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, 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. 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") log("jury_but.DecisionsProposeesAnnee.record_form")
code_annee = self.codes[0] # si pas dans le form, valeur par defaut 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: def record(self, code: str, mark_recorded: bool = True) -> bool:
"""Enregistre le code de l'année, et au besoin l'autorisation d'inscription. """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 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 Si mark_recorded est vrai, positionne self.recorded
""" """
if self.inscription_etat != scu.INSCRIT: if self.inscription_etat != scu.INSCRIT:
@ -761,22 +733,20 @@ class DecisionsProposeesAnnee(DecisionsProposees):
self.validation.date = datetime.now() self.validation.date = datetime.now()
db.session.add(self.validation) db.session.add(self.validation)
db.session.commit()
log(f"Recording {self}: {code}") log(f"Recording {self}: {code}")
Scolog.logdb( Scolog.logdb(
method="jury_but", method="jury_but",
etudid=self.etud.id, etudid=self.etud.id,
msg=f"Validation année BUT{self.annee_but}: {code}", msg=f"Validation année BUT{self.annee_but}: {code}",
) )
db.session.commit()
if mark_recorded: if mark_recorded:
self.recorded = True self.recorded = True
self.invalidate_formsemestre_cache() self.invalidate_formsemestre_cache()
return True return True
def record_autorisation_inscription(self, code: str): def record_autorisation_inscription(self, code: str):
"""Autorisation d'inscription dans semestre suivant. """Autorisation d'inscription dans semestre suivant"""
code: code jury sur année BUT
"""
if self.autorisations_recorded: if self.autorisations_recorded:
return return
if self.inscription_etat != scu.INSCRIT: if self.inscription_etat != scu.INSCRIT:
@ -804,33 +774,16 @@ class DecisionsProposeesAnnee(DecisionsProposees):
if self.formsemestre_pair is not None: if self.formsemestre_pair is not None:
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id) sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id)
def _get_current_res(self) -> ResultatsSemestreBUT: def has_notes_en_attente(self) -> bool:
"Les res. du semestre d'origine du deca" "Vrai si l'étudiant a au moins une note en attente dans le semestre origine de ce deca"
return ( res = (
self.res_pair self.res_pair
if self.formsemestre_pair if self.formsemestre_pair
and (self.formsemestre.id == self.formsemestre_pair.id) and (self.formsemestre.id == self.formsemestre_pair.id)
else self.res_impair 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() 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: def record_all(self, only_validantes: bool = False) -> bool:
"""Enregistre les codes qui n'ont pas été spécifiés par le formulaire, """Enregistre les codes qui n'ont pas été spécifiés par le formulaire,
et sont donc en mode "automatique". et sont donc en mode "automatique".
@ -843,15 +796,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
Return: True si au moins un code modifié et enregistré. Return: True si au moins un code modifié et enregistré.
""" """
modif = False modif = False
if only_validantes: # Vérification notes en attente dans formsemestre origine
if self.has_notes_en_attente(): if only_validantes and self.has_notes_en_attente():
# notes en attente dans formsemestre origine return False
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
# Toujours valider dans l'ordre UE, RCUE, Année # Toujours valider dans l'ordre UE, RCUE, Année
annee_scolaire = self.formsemestre.annee_scolaire() annee_scolaire = self.formsemestre.annee_scolaire()
@ -893,7 +840,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
not only_validantes not only_validantes
) or code in sco_codes.CODES_ANNEE_BUT_VALIDES_DE_DROIT: ) or code in sco_codes.CODES_ANNEE_BUT_VALIDES_DE_DROIT:
modif |= self.record(code) modif |= self.record(code)
self.record_autorisation_inscription(code) self.record_autorisation_inscription(code)
return modif return modif
def erase(self, only_one_sem=False): 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 Si only_one_sem, n'efface que les décisions UE et les
autorisations de passage du semestre d'origine du deca. 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. Dans tous les cas, efface les validations de l'année en cours.
(commite la session.) (commite la session.)
""" """
@ -955,17 +900,6 @@ class DecisionsProposeesAnnee(DecisionsProposees):
msg=f"Validation année BUT{self.annee_but}: effacée", 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 # Efface éventuelles validations de semestre
# (en principe inutilisées en BUT) # (en principe inutilisées en BUT)
# et autres UEs (en cas de changement d'architecture de formation depuis le jury ?) # et autres UEs (en cas de changement d'architecture de formation depuis le jury ?)
@ -1007,36 +941,6 @@ class DecisionsProposeesAnnee(DecisionsProposees):
pour PV jurys pour PV jurys
""" """
validations = [] 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): for res in (self.res_impair, self.res_pair):
if res: if res:
dec_ues = [ dec_ues = [
@ -1045,10 +949,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
if ue.type == UE_STANDARD and ue.id in self.decisions_ues if ue.type == UE_STANDARD and ue.id in self.decisions_ues
] ]
valids = [dec_ue.descr_validation() for dec_ue in dec_ues] valids = [dec_ue.descr_validation() for dec_ue in dec_ues]
# présentation de la liste des UEs: validations.append(", ".join(v for v in valids if v))
if valids:
validations.append(", ".join(v for v in valids if v))
return line_sep.join(validations) return line_sep.join(validations)
def descr_pb_coherence(self) -> list[str]: 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.code_valide not in CODES_UE_VALIDES:
if ( if (
dec_ue.ue_status dec_ue.ue_status
and dec_ue.ue_status["is_capitalized"] and dec_ue.ue_status["was_capitalized"]
): ):
messages.append( messages.append(
f"Information: l'UE {ue.acronyme} capitalisée est utilisée pour un RCUE cette année" f"Information: l'UE {ue.acronyme} capitalisée est utilisée pour un RCUE cette année"
) )
else: else:
messages.append( 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: else:
messages.append( messages.append(
f"L'UE {ue.acronyme} n'a pas décision (???)" 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 return messages
def valide_diplome(self) -> bool: def valide_diplome(self) -> bool:
"Vrai si l'étudiant a validé son diplôme (décision enregistrée)" "Vrai si l'étudiant à validé son diplôme"
return self.annee_but == 3 and sco_codes.code_annee_validant(self.code_valide) return False # TODO XXX
def list_ue_parcour_etud( def list_ue_parcour_etud(
@ -1228,12 +1125,13 @@ class DecisionsProposeesRCUE(DecisionsProposees):
code=code, code=code,
) )
db.session.add(self.validation) db.session.add(self.validation)
db.session.commit()
Scolog.logdb( Scolog.logdb(
method="jury_but", method="jury_but",
etudid=self.etud.id, etudid=self.etud.id,
msg=f"Validation {self.rcue}: {code}", msg=f"Validation {self.rcue}: {code}",
commit=True,
) )
db.session.commit()
log(f"rcue.record {self}: {code}") log(f"rcue.record {self}: {code}")
# Modifie au besoin les codes d'UE # Modifie au besoin les codes d'UE
@ -1570,11 +1468,9 @@ class DecisionsProposeesUE(DecisionsProposees):
self.validation = None # cache toute validation self.validation = None # cache toute validation
self.explanation = "non inscrit (dem. ou déf.)" self.explanation = "non inscrit (dem. ou déf.)"
self.codes = [ self.codes = [
( sco_codes.DEM
sco_codes.DEM if res.get_etud_etat(etud.id) == scu.DEMISSION
if res.get_etud_etat(etud.id) == scu.DEMISSION else sco_codes.DEF
else sco_codes.DEF
)
] ]
return return
@ -1588,7 +1484,7 @@ class DecisionsProposeesUE(DecisionsProposees):
def __repr__(self) -> str: def __repr__(self) -> str:
return f"""<{self.__class__.__name__} ue={self.ue.acronyme} valid={self.code_valide 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): def compute_codes(self):
"""Calcul des .codes attribuables et de l'explanation associée""" """Calcul des .codes attribuables et de l'explanation associée"""
@ -1646,12 +1542,13 @@ class DecisionsProposeesUE(DecisionsProposees):
moy_ue=self.moy_ue, moy_ue=self.moy_ue,
) )
db.session.add(self.validation) db.session.add(self.validation)
db.session.commit()
Scolog.logdb( Scolog.logdb(
method="jury_but", method="jury_but",
etudid=self.etud.id, etudid=self.etud.id,
msg=f"Validation UE {self.ue.id} {self.ue.acronyme}({self.moy_ue}): {code}", 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}") log(f"DecisionsProposeesUE: recording {self.validation}")
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id) sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id)

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -8,12 +8,11 @@
""" """
from flask import g, request, url_for 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 import log
from app.but import jury_but from app.but import jury_but
from app.but.cursus_but import but_ects_valides from app.but.cursus_but import but_ects_valides
from app.models.but_validations import ValidationDUT120
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
@ -56,21 +55,11 @@ def pvjury_page_but(formsemestre_id: int, fmt="html"):
else: else:
line_sep = "\n" line_sep = "\n"
rows, titles = pvjury_table_but(formsemestre, line_sep=line_sep) 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 # Style excel... passages à la ligne sur \n
xls_style_base = sco_excel.excel_make_style() xls_style_base = sco_excel.excel_make_style()
xls_style_base["alignment"] = Alignment(wrapText=True, vertical="top") xls_style_base["alignment"] = Alignment(wrapText=True, vertical="top")
tab = GenTable( tab = GenTable(
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}", base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}",
caption=title, caption=title,
@ -80,7 +69,7 @@ def pvjury_page_but(formsemestre_id: int, fmt="html"):
html_title=f"""<div style="margin-bottom: 8px;"><span html_title=f"""<div style="margin-bottom: 8px;"><span
style="font-size: 120%; font-weight: bold;">{title}</span> style="font-size: 120%; font-weight: bold;">{title}</span>
<span style="padding-left: 20px;"> <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")}" scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, fmt="xlsx")}"
class="stdlink">version excel</a></span></div> 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 !" "pas de référentiel de compétences associé à la formation de ce semestre !"
) )
titles = { titles = {
"nom_pv": "Code" if anonymous else "Nom", "nom": "Code" if anonymous else "Nom",
"cursus": "Cursus", "cursus": "Cursus",
"ects": "ECTS", "ects": "ECTS",
"ues": "UE validées", "ues": "UE validées",
@ -155,64 +144,33 @@ def pvjury_table_but(
except ScoValueError: except ScoValueError:
deca = None 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 = { row = {
"nom_pv": ( "nom": etud.code_ine or etud.code_nip or etud.id
etud.code_ine or etud.code_nip or etud.id if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id else etud.etat_civil_pv(
else etud.etat_civil_pv( line_sep=line_sep, with_paragraph=with_paragraph_nom
line_sep=line_sep, with_paragraph=with_paragraph_nom
)
), ),
"_nom_pv_order": etud.sort_key, "_nom_order": etud.sort_key,
"_nom_pv_target_attrs": f'class="etudinfo" id="{etud.id}"', "_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_pv_td_attrs": f'id="{etud.id}" class="etudinfo"', "_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_pv_target": url_for( "_nom_target": url_for(
"scolar.fiche_etud", "scolar.ficheEtud",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
etudid=etud.id, etudid=etud.id,
), ),
"cursus": _descr_cursus_but(etud), "cursus": _descr_cursus_but(etud),
"ects": ( "ects": f"""{deca.ects_annee():g}<br><br>Tot. {but_ects_valides(etud, referentiel_competence_id ):g}""",
f"""{deca.ects_annee():g}<br><br>Tot. {ects_but_valides:g}"""
if deca
else ""
),
"_ects_xls": deca.ects_annee() if deca else "",
"ects_but": ects_but_valides,
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-", "ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": ( "niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
deca.descr_niveaux_validation(line_sep=line_sep) if deca else "-" if deca
), else "-",
"decision_but": deca.code_valide if deca else "", "decision_but": deca.code_valide if deca else "",
"devenir": ( "devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
"Diplôme obtenu" if deca
if has_diplome else "",
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,
} }
if (deca and deca.valide_diplome()) or not only_diplome: if deca.valide_diplome() or not only_diplome:
rows.append(row) rows.append(row)
rows.sort(key=lambda x: x["_nom_pv_order"]) rows.sort(key=lambda x: x["_nom_order"])
return rows, titles return rows, titles

View File

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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -9,15 +9,15 @@
from flask import g, url_for from flask import g, url_for
from app import db from app import db
from app.but import jury_but, jury_dut120 from app.but import jury_but
from app.models import Identite, FormSemestre, ScolarNews, ValidationDUT120 from app.models import Identite, FormSemestre, ScolarNews
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but( def formsemestre_validation_auto_but(
formsemestre: FormSemestre, only_adm: bool = True, dry_run=False, with_dut120=True formsemestre: FormSemestre, only_adm: bool = True
) -> tuple[int, list[jury_but.DecisionsProposeesAnnee]]: ) -> int:
"""Calcul automatique des décisions de jury sur une "année" BUT. """Calcul automatique des décisions de jury sur une "année" BUT.
- N'enregistre jamais de décisions de l'année scolaire précédente, même - N'enregistre jamais de décisions de l'année scolaire précédente, même
@ -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 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) (mode à n'utiliser que pour les tests unitaires vérifiant la saisie des jurys)
Enregistre aussi le DUT120. Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code.
Returns:
- En mode normal, (nombre d'étudiants pour lesquels on a enregistré au moins un code, []])
- En mode dry_run, (0, list[DecisionsProposeesAnnee])
""" """
if not formsemestre.formation.is_apc(): if not formsemestre.formation.is_apc():
raise ScoValueError("fonction réservée aux formations BUT") raise ScoValueError("fonction réservée aux formations BUT")
nb_etud_modif = 0 nb_etud_modif = 0
decas = []
with sco_cache.DeferredSemCacheManager(): with sco_cache.DeferredSemCacheManager():
for etudid in formsemestre.etuds_inscriptions: for etudid in formsemestre.etuds_inscriptions:
etud = Identite.get_etud(etudid) etud = Identite.get_etud(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if not dry_run: nb_etud_modif += deca.record_all(only_validantes=only_adm)
modified = deca.record_all(only_validantes=only_adm)
modified |= validation_dut120_auto(etud, formsemestre)
if modified:
nb_etud_modif += 1
else:
decas.append(deca)
db.session.commit() db.session.commit()
ScolarNews.add( ScolarNews.add(
@ -60,29 +49,4 @@ def formsemestre_validation_auto_but(
formsemestre_id=formsemestre.id, formsemestre_id=formsemestre.id,
), ),
) )
return nb_etud_modif, decas return nb_etud_modif
def validation_dut120_auto(etud: Identite, formsemestre: FormSemestre) -> bool:
"""Si l'étudiant n'a pas déjà validé son DUT120 dans cette spécialité
et qu'il satisfait les confitions, l'enregistre.
Returns True si nouvelle décision enregistrée.
"""
refcomp = formsemestre.formation.referentiel_competence
if not refcomp:
raise ScoValueError("formation non associée à un référentiel de compétences")
validation = ValidationDUT120.query.filter_by(
etudid=etud.id, referentiel_competence_id=refcomp.id
).first()
if validation:
return False # déjà enregistré
if jury_dut120.etud_valide_dut120(etud, refcomp.id):
new_validation = ValidationDUT120(
etudid=etud.id,
referentiel_competence_id=refcomp.id,
formsemestre_id=formsemestre.id, # Replace with appropriate value
)
db.session.add(new_validation)
db.session.commit()
return True
return False # ne peut pas valider

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -21,6 +21,8 @@ from app.but.jury_but import (
DecisionsProposeesRCUE, DecisionsProposeesRCUE,
DecisionsProposeesUE, DecisionsProposeesUE,
) )
from app.comp import res_sem
from app.comp.res_but import ResultatsSemestreBUT
from app.models import ( from app.models import (
ApcNiveau, ApcNiveau,
FormSemestre, FormSemestre,
@ -31,8 +33,11 @@ from app.models import (
ScolarFormSemestreValidation, ScolarFormSemestreValidation,
ScolarNews, 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 import codes_cursus as sco_codes
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -71,9 +76,9 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
f""" f"""
<div class="titre_niveaux"> <div class="titre_niveaux">
<b>Niveaux de compétences et unités d'enseignement du BUT{deca.annee_but}</b> <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={ 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, etudid=deca.etud.id,
formsemestre_id=formsemestre_2.id if formsemestre_2 else formsemestre_1.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() <span class="avertissement_redoublement">{formsemestre_2.annee_scolaire_str()
if formsemestre_2 else ""}</span> if formsemestre_2 else ""}</span>
</div> </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(): for dec_rcue in deca.get_decisions_rcues_annee():
@ -104,32 +109,23 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
</div>""" </div>"""
) )
ue_impair, ue_pair = rcue.ue_1, rcue.ue_2 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) # Les UEs à afficher,
# tuples (UniteEns, read_only, dispense) # qui
ues_ro_dispense = [ ues_ro = [
( (
ue_impair, ue_impair,
rcue.ue_cur_impair is None, 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, ue_pair,
rcue.ue_cur_pair is None, 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: # Ordonne selon les dates des 2 semestres considérés:
if reverse_semestre: if reverse_semestre:
ues_ro_dispense[0], ues_ro_dispense[1] = ( ues_ro[0], ues_ro[1] = ues_ro[1], ues_ro[0]
ues_ro_dispense[1],
ues_ro_dispense[0],
)
# Colonnes d'UE: # Colonnes d'UE:
for ue, ue_read_only, ue_dispense in ues_ro_dispense: for ue, ue_read_only in ues_ro:
if ue: if ue:
H.append( H.append(
_gen_but_niveau_ue( _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, disabled=read_only or ue_read_only,
annee_prec=ue_read_only, annee_prec=ue_read_only,
niveau_id=ue.niveau_competence.id, niveau_id=ue.niveau_competence.id,
ue_dispense=ue_dispense,
) )
) )
else: else:
@ -177,7 +172,7 @@ def _gen_but_select(
] ]
) )
return f"""<select required name="{name}" 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_code="{code_valide or (codes[0] if codes else '')}"
data-orig_recorded="{code_valide or ''}" data-orig_recorded="{code_valide or ''}"
onchange="change_menu_code(this);" onchange="change_menu_code(this);"
@ -193,30 +188,21 @@ def _gen_but_niveau_ue(
disabled: bool = False, disabled: bool = False,
annee_prec: bool = False, annee_prec: bool = False,
niveau_id: int = None, niveau_id: int = None,
ue_dispense: bool = False,
) -> str: ) -> str:
if dec_ue.ue_status and dec_ue.ue_status["is_capitalized"]: if dec_ue.ue_status and dec_ue.ue_status["is_capitalized"]:
moy_ue_str = f"""<span class="ue_cap">{ moy_ue_str = f"""<span class="ue_cap">{
scu.fmt_note(dec_ue.moy_ue_with_cap)}</span>""" 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"> scoplement = f"""<div class="scoplement">
<div> <div>
<b>UE {ue.acronyme} capitalisée </b> <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> </span>
</div> </div>
<div> <div>UE en cours
{ etat_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>
</div> </div>
""" """
@ -228,7 +214,7 @@ def _gen_but_niveau_ue(
<div> <div>
<b>UE {ue.acronyme} antérieure </b> <b>UE {ue.acronyme} antérieure </b>
<span>validée {dec_ue.validation.code} <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> </span>
</div> </div>
<div>Non reprise dans l'année en cours</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>""" moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
if dec_ue.code_valide: if dec_ue.code_valide:
date_str = ( 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 if dec_ue.validation and dec_ue.validation.event_date
else "" else ""
) )
@ -256,13 +244,7 @@ def _gen_but_niveau_ue(
</div> </div>
""" """
else: else:
if dec_ue.ue_status and dec_ue.ue_status["was_capitalized"]: scoplement = ""
scoplement = """<div class="scoplement">
UE déjà capitalisée avec résultat moins favorable.
</div>
"""
else:
scoplement = ""
ue_class = "" # 'recorded' if dec_ue.code_valide is not None else '' ue_class = "" # 'recorded' if dec_ue.code_valide is not None else ''
if dec_ue.code_valide is not None and dec_ue.codes: if dec_ue.code_valide 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} return f"""<div class="but_niveau_ue {ue_class}
{'annee_prec' if annee_prec else ''} {'annee_prec' if annee_prec else ''}
"> ">
<div title="{ue.titre or ''}">{ue.acronyme}</div> <div title="{ue.titre}">{ue.acronyme}</div>
<div class="but_note with_scoplement"> <div class="but_note with_scoplement">
<div>{moy_ue_str}</div> <div>{moy_ue_str}</div>
{scoplement} {scoplement}
</div> </div>
<div class="but_code">{ <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.codes,
dec_ue.code_valide, dec_ue.code_valide,
disabled=disabled, disabled=disabled,
klass=f"code_ue ue_rcue_{niveau_id}" if not disabled else "" klass=f"code_ue ue_rcue_{niveau_id}" if not disabled else ""
) )
}</div> }</div>
</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: def infos_fiche_etud_html(etudid: int) -> str:
"""Section html pour fiche etudiant """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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -10,25 +10,23 @@ Non spécifique au BUT.
""" """
from flask import render_template from flask import render_template
from flask_login import current_user
import sqlalchemy as sa import sqlalchemy as sa
from app.models import ( from app.models import (
ApcValidationAnnee, ApcValidationAnnee,
ApcValidationRCUE, ApcValidationRCUE,
Identite, Identite,
UniteEns,
ScolarAutorisationInscription, ScolarAutorisationInscription,
ScolarFormSemestreValidation, ScolarFormSemestreValidation,
UniteEns,
ValidationDUT120,
) )
from app.scodoc.sco_permissions import Permission
from app.views import ScoData from app.views import ScoData
def jury_delete_manual(etud: Identite): def jury_delete_manual(etud: Identite):
"""Vue présentant *toutes* les décisions de jury concernant cet étudiant """Vue (réservée au chef de dept.)
et permettant (si permission) de les supprimer une à une. présentant *toutes* les décisions de jury concernant cet étudiant
et permettant de les supprimer une à une.
""" """
sem_vals = ScolarFormSemestreValidation.query.filter_by( sem_vals = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, ue_id=None etudid=etud.id, ue_id=None
@ -62,12 +60,8 @@ def jury_delete_manual(etud: Identite):
sem_vals=sem_vals, sem_vals=sem_vals,
ue_vals=ue_vals, ue_vals=ue_vals,
autorisations=autorisations, autorisations=autorisations,
dut120_vals=ValidationDUT120.query.filter_by(etudid=etud.id).order_by(
ValidationDUT120.date
),
rcue_vals=rcue_vals, rcue_vals=rcue_vals,
annee_but_vals=annee_but_vals, annee_but_vals=annee_but_vals,
sco=ScoData(), sco=ScoData(),
title=f"Toutes les décisions de jury enregistrées pour {etud.html_link_fiche()}", 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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -75,7 +75,7 @@ class RegroupementCoherentUE:
else None else None
) )
# Autres validations pour les UEs paire/impaire # Autres validations pour l'UE paire
self.validation_ue_best_pair = best_autre_ue_validation( self.validation_ue_best_pair = best_autre_ue_validation(
etud.id, etud.id,
niveau.id, niveau.id,
@ -101,24 +101,14 @@ class RegroupementCoherentUE:
"résultats formsemestre de l'UE si elle est courante, None sinon" "résultats formsemestre de l'UE si elle est courante, None sinon"
self.ue_status_impair = None self.ue_status_impair = None
if self.ue_cur_impair: if self.ue_cur_impair:
# UE courante
ue_status = res_impair.get_etud_ue_status(etud.id, self.ue_cur_impair.id) 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.moy_ue_1 = ue_status["moy"] if ue_status else None # avec capitalisée
self.ue_1 = self.ue_cur_impair self.ue_1 = self.ue_cur_impair
self.res_impair = res_impair self.res_impair = res_impair
self.ue_status_impair = ue_status self.ue_status_impair = ue_status
elif self.validation_ue_best_impair: elif self.validation_ue_best_impair:
# UE capitalisée
self.moy_ue_1 = self.validation_ue_best_impair.moy_ue self.moy_ue_1 = self.validation_ue_best_impair.moy_ue
self.ue_1 = self.validation_ue_best_impair.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: else:
self.moy_ue_1, self.ue_1 = None, None self.moy_ue_1, self.ue_1 = None, None
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0 self.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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # 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 from flask import render_template
import sqlalchemy as sa
from app import log from app import log
from app.but import cursus_but from app.but import cursus_but

View File

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

View File

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

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -27,7 +27,6 @@
"""caches pour tables APC """caches pour tables APC
""" """
from flask import g
from app.scodoc import sco_cache from app.scodoc import sco_cache
@ -48,27 +47,3 @@ class EvaluationsPoidsCache(sco_cache.ScoDocCache):
""" """
prefix = "EPC" 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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -23,7 +23,6 @@ from app.models import (
) )
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import codes_cursus from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu
class ValidationsSemestre(ResultatsCache): class ValidationsSemestre(ResultatsCache):
@ -39,7 +38,7 @@ class ValidationsSemestre(ResultatsCache):
super().__init__(formsemestre, sco_cache.ValidationsSemestreCache) super().__init__(formsemestre, sco_cache.ValidationsSemestreCache)
self.decisions_jury = {} self.decisions_jury = {}
"""Décisions prises dans ce semestre: """Décisions prises dans ce semestre:
{ etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }}""" { etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }}"""
self.decisions_jury_ues = {} self.decisions_jury_ues = {}
"""Décisions sur des UEs dans ce semestre: """Décisions sur des UEs dans ce semestre:
@ -85,7 +84,7 @@ class ValidationsSemestre(ResultatsCache):
"code": decision.code, "code": decision.code,
"assidu": decision.assidu, "assidu": decision.assidu,
"compense_formsemestre_id": decision.compense_formsemestre_id, "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 self.decisions_jury = decisions_jury
@ -108,7 +107,7 @@ class ValidationsSemestre(ResultatsCache):
decisions_jury_ues[decision.etudid][decision.ue.id] = { decisions_jury_ues[decision.etudid][decision.ue.id] = {
"code": decision.code, "code": decision.code,
"ects": ects, # 0. si UE non validée "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 self.decisions_jury_ues = decisions_jury_ues
@ -146,11 +145,11 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
query = sa.text( query = sa.text(
""" """
SELECT DISTINCT SFV.*, ue.ue_code SELECT DISTINCT SFV.*, ue.ue_code
FROM FROM
notes_ue ue, notes_ue ue,
notes_formations nf, notes_formations nf,
notes_formations nf2, notes_formations nf2,
scolar_formsemestre_validation SFV, scolar_formsemestre_validation SFV,
notes_formsemestre sem, notes_formsemestre sem,
notes_formsemestre_inscription ins notes_formsemestre_inscription ins

View File

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

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # 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 import dataclasses
from dataclasses import dataclass from dataclasses import dataclass
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import sqlalchemy as sa 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_cache
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoBugCatcher
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
@ -54,7 +56,6 @@ class EvaluationEtat:
evaluation_id: int evaluation_id: int
nb_attente: int nb_attente: int
nb_notes: int # nb notes d'étudiants inscrits au semestre et au modimpl
is_complete: bool is_complete: bool
def to_dict(self): def to_dict(self):
@ -71,15 +72,7 @@ class ModuleImplResults:
les caches sont gérés par ResultatsSemestre. les caches sont gérés par ResultatsSemestre.
""" """
def __init__( def __init__(self, moduleimpl: ModuleImpl):
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.
"""
self.moduleimpl_id = moduleimpl.id self.moduleimpl_id = moduleimpl.id
self.module_id = moduleimpl.module.id self.module_id = moduleimpl.module.id
self.etudids = None self.etudids = None
@ -112,23 +105,14 @@ class ModuleImplResults:
""" """
self.evals_etudids_sans_note = {} self.evals_etudids_sans_note = {}
"""dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions.""" """dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
self.evals_type = {} self.load_notes()
"""Type de chaque eval { evaluation.id : evaluation.evaluation_type }"""
self.load_notes(etudids, etudids_actifs)
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index) 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""" """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) 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""" """1 bool par etud, indique si sa moyenne de module utilise la note de rattrapage"""
def load_notes( def load_notes(self): # ré-écriture de df_load_modimpl_notes
self, etudids: list[int], etudids_actifs: set[int]
): # ré-écriture de df_load_modimpl_notes
"""Charge toutes les notes de toutes les évaluations du module. """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 Dataframe evals_notes
colonnes: le nom de la colonne est l'evaluation_id (int) colonnes: le nom de la colonne est l'evaluation_id (int)
index (lignes): etudid (int) index (lignes): etudid (int)
@ -151,12 +135,12 @@ class ModuleImplResults:
qui ont des notes ATT. qui ont des notes ATT.
""" """
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id) 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": # --- Calcul nombre d'inscrits pour déterminer les évaluations "completes":
# on prend les inscrits au module ET au semestre (donc sans démissionnaires) # on prend les inscrits au module ET au semestre (donc sans démissionnaires)
inscrits_module = {ins.etud.id for ins in moduleimpl.inscriptions}.intersection( inscrits_module = {ins.etud.id for ins in moduleimpl.inscriptions}.intersection(
etudids_actifs moduleimpl.formsemestre.etudids_actifs
) )
self.nb_inscrits_module = len(inscrits_module) self.nb_inscrits_module = len(inscrits_module)
@ -164,24 +148,19 @@ class ModuleImplResults:
evals_notes = pd.DataFrame(index=self.etudids, dtype=float) evals_notes = pd.DataFrame(index=self.etudids, dtype=float)
self.evaluations_completes = [] self.evaluations_completes = []
self.evaluations_completes_dict = {} self.evaluations_completes_dict = {}
self.etudids_attente = set() # empty
self.evals_type = {}
evaluation: Evaluation
for evaluation in moduleimpl.evaluations: for evaluation in moduleimpl.evaluations:
self.evals_type[evaluation.id] = evaluation.evaluation_type
eval_df = self._load_evaluation_notes(evaluation) eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi # is_complete ssi tous les inscrits (non dem) au semestre ont une note
# tous les inscrits (non dem) au module ont une note # ou évaluation déclarée "à prise en compte immédiate"
# ou évaluation déclarée "à prise en compte immédiate" # Les évaluations de rattrapage et 2eme session sont toujours complètes
# ou rattrapage, 2eme session, bonus
# ET pas bloquée par date (is_blocked)
is_blocked = evaluation.is_blocked()
etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem. etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem.
is_complete = ( 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 (evaluation.publish_incomplete)
or (not etudids_sans_note) or (not etudids_sans_note)
) and not is_blocked )
self.evaluations_completes.append(is_complete) self.evaluations_completes.append(is_complete)
self.evaluations_completes_dict[evaluation.id] = is_complete self.evaluations_completes_dict[evaluation.id] = is_complete
self.evals_etudids_sans_note[evaluation.id] = etudids_sans_note self.evals_etudids_sans_note[evaluation.id] = etudids_sans_note
@ -189,39 +168,25 @@ class ModuleImplResults:
# NULL en base => ABS (= -999) # NULL en base => ABS (= -999)
eval_df.fillna(scu.NOTES_ABSENCE, inplace=True) eval_df.fillna(scu.NOTES_ABSENCE, inplace=True)
# Ce merge ne garde que les étudiants inscrits au module # 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): # (notes non saisies ou etuds non inscrits au module):
evals_notes = evals_notes.merge( evals_notes = evals_notes.merge(
eval_df, how="left", left_index=True, right_index=True eval_df, how="left", left_index=True, right_index=True
) )
# Notes en attente: (ne prend en compte que les inscrits, non démissionnaires) # Notes en attente: (ne prend en compte que les inscrits, non démissionnaires)
eval_notes_inscr = evals_notes[str(evaluation.id)][list(inscrits_module)] eval_notes_inscr = evals_notes[str(evaluation.id)][list(inscrits_module)]
# Nombre de notes (non vides, incluant ATT etc) des inscrits: eval_etudids_attente = set(
nb_notes = eval_notes_inscr.notna().sum() eval_notes_inscr.iloc[
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
if is_blocked: ].index
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:
self.etudids_attente |= eval_etudids_attente self.etudids_attente |= eval_etudids_attente
self.evaluations_etat[evaluation.id] = EvaluationEtat( self.evaluations_etat[evaluation.id] = EvaluationEtat(
evaluation_id=evaluation.id, evaluation_id=evaluation.id,
nb_attente=len(eval_etudids_attente), nb_attente=len(eval_etudids_attente),
nb_notes=int(nb_notes),
is_complete=is_complete, 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) self.en_attente = bool(self.etudids_attente)
# Force columns names to integers (evaluation ids) # 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)]) eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)])
return eval_df 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. """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) Résultat: 2d-array of floats, shape (nb_evals, 1)
""" """
return ( return (
np.array( np.array(
[ [
( e.coefficient
e.coefficient if e.evaluation_type == scu.EVALUATION_NORMALE
if e.evaluation_type == Evaluation.EVALUATION_NORMALE else 0.0
else 0.0 for e in moduleimpl.evaluations
)
for e in modimpl.evaluations
], ],
dtype=float, dtype=float,
) )
* self.evaluations_completes * self.evaluations_completes
).reshape(-1, 1) ).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 # was _list_notes_evals_titles
def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]: def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"Liste des évaluations complètes" "Liste des évaluations complètes"
@ -309,7 +266,7 @@ class ModuleImplResults:
) / [e.note_max / 20.0 for e in moduleimpl.evaluations] ) / [e.note_max / 20.0 for e in moduleimpl.evaluations]
def get_eval_notes_dict(self, evaluation_id: int) -> dict: 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 } { etudid : valeur }
avec les valeurs float, ou "ABS" ou EXC avec les valeurs float, ou "ABS" ou EXC
""" """
@ -318,55 +275,44 @@ class ModuleImplResults:
for (etudid, x) in self.evals_notes[evaluation_id].items() for (etudid, x) in self.evals_notes[evaluation_id].items()
} }
def get_evaluations_rattrapage(self, moduleimpl: ModuleImpl) -> list[Evaluation]: def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl):
"""Les évaluations de rattrapage de ce module. """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 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 e
for e in moduleimpl.evaluations 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]: def get_evaluation_session2(self, moduleimpl: ModuleImpl):
"""Les évaluations de deuxième session de ce module, ou None s'il n'en a pas. """L'évaluation 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. Session 2: remplace la note de moyenne des autres évals.
""" """
return [ eval_list = [
e e
for e in moduleimpl.evaluations for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_SESSION2 if e.evaluation_type == scu.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 eval_list:
return eval_list[0]
return None
class ModuleImplResultsAPC(ModuleImplResults): class ModuleImplResultsAPC(ModuleImplResults):
"Calcul des moyennes de modules à la mode BUT" "Calcul des moyennes de modules à la mode BUT"
def compute_module_moy( def compute_module_moy(
self, evals_poids_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame self,
evals_poids_df: pd.DataFrame,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Calcule les moyennes des étudiants dans ce module """Calcule les moyennes des étudiants dans ce module
Argument: Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs
evals_poids: DataFrame, colonnes: UEs, lignes: EVALs
modimpl_coefs_df: DataFrame, colonnes: modimpl_id, lignes: ue_id
Résultat: DataFrame, colonnes UE, lignes etud Résultat: DataFrame, colonnes UE, lignes etud
= la note de l'étudiant dans chaque UE pour ce module. = 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) return pd.DataFrame(index=[], columns=evals_poids_df.columns)
if nb_ues == 0: if nb_ues == 0:
return pd.DataFrame(index=self.evals_notes.index, columns=[]) 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_coefs = self.get_evaluations_coefs(modimpl)
evals_poids = evals_poids_df.values * evals_coefs evals_poids = evals_poids_df.values * evals_coefs
# -> evals_poids shape : (nb_evals, nb_ues) # -> evals_poids shape : (nb_evals, nb_ues)
@ -401,7 +346,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
# et dans dans evals_poids_etuds # et dans dans evals_poids_etuds
# (rappel: la comparaison est toujours false face à un NaN) # (rappel: la comparaison est toujours false face à un NaN)
# shape: (nb_etuds, nb_evals, nb_ues) # 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( evals_poids_etuds = np.where(
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE, np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
poids_stacked, poids_stacked,
@ -409,61 +354,51 @@ class ModuleImplResultsAPC(ModuleImplResults):
) )
# Calcule la moyenne pondérée sur les notes disponibles: # 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 = 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) with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module = np.sum( etuds_moy_module = np.sum(
evals_poids_etuds * evals_notes_stacked, axis=1 evals_poids_etuds * evals_notes_stacked, axis=1
) / np.sum(evals_poids_etuds, 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) # Session2 : quand elle existe, remplace la note de module
evals_rat = self.get_evaluations_rattrapage(modimpl) eval_session2 = self.get_evaluation_session2(modimpl)
if evals_session2: if eval_session2:
# Session2 : quand elle existe, remplace la note de module notes_session2 = self.evals_notes[eval_session2.id].values
# Calcul moyenne notes session2 et remplace (si la note session 2 existe) # n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_moy_module_s2 = self._compute_moy_special( etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
modimpl,
evals_notes_stacked,
evals_poids_df,
Evaluation.EVALUATION_SESSION2,
)
# Vrai si toutes les UEs avec coef non nul ont bien une note de session 2 calculée:
mod_coefs = modimpl_coefs_df[modimpl.id]
etuds_use_session2 = np.all(
np.isfinite(etuds_moy_module_s2[:, mod_coefs != 0]), axis=1
)
etuds_moy_module = np.where( etuds_moy_module = np.where(
etuds_use_session2[:, np.newaxis], 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, etuds_moy_module,
) )
self.etuds_use_session2 = pd.Series( self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index etuds_use_session2, index=self.evals_notes.index
) )
elif evals_rat: else:
etuds_moy_module_rat = self._compute_moy_special( # Rattrapage: remplace la note de module ssi elle est supérieure
modimpl, eval_rat = self.get_evaluation_rattrapage(modimpl)
evals_notes_stacked, if eval_rat:
evals_poids_df, notes_rat = self.evals_notes[eval_rat.id].values
Evaluation.EVALUATION_RATTRAPAGE, # remplace les notes invalides (ATT, EXC...) par des NaN
) notes_rat = np.where(
etuds_ue_use_rattrapage = ( notes_rat > scu.NOTES_ABSENCE,
etuds_moy_module_rat > etuds_moy_module notes_rat / (eval_rat.note_max / 20.0),
) # etud x UE np.nan,
etuds_moy_module = np.where( )
etuds_ue_use_rattrapage, etuds_moy_module_rat, etuds_moy_module # "É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
self.etuds_use_rattrapage = pd.Series( notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1)
np.any(etuds_ue_use_rattrapage, axis=1), index=self.evals_notes.index # prend le max
) etuds_use_rattrapage = notes_rat_ues > etuds_moy_module
# Application des évaluations bonus: etuds_moy_module = np.where(
etuds_moy_module = self.apply_bonus( etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
etuds_moy_module, )
modimpl, # Serie indiquant que l'étudiant utilise une note de rattrapage sur l'une des UE:
evals_poids_df, self.etuds_use_rattrapage = pd.Series(
evals_notes_stacked, etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
) )
self.etuds_moy_module = pd.DataFrame( self.etuds_moy_module = pd.DataFrame(
etuds_moy_module, etuds_moy_module,
index=self.evals_notes.index, index=self.evals_notes.index,
@ -471,58 +406,6 @@ class ModuleImplResultsAPC(ModuleImplResults):
) )
return self.etuds_moy_module 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]: def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
"""Charge poids des évaluations d'un module et retourne un dataframe """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) 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) ues = modimpl.formsemestre.get_ues(with_sport=False)
ue_ids = [ue.id for ue in ues] 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) evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float)
if ( if (
modimpl.module.module_type == ModuleType.RESSOURCE 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): ).filter_by(moduleimpl_id=moduleimpl_id):
try: try:
evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids 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... pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre...
# Initialise poids non enregistrés: # Initialise poids non enregistrés:
@ -571,7 +455,6 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
return evals_poids, ues return evals_poids, ues
# appelé par ModuleImpl.check_apc_conformity()
def moduleimpl_is_conforme( def moduleimpl_is_conforme(
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> bool: ) -> bool:
@ -593,12 +476,12 @@ def moduleimpl_is_conforme(
if len(modimpl_coefs_df) != nb_ues: if len(modimpl_coefs_df) != nb_ues:
# il arrive (#bug) que le cache ne soit pas à jour... # il arrive (#bug) que le cache ne soit pas à jour...
sco_cache.invalidate_formsemestre() 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: if moduleimpl.id not in modimpl_coefs_df:
# soupçon de bug cache coef ? # soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre() 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 module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids)) 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 evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1) ) / np.sum(evals_coefs_etuds, axis=1)
evals_session2 = self.get_evaluations_session2(modimpl) # Session2 : quand elle existe, remplace la note de module
evals_rat = self.get_evaluations_rattrapage(modimpl) eval_session2 = self.get_evaluation_session2(modimpl)
if evals_session2: if eval_session2:
# Session2 : quand elle existe, remplace la note de module notes_session2 = self.evals_notes[eval_session2.id].values
# Calcule la moyenne des évaluations de session2 # n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_moy_module_s2 = self._compute_moy_special( etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
modimpl, evals_notes_20, Evaluation.EVALUATION_SESSION2
)
etuds_use_session2 = np.isfinite(etuds_moy_module_s2)
etuds_moy_module = np.where( etuds_moy_module = np.where(
etuds_use_session2, etuds_use_session2,
etuds_moy_module_s2, notes_session2 / (eval_session2.note_max / 20.0),
etuds_moy_module, etuds_moy_module,
) )
self.etuds_use_session2 = pd.Series( self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index etuds_use_session2, index=self.evals_notes.index
) )
elif evals_rat: else:
# Rattrapage: remplace la note de module ssi elle est supérieure # Rattrapage: remplace la note de module ssi elle est supérieure
# Calcule la moyenne des évaluations de rattrapage eval_rat = self.get_evaluation_rattrapage(modimpl)
etuds_moy_module_rat = self._compute_moy_special( if eval_rat:
modimpl, evals_notes_20, Evaluation.EVALUATION_RATTRAPAGE notes_rat = self.evals_notes[eval_rat.id].values
) # remplace les notes invalides (ATT, EXC...) par des NaN
etuds_use_rattrapage = etuds_moy_module_rat > etuds_moy_module notes_rat = np.where(
etuds_moy_module = np.where( notes_rat > scu.NOTES_ABSENCE,
etuds_use_rattrapage, etuds_moy_module_rat, etuds_moy_module notes_rat / (eval_rat.note_max / 20.0),
) np.nan,
self.etuds_use_rattrapage = pd.Series( )
etuds_use_rattrapage, index=self.evals_notes.index # prend le max
) etuds_use_rattrapage = notes_rat > etuds_moy_module
etuds_moy_module = np.where(
# Application des évaluations bonus: etuds_use_rattrapage, notes_rat, etuds_moy_module
etuds_moy_module = self.apply_bonus( )
etuds_moy_module, self.etuds_use_rattrapage = pd.Series(
modimpl, etuds_use_rattrapage, index=self.evals_notes.index
evals_notes_20, )
)
self.etuds_moy_module = pd.Series( self.etuds_moy_module = pd.Series(
etuds_moy_module, etuds_moy_module,
index=self.evals_notes.index, index=self.evals_notes.index,
) )
return self.etuds_moy_module 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 # 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 # 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 # 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( flash(
Markup( Markup(
f"""Calcul moyenne générale impossible: ECTS des UE manquants !<br> f"""Calcul moyenne générale impossible: ECTS des UE manquants !<br>
(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>)""" 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]: 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. numérique) en tenant compte des ex-aequos.
Result: couple (tuple) Result: couple (tuple)

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # 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 # 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
# sur toutes les UE) # sur toutes les UE)
default_poids = { default_poids = {
mod.id: ( mod.id: 1.0
1.0 if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT)
if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT) else 0.0
else 0.0
)
for mod in modules 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 # 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
# sur toutes les UE) # sur toutes les UE)
default_poids = { default_poids = {
modimpl.id: ( modimpl.id: 1.0
1.0 if (modimpl.module.module_type == ModuleType.STANDARD)
if (modimpl.module.module_type == ModuleType.STANDARD) and (modimpl.module.ue.type == UE_SPORT)
and (modimpl.module.ue.type == UE_SPORT) else 0.0
else 0.0
)
for modimpl in formsemestre.modimpls_sorted 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) return modimpls_notes.swapaxes(0, 1)
def notes_sem_load_cube( def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
formsemestre: FormSemestre, modimpl_coefs_df: pd.DataFrame
) -> tuple:
"""Construit le "cube" (tenseur) des notes du semestre. """Construit le "cube" (tenseur) des notes du semestre.
Charge toutes les notes (sql), calcule les moyennes des modules Charge toutes les notes (sql), calcule les moyennes des modules
et assemble le cube. et assemble le cube.
@ -206,11 +200,10 @@ def notes_sem_load_cube(
modimpls_results = {} modimpls_results = {}
modimpls_evals_poids = {} modimpls_evals_poids = {}
modimpls_notes = [] modimpls_notes = []
etudids, etudids_actifs = formsemestre.etudids_actifs()
for modimpl in formsemestre.modimpls_sorted: for modimpl in formsemestre.modimpls_sorted:
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs) mod_results = moy_mod.ModuleImplResultsAPC(modimpl)
evals_poids = modimpl.get_evaluations_poids() evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
etuds_moy_module = mod_results.compute_module_moy(evals_poids, modimpl_coefs_df) etuds_moy_module = mod_results.compute_module_moy(evals_poids)
modimpls_results[modimpl.id] = mod_results modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids modimpls_evals_poids[modimpl.id] = evals_poids
modimpls_notes.append(etuds_moy_module) modimpls_notes.append(etuds_moy_module)

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -59,17 +59,16 @@ class ResultatsSemestreBUT(NotesTableCompat):
def compute(self): def compute(self):
"Charge les notes et inscriptions et calcule les moyennes d'UE et gen." "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.sem_cube,
self.modimpls_evals_poids, self.modimpls_evals_poids,
self.modimpls_results, 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.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre)
self.ues_inscr_parcours_df = self.load_ues_inscr_parcours() 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 # l'idx de la colonne du mod modimpl.id est
# modimpl_coefs_df.columns.get_loc(modimpl.id) # modimpl_coefs_df.columns.get_loc(modimpl.id)
# idx de l'UE: modimpl_coefs_df.index.get_loc(ue.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()] return s.index[s.notna()]
def etud_parcours_ues_ids(self, etudid: int) -> set[int]: 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. du parcours dans lequel il est inscrit.
Se base sur le parcours dans ce semestre, et le référentiel de compétences. 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. Note: il n'est pas nécessairement inscrit à toutes ces UEs.
@ -308,7 +307,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
return ues_ids 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 """True s'il y a une décision (quelconque) de jury
émanant de ce formsemestre pour cet étudiant. émanant de ce formsemestre pour cet étudiant.
prend aussi en compte les autorisations de passage. prend aussi en compte les autorisations de passage.
@ -319,12 +318,9 @@ class ResultatsSemestreBUT(NotesTableCompat):
or ApcValidationAnnee.query.filter_by( or ApcValidationAnnee.query.filter_by(
formsemestre_id=self.formsemestre.id, etudid=etudid formsemestre_id=self.formsemestre.id, etudid=etudid
).count() ).count()
or ( or ApcValidationRCUE.query.filter_by(
include_rcues formsemestre_id=self.formsemestre.id, etudid=etudid
and ApcValidationRCUE.query.filter_by( ).count()
formsemestre_id=self.formsemestre.id, etudid=etudid
).count()
)
) )
def get_validations_annee(self) -> dict[int, ApcValidationAnnee]: def get_validations_annee(self) -> dict[int, ApcValidationAnnee]:

View File

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

View File

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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -9,13 +9,12 @@
from collections import Counter, defaultdict from collections import Counter, defaultdict
from collections.abc import Generator from collections.abc import Generator
import datetime
from functools import cached_property from functools import cached_property
from operator import attrgetter from operator import attrgetter
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import sqlalchemy as sa
from flask import g, url_for from flask import g, url_for
from app import db from app import db
@ -23,19 +22,14 @@ from app.comp import res_sem
from app.comp.res_cache import ResultatsCache from app.comp.res_cache import ResultatsCache
from app.comp.jury import ValidationsSemestre from app.comp.jury import ValidationsSemestre
from app.comp.moy_mod import ModuleImplResults from app.comp.moy_mod import ModuleImplResults
from app.models import ( from app.models import FormSemestre, FormSemestreUECoef
Evaluation, from app.models import Identite
FormSemestre, from app.models import ModuleImpl, ModuleImplInscription
FormSemestreUECoef, from app.models import ScolarAutorisationInscription
Identite, from app.models.ues import UniteEns
ModuleImpl,
ModuleImplInscription,
ScolarAutorisationInscription,
UniteEns,
)
from app.scodoc.sco_cache import ResultatsSemestreCache from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.codes_cursus import UE_SPORT 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 from app.scodoc import sco_utils as scu
@ -150,7 +144,7 @@ class ResultatsSemestre(ResultatsCache):
def etud_ects_tot_sem(self, etudid: int) -> float: def etud_ects_tot_sem(self, etudid: int) -> float:
"""Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)""" """Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)"""
etud_ues = self.etud_ues(etudid) 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: def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray:
"""Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue. """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()] *[mr.etudids_attente for mr in self.modimpls_results.values()]
) )
# Etat des évaluations # # Etat des évaluations
def get_evaluation_etat(self, evaluation: Evaluation) -> dict: # # (se substitue à do_evaluation_etat, sans les moyennes par groupes)
"""État d'une évaluation # def get_evaluations_etats(evaluation_id: int) -> dict:
{ # """Renvoie dict avec les clés:
"coefficient" : float, # 0 si None # last_modif
"description" : str, # de l'évaluation, "" si None # nb_evals_completes
"etat" { # nb_evals_en_cours
"blocked" : bool, # vrai si prise en compte bloquée # nb_evals_vides
"evalcomplete" : bool, # attente
"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"]
#
# --- JURY... # --- JURY...
def get_formsemestre_validations(self) -> ValidationsSemestre: def get_formsemestre_validations(self) -> ValidationsSemestre:
@ -436,28 +360,11 @@ class ResultatsSemestre(ResultatsCache):
ue_cap_dict["compense_formsemestre_id"] = None ue_cap_dict["compense_formsemestre_id"] = None
return ue_cap_dict 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. """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. Result: dict, ou None si l'UE 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
}
""" """
ue: UniteEns = db.session.get(UniteEns, ue_id) ue: UniteEns = db.session.get(UniteEns, ue_id)
if not ue:
return None
ue_dict = ue.to_dict() ue_dict = ue.to_dict()
if ue.type == UE_SPORT: if ue.type == UE_SPORT:
@ -476,7 +383,7 @@ class ResultatsSemestre(ResultatsCache):
"ects": 0.0, "ects": 0.0,
"ects_ue": ue.ects, "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 return None
if not self.validations: if not self.validations:
self.validations = res_sem.load_formsemestre_validations(self.formsemestre) self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
@ -518,8 +425,7 @@ class ResultatsSemestre(ResultatsCache):
Corrigez ou faite corriger le programme Corrigez ou faite corriger le programme
<a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, <a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept,
formation_id=ue_capitalized.formation_id)}">via cette page</a>. formation_id=ue_capitalized.formation_id)}">via cette page</a>.
""", """
safe=True,
) )
else: else:
# Coefs de l'UE capitalisée en formation classique: # 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, "is_external": ue_cap["is_external"] if is_capitalized else ue.is_external,
"coef_ue": coef_ue, "coef_ue": coef_ue,
"ects_pot": ue.ects or 0.0, "ects_pot": ue.ects or 0.0,
"ects": ( "ects": self.validations.decisions_jury_ues.get(etudid, {})
self.validations.decisions_jury_ues.get(etudid, {}) .get(ue.id, {})
.get(ue.id, {}) .get("ects", 0.0)
.get("ects", 0.0) if self.validations.decisions_jury_ues
if self.validations.decisions_jury_ues else 0.0,
else 0.0
),
"ects_ue": ue.ects, "ects_ue": ue.ects,
"cur_moy_ue": cur_moy_ue, "cur_moy_ue": cur_moy_ue,
"moy": moy_ue, "moy": moy_ue,

View File

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

View File

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

View File

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

View File

@ -1,17 +1,16 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
import datetime
from threading import Thread from threading import Thread
from flask import current_app, g 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.departements import Departement
from app.models.config import ScoDocSiteConfig from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
@ -20,15 +19,7 @@ from app.scodoc import sco_preferences
def send_async_email(app, msg): def send_async_email(app, msg):
"Send an email, async" "Send an email, async"
with app.app_context(): with app.app_context():
try: mail.send(msg)
mail.send(msg)
except BadHeaderError:
log(
f"""send_async_email: BadHeaderError
msg={msg}
"""
)
raise
def send_email( def send_email(
@ -92,12 +83,9 @@ Adresses d'origine:
\n\n""" \n\n"""
+ msg.body + 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( current_app.logger.info(
f"""[{formatted_time}] email sent to{ f"""email sent to{
' (mode test)' if email_test_mode_address else '' ' (mode test)' if email_test_mode_address else ''
}: {msg.recipients} }: {msg.recipients}
from sender {msg.sender} from sender {msg.sender}

View File

@ -6,7 +6,6 @@ from flask import Blueprint
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.auth.models import User from app.auth.models import User
from app.models import Departement from app.models import Departement
import app.scodoc.sco_utils as scu
bp = Blueprint("entreprises", __name__) bp = Blueprint("entreprises", __name__)
@ -16,12 +15,12 @@ SIRET_PROVISOIRE_START = "xx"
@bp.app_template_filter() @bp.app_template_filter()
def format_prenom(s): def format_prenom(s):
return scu.format_prenom(s) return sco_etud.format_prenom(s)
@bp.app_template_filter() @bp.app_template_filter()
def format_nom(s): def format_nom(s):
return scu.format_nom(s) return sco_etud.format_nom(s)
@bp.app_template_filter() @bp.app_template_filter()
@ -59,4 +58,3 @@ def check_taxe_now(taxes):
from app.entreprises import routes 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 # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # 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" __tablename__ = "are_historique"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) 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) entreprise_id = db.Column(db.Integer)
object = db.Column(db.Text) object = db.Column(db.Text)
object_id = db.Column(db.Integer) object_id = db.Column(db.Integer)

View File

@ -338,11 +338,9 @@ def add_entreprise():
if form.validate_on_submit(): if form.validate_on_submit():
entreprise = Entreprise( entreprise = Entreprise(
nom=form.nom_entreprise.data.strip(), nom=form.nom_entreprise.data.strip(),
siret=( siret=form.siret.data.strip()
form.siret.data.strip() if 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
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, siret_provisoire=False if form.siret.data.strip() else True,
association=form.association.data, association=form.association.data,
adresse=form.adresse.data.strip(), adresse=form.adresse.data.strip(),
@ -354,7 +352,7 @@ def add_entreprise():
db.session.add(entreprise) db.session.add(entreprise)
db.session.commit() db.session.commit()
db.session.refresh(entreprise) db.session.refresh(entreprise)
except Exception: except:
db.session.rollback() db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.") flash("Une erreur est survenue veuillez réessayer.")
return render_template( return render_template(
@ -806,9 +804,9 @@ def add_offre(entreprise_id):
missions=form.missions.data.strip(), missions=form.missions.data.strip(),
duree=form.duree.data.strip(), duree=form.duree.data.strip(),
expiration_date=form.expiration_date.data, expiration_date=form.expiration_date.data,
correspondant_id=( correspondant_id=form.correspondant.data
form.correspondant.data if form.correspondant.data != "" else None if form.correspondant.data != ""
), else None,
) )
db.session.add(offre) db.session.add(offre)
db.session.commit() db.session.commit()
@ -1330,11 +1328,9 @@ def add_contact(entreprise_id):
).first_or_404(description=f"entreprise {entreprise_id} inconnue") ).first_or_404(description=f"entreprise {entreprise_id} inconnue")
form = ContactCreationForm( form = ContactCreationForm(
date=f"{datetime.now().strftime('%Y-%m-%dT%H:%M')}", date=f"{datetime.now().strftime('%Y-%m-%dT%H:%M')}",
utilisateur=( utilisateur=f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
f"{current_user.nom} {current_user.prenom} ({current_user.user_name})" if current_user.nom and current_user.prenom
if current_user.nom and current_user.prenom else "",
else ""
),
) )
if request.method == "POST" and form.cancel.data: if request.method == "POST" and form.cancel.data:
return redirect(url_for("entreprises.contacts", entreprise_id=entreprise_id)) 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_debut=form.date_debut.data,
date_fin=form.date_fin.data, date_fin=form.date_fin.data,
formation_text=formation.formsemestre.titre if formation else None, formation_text=formation.formsemestre.titre if formation else None,
formation_scodoc=( formation_scodoc=formation.formsemestre.formsemestre_id
formation.formsemestre.formsemestre_id if formation else None if formation
), else None,
notes=form.notes.data.strip(), notes=form.notes.data.strip(),
) )
db.session.add(stage_apprentissage) db.session.add(stage_apprentissage)
@ -1584,8 +1580,8 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
) )
) )
elif request.method == "GET": elif request.method == "GET":
form.etudiant.data = f"""{scu.format_nom(etudiant.nom)} { form.etudiant.data = f"""{sco_etud.format_nom(etudiant.nom)} {
scu.format_prenom(etudiant.prenom)}""" sco_etud.format_prenom(etudiant.prenom)}"""
form.etudid.data = etudiant.id form.etudid.data = etudiant.id
form.type_offre.data = stage_apprentissage.type_offre form.type_offre.data = stage_apprentissage.type_offre
form.date_debut.data = stage_apprentissage.date_debut form.date_debut.data = stage_apprentissage.date_debut
@ -1703,7 +1699,7 @@ def json_etudiants():
list = [] list = []
for etudiant in etudiants: for etudiant in etudiants:
content = {} 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: if etudiant.inscription_courante() is not None:
content = { content = {
"id": f"{etudiant.id}", "id": f"{etudiant.id}",
@ -1806,7 +1802,7 @@ def import_donnees():
db.session.add(entreprise) db.session.add(entreprise)
db.session.commit() db.session.commit()
db.session.refresh(entreprise) db.session.refresh(entreprise)
except Exception: except:
db.session.rollback() db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.") flash("Une erreur est survenue veuillez réessayer.")
return render_template( 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 pass
parcours: list[ApcParcours] = ue.formation.referentiel_competence.parcours 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: for parcour in parcours:
ects = ue.get_ects(parcour, only_parcours=True) ects = ue.get_ects(parcour, only_parcours=True)
setattr( setattr(

View File

@ -4,7 +4,7 @@
# #
# ScoDoc # 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 # 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 # 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], formations: list[Formation],
) -> FormSemestreChangeFormationForm: ) -> FormSemestreChangeFormationForm:
"Create our dynamical form" "Create our dynamical form"
# see https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition # see https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition
class F(FormSemestreChangeFormationForm): class F(FormSemestreChangeFormationForm):
pass 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 flask_wtf import FlaskForm
from wtforms import validators 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 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 # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# ScoDoc # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -29,16 +29,56 @@
Formulaire configuration Module Assiduités Formulaire configuration Module Assiduités
""" """
import datetime import datetime
import re
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import DecimalField, SubmitField, ValidationError from wtforms import DecimalField, SubmitField, ValidationError
from wtforms.fields.simple import StringField from wtforms.fields.simple import StringField
from wtforms.validators import Optional, Length from wtforms.validators import Optional
from wtforms.widgets import TimeInput 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): def check_tick_time(form, field):
"""Le tick_time doit être entre 0 et 60 minutes""" """Le tick_time doit être entre 0 et 60 minutes"""
if field.data < 1 or field.data > 59: 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}") 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): class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduité" "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)", "Granularité de la timeline (temps en minutes)",
places=0, places=0,
validators=[check_tick_time], 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.""", Si ce champ n'est pas renseigné, les emplois du temps ne seront pas utilisés.""",
validators=[Optional(), check_ics_path], 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") submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -5,7 +5,7 @@
# #
# ScoDoc # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -82,7 +82,7 @@ class ConfigCASForm(FlaskForm):
cas_attribute_id = StringField( cas_attribute_id = StringField(
label="Attribut CAS utilisé comme id (laissez vide pour prendre l'id par défaut)", 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.""", comptes utilisateurs.""",
) )

View File

@ -5,7 +5,7 @@
# #
# ScoDoc # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# ScoDoc # 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 # 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 # 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() 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}) cancel_bonus = SubmitField("Annuler", render_kw={"formnovalidate": True})
class ScoDocConfigurationForm(FlaskForm): class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration avancée" "Panneau de configuration avancée"
disable_passerelle = BooleanField( # disable car par défaut activée enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
"""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."""
)
month_debut_annee_scolaire = SelectField( month_debut_annee_scolaire = SelectField(
label="Mois de début des années scolaires", label="Mois de début des années scolaires",
description="""Date pivot. En France métropolitaine, août. 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.""", Attention: si ce champ peut aussi être défini dans chaque département.""",
validators=[Optional(), Email()], validators=[Optional(), Email()],
) )
user_require_email_institutionnel = BooleanField( disable_bul_pdf = BooleanField("empêcher les exports des bulletins en PDF")
"imposer la saisie du mail institutionnel dans le formulaire de création utilisateur" submit_scodoc = SubmitField("Valider")
)
disable_bul_pdf = BooleanField(
"interdire les exports des bulletins en PDF (déconseillé)"
)
submit_scodoc = SubmitField("Enregistrer ces paramètres")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True}) cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
@ -100,12 +93,10 @@ def configuration():
form_scodoc = ScoDocConfigurationForm( form_scodoc = ScoDocConfigurationForm(
data={ data={
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(), "enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
"disable_passerelle": ScoDocSiteConfig.is_passerelle_disabled(),
"month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(), "month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(),
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(), "month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
"email_from_addr": ScoDocSiteConfig.get("email_from_addr"), "email_from_addr": ScoDocSiteConfig.get("email_from_addr"),
"disable_bul_pdf": ScoDocSiteConfig.is_bul_pdf_disabled(), "disable_bul_pdf": ScoDocSiteConfig.is_bul_pdf_disabled(),
"user_require_email_institutionnel": ScoDocSiteConfig.is_user_require_email_institutionnel_enabled(),
} }
) )
if request.method == "POST" and ( if request.method == "POST" and (
@ -126,12 +117,12 @@ def configuration():
flash("Fonction bonus inchangée.") flash("Fonction bonus inchangée.")
return redirect(url_for("scodoc.index")) return redirect(url_for("scodoc.index"))
elif form_scodoc.submit_scodoc.data and form_scodoc.validate(): elif form_scodoc.submit_scodoc.data and form_scodoc.validate():
if ScoDocSiteConfig.disable_passerelle( if ScoDocSiteConfig.enable_entreprises(
disabled=form_scodoc.data["disable_passerelle"] enabled=form_scodoc.data["enable_entreprises"]
): ):
flash( flash(
"Fonction passerelle " "Module entreprise "
+ ("cachées" if form_scodoc.data["disable_passerelle"] else "montrées") + ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
) )
if ScoDocSiteConfig.set_month_debut_annee_scolaire( if ScoDocSiteConfig.set_month_debut_annee_scolaire(
int(form_scodoc.data["month_debut_annee_scolaire"]) int(form_scodoc.data["month_debut_annee_scolaire"])
@ -158,23 +149,10 @@ def configuration():
"Exports PDF " "Exports PDF "
+ ("désactivés" if form_scodoc.data["disable_bul_pdf"] else "réactivés") + ("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 redirect(url_for("scodoc.index"))
return render_template( return render_template(
"configuration.j2", "configuration.j2",
is_entreprises_enabled=ScoDocSiteConfig.is_entreprises_enabled(),
form_bonus=form_bonus, form_bonus=form_bonus,
form_scodoc=form_scodoc, form_scodoc=form_scodoc,
scu=scu, 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 # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# ScoDoc # 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 # 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 # 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 """Modèles base de données ScoDoc
""" """
from flask import abort, g
import sqlalchemy import sqlalchemy
import app
from app import db from app import db
CODE_STR_LEN = 16 # chaine pour les codes CODE_STR_LEN = 16 # chaine pour les codes
@ -25,17 +23,8 @@ convention = {
metadata_obj = sqlalchemy.MetaData(naming_convention=convention) metadata_obj = sqlalchemy.MetaData(naming_convention=convention)
class ScoDocModel(db.Model): class ScoDocModel:
"""Superclass for our models. Add some useful methods for editing, cloning, etc. "Mixin class for our models. Add somme 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
def clone(self, not_copying=()): def clone(self, not_copying=()):
"""Clone, not copying the given attrs """Clone, not copying the given attrs
@ -51,28 +40,21 @@ class ScoDocModel(db.Model):
return copy return copy
@classmethod @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. """Create a new instance of the model with attributes given in dict.
The instance is added to the session (but not flushed nor committed). 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))
args = cls.convert_dict_fields(cls.filter_model_attributes(data)) obj = cls(**args)
if args:
obj = cls(**args)
else:
obj = cls()
else:
obj = cls()
db.session.add(obj) db.session.add(obj)
return obj return obj
@classmethod @classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict: def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded. """Returns a copy of dict with only the keys belonging to the Model and not in excluded.
Add 'id' to excluded.""" By default, excluded == { 'id' }"""
excluded = excluded or set() excluded = {"id"} if excluded is None else set()
excluded.add("id") # always exclude id
# Les attributs du modèle qui sont des variables: (élimine les __ et les alias comme adm_id) # Les attributs du modèle qui sont des variables: (élimine les __ et les alias comme adm_id)
my_attributes = [ my_attributes = [
a a
@ -88,7 +70,7 @@ class ScoDocModel(db.Model):
@classmethod @classmethod
def convert_dict_fields(cls, args: dict) -> dict: 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. By default, do nothing, but is overloaded by some subclasses.
args: dict with args in application. args: dict with args in application.
returns: dict to store in model's db. returns: dict to store in model's db.
@ -96,63 +78,13 @@ class ScoDocModel(db.Model):
# virtual, by default, do nothing # virtual, by default, do nothing
return args return args
def from_dict(self, args: dict, excluded: set[str] | None = None) -> bool: def from_dict(self, args: dict):
"""Update object's fields given in dict. Add to session but don't commit. "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))
"""
args_dict = self.convert_dict_fields(
self.filter_model_attributes(args, excluded=excluded)
)
modified = False
for key, value in args_dict.items(): for key, value in args_dict.items():
if hasattr(self, key) and value != getattr(self, key): if hasattr(self, key):
setattr(self, key, value) setattr(self, key, value)
modified = True
db.session.add(self) 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 from app.models.absences import Absence, AbsenceNotification, BilletAbsence
@ -198,6 +130,7 @@ from app.models.notes import (
NotesNotesLog, NotesNotesLog,
) )
from app.models.validations import ( from app.models.validations import (
ScolarEvent,
ScolarFormSemestreValidation, ScolarFormSemestreValidation,
ScolarAutorisationInscription, ScolarAutorisationInscription,
) )
@ -211,13 +144,8 @@ from app.models.but_refcomp import (
ApcReferentielCompetences, ApcReferentielCompetences,
ApcSituationPro, ApcSituationPro,
) )
from app.models.but_validations import ( from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
ApcValidationAnnee,
ApcValidationRCUE,
ValidationDUT120,
)
from app.models.config import ScoDocSiteConfig from app.models.config import ScoDocSiteConfig
from app.models.assiduites import Assiduite, Justificatif 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): class Absence(db.Model):
"""LEGACY """une absence (sur une demi-journée)"""
Ce modèle n'est PLUS UTILISE depuis ScoDoc 9.6 et remplacé par assiduité.
une absence (sur une demi-journée)
"""
__tablename__ = "absences" __tablename__ = "absences"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

View File

@ -1,38 +1,25 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
"""Gestion de l'assiduité (assiduités + justificatifs)""" """Gestion de l'assiduité (assiduités + justificatifs)
"""
from datetime import datetime from datetime import datetime
from flask_login import current_user from app import db, log
from flask_sqlalchemy.query import Query from app.models import ModuleImpl, Scolog, FormSemestre, FormSemestreInscription
from app import db, log, g, set_sco_dept
from app.models import (
ModuleImpl,
Module,
Scolog,
FormSemestre,
FormSemestreInscription,
ScoDocModel,
)
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.auth.models import User from app.auth.models import User
from app.scodoc import sco_abs_notification 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_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 ( from app.scodoc.sco_utils import (
EtatAssiduite, EtatAssiduite,
EtatJustificatif, EtatJustificatif,
localize_datetime, localize_datetime,
is_assiduites_module_forced, is_assiduites_module_forced,
NonWorkDays,
) )
from flask_sqlalchemy.query import Query
class Assiduite(ScoDocModel):
class Assiduite(db.Model):
""" """
Représente une assiduité: Représente une assiduité:
- une plage horaire lié à un état et un étudiant - une plage horaire lié à un état et un étudiant
@ -90,14 +77,10 @@ class Assiduite(ScoDocModel):
lazy="select", lazy="select",
) )
# Argument "restrict" obligatoire car on override la fonction "to_dict" de ScoDocModel def to_dict(self, format_api=True) -> dict:
# pylint: disable-next=unused-argument """Retourne la représentation json de l'assiduité"""
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.
"""
etat = self.etat etat = self.etat
user: User | None = None user: User = None
if format_api: if format_api:
# format api utilise les noms "present,absent,retard" au lieu des int # format api utilise les noms "present,absent,retard" au lieu des int
etat = EtatAssiduite.inverse().get(self.etat).name etat = EtatAssiduite.inverse().get(self.etat).name
@ -115,9 +98,9 @@ class Assiduite(ScoDocModel):
"entry_date": self.entry_date, "entry_date": self.entry_date,
"user_id": None if user is None else user.id, # l'uid "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_name": None if user is None else user.user_name, # le login
"user_nom_complet": ( "user_nom_complet": None
None if user is None else user.get_nomcomplet() if user is None
), # "Marie Dupont" else user.get_nomcomplet(), # "Marie Dupont"
"est_just": self.est_just, "est_just": self.est_just,
"external_data": self.external_data, "external_data": self.external_data,
} }
@ -152,56 +135,16 @@ class Assiduite(ScoDocModel):
external_data: dict = None, external_data: dict = None,
notify_mail=False, notify_mail=False,
) -> "Assiduite": ) -> "Assiduite":
"""Créer une nouvelle assiduité pour l'étudiant. """Créer une nouvelle assiduité pour l'étudiant"""
Les datetime doivent être en timezone serveur.
Raises ScoValueError en cas de conflit ou erreur.
"""
if date_debut.tzinfo is None: if date_debut.tzinfo is None:
log( log(
f"Warning: create_assiduite: date_debut without timezone ({date_debut})" f"Warning: create_assiduite: date_debut without timezone ({date_debut})"
) )
if date_fin.tzinfo is None: if date_fin.tzinfo is None:
log(f"Warning: create_assiduite: date_fin without timezone ({date_fin})") 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 # Vérification de non duplication des périodes
assiduites: Query = etud.assiduites assiduites: Query = etud.assiduites
if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite): 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( raise ScoValueError(
"Duplication: la période rentre en conflit avec une plage enregistrée" "Duplication: la période rentre en conflit avec une plage enregistrée"
) )
@ -251,8 +194,7 @@ class Assiduite(ScoDocModel):
user_id=user_id, user_id=user_id,
) )
db.session.add(nouv_assiduite) db.session.add(nouv_assiduite)
db.session.flush() log(f"create_assiduite: {etud.id} {nouv_assiduite}")
log(f"create_assiduite: {etud.id} id={nouv_assiduite.id} {nouv_assiduite}")
Scolog.logdb( Scolog.logdb(
method="create_assiduite", method="create_assiduite",
etudid=etud.id, etudid=etud.id,
@ -262,166 +204,8 @@ class Assiduite(ScoDocModel):
sco_abs_notification.abs_notify(etud.id, nouv_assiduite.date_debut) sco_abs_notification.abs_notify(etud.id, nouv_assiduite.date_debut)
return nouv_assiduite 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: class Justificatif(db.Model):
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):
""" """
Représente un justificatif: Représente un justificatif:
- une plage horaire lié à un état et un étudiant - 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()) 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( user_id = db.Column(
db.Integer, db.Integer,
@ -473,35 +255,23 @@ class Justificatif(ScoDocModel):
etudiant = db.relationship( etudiant = db.relationship(
"Identite", back_populates="justificatifs", lazy="joined" "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) external_data = db.Column(db.JSON, nullable=True)
@classmethod def to_dict(self, format_api: bool = False) -> dict:
def get_justificatif(cls, justif_id: int) -> "Justificatif": """transformation de l'objet en dictionnaire sérialisable"""
"""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
"""
etat = self.etat etat = self.etat
user: User = self.user if self.user_id is not None else None username = self.user_id
if format_api: if format_api:
etat = EtatJustificatif.inverse().get(self.etat).name 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 = { data = {
"justif_id": self.justif_id, "justif_id": self.justif_id,
@ -510,49 +280,30 @@ class Justificatif(ScoDocModel):
"date_debut": self.date_debut, "date_debut": self.date_debut,
"date_fin": self.date_fin, "date_fin": self.date_fin,
"etat": etat, "etat": etat,
"raison": None if restrict else self.raison, "raison": self.raison,
"fichier": None if restrict else self.fichier, "fichier": self.fichier,
"entry_date": self.entry_date, "entry_date": self.entry_date,
"user_id": None if user is None else user.id, # l'uid "user_id": username,
"user_name": None if user is None else user.user_name, # le login "external_data": self.external_data,
"user_nom_complet": None if user is None else user.get_nomcomplet(),
"external_data": None if restrict else self.external_data,
} }
return data return data
def __repr__(self) -> str: def __str__(self) -> str:
"chaine pour journaux et debug (lisible par humain français)" "chaine pour journaux et debug (lisible par humain français)"
try: try:
etat_str = EtatJustificatif(self.etat).name etat_str = EtatJustificatif(self.etat).name
except ValueError: except ValueError:
etat_str = "Invalide" 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_debut.strftime("%d/%m/%Y %Hh%M")
} à { } à {
self.date_fin.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 @classmethod
def create_justificatif( def create_justificatif(
cls, cls,
etudiant: Identite, etud: Identite,
# On a besoin des arguments mais on utilise "locals" pour les récupérer
# pylint: disable=unused-argument
date_debut: datetime, date_debut: datetime,
date_fin: datetime, date_fin: datetime,
etat: EtatJustificatif, etat: EtatJustificatif,
@ -561,163 +312,38 @@ class Justificatif(ScoDocModel):
user_id: int = None, user_id: int = None,
external_data: dict = None, external_data: dict = None,
) -> "Justificatif": ) -> "Justificatif":
"""Créer un nouveau justificatif pour l'étudiant. """Créer un nouveau justificatif pour l'étudiant"""
Raises ScoValueError si paramètres incorrects. nouv_justificatif = Justificatif(
""" date_debut=date_debut,
nouv_justificatif = cls.create_from_dict(locals()) date_fin=date_fin,
log(f"create_justificatif: etudid={etudiant.id} {nouv_justificatif}") 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( Scolog.logdb(
method="create_justificatif", method="create_justificatif",
etudid=etudiant.id, etudid=etud.id,
msg=f"justificatif: {nouv_justificatif}", msg=f"justificatif: {nouv_justificatif}",
) )
db.session.commit()
return nouv_justificatif 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( def is_period_conflicting(
date_debut: datetime, date_debut: datetime,
date_fin: datetime, date_fin: datetime,
collection: Query, collection: Query,
collection_cls: Assiduite | Justificatif, collection_cls: Assiduite | Justificatif,
obj_id: int = -1,
) -> bool: ) -> bool:
""" """
Vérifie si une date n'entre pas en collision Vérifie si une date n'entre pas en collision
avec les justificatifs ou assiduites déjà présentes 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 # On s'assure que les dates soient avec TimeZone
@ -725,14 +351,74 @@ def is_period_conflicting(
date_fin = localize_datetime(date_fin) date_fin = localize_datetime(date_fin)
count: int = collection.filter( count: int = collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut
collection_cls.date_fin > date_debut,
collection_cls.id != obj_id,
).count() ).count()
return count > 0 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]: def get_assiduites_justif(assiduite_id: int, long: bool) -> list[int | dict]:
""" """
get_assiduites_justif Récupération des justificatifs d'une assiduité 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 des identifiants des justificatifs
Returns: Returns:
list[int | dict]: La liste des justificatifs (par défaut uniquement list[int | dict]: La liste des justificatifs (par défaut uniquement les identifiants, sinon les Dict si long est vrai)
les identifiants, sinon les dict si long est vrai)
""" """
assi: Assiduite = Assiduite.query.get_or_404(assiduite_id) assi: Assiduite = Assiduite.query.get_or_404(assiduite_id)
return get_justifs_from_date(assi.etudid, assi.date_debut, assi.date_fin, long) 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. Defaults to False.
Returns: Returns:
list[int | dict]: La liste des justificatifs (par défaut uniquement list[int | dict]: La liste des justificatifs (par défaut uniquement les identifiants, sinon les Dict si long est vrai)
les identifiants, sinon les dict si long est vrai)
""" """
# On récupère les justificatifs d'un étudiant couvrant la période donnée # On récupère les justificatifs d'un étudiant couvrant la période donnée
justifs: Query = Justificatif.query.filter( justifs: Query = Justificatif.query.filter(
@ -788,20 +472,16 @@ def get_justifs_from_date(
if valid: if valid:
justifs = justifs.filter(Justificatif.etat == EtatJustificatif.VALIDE) justifs = justifs.filter(Justificatif.etat == EtatJustificatif.VALIDE)
# On renvoie la liste des id des justificatifs si long est Faux, # On renvoie la liste des id des justificatifs si long est Faux, sinon on renvoie les dicts des justificatifs
# sinon on renvoie les dicts des justificatifs return [j.justif_id if not long else j.to_dict(True) for j in justifs]
if long:
return [j.to_dict(True) for j in justifs]
return [j.justif_id for j in justifs]
def get_formsemestre_from_data(data: dict[str, datetime | int]) -> FormSemestre: 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 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: Args:
data (dict[str, datetime | int]): Une représentation simplifiée d'une data (dict[str, datetime | int]): Une réprésentation simplifiée d'une assiduité ou d'un justificatif
assiduité ou d'un justificatif
data = { data = {
"etudid" : int, "etudid" : int,
@ -824,29 +504,3 @@ def get_formsemestre_from_data(data: dict[str, datetime | int]) -> FormSemestre:
) )
.first() .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 # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
"""ScoDoc 9 models : Référentiel Compétence BUT 2021 """ScoDoc 9 models : Référentiel Compétence BUT 2021
@ -8,19 +8,16 @@
from datetime import datetime from datetime import datetime
import functools import functools
from operator import attrgetter from operator import attrgetter
import yaml
from flask import g from flask import g
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
from sqlalchemy.orm import class_mapper from sqlalchemy.orm import class_mapper
import sqlalchemy import sqlalchemy
from app import db, log from app import db
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
REFCOMP_EQUIVALENCE_FILENAME = "ressources/referentiels/equivalences.yaml"
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns # 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): def __repr__(self):
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>" return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
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: def get_version(self) -> str:
"La version, normalement sous forme de date iso yyy-mm-dd" "La version, normalement sous forme de date iso yyy-mm-dd"
if not self.version_orebut: if not self.version_orebut:
@ -132,11 +124,9 @@ class ApcReferentielCompetences(db.Model, XMLModel):
"type_departement": self.type_departement, "type_departement": self.type_departement,
"type_titre": self.type_titre, "type_titre": self.type_titre,
"version_orebut": self.version_orebut, "version_orebut": self.version_orebut,
"scodoc_date_loaded": ( "scodoc_date_loaded": self.scodoc_date_loaded.isoformat() + "Z"
self.scodoc_date_loaded.isoformat() + "Z" if self.scodoc_date_loaded
if self.scodoc_date_loaded else "",
else ""
),
"scodoc_orig_filename": self.scodoc_orig_filename, "scodoc_orig_filename": self.scodoc_orig_filename,
"competences": { "competences": {
x.titre: x.to_dict(with_app_critiques=with_app_critiques) x.titre: x.to_dict(with_app_critiques=with_app_critiques)
@ -244,100 +234,6 @@ class ApcReferentielCompetences(db.Model, XMLModel):
return parcours_info 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): class ApcCompetence(db.Model, XMLModel):
"Compétence" "Compétence"
@ -478,11 +374,9 @@ class ApcNiveau(db.Model, XMLModel):
"libelle": self.libelle, "libelle": self.libelle,
"annee": self.annee, "annee": self.annee,
"ordre": self.ordre, "ordre": self.ordre,
"app_critiques": ( "app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
{x.code: x.to_dict() for x in self.app_critiques} if with_app_critiques
if with_app_critiques else {},
else {}
),
} }
def to_dict_bul(self): def to_dict_bul(self):
@ -570,9 +464,9 @@ class ApcNiveau(db.Model, XMLModel):
return [] return []
if competence is None: if competence is None:
parcour_niveaux: list[ApcParcoursNiveauCompetence] = ( parcour_niveaux: list[
annee_parcour.niveaux_competences ApcParcoursNiveauCompetence
) ] = annee_parcour.niveaux_competences
niveaux: list[ApcNiveau] = [ niveaux: list[ApcNiveau] = [
pn.competence.niveaux.filter_by(ordre=pn.niveau).first() pn.competence.niveaux.filter_by(ordre=pn.niveau).first()
for pn in parcour_niveaux for pn in parcour_niveaux
@ -604,7 +498,6 @@ app_critiques_modules = db.Table(
db.ForeignKey("apc_app_critique.id", ondelete="CASCADE"), db.ForeignKey("apc_app_critique.id", ondelete="CASCADE"),
primary_key=True, 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 """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 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.but_refcomp import ApcNiveau
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
class ApcValidationRCUE(ScoDocModel): class ApcValidationRCUE(db.Model):
"""Validation des niveaux de compétences """Validation des niveaux de compétences
aka "regroupements cohérents d'UE" dans le jargon BUT. 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) ue2 = db.relationship("UniteEns", foreign_keys=ue2_id)
parcour = db.relationship("ApcParcours") parcour = db.relationship("ApcParcours")
_sco_dept_relations = ("Identite",) # pour accéder au département
def __repr__(self): def __repr__(self):
return f"""<{self.__class__.__name__} {self.id} {self.etud} { return f"""<{self.__class__.__name__} {self.id} {self.etud} {
self.ue1}/{self.ue2}:{self.code!r}>""" self.ue1}/{self.ue2}:{self.code!r}>"""
def __str__(self): def __str__(self):
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: { return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
self.code} enregistrée le {self.date.strftime(scu.DATE_FMT)}""" self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
def html(self) -> str: def html(self) -> str:
"description en HTML" "description en HTML"
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
<b>{self.code}</b> <b>{self.code}</b>
<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: def annee(self) -> str:
"""l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """ """l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """
niveau = self.niveau() niveau = self.niveau()
return niveau.annee if niveau else None return niveau.annee if niveau else None
def niveau(self) -> ApcNiveau | None: def niveau(self) -> ApcNiveau:
"""Le niveau de compétence associé à cet RCUE.""" """Le niveau de compétence associé à cet RCUE."""
# Par convention, il est donné par la seconde UE # Par convention, il est donné par la seconde UE
# à défaut (si l'UE a été désacciée entre temps), la première return self.ue2.niveau_competence
# et à défaut, renvoie None
return self.ue2.niveau_competence or self.ue1.niveau_competence
def to_dict(self): def to_dict(self):
"as a dict" "as a dict"
@ -118,14 +111,8 @@ class ApcValidationRCUE(ScoDocModel):
"formsemestre_id": self.formsemestre_id, "formsemestre_id": self.formsemestre_id,
} }
def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée associés à cette validation RCUE.
Prend les codes des deux UEs
"""
return self.ue1.get_codes_apogee_rcue() | self.ue2.get_codes_apogee_rcue()
class ApcValidationAnnee(db.Model):
class ApcValidationAnnee(ScoDocModel):
"""Validation des années du BUT""" """Validation des années du BUT"""
__tablename__ = "apc_validation_annee" __tablename__ = "apc_validation_annee"
@ -156,8 +143,6 @@ class ApcValidationAnnee(ScoDocModel):
etud = db.relationship("Identite", backref="apc_validations_annees") etud = db.relationship("Identite", backref="apc_validations_annees")
formsemestre = db.relationship("FormSemestre", 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): def __repr__(self):
return f"""<{self.__class__.__name__} {self.id} {self.etud return f"""<{self.__class__.__name__} {self.id} {self.etud
} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>""" } BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>"""
@ -177,7 +162,7 @@ class ApcValidationAnnee(ScoDocModel):
def html(self) -> str: def html(self) -> str:
"Affichage html" "Affichage html"
date_str = ( 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 if self.date
else "(sans date)" else "(sans date)"
) )
@ -215,9 +200,17 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
.order_by(UniteEns.numero, UniteEns.acronyme) .order_by(UniteEns.numero, UniteEns.acronyme)
) )
decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues] decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues]
titres_rcues = _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"] = ", ".join(titres_rcues)
decisions["descr_decisions_rcue_list"] = titres_rcues
decisions["descr_decisions_niveaux"] = ( decisions["descr_decisions_niveaux"] = (
"Niveaux de compétences: " + decisions["descr_decisions_rcue"] "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_rcue"] = ""
decisions["descr_decisions_niveaux"] = "" decisions["descr_decisions_niveaux"] = ""
# --- Année: prend la validation pour l'année scolaire et l'ordre de ce semestre # --- 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
annee_but = (formsemestre.semestre_id + 1) // 2 validation = ApcValidationAnnee.query.filter_by(
validation = ApcValidationAnnee.query.filter_by( etudid=etud.id,
etudid=etud.id, annee_scolaire=formsemestre.annee_scolaire(),
annee_scolaire=formsemestre.annee_scolaire(), ordre=annee_but,
ordre=annee_but, referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
referentiel_competence_id=formsemestre.formation.referentiel_competence_id, ).first()
).first() if validation:
if validation: decisions["decision_annee"] = validation.to_dict_bul()
decisions["decision_annee"] = validation.to_dict_bul()
else:
decisions["decision_annee"] = None
else: else:
decisions["decision_annee"] = None decisions["decision_annee"] = None
return decisions 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, "INSTITUTION_CITY": str,
"DEFAULT_PDF_FOOTER_TEMPLATE": str, "DEFAULT_PDF_FOOTER_TEMPLATE": str,
"enable_entreprises": bool, "enable_entreprises": bool,
"disable_passerelle": bool, # remplace pref. bul_display_publication
"month_debut_annee_scolaire": int, "month_debut_annee_scolaire": int,
"month_debut_periode2": int, "month_debut_periode2": int,
"disable_bul_pdf": bool, "disable_bul_pdf": bool,
"user_require_email_institutionnel": bool,
# CAS # CAS
"cas_enable": bool, "cas_enable": bool,
"cas_server": str, "cas_server": str,
@ -233,52 +231,49 @@ class ScoDocSiteConfig(db.Model):
cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first() cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first()
return cfg is not None and cfg.value 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 @classmethod
def is_entreprises_enabled(cls) -> bool: def is_entreprises_enabled(cls) -> bool:
"""True si on doit activer le module entreprise""" """True si on doit activer le module entreprise"""
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first() cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
return cfg is not None and cfg.value 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 @classmethod
def is_bul_pdf_disabled(cls) -> bool: 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() cfg = ScoDocSiteConfig.query.filter_by(name="disable_bul_pdf").first()
return cfg is not None and cfg.value return cfg is not None and cfg.value
@classmethod @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.""" """Active (ou déactive) le module entreprises. True si changement."""
return cls.set("enable_entreprises", "on" if enabled else "") if enabled != ScoDocSiteConfig.is_entreprises_enabled():
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
@classmethod if cfg is None:
def disable_passerelle(cls, disabled: bool = True) -> bool: cfg = ScoDocSiteConfig(
"""Désactive (ou active) les fonctions liées à la présence d'une passerelle. True si changement.""" name="enable_entreprises", value="on" if enabled else ""
return cls.set("disable_passerelle", "on" if disabled else "") )
else:
cfg.value = "on" if enabled else ""
db.session.add(cfg)
db.session.commit()
return True
return False
@classmethod @classmethod
def disable_bul_pdf(cls, enabled=True) -> bool: def disable_bul_pdf(cls, enabled=True) -> bool:
"""Interdit (ou autorise) les exports PDF. True si changement.""" """Interedit (ou autorise) les exports PDF. True si changement."""
return cls.set("disable_bul_pdf", "on" if enabled else "") 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 @classmethod
def get(cls, name: str, default: str = "") -> str: def get(cls, name: str, default: str = "") -> str:
@ -297,10 +292,9 @@ class ScoDocSiteConfig(db.Model):
if cfg is None: if cfg is None:
cfg = ScoDocSiteConfig(name=name, value=value_str) cfg = ScoDocSiteConfig(name=name, value=value_str)
else: else:
cfg.value = value_str cfg.value = str(value or "")
current_app.logger.info( current_app.logger.info(
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}{ f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}...'"""
'...' if len(cfg.value)>32 else ''}'"""
) )
db.session.add(cfg) db.session.add(cfg)
db.session.commit() db.session.commit()
@ -309,7 +303,7 @@ class ScoDocSiteConfig(db.Model):
@classmethod @classmethod
def _get_int_field(cls, name: str, default=None) -> int: 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() cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if (cfg is None) or cfg.value is None: if (cfg is None) or cfg.value is None:
return default return default
@ -323,7 +317,7 @@ class ScoDocSiteConfig(db.Model):
default=None, default=None,
range_values: tuple = (), range_values: tuple = (),
) -> bool: ) -> bool:
"""Set champ integer. True si changement.""" """Set champs integer. True si changement."""
if value != cls._get_int_field(name, default=default): if value != cls._get_int_field(name, default=default):
if not isinstance(value, int) or ( if not isinstance(value, int) or (
range_values and (value < range_values[0]) or (value > range_values[1]) 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): def __repr__(self):
return f"<{self.__class__.__name__}(id={self.id}, acronym='{self.acronym}')>" 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): def to_dict(self, with_dept_name=True, with_dept_preferences=False):
data = { data = {
"id": self.id, "id": self.id,

View File

@ -15,15 +15,14 @@ from sqlalchemy import desc, text
from app import db, log from app import db, log
from app import models 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 import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat 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 import app.scodoc.sco_utils as scu
class Identite(models.ScoDocModel): class Identite(db.Model, models.ScoDocModel):
"""étudiant""" """étudiant"""
__tablename__ = "identite" __tablename__ = "identite"
@ -101,12 +100,7 @@ class Identite(models.ScoDocModel):
adresses = db.relationship( adresses = db.relationship(
"Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic" "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") billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
# #
dispense_ues = db.relationship( dispense_ues = db.relationship(
@ -124,9 +118,6 @@ class Identite(models.ScoDocModel):
"Justificatif", back_populates="etudiant", lazy="dynamic", cascade="all, delete" "Justificatif", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
) )
# Champs "protégés" par ViewEtudData (RGPD)
protected_attrs = {"boursier", "nationalite"}
def __repr__(self): def __repr__(self):
return ( return (
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>" f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
@ -179,15 +170,9 @@ class Identite(models.ScoDocModel):
def html_link_fiche(self) -> str: def html_link_fiche(self) -> str:
"lien vers la fiche" "lien vers la fiche"
return ( return f"""<a class="stdlink" href="{
f"""<a class="etudlink" href="{self.url_fiche()}">{self.nom_prenom()}</a>""" url_for("scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id)
) }">{self.nomprenom}</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
)
@classmethod @classmethod
def from_request(cls, etudid=None, code_nip=None) -> "Identite": 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() return cls.query.filter_by(**args).first_or_404()
@classmethod @classmethod
def get_etud(cls, etudid: int, accept_none=False) -> "Identite": def get_etud(cls, etudid: int) -> "Identite":
"""Etudiant ou 404 (ou None si accept_none), """Etudiant ou 404, cherche uniquement dans le département courant"""
cherche uniquement dans le département courant. if g.scodoc_dept:
Si accept_none, return None si l'id est invalide ou ne correspond return cls.query.filter_by(
pas à un étudiant. id=etudid, dept_id=g.scodoc_dept_id
""" ).first_or_404()
if not isinstance(etudid, int): return cls.query.filter_by(id=etudid).first_or_404()
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()
@classmethod @classmethod
def create_etud(cls, **args) -> "Identite": def create_etud(cls, **args) -> "Identite":
@ -230,48 +200,19 @@ class Identite(models.ScoDocModel):
return cls.create_from_dict(args) return cls.create_from_dict(args)
@classmethod @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. """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) (added to session but not flushed nor commited)
""" """
if not "dept_id" in args: etud: Identite = super(cls, cls).create_from_dict(data)
if "dept" in args: if (data.get("admission_id", None) is None) and (
departement = Departement.query.filter_by(acronym=args["dept"]).first() data.get("admission", None) is None
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.admission = Admission() etud.admission = Admission()
etud.adresses.append(Adresse(typeadresse="domicile")) etud.adresses.append(Adresse(typeadresse="domicile"))
db.session.flush() db.session.flush()
event = ScolarEvent(etud=etud, event_type="CREATION")
db.session.add(event)
log(f"Identite.create {etud}")
return 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 @property
def civilite_str(self) -> str: def civilite_str(self) -> str:
"""returns civilité usuelle: 'M.' ou 'Mme' ou '' (pour le genre neutre, """returns civilité usuelle: 'M.' ou 'Mme' ou '' (pour le genre neutre,
@ -314,24 +255,18 @@ class Identite(models.ScoDocModel):
else: else:
return self.nom return self.nom
@property @cached_property
def nomprenom(self, reverse=False) -> str: def nomprenom(self, reverse=False) -> str:
"""DEPRECATED: préférer nom_prenom() """Civilité/nom/prenom pour affichages: "M. Pierre Dupont"
Civilité/prénom/nom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité. 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 nom = self.nom_usuel or self.nom
prenom = self.prenom_str prenom = self.prenom_str
if reverse: if reverse:
return f"{nom} {prenom}".strip() fields = (nom, prenom)
return f"{self.civilite_str} {prenom} {nom}".strip() else:
fields = (self.civilite_str, prenom, nom)
def nom_prenom(self) -> str: return " ".join([x for x in fields if x])
"""Civilite NOM Prénom
Prend l'identité courante et non celle de l'état civil si elles diffèrent.
"""
return f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()} {self.prenom_str}"
@property @property
def prenom_str(self): def prenom_str(self):
@ -347,10 +282,12 @@ class Identite(models.ScoDocModel):
@property @property
def etat_civil(self) -> str: def etat_civil(self) -> str:
"M. PRÉNOM NOM, utilisant les données état civil si présentes, usuelles sinon." "M. Prénom NOM, utilisant les données état civil si présentes, usuelles sinon."
return f"""{self.civilite_etat_civil_str} { if self.prenom_etat_civil:
self.prenom_etat_civil or self.prenom or ''} { civ = {"M": "M.", "F": "Mme", "X": ""}[self.civilite_etat_civil]
self.nom or ''}""".strip() return f"{civ} {self.prenom_etat_civil} {self.nom}"
else:
return self.nomprenom
@property @property
def nom_short(self): 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()}." return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}."
@cached_property @cached_property
def sort_key(self) -> str: def sort_key(self) -> tuple:
"clé pour tris par ordre alphabétique" "clé pour tris par ordre alphabétique"
# Note: scodoc7 utilisait sco_etud.etud_sort_key, à mettre à jour return (
# si on modifie cette méthode. scu.sanitize_string(
return scu.sanitize_string( self.nom_usuel or self.nom or "", remove_spaces=False
(self.nom_usuel or self.nom or "") + ";" + (self.prenom or ""), ).lower(),
remove_spaces=False, scu.sanitize_string(self.prenom or "", remove_spaces=False).lower(),
).lower() )
def get_first_email(self, field="email") -> str: def get_first_email(self, field="email") -> str:
"Le mail associé à la première adresse de l'étudiant, ou None" "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 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, """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) triée par date_debut
(si recent_first=False, le plus ancien en tête)
""" """
return sorted( return sorted(
[ins.formsemestre for ins in self.formsemestre_inscriptions], [ins.formsemestre for ins in self.formsemestre_inscriptions],
key=attrgetter("date_debut"), 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 @classmethod
def convert_dict_fields(cls, args: dict) -> dict: def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect. """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. returns: dict to store in model's db.
""" """
# Les champs qui sont toujours stockés en majuscules: # Les champs qui sont toujours stockés en majuscules:
@ -451,6 +341,8 @@ class Identite(models.ScoDocModel):
"code_ine", "code_ine",
} }
args_dict = {} args_dict = {}
if not "dept_id" in args:
args["dept_id"] = g.scodoc_dept_id
for key, value in args.items(): for key, value in args.items():
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property): if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
# compat scodoc7 (mauvaise idée de l'époque) # compat scodoc7 (mauvaise idée de l'époque)
@ -463,14 +355,14 @@ class Identite(models.ScoDocModel):
elif key == "civilite_etat_civil": elif key == "civilite_etat_civil":
value = input_civilite_etat_civil(value) value = input_civilite_etat_civil(value)
elif key == "boursier": elif key == "boursier":
value = scu.to_bool(value) value = bool(value)
elif key == "date_naissance": elif key == "date_naissance":
value = ndb.DateDMYtoISO(value) value = ndb.DateDMYtoISO(value)
args_dict[key] = value args_dict[key] = value
return args_dict return args_dict
def to_dict_short(self) -> dict: def to_dict_short(self) -> dict:
"""Les champs essentiels (aucune donnée perso protégée)""" """Les champs essentiels"""
return { return {
"id": self.id, "id": self.id,
"civilite": self.civilite, "civilite": self.civilite,
@ -485,11 +377,9 @@ class Identite(models.ScoDocModel):
"prenom_etat_civil": self.prenom_etat_civil, "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, """Représentation dictionnaire,
compatible ScoDoc7 mais sans infos admission. compatible ScoDoc7 mais sans infos admission
Si restrict, cache les infos "personnelles" si pas permission ViewEtudData
Si with_inscriptions, inclut les champs "inscription"
""" """
e_dict = self.__dict__.copy() # dict(self.__dict__) e_dict = self.__dict__.copy() # dict(self.__dict__)
e_dict.pop("_sa_instance_state", None) e_dict.pop("_sa_instance_state", None)
@ -500,9 +390,7 @@ class Identite(models.ScoDocModel):
e_dict["nomprenom"] = self.nomprenom e_dict["nomprenom"] = self.nomprenom
adresse = self.adresses.first() adresse = self.adresses.first()
if adresse: if adresse:
e_dict.update(adresse.to_dict(restrict=restrict)) e_dict.update(adresse.to_dict())
if with_inscriptions:
e_dict.update(self.inscription_descr())
return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty
def to_dict_bul(self, include_urls=True): def to_dict_bul(self, include_urls=True):
@ -517,11 +405,9 @@ class Identite(models.ScoDocModel):
"civilite": self.civilite, "civilite": self.civilite,
"code_ine": self.code_ine or "", "code_ine": self.code_ine or "",
"code_nip": self.code_nip or "", "code_nip": self.code_nip or "",
"date_naissance": ( "date_naissance": self.date_naissance.strftime("%d/%m/%Y")
self.date_naissance.strftime(scu.DATE_FMT) if self.date_naissance
if self.date_naissance else "",
else ""
),
"dept_acronym": self.departement.acronym, "dept_acronym": self.departement.acronym,
"dept_id": self.dept_id, "dept_id": self.dept_id,
"dept_naissance": self.dept_naissance or "", "dept_naissance": self.dept_naissance or "",
@ -539,7 +425,7 @@ class Identite(models.ScoDocModel):
if include_urls and has_request_context(): if include_urls and has_request_context():
# test request context so we can use this func in tests under the flask shell # test request context so we can use this func in tests under the flask shell
d["fiche_url"] = url_for( 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) d["photo_url"] = sco_photos.get_etud_photo_url(self.id)
adresse = self.adresses.first() adresse = self.adresses.first()
@ -548,37 +434,22 @@ class Identite(models.ScoDocModel):
d["id"] = self.id # a été écrasé par l'id de adresse d["id"] = self.id # a été écrasé par l'id de adresse
return d return d
def to_dict_api(self, restrict=False, with_annotations=False) -> dict: def to_dict_api(self) -> dict:
"""Représentation dictionnaire pour export API, avec adresses et admission. """Représentation dictionnaire pour export API, avec adresses et admission."""
Si restrict, supprime les infos "personnelles" (boursier)
"""
e = dict(self.__dict__) e = dict(self.__dict__)
e.pop("_sa_instance_state", None) e.pop("_sa_instance_state", None)
admission = self.admission admission = self.admission
e["admission"] = admission.to_dict() if admission is not None else None 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["dept_acronym"] = self.departement.acronym
e.pop("departement", None) e.pop("departement", None)
e["sort_key"] = self.sort_key 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 return e
def inscriptions(self) -> list["FormSemestreInscription"]: def inscriptions(self) -> list["FormSemestreInscription"]:
"Liste des inscriptions à des formsemestres, triée, la plus récente en tête" "Liste des inscriptions à des formsemestres, triée, la plus récente en tête"
from app.models.formsemestre import FormSemestre, FormSemestreInscription
return ( return (
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
.filter( .filter(
@ -588,7 +459,7 @@ class Identite(models.ScoDocModel):
.all() .all()
) )
def inscription_courante(self) -> "FormSemestreInscription | None": def inscription_courante(self):
"""La première inscription à un formsemestre _actuellement_ en cours. """La première inscription à un formsemestre _actuellement_ en cours.
None s'il n'y en a pas (ou plus, ou pas encore). 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). (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). 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 ( return (
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
.filter( .filter(
@ -626,9 +499,7 @@ class Identite(models.ScoDocModel):
return r[0] if r else None return r[0] if r else None
def inscription_descr(self) -> dict: def inscription_descr(self) -> dict:
"""Description de l'état d'inscription """Description de l'état d'inscription"""
avec champs compatibles templates ScoDoc7
"""
inscription_courante = self.inscription_courante() inscription_courante = self.inscription_courante()
if inscription_courante: if inscription_courante:
titre_sem = inscription_courante.formsemestre.titre_mois() titre_sem = inscription_courante.formsemestre.titre_mois()
@ -639,7 +510,7 @@ class Identite(models.ScoDocModel):
else: else:
inscr_txt = "Inscrit en" inscr_txt = "Inscrit en"
result = { return {
"etat_in_cursem": inscription_courante.etat, "etat_in_cursem": inscription_courante.etat,
"inscription_courante": inscription_courante, "inscription_courante": inscription_courante,
"inscription": titre_sem, "inscription": titre_sem,
@ -662,20 +533,15 @@ class Identite(models.ScoDocModel):
inscription = "ancien" inscription = "ancien"
situation = "ancien élève" situation = "ancien élève"
else: else:
inscription = "non inscrit" inscription = ("non inscrit",)
situation = inscription situation = inscription
result = { return {
"etat_in_cursem": "?", "etat_in_cursem": "?",
"inscription_courante": None, "inscription_courante": None,
"inscription": inscription, "inscription": inscription,
"inscription_str": inscription, "inscription_str": inscription,
"situation": situation, "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: def inscription_etat(self, formsemestre_id: int) -> str:
"""État de l'inscription de cet étudiant au semestre: """État de l'inscription de cet étudiant au semestre:
@ -772,7 +638,7 @@ class Identite(models.ScoDocModel):
""" """
if with_paragraph: if with_paragraph:
return f"""{self.etat_civil}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le { 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 ""}""" line_sep}à {self.lieu_naissance or ""}"""
return self.etat_civil 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( def make_etud_args(
etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True
) -> dict: ) -> dict:
@ -928,7 +742,7 @@ def pivot_year(y) -> int:
return y return y
class Adresse(models.ScoDocModel): class Adresse(db.Model, models.ScoDocModel):
"""Adresse d'un étudiant """Adresse d'un étudiant
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule) (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) description = db.Column(db.Text)
# Champs "protégés" par ViewEtudData (RGPD) def to_dict(self, convert_nulls_to_str=False):
protected_attrs = { """Représentation dictionnaire,"""
"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)."""
e = dict(self.__dict__) e = dict(self.__dict__)
e.pop("_sa_instance_state", None) e.pop("_sa_instance_state", None)
if convert_nulls_to_str: if convert_nulls_to_str:
e = {k: v or "" for k, v in e.items()} return {k: e[k] or "" for k in e}
if restrict:
e = {k: v for (k, v) in e.items() if k not in self.protected_attrs}
return e return e
class Admission(models.ScoDocModel): class Admission(db.Model, models.ScoDocModel):
"""Informations liées à l'admission d'un étudiant""" """Informations liées à l'admission d'un étudiant"""
__tablename__ = "admissions" __tablename__ = "admissions"
@ -1028,16 +829,12 @@ class Admission(models.ScoDocModel):
# classement (1..Ngr) par le jury dans le groupe APB # classement (1..Ngr) par le jury dans le groupe APB
apb_classement_gr = db.Column(db.Integer) 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: def get_bac(self) -> Baccalaureat:
"Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères." "Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères."
return Baccalaureat(self.bac, specialite=self.specialite) return Baccalaureat(self.bac, specialite=self.specialite)
def to_dict(self, no_nulls=False, restrict=False): def to_dict(self, no_nulls=False):
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD).""" """Représentation dictionnaire,"""
d = dict(self.__dict__) d = dict(self.__dict__)
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
if no_nulls: if no_nulls:
@ -1052,8 +849,6 @@ class Admission(models.ScoDocModel):
d[key] = 0 d[key] = 0
elif isinstance(col_type, sqlalchemy.Boolean): elif isinstance(col_type, sqlalchemy.Boolean):
d[key] = False d[key] = False
if restrict:
d = {k: v for (k, v) in d.items() if k in self.not_protected_attrs}
return d return d
@classmethod @classmethod
@ -1080,9 +875,8 @@ class Admission(models.ScoDocModel):
return args_dict return args_dict
class ItemSuivi(models.ScoDocModel): # Suivi scolarité / débouchés
"""Suivi scolarité / débouchés""" class ItemSuivi(db.Model):
__tablename__ = "itemsuivi" __tablename__ = "itemsuivi"
id = db.Column(db.Integer, primary_key=True) 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()) item_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
situation = db.Column(db.Text) situation = db.Column(db.Text)
_sco_dept_relations = ("Identite",) # accès au dept_id
class ItemSuiviTag(db.Model): class ItemSuiviTag(db.Model):
__tablename__ = "itemsuivi_tags" __tablename__ = "itemsuivi_tags"
@ -1117,25 +909,13 @@ itemsuivi_tags_assoc = db.Table(
) )
class EtudAnnotation(models.ScoDocModel): class EtudAnnotation(db.Model):
"""Annotation sur un étudiant""" """Annotation sur un étudiant"""
__tablename__ = "etud_annotations" __tablename__ = "etud_annotations"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) 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 author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user
comment = db.Column(db.Text) 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 import datetime
from operator import attrgetter from operator import attrgetter
from flask import abort, g, url_for from flask import g, url_for
from flask_login import current_user from flask_login import current_user
import sqlalchemy as sa import sqlalchemy as sa
from app import db, log from app import db, log
from app import models
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.events import ScolarNews from app.models.events import ScolarNews
from app.models.moduleimpls import ModuleImpl
from app.models.notes import NotesNotes from app.models.notes import NotesNotes
from app.models.ues import UniteEns
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
@ -24,8 +25,10 @@ MAX_EVALUATION_DURATION = datetime.timedelta(days=365)
NOON = datetime.time(12, 00) NOON = datetime.time(12, 00)
DEFAULT_EVALUATION_TIME = datetime.time(8, 0) 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, ...)""" """Evaluation (contrôle, examen, ...)"""
__tablename__ = "notes_evaluation" __tablename__ = "notes_evaluation"
@ -37,9 +40,9 @@ class Evaluation(models.ScoDocModel):
) )
date_debut = db.Column(db.DateTime(timezone=True), nullable=True) date_debut = db.Column(db.DateTime(timezone=True), nullable=True)
date_fin = 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) description = db.Column(db.Text)
note_max = db.Column(db.Float, nullable=False) note_max = db.Column(db.Float)
coefficient = db.Column(db.Float, nullable=False) coefficient = db.Column(db.Float)
visibulletin = db.Column( visibulletin = db.Column(
db.Boolean, nullable=False, default=True, server_default="true" db.Boolean, nullable=False, default=True, server_default="true"
) )
@ -47,41 +50,15 @@ class Evaluation(models.ScoDocModel):
publish_incomplete = db.Column( publish_incomplete = db.Column(
db.Boolean, nullable=False, default=False, server_default="false" 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( evaluation_type = db.Column(
db.Integer, nullable=False, default=0, server_default="0" 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 # ordre de presentation (par défaut, le plus petit numero
# est la plus ancienne eval): # est la plus ancienne eval):
numero = db.Column(db.Integer, nullable=False, default=0) numero = db.Column(db.Integer, nullable=False, default=0)
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True) 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): def __repr__(self):
return f"""<Evaluation {self.id} { return f"""<Evaluation {self.id} {
self.date_debut.isoformat() if self.date_debut else ''} "{ self.date_debut.isoformat() if self.date_debut else ''} "{
@ -90,22 +67,20 @@ class Evaluation(models.ScoDocModel):
@classmethod @classmethod
def create( def create(
cls, cls,
moduleimpl: "ModuleImpl" = None, moduleimpl: ModuleImpl = None,
date_debut: datetime.datetime = None, date_debut: datetime.datetime = None,
date_fin: datetime.datetime = None, date_fin: datetime.datetime = None,
description=None, description=None,
note_max=None, note_max=None,
blocked_until=None,
coefficient=None, coefficient=None,
visibulletin=None, visibulletin=None,
publish_incomplete=None, publish_incomplete=None,
evaluation_type=None, evaluation_type=None,
numero=None, numero=None,
**kw, # ceci pour absorber les éventuel arguments excedentaires **kw, # ceci pour absorber les éventuel arguments excedentaires
) -> "Evaluation": ):
"""Create an evaluation. Check permission and all arguments. """Create an evaluation. Check permission and all arguments.
Ne crée pas les poids vers les UEs. Ne crée pas les poids vers les UEs.
Add to session, do not commit.
""" """
if not moduleimpl.can_edit_evaluation(current_user): if not moduleimpl.can_edit_evaluation(current_user):
raise AccessDenied( raise AccessDenied(
@ -114,15 +89,13 @@ class Evaluation(models.ScoDocModel):
args = locals() args = locals()
del args["cls"] del args["cls"]
del args["kw"] del args["kw"]
check_and_convert_evaluation_args(args, moduleimpl) check_convert_evaluation_args(moduleimpl, args)
# Check numeros # Check numeros
Evaluation.moduleimpl_evaluation_renumber(moduleimpl, only_if_unumbered=True) Evaluation.moduleimpl_evaluation_renumber(moduleimpl, only_if_unumbered=True)
if not "numero" in args or args["numero"] is None: if not "numero" in args or args["numero"] is None:
args["numero"] = cls.get_new_numero(moduleimpl, args["date_debut"]) args["numero"] = cls.get_new_numero(moduleimpl, args["date_debut"])
# #
evaluation = Evaluation(**args) evaluation = Evaluation(**args)
db.session.add(evaluation)
db.session.flush()
sco_cache.invalidate_formsemestre(formsemestre_id=moduleimpl.formsemestre_id) sco_cache.invalidate_formsemestre(formsemestre_id=moduleimpl.formsemestre_id)
url = url_for( url = url_for(
"notes.moduleimpl_status", "notes.moduleimpl_status",
@ -141,7 +114,7 @@ class Evaluation(models.ScoDocModel):
@classmethod @classmethod
def get_new_numero( def get_new_numero(
cls, moduleimpl: "ModuleImpl", date_debut: datetime.datetime cls, moduleimpl: ModuleImpl, date_debut: datetime.datetime
) -> int: ) -> int:
"""Get a new numero for an evaluation in this moduleimpl """Get a new numero for an evaluation in this moduleimpl
If necessary, renumber existing evals to make room for a new one. 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)" "delete evaluation (commit) (check permission)"
from app.scodoc import sco_evaluation_db from app.scodoc import sco_evaluation_db
modimpl: "ModuleImpl" = self.moduleimpl modimpl: ModuleImpl = self.moduleimpl
if not modimpl.can_edit_evaluation(current_user): if not modimpl.can_edit_evaluation(current_user):
raise AccessDenied( raise AccessDenied(
f"Modification évaluation impossible pour {current_user.get_nomplogin()}" f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
@ -213,24 +186,18 @@ class Evaluation(models.ScoDocModel):
# ScoDoc7 output_formators # ScoDoc7 output_formators
e_dict["evaluation_id"] = self.id e_dict["evaluation_id"] = self.id
e_dict["date_debut"] = self.date_debut.isoformat() if self.date_debut else None 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["numero"] = self.numero or 0
e_dict["poids"] = self.get_ue_poids_dict() # { ue_id : poids } e_dict["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
# Deprecated # Deprecated
e_dict["jour"] = ( e_dict["jour"] = self.date_debut.strftime("%d/%m/%Y") if self.date_debut else ""
self.date_debut.strftime(scu.DATE_FMT) if self.date_debut else ""
)
return evaluation_enrich_dict(self, e_dict) return evaluation_enrich_dict(self, e_dict)
def to_dict_api(self) -> dict: def to_dict_api(self) -> dict:
"Représentation dict pour API JSON" "Représentation dict pour API JSON"
return { return {
"blocked": self.is_blocked(),
"blocked_until": (
self.blocked_until.isoformat() if self.blocked_until else ""
),
"coefficient": self.coefficient, "coefficient": self.coefficient,
"date_debut": self.date_debut.isoformat() if self.date_debut else "", "date_debut": self.date_debut.isoformat() if self.date_debut else "",
"date_fin": self.date_fin.isoformat() if self.date_fin else "", "date_fin": self.date_fin.isoformat() if self.date_fin else "",
@ -245,9 +212,9 @@ class Evaluation(models.ScoDocModel):
"visibulletin": self.visibulletin, "visibulletin": self.visibulletin,
# Deprecated (supprimer avant #sco9.7) # Deprecated (supprimer avant #sco9.7)
"date": self.date_debut.date().isoformat() if self.date_debut else "", "date": self.date_debut.date().isoformat() if self.date_debut else "",
"heure_debut": ( "heure_debut": self.date_debut.time().isoformat()
self.date_debut.time().isoformat() if self.date_debut else "" if self.date_debut
), else "",
"heure_fin": self.date_fin.time().isoformat() if self.date_fin else "", "heure_fin": self.date_fin.time().isoformat() if self.date_fin else "",
} }
@ -267,28 +234,14 @@ class Evaluation(models.ScoDocModel):
return e_dict return e_dict
@classmethod def from_dict(self, data):
def get_evaluation( """Set evaluation attributes from given dict values."""
cls, evaluation_id: int | str, dept_id: int = None, accept_none=False check_convert_evaluation_args(self.moduleimpl, data)
) -> "Evaluation": if data.get("numero") is None:
"""Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant. data["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1
Si accept_none, return None si l'id est invalide ou n'existe pas. for k in self.__dict__.keys():
""" if k != "_sa_instance_state" and k != "id" and k in data:
from app.models import FormSemestre setattr(self, k, data[k])
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()
@classmethod @classmethod
def get_max_numero(cls, moduleimpl_id: int) -> int: def get_max_numero(cls, moduleimpl_id: int) -> int:
@ -304,7 +257,7 @@ class Evaluation(models.ScoDocModel):
@classmethod @classmethod
def moduleimpl_evaluation_renumber( 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) """Renumber evaluations in this moduleimpl, according to their date. (numero=0: oldest one)
Needed because previous versions of ScoDoc did not have eval numeros Needed because previous versions of ScoDoc did not have eval numeros
@ -314,9 +267,7 @@ class Evaluation(models.ScoDocModel):
evaluations = moduleimpl.evaluations.order_by( evaluations = moduleimpl.evaluations.order_by(
Evaluation.date_debut, Evaluation.numero Evaluation.date_debut, Evaluation.numero
).all() ).all()
numeros_distincts = {e.numero for e in evaluations if e.numero is not None} all_numbered = all(e.numero is not None for e in evaluations)
# pas de None, pas de dupliqués
all_numbered = len(numeros_distincts) == len(evaluations)
if all_numbered and only_if_unumbered: if all_numbered and only_if_unumbered:
return # all ok return # all ok
@ -327,15 +278,14 @@ class Evaluation(models.ScoDocModel):
db.session.add(e) db.session.add(e)
i += 1 i += 1
db.session.commit() db.session.commit()
sco_cache.invalidate_formsemestre(moduleimpl.formsemestre_id)
def descr_heure(self) -> str: def descr_heure(self) -> str:
"Description de la plage horaire pour affichages ('de 13h00 à 14h00')" "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): 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: elif self.date_debut and self.date_fin:
return f"""de {self.date_debut.strftime(scu.TIME_FMT) return f"""de {self.date_debut.strftime("%Hh%M")
} à {self.date_fin.strftime(scu.TIME_FMT)}""" } à {self.date_fin.strftime("%Hh%M")}"""
else: else:
return "" return ""
@ -362,15 +312,13 @@ class Evaluation(models.ScoDocModel):
def _h(dt: datetime.datetime) -> str: def _h(dt: datetime.datetime) -> str:
if dt.minute: if dt.minute:
return dt.strftime(scu.TIME_FMT) return dt.strftime("%Hh%M")
return f"{dt.hour}h" return f"{dt.hour}h"
if self.date_fin is None: if self.date_fin is None:
return f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}" 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.date() == self.date_fin.date(): # même jour
if self.date_debut.time() == self.date_fin.time(): 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 ( return (
f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}" 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.""" Chaine vide si non renseignée."""
return self.date_fin.time().isoformat("minutes") if self.date_fin else "" 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: def is_matin(self) -> bool:
"Evaluation commençant le matin (faux si pas de date)" "Evaluation commençant le matin (faux si pas de date)"
if not self.date_debut: if not self.date_debut:
@ -402,14 +363,6 @@ class Evaluation(models.ScoDocModel):
return False return False
return self.date_debut.time() >= NOON 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: def set_default_poids(self) -> bool:
"""Initialize les poids vers les UE à leurs valeurs par défaut """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. C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
@ -434,28 +387,22 @@ class Evaluation(models.ScoDocModel):
return modified return modified
def set_ue_poids(self, ue, poids: float) -> None: 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}) self.update_ue_poids_dict({ue.id: poids})
def set_ue_poids_dict(self, ue_poids_dict: dict) -> None: def set_ue_poids_dict(self, ue_poids_dict: dict) -> None:
"""set poids vers les UE (remplace existants) """set poids vers les UE (remplace existants)
ue_poids_dict = { ue_id : poids } ue_poids_dict = { ue_id : poids }
Commit session.
""" """
from app.models.ues import UniteEns
L = [] L = []
for ue_id, poids in ue_poids_dict.items(): for ue_id, poids in ue_poids_dict.items():
ue = db.session.get(UniteEns, ue_id) ue = db.session.get(UniteEns, ue_id)
if ue is None: if ue is None:
raise ScoValueError("poids vers une UE inexistante") raise ScoValueError("poids vers une UE inexistante")
ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids) ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids)
db.session.add(ue_poids)
L.append(ue_poids) L.append(ue_poids)
db.session.add(ue_poids)
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
db.session.commit()
self.moduleimpl.invalidate_evaluations_poids() # inval cache self.moduleimpl.invalidate_evaluations_poids() # inval cache
def update_ue_poids_dict(self, ue_poids_dict: dict) -> None: 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: def get_ue_poids_str(self) -> str:
"""string describing poids, for excel cells and pdfs """string describing poids, for excel cells and pdfs
Note: les poids nuls ou non initialisés (poids par défaut), Note: si les poids ne sont pas initialisés (poids par défaut),
ne sont pas affichés. ils ne sont pas affichés.
""" """
# restreint aux UE du semestre dans lequel est cette évaluation # 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 # 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( for p in sorted(
self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme) 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() 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): class EvaluationUEPoids(db.Model):
"""Poids des évaluations (BUT) """Poids des évaluations (BUT)
@ -550,7 +474,7 @@ class EvaluationUEPoids(db.Model):
backref=db.backref("ue_poids", cascade="all, delete-orphan"), backref=db.backref("ue_poids", cascade="all, delete-orphan"),
) )
ue = db.relationship( ue = db.relationship(
"UniteEns", UniteEns,
backref=db.backref("evaluation_ue_poids", cascade="all, delete-orphan"), 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): def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
"""add or convert some fields in an evaluation dict""" """add or convert some fields in an evaluation dict"""
# For ScoDoc7 compat # For ScoDoc7 compat
e_dict["heure_debut"] = e.date_debut.strftime(scu.TIME_FMT) if e.date_debut else "" e_dict["heure_debut"] = e.date_debut.strftime("%Hh%M") if e.date_debut else ""
e_dict["heure_fin"] = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin 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 "" e_dict["jour_iso"] = e.date_debut.isoformat() if e.date_debut else ""
# Calcule durée en minutes # Calcule durée en minutes
e_dict["descrheure"] = e.descr_heure() e_dict["descrheure"] = e.descr_heure()
@ -582,7 +506,7 @@ def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
return e_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. """Check coefficient, dates and duration, raises exception if invalid.
Convert date and time strings to date and time objects. 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 # --- evaluation_type
try: try:
data["evaluation_type"] = int(data.get("evaluation_type", 0) or 0) 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") raise ScoValueError("invalid evaluation_type value")
except ValueError as exc: except ValueError as exc:
raise ScoValueError("invalid evaluation_type value") from 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: if coef < 0:
raise ScoValueError("invalid coefficient value (must be positive or null)") raise ScoValueError("invalid coefficient value (must be positive or null)")
data["coefficient"] = coef data["coefficient"] = coef
# --- date de l'évaluation dans le semestre ? # --- date de l'évaluation
formsemestre = moduleimpl.formsemestre formsemestre = moduleimpl.formsemestre
date_debut = data.get("date_debut", None) date_debut = data.get("date_debut", None)
if date_debut: if date_debut:
@ -637,7 +561,7 @@ def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
): ):
raise ScoValueError( raise ScoValueError(
f"""La date de début de l'évaluation ({ 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 !""", }) n'est pas dans le semestre !""",
dest_url="javascript:history.back();", dest_url="javascript:history.back();",
) )
@ -652,19 +576,27 @@ def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
): ):
raise ScoValueError( raise ScoValueError(
f"""La date de fin de l'évaluation ({ 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 !""", }) n'est pas dans le semestre !""",
dest_url="javascript:history.back();", dest_url="javascript:history.back();",
) )
if date_debut and date_fin: if date_debut and date_fin:
duration = data["date_fin"] - data["date_debut"] duration = data["date_fin"] - data["date_debut"]
if duration.total_seconds() < 0 or duration > MAX_EVALUATION_DURATION: if duration.total_seconds() < 0 or duration > MAX_EVALUATION_DURATION:
raise ScoValueError( raise ScoValueError("Heures de l'évaluation incohérentes !")
"Heures de l'évaluation incohérentes !", # # --- heures
dest_url="javascript:history.back();", # heure_debut = data.get("heure_debut", None)
) # if heure_debut and not isinstance(heure_debut, datetime.time):
if "blocked_until" in data: # if date_format == "dmy":
data["blocked_until"] = data["blocked_until"] or None # 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: 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)) 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( def _moduleimpl_evaluation_insert_before(
evaluations: list[Evaluation], next_eval: Evaluation evaluations: list[Evaluation], next_eval: Evaluation
) -> int: ) -> int:
@ -694,6 +639,3 @@ def _moduleimpl_evaluation_insert_before(
db.session.add(e) db.session.add(e)
db.session.commit() db.session.commit()
return n 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 email
from app import log from app import log
from app.auth.models import User 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 import app.scodoc.sco_utils as scu
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
class Scolog(ScoDocModel): class Scolog(db.Model):
"""Log des actions (journal modif etudiants)""" """Log des actions (journal modif etudiants)"""
__tablename__ = "scolog" __tablename__ = "scolog"
@ -27,15 +28,14 @@ class Scolog(ScoDocModel):
method = db.Column(db.Text) method = db.Column(db.Text)
msg = db.Column(db.Text) msg = db.Column(db.Text)
etudid = db.Column(db.Integer) # sans contrainte pour garder logs après suppression 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 # zope_remote_addr suppressed
@classmethod @classmethod
def logdb( def logdb(
cls, method: str = None, etudid: int = None, msg: str = None, commit=False cls, method: str = None, etudid: int = None, msg: str = None, commit=False
): ):
"""Add entry in student's log (replacement for old scolog.logdb). """Add entry in student's log (replacement for old scolog.logdb)"""
Par défaut ne commite pas."""
entry = Scolog( entry = Scolog(
method=method, method=method,
msg=msg, msg=msg,
@ -46,21 +46,6 @@ class Scolog(ScoDocModel):
if commit: if commit:
db.session.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): class ScolarNews(db.Model):
"""Nouvelles pour page d'accueil""" """Nouvelles pour page d'accueil"""
@ -92,9 +77,7 @@ class ScolarNews(db.Model):
date = db.Column( date = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), index=True db.DateTime(timezone=True), server_default=db.func.now(), index=True
) )
authenticated_user = db.Column( authenticated_user = db.Column(db.Text, index=True) # login, sans contrainte
db.Text, index=True
) # user_name login, sans contrainte
# type in 'INSCR', 'NOTES', 'FORM', 'SEM', 'MISC' # type in 'INSCR', 'NOTES', 'FORM', 'SEM', 'MISC'
type = db.Column(db.String(SHORT_STR_LEN), index=True) type = db.Column(db.String(SHORT_STR_LEN), index=True)
object = db.Column( object = db.Column(
@ -150,7 +133,7 @@ class ScolarNews(db.Model):
return query.order_by(cls.date.desc()).limit(n).all() return query.order_by(cls.date.desc()).limit(n).all()
@classmethod @classmethod
def add(cls, typ, obj=None, text="", url=None, max_frequency=600, dept_id=None): def add(cls, typ, obj=None, text="", url=None, max_frequency=600):
"""Enregistre une nouvelle """Enregistre une nouvelle
Si max_frequency, ne génère pas 2 nouvelles "identiques" Si max_frequency, ne génère pas 2 nouvelles "identiques"
à moins de max_frequency secondes d'intervalle (10 minutes par défaut). à 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). même (obj, typ, user).
La nouvelle enregistrée est aussi envoyée par mail. La nouvelle enregistrée est aussi envoyée par mail.
""" """
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
if max_frequency: if max_frequency:
last_news = ( last_news = (
cls.query.filter_by( cls.query.filter_by(
dept_id=dept_id, dept_id=g.scodoc_dept_id,
authenticated_user=current_user.user_name, authenticated_user=current_user.user_name,
type=typ, type=typ,
object=obj, object=obj,
@ -181,7 +163,7 @@ class ScolarNews(db.Model):
return return
news = ScolarNews( news = ScolarNews(
dept_id=dept_id, dept_id=g.scodoc_dept_id,
authenticated_user=current_user.user_name, authenticated_user=current_user.user_name,
type=typ, type=typ,
object=obj, object=obj,
@ -198,7 +180,6 @@ class ScolarNews(db.Model):
None si inexistant None si inexistant
""" """
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.models.moduleimpls import ModuleImpl
formsemestre_id = None formsemestre_id = None
if self.type == self.NEWS_INSCR: if self.type == self.NEWS_INSCR:
@ -250,9 +231,7 @@ class ScolarNews(db.Model):
) )
# Transforme les URL en URL absolues # Transforme les URL en URL absolues
base = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)[ base = scu.ScoURL()
: -len("/index_html")
]
txt = re.sub('href=/.*?"', 'href="' + base + "/", txt) txt = re.sub('href=/.*?"', 'href="' + base + "/", txt)
# Transforme les liens HTML en texte brut: '<a href="url">texte</a>' devient 'texte: url' # 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) news_list = cls.last_news(n=n)
if not news_list: if not news_list:
return "" return ""
dept_news_url = url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
H = [ H = [
f"""<div class="scobox news"><div class="scobox-title"><a href="{ f"""<div class="news"><span class="newstitle"><a href="{
dept_news_url url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
}">Dernières opérations</a> }">Dernières opérations</a>
</div><ul class="newslist">""" </span><ul class="newslist">"""
] ]
for news in news_list: 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 f"""<li class="newslist"><span class="newsdate">{news.formatted_date()}</span><span
class="newstext">{news}</span></li>""" 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 # Informations générales
H.append( H.append(
f"""<div> f"""<div><a class="discretelink" href="{scu.SCO_ANNONCES_WEBSITE}">
Pour en savoir plus sur ScoDoc voir Pour en savoir plus sur ScoDoc voir le site scodoc.org</a>.
<a class="stdlink" href="{scu.SCO_ANNONCES_WEBSITE}">scodoc.org</a>
</div> </div>
""" """
) )
H.append("</div>")
return "\n".join(H) return "\n".join(H)

View File

@ -1,13 +1,11 @@
"""ScoDoc 9 models : Formations """ScoDoc 9 models : Formations
""" """
from flask import abort, g
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
import app import app
from app import db from app import db
from app.comp import df_cache 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 ( from app.models.but_refcomp import (
ApcAnneeParcours, ApcAnneeParcours,
ApcCompetence, ApcCompetence,
@ -23,7 +21,7 @@ from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_STANDARD from app.scodoc.codes_cursus import UE_STANDARD
class Formation(ScoDocModel): class Formation(db.Model):
"""Programme pédagogique d'une formation""" """Programme pédagogique d'une formation"""
__tablename__ = "notes_formations" __tablename__ = "notes_formations"
@ -66,21 +64,6 @@ class Formation(ScoDocModel):
"titre complet pour affichage" "titre complet pour affichage"
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}""" return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
@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): def to_dict(self, with_refcomp_attrs=False, with_departement=True):
"""As a dict. """As a dict.
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp. Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.
@ -297,7 +280,7 @@ class Formation(ScoDocModel):
db.session.commit() db.session.commit()
class Matiere(ScoDocModel): class Matiere(db.Model):
"""Matières: regroupe les modules d'une UE """Matières: regroupe les modules d'une UE
La matière a peu d'utilité en dehors de la présentation des modules La matière a peu d'utilité en dehors de la présentation des modules
d'une UE. d'une UE.
@ -313,7 +296,6 @@ class Matiere(ScoDocModel):
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
modules = db.relationship("Module", lazy="dynamic", backref="matiere") modules = db.relationship("Module", lazy="dynamic", backref="matiere")
_sco_dept_relations = ("UniteEns", "Formation") # accès au dept_id
def __repr__(self): def __repr__(self):
return f"""<{self.__class__.__name__}(id={self.id}, ue_id={ return f"""<{self.__class__.__name__}(id={self.id}, ue_id={

View File

@ -1,7 +1,7 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -10,22 +10,19 @@
"""ScoDoc models: formsemestre """ScoDoc models: formsemestre
""" """
from collections import defaultdict
import datetime import datetime
from functools import cached_property from functools import cached_property
from itertools import chain
from operator import attrgetter from operator import attrgetter
from flask_login import current_user 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.sql import text
from sqlalchemy import func from sqlalchemy import func
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import db, log from app import db, log
from app.auth.models import User 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 import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models.but_refcomp import ( from app.models.but_refcomp import (
ApcParcours, ApcParcours,
@ -33,17 +30,11 @@ from app.models.but_refcomp import (
parcours_formsemestre, parcours_formsemestre,
) )
from app.models.config import ScoDocSiteConfig from app.models.config import ScoDocSiteConfig
from app.models.departements import Departement
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.evaluations import Evaluation from app.models.evaluations import Evaluation
from app.models.events import ScolarNews
from app.models.formations import Formation from app.models.formations import Formation
from app.models.groups import GroupDescr, Partition from app.models.groups import GroupDescr, Partition
from app.models.moduleimpls import ( from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
ModuleImpl,
ModuleImplInscription,
notes_modules_enseignants,
)
from app.models.modules import Module from app.models.modules import Module
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation 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_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric
from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.sco_utils import translate_assiduites_metric
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
class FormSemestre(models.ScoDocModel): class FormSemestre(db.Model):
"""Mise en oeuvre d'un semestre de formation""" """Mise en oeuvre d'un semestre de formation"""
__tablename__ = "notes_formsemestre" __tablename__ = "notes_formsemestre"
@ -70,7 +63,7 @@ class FormSemestre(models.ScoDocModel):
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1") semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
titre = db.Column(db.Text(), nullable=False) titre = db.Column(db.Text(), nullable=False)
date_debut = db.Column(db.Date(), 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) edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)" "identifiant emplois du temps (unicité non imposée)"
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true") 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( bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" 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( block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" 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" db.Boolean(), nullable=False, default=False, server_default="false"
) )
"Si vrai, la moyenne générale indicative BUT n'est pas calculée" "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( gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" 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" "autorise les enseignants à créer des évals dans leurs modimpls"
elt_sem_apo = db.Column(db.Text()) # peut être fort long ! 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()) elt_annee_apo = db.Column(db.Text())
"code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'" "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 # Data pour groups_auto_assignment
# (ce champ est utilisé uniquement via l'API par le front js) # (ce champ est utilisé uniquement via l'API par le front js)
@ -193,15 +180,9 @@ class FormSemestre(models.ScoDocModel):
@classmethod @classmethod
def get_formsemestre( def get_formsemestre(
cls, formsemestre_id: int | str, dept_id: int = None cls, formsemestre_id: int, dept_id: int = None
) -> "FormSemestre": ) -> "FormSemestre":
"""FormSemestre ou 404, cherche uniquement dans le département spécifié """ "FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant"""
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")
if g.scodoc_dept: if g.scodoc_dept:
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
if dept_id is not None: if dept_id is not None:
@ -210,72 +191,8 @@ class FormSemestre(models.ScoDocModel):
).first_or_404() ).first_or_404()
return cls.query.filter_by(id=formsemestre_id).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: 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)""" (pour avoir le plus récent d'abord, sort avec reverse=True)"""
return (self.date_debut, self.semestre_id) return (self.date_debut, self.semestre_id)
@ -291,12 +208,12 @@ class FormSemestre(models.ScoDocModel):
d["formsemestre_id"] = self.id d["formsemestre_id"] = self.id
d["titre_num"] = self.titre_num() d["titre_num"] = self.titre_num()
if self.date_debut: 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() d["date_debut_iso"] = self.date_debut.isoformat()
else: else:
d["date_debut"] = d["date_debut_iso"] = "" d["date_debut"] = d["date_debut_iso"] = ""
if self.date_fin: 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() d["date_fin_iso"] = self.date_fin.isoformat()
else: else:
d["date_fin"] = d["date_fin_iso"] = "" d["date_fin"] = d["date_fin_iso"] = ""
@ -314,20 +231,19 @@ class FormSemestre(models.ScoDocModel):
def to_dict_api(self): 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 = dict(self.__dict__)
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
d.pop("groups_auto_assignment_data", None) d.pop("groups_auto_assignment_data", None)
d["annee_scolaire"] = self.annee_scolaire() d["annee_scolaire"] = self.annee_scolaire()
d["bul_hide_xml"] = self.bul_hide_xml
if self.date_debut: 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() d["date_debut_iso"] = self.date_debut.isoformat()
else: else:
d["date_debut"] = d["date_debut_iso"] = "" d["date_debut"] = d["date_debut_iso"] = ""
if self.date_fin: 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() d["date_fin_iso"] = self.date_fin.isoformat()
else: else:
d["date_fin"] = d["date_fin_iso"] = "" d["date_fin"] = d["date_fin_iso"] = ""
@ -353,15 +269,10 @@ class FormSemestre(models.ScoDocModel):
return default_partition.groups.first() return default_partition.groups.first()
raise ScoValueError("Le semestre n'a pas de groupe par défaut") raise ScoValueError("Le semestre n'a pas de groupe par défaut")
def get_edt_ids(self) -> list[str]: def get_edt_id(self) -> str:
"""Les ids pour l'emploi du temps: à défaut, les codes étape Apogée. "l'id pour l'emploi du temps: à défaut, le 1er code é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.
"""
return ( return (
scu.split_id(self.edt_id) self.edt_id or "" or (self.etapes[0].etape_apo if len(self.etapes) else "")
or [e.etape_apo.strip() for e in self.etapes if e.etape_apo]
or []
) )
def get_infos_dict(self) -> dict: def get_infos_dict(self) -> dict:
@ -466,80 +377,6 @@ class FormSemestre(models.ScoDocModel):
_cache[key] = ues _cache[key] = ues
return 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]: def get_evaluations(self) -> list[Evaluation]:
"Liste de toutes les évaluations du semestre, triées par module/numero" "Liste de toutes les évaluations du semestre, triées par module/numero"
return ( return (
@ -550,7 +387,7 @@ class FormSemestre(models.ScoDocModel):
Module.numero, Module.numero,
Module.code, Module.code,
Evaluation.numero, Evaluation.numero,
Evaluation.date_debut, Evaluation.date_debut.desc(),
) )
.all() .all()
) )
@ -560,7 +397,6 @@ class FormSemestre(models.ScoDocModel):
"""Liste des modimpls du semestre (y compris bonus) """Liste des modimpls du semestre (y compris bonus)
- triée par type/numéro/code en APC - triée par type/numéro/code en APC
- triée par numéros d'UE/matières/modules pour les formations standard. - 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() modimpls = self.modimpls.all()
if self.formation.is_apc(): if self.formation.is_apc():
@ -572,14 +408,6 @@ class FormSemestre(models.ScoDocModel):
) )
) )
else: else:
modimpls = [
mi
for mi in modimpls
if (
mi.module.module_type
not in (scu.ModuleType.RESSOURCE, scu.ModuleType.SAE)
)
]
modimpls.sort( modimpls.sort(
key=lambda m: ( key=lambda m: (
m.module.ue.numero or 0, 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: def est_terminal(self) -> bool:
"Vrai si dernier semestre de son cursus (ou formation mono-semestre)" "Vrai si dernier semestre de son cursus (ou formation mono-semestre)"
return (self.semestre_id < 0) or ( return (self.semestre_id < 0) or (
@ -726,7 +519,7 @@ class FormSemestre(models.ScoDocModel):
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2, mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
jour_pivot_annee=1, jour_pivot_annee=1,
jour_pivot_periode=1, jour_pivot_periode=1,
) -> tuple[int, int]: ):
"""Calcule la session associée à un formsemestre commençant en date_debut """Calcule la session associée à un formsemestre commençant en date_debut
sous la forme (année, période) sous la forme (année, période)
année: première année de l'année scolaire 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(), mois_pivot_periode=ScoDocSiteConfig.get_month_debut_periode2(),
) )
@classmethod def etapes_apo_vdi(self) -> list[ApoEtapeVDI]:
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"]:
"Liste des vdis" "Liste des vdis"
# was read_formsemestre_etapes # was read_formsemestre_etapes
return [e.as_apovdi() for e in self.etapes if e.etape_apo] return [e.as_apovdi() for e in self.etapes if e.etape_apo]
@ -809,9 +582,9 @@ class FormSemestre(models.ScoDocModel):
return "" return ""
return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape])) return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
def add_etape(self, etape_apo: str | ApoEtapeVDI): def add_etape(self, etape_apo: str):
"Ajoute une étape" "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) db.session.add(etape)
def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]: def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
@ -845,11 +618,11 @@ class FormSemestre(models.ScoDocModel):
else: else:
return ", ".join([u.get_nomcomplet() for u in self.responsables]) 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" "True si l'user est l'un des responsables du semestre"
return user.id in [u.id for u in self.responsables] 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" "Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre"
user = user or current_user user = user or current_user
return user.has_permission(Permission.EditFormSemestre) or self.est_responsable( 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: def can_change_groups(self, user: User = None) -> bool:
"""Vrai si l'utilisateur (par def. current) peut changer les groupes dans """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: if not self.etat:
return False # semestre verrouillé return False # semestre verrouillé
@ -867,7 +640,7 @@ class FormSemestre(models.ScoDocModel):
return True # typiquement admin, chef dept return True # typiquement admin, chef dept
return self.est_responsable(user) 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 """Vrai si utilisateur (par def. current) peut saisir decision de jury
dans ce semestre: vérifie permission et verrouillage. dans ce semestre: vérifie permission et verrouillage.
""" """
@ -978,9 +751,9 @@ class FormSemestre(models.ScoDocModel):
descr_sem += " " + self.modalite descr_sem += " " + self.modalite
return descr_sem 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: """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. Utilise un cache.
""" """
from app.scodoc import sco_assiduites from app.scodoc import sco_assiduites
@ -995,12 +768,7 @@ class FormSemestre(models.ScoDocModel):
def get_codes_apogee(self, category=None) -> set[str]: def get_codes_apogee(self, category=None) -> set[str]:
"""Les codes Apogée (codés en base comme "VRT1,VRT2") """Les codes Apogée (codés en base comme "VRT1,VRT2")
category: category: None: tous, "etapes": étapes associées, "sem: code semestre", "annee": code annuel
None: tous,
"etapes": étapes associées,
"sem: code semestre"
"annee": code annuel
"passage": code passage
""" """
codes = set() codes = set()
if category is None or category == "etapes": 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} 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: 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} 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 return codes
def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]: 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] partitions += [p for p in self.partitions if p.partition_name is None]
return partitions return partitions
def etudids_actifs(self) -> tuple[list[int], set[int]]: @cached_property
"""Liste les etudids inscrits (incluant DEM et DEF), def etudids_actifs(self) -> set:
qui sera l'index des dataframes de notes "Set des etudids inscrits non démissionnaires et non défaillants"
et donne l'ensemble des inscrits non DEM ni DEF. return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT}
"""
return [inscr.etudid for inscr in self.inscriptions], {
ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT
}
@property @cached_property
def etuds_inscriptions(self) -> dict: def etuds_inscriptions(self) -> dict:
"""Map { etudid : inscription } (incluant DEM et DEF)""" """Map { etudid : inscription } (incluant DEM et DEF)"""
return {ins.etud.id: ins for ins in self.inscriptions} return {ins.etud.id: ins for ins in self.inscriptions}
@ -1278,33 +1040,6 @@ class FormSemestre(models.ScoDocModel):
nb_recorded += 1 nb_recorded += 1
return nb_recorded 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 # Association id des utilisateurs responsables (aka directeurs des etudes) du semestre
notes_formsemestre_responsables = db.Table( notes_formsemestre_responsables = db.Table(
@ -1334,18 +1069,10 @@ class FormSemestreEtape(db.Model):
"Etape False if code empty" "Etape False if code empty"
return self.etape_apo is not None and (len(self.etape_apo) > 0) 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): def __repr__(self):
return f"<Etape {self.id} apo={self.etape_apo!r}>" return f"<Etape {self.id} apo={self.etape_apo!r}>"
def __str__(self): def as_apovdi(self) -> ApoEtapeVDI:
return self.etape_apo or ""
def as_apovdi(self) -> "ApoEtapeVDI":
return ApoEtapeVDI(self.etape_apo) return ApoEtapeVDI(self.etape_apo)
@ -1498,9 +1225,8 @@ class FormSemestreInscription(db.Model):
def __repr__(self): def __repr__(self):
return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={ return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={
self.formsemestre_id} (S{self.formsemestre.semestre_id}) etat={self.etat} { self.formsemestre_id} etat={self.etat} {
('parcours="'+str(self.parcour.code)+'"') if self.parcour else '' ('parcours='+str(self.parcour)) if self.parcour else ''}>"""
} {('etape="'+self.etape+'"') if self.etape else ''}>"""
class NotesSemSet(db.Model): class NotesSemSet(db.Model):

View File

@ -1,7 +1,7 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -11,15 +11,14 @@ from operator import attrgetter
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app import db, log 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.etudiants import Identite
from app.models.events import Scolog
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
class Partition(ScoDocModel): class Partition(db.Model):
"""Partition: découpage d'une promotion en groupes""" """Partition: découpage d'une promotion en groupes"""
__table_args__ = (db.UniqueConstraint("formsemestre_id", "partition_name"),) __table_args__ = (db.UniqueConstraint("formsemestre_id", "partition_name"),)
@ -54,7 +53,6 @@ class Partition(ScoDocModel):
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="GroupDescr.numero, GroupDescr.group_name", order_by="GroupDescr.numero, GroupDescr.group_name",
) )
_sco_dept_relations = ("FormSemestre",)
def __init__(self, **kwargs): def __init__(self, **kwargs):
super(Partition, self).__init__(**kwargs) super(Partition, self).__init__(**kwargs)
@ -94,10 +92,6 @@ class Partition(ScoDocModel):
): ):
group.remove_etud(etud) 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: def is_parcours(self) -> bool:
"Vrai s'il s'agit de la partition de parcours" "Vrai s'il s'agit de la partition de parcours"
return self.partition_name == scu.PARTITION_PARCOURS return self.partition_name == scu.PARTITION_PARCOURS
@ -210,7 +204,7 @@ class Partition(ScoDocModel):
return group return group
class GroupDescr(ScoDocModel): class GroupDescr(db.Model):
"""Description d'un groupe d'une partition""" """Description d'un groupe d'une partition"""
__tablename__ = "group_descr" __tablename__ = "group_descr"
@ -226,11 +220,6 @@ class GroupDescr(ScoDocModel):
numero = db.Column(db.Integer, nullable=False, default=0) numero = db.Column(db.Integer, nullable=False, default=0)
"Numero = ordre de presentation" "Numero = ordre de presentation"
_sco_dept_relations = (
"Partition",
"FormSemestre",
)
etuds = db.relationship( etuds = db.relationship(
"Identite", "Identite",
secondary="group_membership", secondary="group_membership",
@ -252,20 +241,15 @@ class GroupDescr(ScoDocModel):
def to_dict(self, with_partition=True) -> dict: def to_dict(self, with_partition=True) -> dict:
"""as a dict, with or without partition""" """as a dict, with or without partition"""
if with_partition:
partition_dict = self.partition.to_dict(with_groups=False)
d = dict(self.__dict__) d = dict(self.__dict__)
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
if with_partition: if with_partition:
d["partition"] = partition_dict d["partition"] = self.partition.to_dict(with_groups=False)
return d return d
def get_edt_ids(self) -> list[str]: def get_edt_id(self) -> str:
"les ids normalisés pour l'emploi du temps: à défaut, le nom scodoc du groupe" "l'id pour l'emploi du temps: à défaut, le nom scodoc du groupe"
return [ return self.edt_id or self.group_name or ""
scu.normalize_edt_id(x)
for x in scu.split_id(self.edt_id) or [self.group_name] or []
]
def get_nb_inscrits(self) -> int: def get_nb_inscrits(self) -> int:
"""Nombre inscrits à ce group et au formsemestre. """Nombre inscrits à ce group et au formsemestre.
@ -298,18 +282,18 @@ class GroupDescr(ScoDocModel):
return False return False
return True 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. """Set group name, and optionally edt_id.
Check permission (partition must be groups_editable) Check permission and invalidate caches. Commit session.
and invalidate caches. Commit session.
dest_url is used for error messages. dest_url is used for error messages.
""" """
if not self.partition.formsemestre.can_change_groups(): if not self.partition.formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
if self.group_name is None: if self.group_name is None:
raise ValueError("can't set a name to default group") raise ValueError("can't set a name to default group")
if not self.partition.groups_editable:
raise AccessDenied("Partition non éditable")
if group_name: if group_name:
group_name = group_name.strip() group_name = group_name.strip()
if not group_name: if not group_name:
@ -322,32 +306,27 @@ class GroupDescr(ScoDocModel):
) )
self.group_name = group_name 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.add(self)
db.session.commit() db.session.commit()
sco_cache.invalidate_formsemestre( sco_cache.invalidate_formsemestre(
formsemestre_id=self.partition.formsemestre_id 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"): def remove_etud(self, etud: "Identite"):
"Enlève l'étudiant de ce groupe s'il en fait partie (ne fait rien sinon)" "Enlève l'étudiant de ce groupe s'il en fait partie (ne fait rien sinon)"
if etud in self.etuds: if etud in self.etuds:
self.etuds.remove(etud) self.etuds.remove(etud)
db.session.commit()
Scolog.logdb( Scolog.logdb(
method="group_remove_etud", method="group_remove_etud",
etudid=etud.id, etudid=etud.id,
msg=f"Retrait du groupe {self.group_name} de {self.partition.partition_name}", msg=f"Retrait du groupe {self.group_name} de {self.partition.partition_name}",
commit=True,
) )
db.session.commit()
# Update parcours # Update parcours
if self.partition.partition_name == scu.PARTITION_PARCOURS: if self.partition.partition_name == scu.PARTITION_PARCOURS:
self.partition.formsemestre.update_inscriptions_parcours_from_groups( self.partition.formsemestre.update_inscriptions_parcours_from_groups(

View File

@ -2,24 +2,20 @@
"""ScoDoc models: moduleimpls """ScoDoc models: moduleimpls
""" """
import pandas as pd import pandas as pd
from flask import abort, g
from flask_login import current_user
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
import app
from app import db from app import db
from app.auth.models import User from app.auth.models import User
from app.comp import df_cache 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.etudiants import Identite
from app.models.evaluations import Evaluation
from app.models.modules import Module from app.models.modules import Module
from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
class ModuleImpl(ScoDocModel): class ModuleImpl(db.Model):
"""Mise en oeuvre d'un module pour une annee/semestre""" """Mise en oeuvre d'un module pour une annee/semestre"""
__tablename__ = "notes_moduleimpl" __tablename__ = "notes_moduleimpl"
@ -38,29 +34,18 @@ class ModuleImpl(ScoDocModel):
index=True, index=True,
nullable=False, nullable=False,
) )
responsable_id = db.Column( responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id"))
"responsable_id", db.Integer, db.ForeignKey("user.id", ondelete="SET NULL")
)
responsable = db.relationship("User", back_populates="modimpls")
# formule de calcul moyenne: # formule de calcul moyenne:
computation_expr = db.Column(db.Text()) computation_expr = db.Column(db.Text())
evaluations = db.relationship( evaluations = db.relationship("Evaluation", lazy="dynamic", backref="moduleimpl")
"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."
enseignants = db.relationship( enseignants = db.relationship(
"User", "User",
secondary="notes_modules_enseignants", secondary="notes_modules_enseignants",
lazy="dynamic", lazy="dynamic",
backref="moduleimpl", backref="moduleimpl",
viewonly=True,
) )
"enseignants du module (sans le responsable)"
_sco_dept_relations = ("FormSemestre",) # accès au dept_id
def __repr__(self): def __repr__(self):
return f"<{self.__class__.__name__} {self.id} module={repr(self.module)}>" 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 {x.strip() for x in self.code_apogee.split(",") if x}
return self.module.get_codes_apogee() return self.module.get_codes_apogee()
def get_edt_ids(self) -> list[str]: def get_edt_id(self) -> str:
"les ids pour l'emploi du temps: à défaut, les codes Apogée" "l'id pour l'emploi du temps: à défaut, le 1er code Apogée"
return [ return (
scu.normalize_edt_id(x) self.edt_id
for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) or (self.code_apogee.split(",")[0] if self.code_apogee else "")
] or self.module.get_edt_ids() or self.module.get_edt_id()
)
def get_evaluations_poids(self) -> pd.DataFrame: def get_evaluations_poids(self) -> pd.DataFrame:
"""Les poids des évaluations vers les UEs (accès via cache redis). """Les poids des évaluations vers les UE (accès via cache)"""
Toutes les évaluations sont considérées (normales, bonus, rattr., etc.)
"""
evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id) evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id)
if evaluations_poids is None: if evaluations_poids is None:
from app.comp import moy_mod from app.comp import moy_mod
@ -92,58 +76,24 @@ class ModuleImpl(ScoDocModel):
df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids) df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids)
return 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): def invalidate_evaluations_poids(self):
"""Invalide poids cachés""" """Invalide poids cachés"""
df_cache.EvaluationsPoidsCache.delete(self.id) df_cache.EvaluationsPoidsCache.delete(self.id)
def check_apc_conformity( def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool:
self, res: "ResultatsSemestreBUT", evaluation_type=Evaluation.EVALUATION_NORMALE """true si les poids des évaluations du module permettent de satisfaire
) -> bool: les coefficients du PN.
"""true si les poids des évaluations du type indiqué (normales par défaut)
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 ( if not self.module.formation.get_cursus().APC_SAE or (
self.module.module_type self.module.module_type != scu.ModuleType.RESSOURCE
not in {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE} and self.module.module_type != scu.ModuleType.SAE
): ):
return True # Non BUT, toujours conforme return True # Non BUT, toujours conforme
from app.comp import moy_mod 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( return moy_mod.moduleimpl_is_conforme(
self, self,
selected_evaluations_poids, self.get_evaluations_poids(),
res.modimpl_coefs_df, 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 allow_ens and user.id in (ens.id for ens in self.enseignants)
return True 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. """Check if user can modify module resp.
If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not. If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.
= Admin, et dir des etud. (si option l'y autorise) = Admin, et dir des etud. (si option l'y autorise)
@ -234,81 +184,21 @@ class ModuleImpl(ScoDocModel):
raise AccessDenied(f"Modification impossible pour {user}") raise AccessDenied(f"Modification impossible pour {user}")
return False return False
def can_change_ens(self, user: User | None = None, raise_exc=True) -> bool: def est_inscrit(self, etud: Identite) -> bool:
"""check if user can modify ens list (raise exception if not)"
if user is None, current user.
""" """
user = current_user if user is None else user Vérifie si l'étudiant est bien inscrit au moduleimpl
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
def can_change_inscriptions(self, user: User | None = None, raise_exc=True) -> bool: Retourne Vrai si c'est le cas, faux sinon
"""check si user peut inscrire/désinsincrire des étudiants à ce module.
Autorise ScoEtudInscrit ou responsables semestre.
""" """
user = current_user if user is None else user
if not self.formsemestre.etat:
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")
return False
# -- check access
# resp. module ou ou perm. EtudInscrit ou resp. semestre
if (
user.id != self.responsable_id
and not user.has_permission(Permission.EtudInscrit)
and user.id not in (u.id for u in self.formsemestre.responsables)
):
if raise_exc:
raise AccessDenied(f"Modification impossible pour {user}")
return False
return True
def est_inscrit(self, etud: Identite): is_module: int = (
""" ModuleImplInscription.query.filter_by(
Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre). etudid=etud.id, moduleimpl_id=self.id
(lent, pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df). ).count()
Retourne ModuleImplInscription si inscrit au module, False sinon. > 0
"""
# 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()
) )
return inscription or False return is_module
def query_inscriptions(self) -> Query:
"""Query ModuleImplInscription: inscrits au moduleimpl et au formsemestre
(pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df).
"""
from app.models.formsemestre import FormSemestre, FormSemestreInscription
return (
ModuleImplInscription.query.filter_by(moduleimpl_id=self.id)
.join(ModuleImpl)
.join(FormSemestre)
.join(FormSemestreInscription)
.filter_by(etudid=ModuleImplInscription.etudid)
)
# Enseignants (chargés de TD ou TP) d'un moduleimpl # Enseignants (chargés de TD ou TP) d'un moduleimpl

View File

@ -1,24 +1,17 @@
"""ScoDoc 9 models : Modules """ScoDoc 9 models : Modules
""" """
from flask import current_app
from flask import current_app, g
from app import db from app import db
from app import models
from app.models import APO_CODE_STR_LEN from app.models import APO_CODE_STR_LEN
from app.models.but_refcomp import ( from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
ApcParcours,
ApcReferentielCompetences,
app_critiques_modules,
parcours_modules,
)
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
class Module(models.ScoDocModel): class Module(db.Model):
"""Module""" """Module"""
__tablename__ = "notes_modules" __tablename__ = "notes_modules"
@ -29,7 +22,6 @@ class Module(models.ScoDocModel):
abbrev = db.Column(db.Text()) # nom court abbrev = db.Column(db.Text()) # nom court
# certains départements ont des codes infiniment longs: donc Text ! # certains départements ont des codes infiniment longs: donc Text !
code = db.Column(db.Text(), nullable=False) code = db.Column(db.Text(), nullable=False)
"code module, chaine non nullable"
heures_cours = db.Column(db.Float) heures_cours = db.Column(db.Float)
heures_td = db.Column(db.Float) heures_td = db.Column(db.Float)
heures_tp = db.Column(db.Float) heures_tp = db.Column(db.Float)
@ -75,8 +67,6 @@ class Module(models.ScoDocModel):
backref=db.backref("modules", lazy=True), backref=db.backref("modules", lazy=True),
) )
_sco_dept_relations = "Formation" # accès au dept_id
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.ue_coefs = [] self.ue_coefs = []
super(Module, self).__init__(**kwargs) super(Module, self).__init__(**kwargs)
@ -85,100 +75,6 @@ class Module(models.ScoDocModel):
return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name
} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>""" } 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): def clone(self):
"""Create a new copy of this module.""" """Create a new copy of this module."""
mod = Module( mod = Module(
@ -210,29 +106,16 @@ class Module(models.ScoDocModel):
mod.app_critiques.append(app_critique) mod.app_critiques.append(app_critique)
return mod return mod
def to_dict( def to_dict(self, convert_objects=False, with_matiere=False, with_ue=False) -> dict:
self,
convert_objects=False,
with_matiere=False,
with_ue=False,
with_parcours_ids=False,
) -> dict:
"""If convert_objects, convert all attributes to native types """If convert_objects, convert all attributes to native types
(suitable jor json encoding). (suitable jor json encoding).
If convert_objects and with_parcours_ids, give parcours as a list of id (API)
""" """
d = dict(self.__dict__) d = dict(self.__dict__)
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
d.pop("formation", None)
if convert_objects: if convert_objects:
if with_parcours_ids: d["parcours"] = [p.to_dict() for p in self.parcours]
d["parcours"] = [p.id for p in self.parcours]
else:
d["parcours"] = [p.to_dict() for p in self.parcours]
d["ue_coefs"] = [ d["ue_coefs"] = [
c.to_dict(convert_objects=False) c.to_dict(convert_objects=convert_objects) for c in self.ue_coefs
for c in self.ue_coefs
# note: don't convert_objects: we do wan't the details of the UEs here
] ]
d["app_critiques"] = {x.code: x.to_dict() for x in self.app_critiques} d["app_critiques"] = {x.code: x.to_dict() for x in self.app_critiques}
if not with_matiere: if not with_matiere:
@ -276,10 +159,6 @@ class Module(models.ScoDocModel):
"Identifiant du module à afficher : abbrev ou titre ou code" "Identifiant du module à afficher : abbrev ou titre ou code"
return self.abbrev or self.titre or self.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: def sort_key_apc(self) -> tuple:
"""Clé de tri pour avoir """Clé de tri pour avoir
présentation par type (res, sae), parcours, type, numéro présentation par type (res, sae), parcours, type, numéro
@ -400,33 +279,19 @@ class Module(models.ScoDocModel):
# Liste seulement les coefs définis: # Liste seulement les coefs définis:
return [(c.ue, c.coef) for c in self.get_ue_coefs_sorted()] 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]: def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée (codés en base comme "VRT1,VRT2")""" """Les codes Apogée (codés en base comme "VRT1,VRT2")"""
if self.code_apogee: if self.code_apogee:
return {x.strip() for x in self.code_apogee.split(",") if x} return {x.strip() for x in self.code_apogee.split(",") if x}
return set() return set()
def get_edt_ids(self) -> list[str]: def get_edt_id(self) -> str:
"les ids pour l'emploi du temps: à défaut, le 1er code Apogée" "l'id pour l'emploi du temps: à défaut, le 1er code Apogée"
return [ return (
scu.normalize_edt_id(x) self.edt_id
for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) or [] or (self.code_apogee.split(",")[0] if self.code_apogee else "")
] or ""
)
def get_parcours(self) -> list[ApcParcours]: def get_parcours(self) -> list[ApcParcours]:
"""Les parcours utilisant ce module. """Les parcours utilisant ce module.
@ -441,14 +306,6 @@ class Module(models.ScoDocModel):
return [] return []
return self.parcours 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): class ModuleUECoef(db.Model):
"""Coefficients des modules vers les UE (APC, BUT) """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) dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
title = db.Column(db.Text(), nullable=False) 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 # Association tag <-> module
notes_modules_tags = db.Table( notes_modules_tags = db.Table(

View File

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

View File

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

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