Compare commits

..

No commits in common. "master" and "bul_but" have entirely different histories.

597 changed files with 36361 additions and 47626 deletions

3
.gitignore vendored
View File

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

View File

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

154
README.md
View File

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

View File

@ -19,18 +19,22 @@ from flask import current_app, g, request
from flask import Flask
from flask import abort, flash, has_request_context
from flask import render_template
# from flask.json import JSONEncoder
from flask.logging import default_handler
from flask_bootstrap import Bootstrap
from flask_caching import Cache
from flask_json import FlaskJSON, json_response
from flask_login import LoginManager, current_user
from flask_mail import Mail
from flask_migrate import Migrate
from flask_moment import Moment
from flask_sqlalchemy import SQLAlchemy
from jinja2 import select_autoescape
import sqlalchemy as sa
import werkzeug.debug
from wtforms.fields import HiddenField
from flask_cas import CAS
@ -56,6 +60,8 @@ login.login_view = "auth.login"
login.login_message = "Identifiez-vous pour accéder à cette page."
mail = Mail()
bootstrap = Bootstrap()
moment = Moment()
CACHE_TYPE = os.environ.get("CACHE_TYPE")
cache = Cache(
@ -86,9 +92,8 @@ def handle_invalid_csrf(exc):
return render_template("error_csrf.j2", exc=exc), 404
# def handle_pdf_format_error(exc):
# return "ay ay ay"
handle_pdf_format_error = handle_sco_value_error
def handle_pdf_format_error(exc):
return "ay ay ay"
def internal_server_error(exc):
@ -300,6 +305,8 @@ def create_app(config_class=DevConfig):
login.init_app(app)
mail.init_app(app)
app.extensions["mail"].debug = 0 # disable copy of mails to stderr
bootstrap.init_app(app)
moment.init_app(app)
cache.init_app(app)
sco_cache.CACHE = cache
if CACHE_TYPE: # non default
@ -332,15 +339,8 @@ def create_app(config_class=DevConfig):
from app.api import api_bp
from app.api import api_web_bp
# Jinja2 configuration
# Enable autoescaping of all templates, including .j2
app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True)
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
# previously in Flask-Bootstrap:
app.jinja_env.globals["bootstrap_is_hidden_field"] = lambda field: isinstance(
field, HiddenField
)
# https://scodoc.fr/ScoDoc
app.register_blueprint(scodoc_bp)
@ -551,8 +551,8 @@ def truncate_database():
CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$
DECLARE
statements CURSOR FOR
SELECT sequence_name
FROM information_schema.sequences
SELECT sequence_name
FROM information_schema.sequences
ORDER BY sequence_name ;
BEGIN
FOR stmt IN statements LOOP
@ -637,12 +637,14 @@ def critical_error(msg):
import app.scodoc.sco_utils as scu
log(f"\n*** CRITICAL ERROR: {msg}")
subject = f"CRITICAL ERROR: {msg}".strip()[:68]
send_scodoc_alarm(subject, msg)
send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
clear_scodoc_cache()
raise ScoValueError(
f"""
Une erreur est survenue, veuillez -essayer.
Une erreur est survenue.
Si le problème persiste, merci de contacter le support ScoDoc via
{scu.SCO_DISCORD_ASSISTANCE}
{msg}
"""

View File

@ -3,11 +3,9 @@
from flask_json import as_json
from flask import Blueprint
from flask import request, g
from flask_login import current_user
from app import db
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoException
from app.scodoc.sco_permissions import Permission
api_bp = Blueprint("api", __name__)
api_web_bp = Blueprint("apiweb", __name__)
@ -50,35 +48,20 @@ def requested_format(default_format="json", allowed_formats=None):
@as_json
def get_model_api_object(
model_cls: db.Model,
model_id: int,
join_cls: db.Model = None,
restrict: bool | None = None,
):
def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None):
"""
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]"
Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemestre) -> join_cls
Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemstre) -> join_cls
exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py
L'agument restrict est passé to_dict, est signale que l'on veut une version restreinte
(sans données personnelles, ou sans informations sur le justificatif d'absence)
"""
query = model_cls.query.filter_by(id=model_id)
if g.scodoc_dept and join_cls is not None:
query = query.join(join_cls).filter_by(dept_id=g.scodoc_dept_id)
unique: model_cls = query.first()
unique: model_cls = query.first_or_404()
if unique is None:
return scu.json_error(
404,
message=f"{model_cls.__name__} inexistant(e)",
)
if restrict is None:
return unique.to_dict(format_api=True)
return unique.to_dict(format_api=True, restrict=restrict)
return unique.to_dict(format_api=True)
from app.api import tokens

View File

@ -1,21 +1,20 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités"""
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask import g, request
from flask_json import as_json
from flask_login import current_user, login_required
from flask_sqlalchemy.query import Query
from sqlalchemy.orm.exc import ObjectDeletedError
from app import db, log, set_sco_dept
from app import db, log
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app.scodoc import sco_preferences
from app.api import api_bp as bp
from app.api import api_web_bp, get_model_api_object, tools
from app.decorators import permission_required, scodoc
@ -25,12 +24,10 @@ from app.models import (
Identite,
ModuleImpl,
Scolog,
Justificatif,
)
from app.models.assiduites import (
get_assiduites_justif,
get_justifs_from_date,
get_formsemestre_from_data,
)
from flask_sqlalchemy.query import Query
from app.models.assiduites import get_assiduites_justif
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@ -40,7 +37,6 @@ from app.scodoc.sco_utils import json_error
@api_web_bp.route("/assiduite/<int:assiduite_id>")
@scodoc
@permission_required(Permission.ScoView)
@as_json
def assiduite(assiduite_id: int = None):
"""Retourne un objet assiduité à partir de son id
@ -54,8 +50,6 @@ def assiduite(assiduite_id: int = None):
"etat": "retard",
"desc": "une description",
"user_id: 1 or null,
"user_name" : login scodoc or null
"user_nom_complet": "Marie Dupont"
"est_just": False or True,
}
"""
@ -86,21 +80,19 @@ def assiduite_justificatifs(assiduite_id: int = None, long: bool = False):
]
"""
return get_assiduites_justif(assiduite_id, long)
return get_assiduites_justif(assiduite_id, True)
# etudid
@bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@bp.route("/assiduites/etudid/<int:etudid>/count", defaults={"with_query": False})
@bp.route("/assiduites/<etudid>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<etudid>/count", defaults={"with_query": False})
@bp.route("/assiduites/<etudid>/count/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<etudid>/count/query", defaults={"with_query": True})
@bp.route("/assiduites/etudid/<etudid>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/etudid/<etudid>/count", defaults={"with_query": False})
@bp.route("/assiduites/etudid/<etudid>/count/query", defaults={"with_query": True})
@api_web_bp.route(
"/assiduites/etudid/<int:etudid>/count", defaults={"with_query": False}
)
@bp.route("/assiduites/etudid/<int:etudid>/count/query", defaults={"with_query": True})
@api_web_bp.route(
"/assiduites/etudid/<int:etudid>/count/query", defaults={"with_query": True}
"/assiduites/etudid/<etudid>/count/query", defaults={"with_query": True}
)
# nip
@bp.route("/assiduites/nip/<nip>/count", defaults={"with_query": False})
@ -165,23 +157,23 @@ def count_assiduites(
"""
# query = Identite.query.filter_by(id=etudid)
# if g.scodoc_dept:
# query = query.filter_by(dept_id=g.scodoc_dept_id)
# etud: Identite = query.first_or_404(etudid)
# Récupération de l'étudiant
etud: Identite = tools.get_etud(etudid, nip, ine)
# Vérification que l'étudiant existe
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
set_sco_dept(etud.departement.acronym)
# Les filtres qui seront appliqués au comptage (type, date, etudid...)
filtered: dict[str, object] = {}
# la métrique du comptage (all, demi, heure, journee)
metric: str = "all"
# Si la requête a des paramètres
if with_query:
metric, filtered = _count_manager(request)
@ -191,16 +183,14 @@ def count_assiduites(
# etudid
@bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@bp.route("/assiduites/etudid/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/etudid/<int:etudid>", defaults={"with_query": False})
@bp.route("/assiduites/etudid/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route(
"/assiduites/etudid/<int:etudid>/query", defaults={"with_query": True}
)
@bp.route("/assiduites/<etudid>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<etudid>", defaults={"with_query": False})
@bp.route("/assiduites/<etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<etudid>/query", defaults={"with_query": True})
@bp.route("/assiduites/etudid/<etudid>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/etudid/<etudid>", defaults={"with_query": False})
@bp.route("/assiduites/etudid/<etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/etudid/<etudid>/query", defaults={"with_query": True})
# nip
@bp.route("/assiduites/nip/<nip>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/nip/<nip>", defaults={"with_query": False})
@ -256,7 +246,11 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False)
"""
# Récupération de l'étudiant
# query = Identite.query.filter_by(id=etudid)
# if g.scodoc_dept:
# query = query.filter_by(dept_id=g.scodoc_dept_id)
# etud: Identite = query.first_or_404(etudid)
etud: Identite = tools.get_etud(etudid, nip, ine)
if etud is None:
@ -264,23 +258,15 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False)
404,
message="étudiant inconnu",
)
# Récupération des assiduités de l'étudiant
assiduites_query: Query = etud.assiduites
# Filtrage des assiduités en fonction des paramètres de la requête
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
# Préparation de la réponse json
data_set: list[dict] = []
for ass in assiduites_query.all():
# conversion Assiduite -> Dict
data = ass.to_dict(format_api=True)
# Ajout des justificatifs (ou non dépendamment de la requête)
data = _with_justifs(data)
# Ajout de l'assiduité dans la liste de retour
data_set.append(data)
return data_set
@ -332,15 +318,13 @@ def assiduites_group(with_query: bool = False):
"""
# Récupération des étudiants dans la requête
etuds = request.args.get("etudids", "")
etuds = etuds.split(",")
try:
etuds = [int(etu) for etu in etuds]
except ValueError:
return json_error(404, "Le champ etudids n'est pas correctement formé")
return json_error(404, "Le champs etudids n'est pas correctement formé")
# Vérification que tous les étudiants sont du même département
query = Identite.query.filter(Identite.id.in_(etuds))
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
@ -350,21 +334,15 @@ def assiduites_group(with_query: bool = False):
404,
"Tous les étudiants ne sont pas dans le même département et/ou n'existe pas.",
)
# Récupération de toutes les assiduités liés aux étudiants
assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds))
# Filtrage des assiduités en fonction des filtres passés dans la requête
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
# Préparation de retour json
# Dict représentant chaque étudiant avec sa liste d'assiduité
data_set: dict[list[dict]] = {str(key): [] for key in etuds}
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data = _with_justifs(data)
# Ajout de l'assiduité dans la liste du bon étudiant
data_set.get(str(data["etudid"])).append(data)
return data_set
@ -389,23 +367,20 @@ def assiduites_group(with_query: bool = False):
@permission_required(Permission.ScoView)
def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne toutes les assiduités du formsemestre"""
# Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
# Récupération des assiduités du formsemestre
assiduites_query = scass.filter_by_formsemestre(
Assiduite.query, Assiduite, formsemestre
)
# Filtrage en fonction des paramètres de la requête
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
# Préparation du retour JSON
data_set: list[dict] = []
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
@ -439,30 +414,21 @@ def count_assiduites_formsemestre(
formsemestre_id: int = None, with_query: bool = False
):
"""Comptage des assiduités du formsemestre"""
# Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
set_sco_dept(formsemestre.departement.acronym)
# Récupération des étudiants du formsemestre
etuds = formsemestre.etuds.all()
etuds_id = [etud.id for etud in etuds]
# Récupération des assiduités des étudiants du semestre
assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds_id))
# Filtrage des assiduités en fonction des dates du semestre
assiduites_query = scass.filter_by_formsemestre(
assiduites_query, Assiduite, formsemestre
)
# Gestion de la métrique de comptage (all,demi,heure,journee)
metric: str = "all"
# Gestion du filtre (en fonction des paramètres de la requête)
filtered: dict = {}
if with_query:
metric, filtered = _count_manager(request)
@ -471,10 +437,10 @@ def count_assiduites_formsemestre(
# etudid
@bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@bp.route("/assiduite/etudid/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/etudid/<int:etudid>/create", methods=["POST"])
@bp.route("/assiduite/<etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/<etudid>/create", methods=["POST"])
@bp.route("/assiduite/etudid/<etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/etudid/<etudid>/create", methods=["POST"])
# nip
@bp.route("/assiduite/nip/<nip>/create", methods=["POST"])
@api_web_bp.route("/assiduite/nip/<nip>/create", methods=["POST"])
@ -485,10 +451,10 @@ def count_assiduites_formsemestre(
@scodoc
@as_json
@login_required
@permission_required(Permission.AbsChange)
@permission_required(Permission.ScoAbsChange)
def assiduite_create(etudid: int = None, nip=None, ine=None):
"""
Enregistrement d'assiduités pour un étudiant (etudid)
Création d'une assiduité pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
[
{
@ -507,37 +473,23 @@ def assiduite_create(etudid: int = None, nip=None, ine=None):
]
"""
# 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",
)
# Mise à jour du "g.scodoc_dept" si route sans dept
if g.scodoc_dept is None and etud.dept_id is not None:
# route sans département
set_sco_dept(etud.departement.acronym)
# Récupération de la liste des assiduités à créer
create_list: list[object] = request.get_json(force=True)
# Vérification que c'est bien une liste
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
# Préparation du retour
errors: list[dict[str, object]] = []
success: list[dict[str, object]] = []
# Pour chaque objet de la liste,
# on récupère son indice et l'objet
errors: list = []
success: list = []
for i, data in enumerate(create_list):
# On créé l'assiduité
# 200 + obj si réussi
# 404 + message d'erreur si non réussi
code, obj = _create_one(data, etud)
code, obj = _create_singular(data, etud)
if code == 404:
errors.append({"indice": i, "message": obj})
else:
@ -554,7 +506,7 @@ def assiduite_create(etudid: int = None, nip=None, ine=None):
@scodoc
@as_json
@login_required
@permission_required(Permission.AbsChange)
@permission_required(Permission.ScoAbsChange)
def assiduites_create():
"""
Création d'une assiduité ou plusieurs assiduites
@ -581,6 +533,7 @@ def assiduites_create():
"""
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
@ -591,11 +544,8 @@ def assiduites_create():
if etud is None:
errors.append({"indice": i, "message": "Cet étudiant n'existe pas."})
continue
if g.scodoc_dept is None and etud.dept_id is not None:
# route sans département
set_sco_dept(etud.departement.acronym)
code, obj = _create_one(data, etud)
code, obj = _create_singular(data, etud)
if code == 404:
errors.append({"indice": i, "message": obj})
else:
@ -605,106 +555,63 @@ def assiduites_create():
return {"errors": errors, "success": success}
def _create_one(
def _create_singular(
data: dict,
etud: Identite,
) -> tuple[int, object]:
"""
Création d'une assiduité à partir d'un dict
Cette fonction vérifie les données du dict (qui vient du JSON API)
Puis crée l'assiduité si la représentation est valide.
Args:
data (dict): représentation json d'une assiduité
etud (Identite): l'étudiant concerné par l'assiduité
Returns:
tuple[int, object]: code, objet
code : 200 si réussi 404 sinon
objet : dict{assiduite_id:?} si réussi str"message d'erreur" sinon
"""
errors: list[str] = []
# -- vérifications de l'objet json --
# cas 1 : ETAT
etat: str = data.get("etat", None)
etat = data.get("etat", None)
if etat is None:
errors.append("param 'etat': manquant")
elif not scu.EtatAssiduite.contains(etat):
errors.append("param 'etat': invalide")
etat: scu.EtatAssiduite = scu.EtatAssiduite.get(etat)
etat = scu.EtatAssiduite.get(etat)
# cas 2 : date_debut
date_debut: str = data.get("date_debut", None)
date_debut = data.get("date_debut", None)
if date_debut is None:
errors.append("param 'date_debut': manquant")
# Conversion de la chaine de caractère en datetime (tz ou non)
deb: datetime = scu.is_iso_formated(date_debut, convert=True)
# si chaine invalide
deb = scu.is_iso_formated(date_debut, convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# Si datetime sans timezone
elif deb.tzinfo is None:
# Mise à jour de la timezone avec celle du serveur
deb: datetime = scu.localize_datetime(deb)
# cas 3 : date_fin (Même fonctionnement ^ )
date_fin: str = data.get("date_fin", None)
# cas 3 : date_fin
date_fin = data.get("date_fin", None)
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin: datetime = scu.is_iso_formated(date_fin, convert=True)
fin = scu.is_iso_formated(date_fin, convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
elif fin.tzinfo is None:
fin: datetime = scu.localize_datetime(fin)
# cas 4 : desc
# cas 4 : moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id not in [False, None]:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
# cas 5 : desc
desc: str = data.get("desc", None)
# cas 5 : external data
external_data: dict = data.get("external_data", None)
external_data = data.get("external_data", None)
if external_data is not None:
if not isinstance(external_data, dict):
errors.append("param 'external_data' : n'est pas un objet JSON")
# cas 6 : moduleimpl_id
# On récupère le moduleimpl
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
# On vérifie si le moduleimpl existe (uniquement s'il a été renseigné)
if moduleimpl_id not in [False, None, "", "-1"]:
# Si le moduleimpl n'est pas "autre" alors on vérifie si l'id est valide
if moduleimpl_id != "autre":
try:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
except ValueError:
moduleimpl = None
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
else:
# Sinon on met à none le moduleimpl
# et on ajoute dans external data
# le module "autre"
moduleimpl_id = None
external_data: dict = external_data if external_data is not None else {}
external_data["module"] = "Autre"
# Si il y a des erreurs alors on ne crée pas l'assiduité et on renvoie les erreurs
if errors:
# Construit une chaine de caractère avec les erreurs séparées par `,`
err: str = ", ".join(errors)
# 404 représente le code d'erreur et err la chaine nouvellement créée
return (404, err)
# SI TOUT EST OK
# TOUT EST OK
try:
# On essaye de créer l'assiduité
nouv_assiduite: Assiduite = Assiduite.create_assiduite(
date_debut=deb,
date_fin=fin,
@ -714,27 +621,17 @@ def _create_one(
description=desc,
user_id=current_user.id,
external_data=external_data,
notify_mail=True,
)
# create_assiduite générera des ScoValueError si jamais il y a un autre problème
# - Etudiant non inscrit dans le module
# - module obligatoire
# - Assiduité conflictuelles
# Si tout s'est bien passé on ajoute l'assiduité à la session
# et on retourne un code 200 avec un objet possèdant l'assiduite_id
db.session.add(nouv_assiduite)
db.session.commit()
return (200, {"assiduite_id": nouv_assiduite.id})
except ScoValueError as excp:
# ici on utilise pas json_error car on doit renvoyer status, message
# Ici json_error ne peut être utilisé car il terminerai le processus de création
# Cela voudrait dire qu'une seule erreur dans une assiduité imposerait de
# tout refaire à partir de l'erreur.
# renvoit un code 404 et le message d'erreur de la ScoValueError
return 404, excp.args[0]
return (
404,
excp.args[0],
)
@bp.route("/assiduite/delete", methods=["POST"])
@ -742,7 +639,7 @@ def _create_one(
@login_required
@scodoc
@as_json
@permission_required(Permission.AbsChange)
@permission_required(Permission.ScoAbsChange)
def assiduite_delete():
"""
Suppression d'une assiduité à partir de son id
@ -756,20 +653,14 @@ def assiduite_delete():
"""
# Récupération des ids envoyés dans la liste
assiduites_list: list[int] = request.get_json(force=True)
if not isinstance(assiduites_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
# Préparation du retour json
output = {"errors": [], "success": []}
# Pour chaque assiduite_id on essaye de supprimer l'assiduité
for i, assiduite_id in enumerate(assiduites_list):
# De la même façon que "_create_one"
# Ici le code est soit 200 si réussi ou 404 si raté
# Le message est le message d'erreur si erreur
code, msg = _delete_one(assiduite_id)
for i, ass in enumerate(assiduites_list):
code, msg = _delete_singular(ass, db)
if code == 404:
output["errors"].append({"indice": i, "message": msg})
else:
@ -779,44 +670,20 @@ def assiduite_delete():
return output
def _delete_one(assiduite_id: int) -> tuple[int, str]:
"""
_delete_singular Supprime une assiduité à partir de son id
Args:
assiduite_id (int): l'identifiant de l'assiduité
Returns:
tuple[int, str]: code, message
code : 200 si réussi, 404 sinon
message : OK si réussi, le message d'erreur sinon
"""
def _delete_singular(assiduite_id: int, database):
assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first()
if assiduite_unique is None:
# Ici json_error ne peut être utilisé car il terminerai le processus de création
# Cela voudrait dire qu'une seule erreur d'id imposerait de
# tout refaire à partir de l'erreur.
return 404, "Assiduite non existante"
# Mise à jour du g.scodoc_dept si la route est sans département
if g.scodoc_dept is None and assiduite_unique.etudiant.dept_id is not None:
# route sans département
set_sco_dept(assiduite_unique.etudiant.departement.acronym)
# Récupération de la version dict de l'assiduité
# Pour invalider le cache
assi_dict = assiduite_unique.to_dict()
# Suppression de l'assiduité et LOG
return (404, "Assiduite non existante")
ass_dict = assiduite_unique.to_dict()
log(f"delete_assiduite: {assiduite_unique.etudiant.id} {assiduite_unique}")
Scolog.logdb(
method="delete_assiduite",
etudid=assiduite_unique.etudiant.id,
msg=f"assiduité: {assiduite_unique}",
)
db.session.delete(assiduite_unique)
# Invalidation du cache
scass.simple_invalidate_cache(assi_dict)
return 200, "OK"
database.session.delete(assiduite_unique)
scass.simple_invalidate_cache(ass_dict)
return (200, "OK")
@bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
@ -824,7 +691,7 @@ def _delete_one(assiduite_id: int) -> tuple[int, str]:
@login_required
@scodoc
@as_json
@permission_required(Permission.AbsChange)
@permission_required(Permission.ScoAbsChange)
def assiduite_edit(assiduite_id: int):
"""
Edition d'une assiduité à partir de son id
@ -836,33 +703,74 @@ def assiduite_edit(assiduite_id: int):
"est_just"?: bool
}
"""
# Récupération de l'assiduité à modifier
assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first()
if assiduite_unique is None:
return json_error(404, "Assiduité non existante")
# Récupération des valeurs à modifier
assiduite_unique: Assiduite = Assiduite.query.filter_by(
id=assiduite_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
# Code 200 si modification réussie
# Code 404 si raté + message d'erreur
code, obj = _edit_one(assiduite_unique, data)
# Vérifications de data
if code == 404:
return json_error(404, obj)
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatAssiduite.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
assiduite_unique.etat = etat
# Cas 2 : Moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
if moduleimpl_id is not None and moduleimpl_id != "":
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
else:
if not moduleimpl.est_inscrit(
Identite.query.filter_by(id=assiduite_unique.etudid).first()
):
errors.append("param 'moduleimpl_id': etud non inscrit")
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
else:
assiduite_unique.moduleimpl_id = None
# Cas 3 : desc
desc = data.get("desc", False)
if desc is not False:
assiduite_unique.description = desc
# Cas 4 : est_just
est_just = data.get("est_just")
if est_just is not None:
if not isinstance(est_just, bool):
errors.append("param 'est_just' : booléen non reconnu")
else:
assiduite_unique.est_just = est_just
external_data = data.get("external_data")
if external_data is not None:
if not isinstance(external_data, dict):
errors.append("param 'external_data' : n'est pas un objet JSON")
else:
assiduite_unique.external_data = external_data
if errors:
err: str = ", ".join(errors)
return json_error(404, err)
# Mise à jour de l'assiduité et LOG
log(f"assiduite_edit: {assiduite_unique.etudiant.id} {assiduite_unique}")
Scolog.logdb(
"assiduite_edit",
assiduite_unique.etudiant.id,
msg=f"assiduite: modif {assiduite_unique}",
)
db.session.add(assiduite_unique)
db.session.commit()
try:
scass.simple_invalidate_cache(assiduite_unique.to_dict())
except ObjectDeletedError:
return json_error(404, "Assiduité supprimée / inexistante")
scass.simple_invalidate_cache(assiduite_unique.to_dict())
return {"OK": True}
@ -872,7 +780,7 @@ def assiduite_edit(assiduite_id: int):
@login_required
@scodoc
@as_json
@permission_required(Permission.AbsChange)
@permission_required(Permission.ScoAbsChange)
def assiduites_edit():
"""
Edition de plusieurs assiduités
@ -905,7 +813,7 @@ def assiduites_edit():
)
continue
code, obj = _edit_one(assi, data)
code, obj = _edit_singular(assi, data)
obj_retour = {
"indice": i,
"message": obj,
@ -920,139 +828,55 @@ def assiduites_edit():
return {"errors": errors, "success": success}
def _edit_one(assiduite_unique: Assiduite, data: dict) -> tuple[int, str]:
"""
_edit_singular Modifie une assiduité à partir de données JSON
Args:
assiduite_unique (Assiduite): l'assiduité à modifier
data (dict): les nouvelles données
Returns:
tuple[int,str]: code, message
code : 200 si réussi, 404 sinon
message : OK si réussi, message d'erreur sinon
"""
# Mise à jour du g.scodoc_dept en cas de route sans département
if g.scodoc_dept is None and assiduite_unique.etudiant.dept_id is not None:
# route sans département
set_sco_dept(assiduite_unique.etudiant.departement.acronym)
def _edit_singular(assiduite_unique, data):
errors: list[str] = []
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat: scu.EtatAssiduite = scu.EtatAssiduite.get(data.get("etat"))
etat = scu.EtatAssiduite.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
# Mise à jour de l'état
assiduite_unique.etat = etat
# Cas 2 : external_data
external_data: dict = data.get("external_data")
if external_data is not None:
if not isinstance(external_data, dict):
errors.append("param 'external_data' : n'est pas un objet JSON")
else:
# Mise à jour de l'external data
assiduite_unique.external_data = external_data
# Cas 3 : Moduleimpl_id
# Cas 2 : Moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
# False si on modifie pas le moduleimpl
if moduleimpl_id is not False:
# Si le module n'est pas nul
if moduleimpl_id not in [None, "", "-1"]:
# Gestion du module Autre
if moduleimpl_id == "autre":
# module autre = moduleimpl_id:None + external_data["module"]:"Autre"
assiduite_unique.moduleimpl_id = None
external_data: dict = (
external_data
if external_data is not None and isinstance(external_data, dict)
else assiduite_unique.external_data
)
external_data: dict = external_data if external_data is not None else {}
external_data["module"] = "Autre"
assiduite_unique.external_data = external_data
if moduleimpl_id is not None:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
else:
# Vérification de l'id et récupération de l'objet ModuleImpl
try:
moduleimpl = ModuleImpl.query.filter_by(
id=int(moduleimpl_id)
).first()
except ValueError:
moduleimpl = None
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
if not moduleimpl.est_inscrit(
Identite.query.filter_by(id=assiduite_unique.etudid).first()
):
errors.append("param 'moduleimpl_id': etud non inscrit")
else:
if not moduleimpl.est_inscrit(assiduite_unique.etudiant):
errors.append("param 'moduleimpl_id': etud non inscrit")
else:
# Mise à jour du moduleimpl
assiduite_unique.moduleimpl_id = moduleimpl_id
assiduite_unique.moduleimpl_id = moduleimpl_id
else:
# Vérification du force module en cas de modification du moduleimpl en moduleimpl nul
# Récupération du formsemestre lié à l'assiduité
formsemestre: FormSemestre = get_formsemestre_from_data(
assiduite_unique.to_dict()
)
force: bool
assiduite_unique.moduleimpl_id = moduleimpl_id
if formsemestre:
force = scu.is_assiduites_module_forced(formsemestre_id=formsemestre.id)
else:
force = scu.is_assiduites_module_forced(
dept_id=assiduite_unique.etudiant.dept_id
)
external_data = (
external_data
if external_data is not None and isinstance(external_data, dict)
else assiduite_unique.external_data
)
if force and not (
external_data is not None and external_data.get("module", False) != ""
):
errors.append(
"param 'moduleimpl_id' : le moduleimpl_id ne peut pas être nul"
)
# Cas 4 : desc
desc: str = data.get("desc", False)
# Cas 3 : desc
desc = data.get("desc", False)
if desc is not False:
assiduite_unique.description = desc
assiduite_unique.desc = desc
# Cas 5 : est_just
if assiduite_unique.etat == scu.EtatAssiduite.PRESENT:
assiduite_unique.est_just = False
else:
assiduite_unique.est_just = (
len(
get_justifs_from_date(
assiduite_unique.etudiant.id,
assiduite_unique.date_debut,
assiduite_unique.date_fin,
valid=True,
)
)
> 0
)
# Cas 4 : est_just
est_just = data.get("est_just")
if est_just is not None:
if not isinstance(est_just, bool):
errors.append("param 'est_just' : booléen non reconnu")
else:
assiduite_unique.est_just = est_just
if errors:
# Retour des erreurs en une seule chaîne séparée par des `,`
err: str = ", ".join(errors)
return (404, err)
# Mise à jour de l'assiduité et LOG
log(f"_edit_singular: {assiduite_unique.etudiant.id} {assiduite_unique}")
Scolog.logdb(
"assiduite_edit",
@ -1122,8 +946,8 @@ def _count_manager(requested) -> tuple[str, dict]:
est_just: str = requested.args.get("est_just")
if est_just is not None:
trues: tuple[str] = ("v", "t", "vrai", "true", "1")
falses: tuple[str] = ("f", "faux", "false", "0")
trues: tuple[str] = ("v", "t", "vrai", "true")
falses: tuple[str] = ("f", "faux", "false")
if est_just.lower() in trues:
filtered["est_just"] = True
@ -1136,45 +960,26 @@ def _count_manager(requested) -> tuple[str, dict]:
if user_id is not False:
filtered["user_id"] = user_id
# cas 9 : split
split = requested.args.get("split", False)
if split is not False:
filtered["split"] = True
return (metric, filtered)
def _filter_manager(requested, assiduites_query: Query) -> Query:
"""
_filter_manager Retourne les assiduites entrées filtrées en fonction de la request
Args:
requested (request): La requête http
assiduites_query (Query): la query d'assiduités à filtrer
Returns:
Query: La query filtrée
Retourne les assiduites entrées filtrées en fonction de la request
"""
# cas 1 : etat assiduite
etat: str = requested.args.get("etat")
etat = requested.args.get("etat")
if etat is not None:
assiduites_query = scass.filter_assiduites_by_etat(assiduites_query, etat)
# cas 2 : date de début
deb: str = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(
deb, True
) # transformation de la chaine en datetime
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin: str = requested.args.get("date_fin", "").replace(" ", "+")
fin: datetime = scu.is_iso_formated(
fin, True
) # transformation de la chaine en datetime
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
# Pour filtrer les dates il faut forcement avoir les deux bornes
# [date_debut : date_fin]
if (deb, fin) != (None, None):
assiduites_query: Query = scass.filter_by_date(
assiduites_query, Assiduite, deb, fin
@ -1210,8 +1015,8 @@ def _filter_manager(requested, assiduites_query: Query) -> Query:
est_just: str = requested.args.get("est_just")
if est_just is not None:
trues: tuple[str] = ("v", "t", "vrai", "true", "1")
falses: tuple[str] = ("f", "faux", "false", "0")
trues: tuple[str] = ("v", "t", "vrai", "true")
falses: tuple[str] = ("f", "faux", "false")
if est_just.lower() in trues:
assiduites_query: Query = scass.filter_assiduites_by_est_just(
@ -1228,35 +1033,10 @@ def _filter_manager(requested, assiduites_query: Query) -> Query:
if user_id is not False:
assiduites_query: Query = scass.filter_by_user_id(assiduites_query, user_id)
# cas 9 : order (renvoie la query ordonnée en "date début Décroissante")
order = requested.args.get("order", None)
if order is not None:
assiduites_query: Query = assiduites_query.order_by(Assiduite.date_debut.desc())
# cas 10 : courant (Ne renvoie que les assiduités de l'année courante)
courant = requested.args.get("courant", None)
if courant is not None:
annee: int = scu.annee_scolaire()
assiduites_query: Query = assiduites_query.filter(
Assiduite.date_debut >= scu.date_debut_annee_scolaire(annee),
Assiduite.date_fin <= scu.date_fin_annee_scolaire(annee),
)
return assiduites_query
def _with_justifs(assi: dict):
"""
_with_justifs ajoute la liste des justificatifs à l'assiduité
Condition : `with_justifs` doit se trouver dans les paramètres de la requête
Args:
assi (dict): un dictionnaire représentant une assiduité
Returns:
dict: l'assiduité avec les justificatifs ajoutés
"""
def _with_justifs(assi):
if request.args.get("with_justifs") is None:
return assi
assi["justificatifs"] = get_assiduites_justif(assi["assiduite_id"], True)

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -38,7 +38,7 @@ def billets_absence_etudiant(etudid: int):
@api_web_bp.route("/billets_absence/create", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.AbsAddBillet)
@permission_required(Permission.ScoAbsAddBillet)
@as_json
def billets_absence_create():
"""Ajout d'un billet d'absence"""
@ -70,7 +70,7 @@ def billets_absence_create():
@api_web_bp.route("/billets_absence/<int:billet_id>/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.AbsAddBillet)
@permission_required(Permission.ScoAbsAddBillet)
@as_json
def billets_absence_delete(billet_id: int):
"""Suppression d'un billet d'absence"""

View File

@ -1,13 +1,13 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
ScoDoc 9 API : accès aux départements
Note: les routes /departement[s] sont publiées sur l'API (/ScoDoc/api/),
Note: les routes /departement[s] sont publiées sur l'API (/ScoDoc/api/),
mais évidemment pas sur l'API web (/ScoDoc/<dept>/api).
"""
from datetime import datetime
@ -271,11 +271,23 @@ def dept_formsemestres_courants(acronym: str):
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
date_courante = request.args.get("date_courante")
date_courante = datetime.fromisoformat(date_courante) if date_courante else None
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 [
formsemestre.to_dict_api()
for formsemestre in FormSemestre.get_dept_formsemestres_courants(
dept, date_courante
d.to_dict_api()
for d in formsemestres.order_by(
FormSemestre.date_debut.desc(),
FormSemestre.modalite,
FormSemestre.semestre_id,
FormSemestre.titre,
)
]
@ -295,7 +307,7 @@ def dept_formsemestres_courants_by_id(dept_id: int):
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = db.func.current_date()
test_date = app.db.func.now()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -10,7 +10,7 @@
from datetime import datetime
from operator import attrgetter
from flask import g, request, Response
from flask import g, request
from flask_json import as_json
from flask_login import current_user
from flask_login import login_required
@ -18,29 +18,23 @@ from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import VARCHAR
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.api import tools
from app.but import bulletin_but_court
from app.decorators import scodoc, permission_required
from app.models import (
Admission,
Departement,
EtudAnnotation,
FormSemestreInscription,
FormSemestre,
Identite,
ScolarNews,
)
from app.scodoc import sco_bulletins
from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc import sco_etud
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error, suppress_accents
import app.scodoc.sco_photos as sco_photos
import app.scodoc.sco_utils as scu
# Un exemple:
# @bp.route("/api_function/<int:arg>")
@ -55,32 +49,6 @@ import app.scodoc.sco_utils as scu
#
def _get_etud_by_code(
code_type: str, code: str, dept: Departement
) -> tuple[bool, Response | Identite]:
"""Get etud, using etudid, NIP or INE
Returns True, etud if ok, or False, error response.
"""
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return False, json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code)
else:
return False, json_error(404, "invalid code_type")
if dept:
query = query.filter_by(dept_id=dept.id)
etud = query.first()
if etud is None:
return False, json_error(404, message="etudiant inexistant")
return True, etud
@bp.route("/etudiants/courants", defaults={"long": False})
@bp.route("/etudiants/courants/long", defaults={"long": True})
@api_web_bp.route("/etudiants/courants", defaults={"long": False})
@ -131,10 +99,7 @@ def etudiants_courants(long=False):
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
if long:
restrict = not current_user.has_permission(Permission.ViewEtudData)
data = [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in etuds
]
data = [etud.to_dict_api() for etud in etuds]
else:
data = [etud.to_dict_short() for etud in etuds]
return data
@ -168,8 +133,8 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
404,
message="étudiant inconnu",
)
restrict = not current_user.has_permission(Permission.ViewEtudData)
return etud.to_dict_api(restrict=restrict, with_annotations=True)
return etud.to_dict_api()
@bp.route("/etudiant/etudid/<int:etudid>/photo")
@ -213,11 +178,11 @@ def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
@api_web_bp.route("/etudiant/etudid/<int:etudid>/photo", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.EtudChangeAdr)
@permission_required(Permission.ScoEtudChangeAdr)
@as_json
def set_photo_image(etudid: int = None):
"""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.ScoEtudChangeAdr)
query = Identite.query.filter_by(id=etudid)
if not None in allowed_depts:
# restreint aux départements autorisés:
@ -281,10 +246,7 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
restrict = not current_user.has_permission(Permission.ViewEtudData)
return [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in query
]
return [etud.to_dict_api() for etud in query]
@bp.route("/etudiants/name/<string:start>")
@ -311,11 +273,7 @@ def etudiants_by_name(start: str = "", min_len=3, limit=32):
)
etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit)
# Note: on raffine le tri pour les caractères spéciaux et nom usuel ici:
restrict = not current_user.has_permission(Permission.ViewEtudData)
return [
etud.to_dict_api(restrict=restrict)
for etud in sorted(etuds, key=attrgetter("sort_key"))
]
return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))]
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@ -396,17 +354,17 @@ def bulletin(
code_type: str = "etudid",
code: str = None,
formsemestre_id: int = None,
version: str = "selectedevals",
version: str = "long",
pdf: bool = False,
with_img_signatures_pdf: bool = True,
):
"""
Retourne le bulletin d'un étudiant dans un formsemestre.
Retourne le bulletin d'un étudiant en fonction de son id et d'un semestre donné
formsemestre_id : l'id d'un formsemestre
code_type : "etudid", "nip" ou "ine"
code : valeur du code INE, NIP ou etudid, selon code_type.
version : type de bulletin (par défaut, "selectedevals"): short, long, selectedevals, butcourt
version : type de bulletin (par défaut, "long"): short, long, long_mat
pdf : si spécifié, bulletin au format PDF (et non JSON).
Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin
@ -414,36 +372,34 @@ def bulletin(
if version == "pdf":
version = "long"
pdf = True
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if version not in (
scu.BULLETINS_VERSIONS_BUT
if formsemestre.formation.is_apc()
else scu.BULLETINS_VERSIONS
):
return json_error(404, "version invalide")
if formsemestre.bul_hide_xml and pdf:
return json_error(403, "bulletin non disponible")
# note: la version json est réduite si bul_hide_xml
# return f"{code_type}={code}, version={version}, pdf={pdf}"
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
if g.scodoc_dept and dept.acronym != g.scodoc_dept:
return json_error(404, "formsemestre inexistant")
return json_error(404, "formsemestre inexistant", as_response=True)
app.set_sco_dept(dept.acronym)
ok, etud = _get_etud_by_code(code_type, code, dept)
if not ok:
return etud # json error
if version == "butcourt":
if pdf:
return bulletin_but_court.bulletin_but(formsemestre_id, etud.id, fmt="pdf")
else:
return json_error(404, message="butcourt available only in pdf")
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code, dept_id=dept.id)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code, dept_id=dept.id)
else:
return json_error(404, "invalid code_type")
etud = query.first()
if etud is None:
return json_error(404, message="etudiant inexistant")
if pdf:
pdf_response, _ = do_formsemestre_bulletinetud(
formsemestre,
etud,
version=version,
fmt="pdf",
format="pdf",
with_img_signatures_pdf=with_img_signatures_pdf,
)
return pdf_response
@ -452,9 +408,9 @@ def bulletin(
)
@bp.route("/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups")
@api_web_bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups"
@bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups",
methods=["GET"],
)
@scodoc
@permission_required(Permission.ScoView)
@ -492,6 +448,7 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
}
]
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
@ -508,173 +465,3 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
data = sco_groups.get_etud_groups(etud.id, formsemestre.id)
return data
@bp.route("/etudiant/create", methods=["POST"], defaults={"force": False})
@bp.route("/etudiant/create/force", methods=["POST"], defaults={"force": True})
@api_web_bp.route("/etudiant/create", methods=["POST"], defaults={"force": False})
@api_web_bp.route("/etudiant/create/force", methods=["POST"], defaults={"force": True})
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def etudiant_create(force=False):
"""Création d'un nouvel étudiant
Si force, crée même si homonymie détectée.
L'étudiant créé n'est pas inscrit à un semestre.
Champs requis: nom, prenom (sauf si config sans prénom), dept (string:acronyme)
"""
args = request.get_json(force=True) # may raise 400 Bad Request
dept = args.get("dept", None)
if not dept:
return scu.json_error(400, "dept requis")
dept_o = Departement.query.filter_by(acronym=dept).first()
if not dept_o:
return scu.json_error(400, "dept invalide")
if g.scodoc_dept and g.scodoc_dept_id != dept_o.id:
return scu.json_error(400, "dept invalide (route departementale)")
else:
app.set_sco_dept(dept)
args["dept_id"] = dept_o.id
# vérifie que le département de création est bien autorisé
if not current_user.has_permission(Permission.EtudInscrit, dept):
return json_error(403, "departement non autorisé")
nom = args.get("nom", None)
prenom = args.get("prenom", None)
ok, homonyms = sco_etud.check_nom_prenom_homonyms(nom=nom, prenom=prenom)
if not ok:
return scu.json_error(400, "nom ou prénom invalide")
if len(homonyms) > 0 and not force:
return scu.json_error(
400, f"{len(homonyms)} homonymes détectés. Vous pouvez utiliser /force."
)
etud = Identite.create_etud(**args)
db.session.flush()
# --- Données admission
admission_args = args.get("admission", None)
if admission_args:
etud.admission.from_dict(admission_args)
# --- Adresse
adresses = args.get("adresses", [])
if adresses:
# ne prend en compte que la première adresse
# car si la base est concue pour avoir plusieurs adresses par étudiant,
# l'application n'en gère plus qu'une seule.
adresse = etud.adresses.first()
adresse.from_dict(adresses[0])
# Poste une nouvelle dans le département concerné:
ScolarNews.add(
typ=ScolarNews.NEWS_INSCR,
text=f"Nouvel étudiant {etud.html_link_fiche()}",
url=etud.url_fiche(),
max_frequency=0,
dept_id=dept_o.id,
)
db.session.commit()
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
db.session.refresh(etud)
r = etud.to_dict_api(restrict=False) # pas de restriction, on vient de le créer
return r
@bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
@api_web_bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def etudiant_edit(
code_type: str = "etudid",
code: str = None,
):
"""Edition des données étudiant (identité, admission, adresses)"""
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
#
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"""
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
"""
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,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -67,7 +67,7 @@ def get_evaluation(evaluation_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def moduleimpl_evaluations(moduleimpl_id: int):
def evaluations(moduleimpl_id: int):
"""
Retourne la liste des évaluations d'un moduleimpl
@ -75,8 +75,14 @@ def moduleimpl_evaluations(moduleimpl_id: int):
Exemple de résultat : voir /evaluation
"""
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
return [evaluation.to_dict_api() for evaluation in modimpl.evaluations]
query = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id)
if g.scodoc_dept:
query = (
query.join(ModuleImpl)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
)
return [e.to_dict_api() for e in query]
@bp.route("/evaluation/<int:evaluation_id>/notes")
@ -140,9 +146,9 @@ def evaluation_notes(evaluation_id: int):
@api_web_bp.route("/evaluation/<int:evaluation_id>/notes/set", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.EnsView)
@permission_required(Permission.ScoEnsView)
@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.
The request content type should be "application/json",
and contains:
@ -181,7 +187,7 @@ def evaluation_set_notes(evaluation_id: int): # evaluation-notes-set
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>/evaluation/create", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.EnsView) # permission gérée dans la fonction
@permission_required(Permission.ScoEnsView) # permission gérée dans la fonction
@as_json
def evaluation_create(moduleimpl_id: int):
"""Création d'une évaluation.
@ -245,7 +251,7 @@ def evaluation_create(moduleimpl_id: int):
@api_web_bp.route("/evaluation/<int:evaluation_id>/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.EnsView) # permission gérée dans la fonction
@permission_required(Permission.ScoEnsView) # permission gérée dans la fonction
@as_json
def evaluation_delete(evaluation_id: int):
"""Suppression d'une évaluation.

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -52,8 +52,7 @@ def formations():
@as_json
def formations_ids():
"""
Retourne la liste de toutes les id de formations
(tous départements, ou du département indiqué dans la route)
Retourne la liste de toutes les id de formations (tous départements)
Exemple de résultat : [ 17, 99, 32 ]
"""
@ -252,7 +251,7 @@ def referentiel_competences(formation_id: int):
@api_web_bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.EditFormation)
@permission_required(Permission.ScoChangeFormation)
@as_json
def set_ue_parcours(ue_id: int):
"""Associe UE et parcours BUT.
@ -287,7 +286,7 @@ def set_ue_parcours(ue_id: int):
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
@permission_required(Permission.ScoChangeFormation)
@as_json
def assoc_ue_niveau(ue_id: int, niveau_id: int):
"""Associe l'UE au niveau de compétence"""
@ -316,7 +315,7 @@ def assoc_ue_niveau(ue_id: int, niveau_id: int):
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
@permission_required(Permission.ScoChangeFormation)
@as_json
def desassoc_ue_niveau(ue_id: int):
"""Désassocie cette UE de son niveau de compétence
@ -329,8 +328,6 @@ def desassoc_ue_niveau(ue_id: int):
ue.niveau_competence = None
db.session.add(ue)
db.session.commit()
# Invalidation du cache
ue.formation.invalidate_cached_sems()
log(f"desassoc_ue_niveau: {ue}")
if g.scodoc_dept:
# "usage web"

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -11,8 +11,8 @@ from operator import attrgetter, itemgetter
from flask import g, make_response, request
from flask_json import as_json
from flask_login import current_user, login_required
import sqlalchemy as sa
from flask_login import login_required
import app
from app import db
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
@ -33,12 +33,11 @@ from app.models import (
)
from app.models.formsemestre import GROUPS_AUTO_ASSIGNMENT_DATA_MAX
from app.scodoc.sco_bulletins import get_formsemestre_bulletin_etud_json
from app.scodoc import sco_edt_cal
from app.scodoc import sco_groups
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import ModuleType
import app.scodoc.sco_utils as scu
from app.tables.recap import TableRecap, RowRecap
from app.tables.recap import TableRecap
@bp.route("/formsemestre/<int:formsemestre_id>")
@ -124,8 +123,8 @@ def formsemestres_query():
annee_scolaire_int = int(annee_scolaire)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid annee_scolaire: not int")
debut_annee = scu.date_debut_annee_scolaire(annee_scolaire_int)
fin_annee = scu.date_fin_annee_scolaire(annee_scolaire_int)
debut_annee = scu.date_debut_anne_scolaire(annee_scolaire_int)
fin_annee = scu.date_fin_anne_scolaire(annee_scolaire_int)
formsemestres = formsemestres.filter(
FormSemestre.date_fin >= debut_annee, FormSemestre.date_debut <= fin_annee
)
@ -171,44 +170,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."""
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/<int:formsemestre_id>/bulletins")
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
@ -398,8 +359,7 @@ def formsemestre_etudiants(
inscriptions = formsemestre.inscriptions
if long:
restrict = not current_user.has_permission(Permission.ViewEtudData)
etuds = [ins.etud.to_dict_api(restrict=restrict) for ins in inscriptions]
etuds = [ins.etud.to_dict_api() for ins in inscriptions]
else:
etuds = [ins.etud.to_dict_short() for ins in inscriptions]
# Ajout des groupes de chaque étudiants
@ -464,7 +424,7 @@ def etat_evals(formsemestre_id: int):
for modimpl_id in nt.modimpls_results:
modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl_id]
modimpl: ModuleImpl = ModuleImpl.query.get_or_404(modimpl_id)
modimpl_dict = modimpl.to_dict(convert_objects=True, with_module=False)
modimpl_dict = modimpl.to_dict(convert_objects=True)
list_eval = []
for evaluation_id in modimpl_results.evaluations_etat:
@ -506,13 +466,13 @@ def etat_evals(formsemestre_id: int):
date_mediane = notes_sorted[len(notes_sorted) // 2].date
eval_dict["saisie_notes"] = {
"datetime_debut": (
date_debut.isoformat() if date_debut is not None else None
),
"datetime_debut": date_debut.isoformat()
if date_debut is not None
else None,
"datetime_fin": date_fin.isoformat() if date_fin is not None else None,
"datetime_mediane": (
date_mediane.isoformat() if date_mediane is not None else None
),
"datetime_mediane": date_mediane.isoformat()
if date_mediane is not None
else None,
}
list_eval.append(eval_dict)
@ -543,30 +503,16 @@ def formsemestre_resultat(formsemestre_id: int):
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
app.set_sco_dept(formsemestre.departement.acronym)
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
# Ajoute le groupe de chaque partition,
etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id)
class RowRecapAPI(RowRecap):
"""Pour table avec partitions et sort_key"""
def add_etud_cols(self):
"""Ajoute colonnes étudiant: codes, noms"""
super().add_etud_cols()
self.add_cell("partitions", "partitions", etud_groups.get(self.etud.id, {}))
self.add_cell("sort_key", "sort_key", self.etud.sort_key)
table = TableRecap(
res,
convert_values=convert_values,
include_evaluations=False,
mode_jury=False,
row_class=RowRecapAPI,
res, convert_values=convert_values, include_evaluations=False, mode_jury=False
)
# Supprime les champs inutiles (mise en forme)
rows = table.to_list()
# Ajoute le groupe de chaque partition:
etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id)
for row in rows:
row["partitions"] = etud_groups.get(row["etudid"], {})
# for row in rows:
# row["partitions"] = etud_groups.get(row["etudid"], {})
return rows
@ -609,27 +555,3 @@ def save_groups_auto_assignment(formsemestre_id: int):
formsemestre.groups_auto_assignment_data = request.data
db.session.add(formsemestre)
db.session.commit()
@bp.route("/formsemestre/<int:formsemestre_id>/edt")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/edt")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_edt(formsemestre_id: int):
"""l'emploi du temps du semestre.
Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur.
group_ids permet de filtrer sur les groupes ScoDoc.
show_modules_titles affiche le titre complet du module (défaut), sinon juste le code.
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
group_ids = request.args.getlist("group_ids", int)
show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False))
return sco_edt_cal.formsemestre_edt_dict(
formsemestre, group_ids=group_ids, show_modules_titles=show_modules_titles
)

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -49,11 +49,6 @@ def decisions_jury(formsemestre_id: int):
"""Décisions du jury des étudiants du formsemestre."""
# APC, pair:
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
if formsemestre is None:
return json_error(
404,
message="formsemestre inconnu",
)
if formsemestre.formation.is_apc():
app.set_sco_dept(formsemestre.departement.acronym)
rows = jury_but_results.get_jury_but_results(formsemestre)
@ -66,7 +61,7 @@ def _news_delete_jury_etud(etud: Identite):
"génère news sur effacement décision"
# n'utilise pas g.scodoc_dept, pas toujours dispo en mode API
url = url_for(
"scolar.fiche_etud", scodoc_dept=etud.departement.acronym, etudid=etud.id
"scolar.ficheEtud", scodoc_dept=etud.departement.acronym, etudid=etud.id
)
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
@ -125,7 +120,7 @@ def _validation_ue_delete(etudid: int, validation_id: int):
# (c'est le cas pour les validations de jury, mais pas pour les "antérieures" non
# rattachées à un formsemestre)
if not g.scodoc_dept: # accès API
if not current_user.has_permission(Permission.EtudInscrit):
if not current_user.has_permission(Permission.ScoEtudInscrit):
return json_error(403, "opération non autorisée (117)")
else:
if validation.formsemestre:
@ -133,7 +128,7 @@ def _validation_ue_delete(etudid: int, validation_id: int):
validation.formsemestre.dept_id != g.scodoc_dept_id
) or not validation.formsemestre.can_edit_jury():
return json_error(403, "opération non autorisée (123)")
elif not current_user.has_permission(Permission.EtudInscrit):
elif not current_user.has_permission(Permission.ScoEtudInscrit):
# Validation non rattachée à un semestre: on doit être chef
return json_error(403, "opération non autorisée (126)")
@ -155,7 +150,7 @@ def _validation_ue_delete(etudid: int, validation_id: int):
)
@login_required
@scodoc
@permission_required(Permission.EtudInscrit)
@permission_required(Permission.ScoEtudInscrit)
@as_json
def autorisation_inscription_delete(etudid: int, validation_id: int):
"Efface cette validation"
@ -183,7 +178,7 @@ def autorisation_inscription_delete(etudid: int, validation_id: int):
)
@login_required
@scodoc
@permission_required(Permission.EtudInscrit)
@permission_required(Permission.ScoEtudInscrit)
@as_json
def validation_rcue_record(etudid: int):
"""Enregistre une validation de RCUE.
@ -310,7 +305,7 @@ def validation_rcue_record(etudid: int):
)
@login_required
@scodoc
@permission_required(Permission.EtudInscrit)
@permission_required(Permission.ScoEtudInscrit)
@as_json
def validation_rcue_delete(etudid: int, validation_id: int):
"Efface cette validation"
@ -338,7 +333,7 @@ def validation_rcue_delete(etudid: int, validation_id: int):
)
@login_required
@scodoc
@permission_required(Permission.EtudInscrit)
@permission_required(Permission.ScoEtudInscrit)
@as_json
def validation_annee_but_delete(etudid: int, validation_id: int):
"Efface cette validation"

View File

@ -3,32 +3,30 @@
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Justificatifs
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask_json import as_json
from flask import g, request
from flask import g, jsonify, request
from flask_login import login_required, current_user
from flask_sqlalchemy.query import Query
from werkzeug.exceptions import NotFound
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db, set_sco_dept
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object, tools
from app.decorators import permission_required, scodoc
from app.models import Identite, Justificatif, Departement, FormSemestre, Scolog
from app.models import Identite, Justificatif, Departement, FormSemestre
from app.models.assiduites import (
get_formsemestre_from_data,
compute_assiduites_justified,
)
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
from app.scodoc.sco_groups import get_group_members
from flask_sqlalchemy.query import Query
# Partie Modèle
@ -47,32 +45,25 @@ def justificatif(justif_id: int = None):
"date_fin": "2022-10-31T10:00+01:00",
"etat": "valide",
"fichier": "archive_id",
"raison": "une raison", // VIDE si pas le droit
"raison": "une raison",
"entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null,
}
"""
return get_model_api_object(
Justificatif,
justif_id,
Identite,
restrict=not current_user.has_permission(Permission.AbsJustifView),
)
return get_model_api_object(Justificatif, justif_id, Identite)
# etudid
@bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
@bp.route("/justificatifs/etudid/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/etudid/<int:etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/etudid/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route(
"/justificatifs/etudid/<int:etudid>/query", defaults={"with_query": True}
)
@bp.route("/justificatifs/<etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/<etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/<etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/<etudid>/query", defaults={"with_query": True})
@bp.route("/justificatifs/etudid/<etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/etudid/<etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/etudid/<etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/etudid/<etudid>/query", defaults={"with_query": True})
# nip
@bp.route("/justificatifs/nip/<nip>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/nip/<nip>", defaults={"with_query": False})
@ -114,7 +105,7 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
query?user_id=[int]
ex query?user_id=3
"""
# Récupération de l'étudiant
etud: Identite = tools.get_etud(etudid, nip, ine)
if etud is None:
@ -122,19 +113,14 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
404,
message="étudiant inconnu",
)
# Récupération des justificatifs de l'étudiant
justificatifs_query = etud.justificatifs
# Filtrage des justificatifs en fonction de la requête
if with_query:
justificatifs_query = _filter_manager(request, justificatifs_query)
# Mise en forme des données puis retour en JSON
data_set: list[dict] = []
restrict = not current_user.has_permission(Permission.AbsJustifView)
for just in justificatifs_query.all():
data = just.to_dict(format_api=True, restrict=restrict)
data = just.to_dict(format_api=True)
data_set.append(data)
return data_set
@ -144,113 +130,22 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
@api_web_bp.route(
"/justificatifs/dept/<int:dept_id>/query", defaults={"with_query": True}
)
@bp.route("/justificatifs/dept/<int:dept_id>", defaults={"with_query": False})
@bp.route("/justificatifs/dept/<int:dept_id>/query", defaults={"with_query": True})
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def justificatifs_dept(dept_id: int = None, with_query: bool = False):
"""
Renvoie tous les justificatifs d'un département
(en ajoutant un champ "formsemestre" si possible)
"""
""" """
dept = Departement.query.get_or_404(dept_id)
etuds = [etud.id for etud in dept.etudiants]
# Récupération du département et des étudiants du département
dept: Departement = Departement.query.get(dept_id)
if dept is None:
return json_error(404, "Assiduité non existante")
etuds: list[int] = [etud.id for etud in dept.etudiants]
justificatifs_query = Justificatif.query.filter(Justificatif.etudid.in_(etuds))
# Récupération des justificatifs des étudiants du département
justificatifs_query: Query = Justificatif.query.filter(
Justificatif.etudid.in_(etuds)
)
# Filtrage des justificatifs
if with_query:
justificatifs_query: Query = _filter_manager(request, justificatifs_query)
# Mise en forme des données et retour JSON
restrict = not current_user.has_permission(Permission.AbsJustifView)
justificatifs_query = _filter_manager(request, justificatifs_query)
data_set: list[dict] = []
for just in justificatifs_query:
data_set.append(_set_sems(just, restrict=restrict))
return data_set
def _set_sems(justi: Justificatif, restrict: bool) -> dict:
"""
_set_sems Ajoute le formsemestre associé au justificatif s'il existe
Si le formsemestre n'existe pas, renvoie la simple représentation du justificatif
Args:
justi (Justificatif): Le justificatif
Returns:
dict: La représentation de l'assiduité en dictionnaire
"""
# Conversion du justificatif en dictionnaire
data = justi.to_dict(format_api=True, restrict=restrict)
# Récupération du formsemestre de l'assiduité
formsemestre: FormSemestre = get_formsemestre_from_data(justi.to_dict())
# Si le formsemestre existe on l'ajoute au dictionnaire
if formsemestre:
data["formsemestre"] = {
"id": formsemestre.id,
"title": formsemestre.session_id(),
}
return data
@bp.route(
"/justificatifs/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
)
@api_web_bp.route(
"/justificatifs/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
)
@bp.route(
"/justificatifs/formsemestre/<int:formsemestre_id>/query",
defaults={"with_query": True},
)
@api_web_bp.route(
"/justificatifs/formsemestre/<int:formsemestre_id>/query",
defaults={"with_query": True},
)
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne tous les justificatifs du formsemestre"""
# Récupération du formsemestre
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.query.filter_by(
id=formsemestre_id
).first()
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
# Récupération des justificatifs du semestre
justificatifs_query: Query = scass.filter_by_formsemestre(
Justificatif.query, Justificatif, formsemestre
)
# Filtrage des justificatifs
if with_query:
justificatifs_query: Query = _filter_manager(request, justificatifs_query)
# Retour des justificatifs en JSON
restrict = not current_user.has_permission(Permission.AbsJustifView)
data_set: list[dict] = []
for justi in justificatifs_query.all():
data = justi.to_dict(format_api=True, restrict=restrict)
for just in justificatifs_query.all():
data = just.to_dict(format_api=True)
data_set.append(data)
return data_set
@ -258,8 +153,8 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
@bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@bp.route("/justificatif/etudid/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/justificatif/etudid/<int:etudid>/create", methods=["POST"])
@bp.route("/justificatif/etudid/<etudid>/create", methods=["POST"])
@api_web_bp.route("/justificatif/etudid/<etudid>/create", methods=["POST"])
# nip
@bp.route("/justificatif/nip/<nip>/create", methods=["POST"])
@api_web_bp.route("/justificatif/nip/<nip>/create", methods=["POST"])
@ -269,7 +164,7 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
@scodoc
@login_required
@as_json
@permission_required(Permission.AbsChange)
@permission_required(Permission.ScoAbsChange)
def justif_create(etudid: int = None, nip=None, ine=None):
"""
Création d'un justificatif pour l'étudiant (etudid)
@ -290,8 +185,6 @@ def justif_create(etudid: int = None, nip=None, ine=None):
]
"""
# Récupération de l'étudiant
etud: Identite = tools.get_etud(etudid, nip, ine)
if etud is None:
@ -299,62 +192,57 @@ def justif_create(etudid: int = None, nip=None, ine=None):
404,
message="étudiant inconnu",
)
set_sco_dept(etud.departement.acronym)
# Récupération des justificatifs à créer
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: list[dict] = []
success: list[dict] = []
# énumération des justificatifs
errors: list = []
success: list = []
justifs: list = []
for i, data in enumerate(create_list):
code, obj, justi = _create_one(data, etud)
code: int
obj: str | dict
justi: Justificatif | None
code, obj, justi = _create_singular(data, etud)
if code == 404:
errors.append({"indice": i, "message": obj})
else:
success.append({"indice": i, "message": obj})
justi.justifier_assiduites()
justifs.append(justi)
scass.simple_invalidate_cache(data, etud.id)
compute_assiduites_justified(etud.etudid, justifs)
return {"errors": errors, "success": success}
def _create_one(
def _create_singular(
data: dict,
etud: Identite,
) -> tuple[int, object, Justificatif]:
) -> tuple[int, object]:
errors: list[str] = []
# -- vérifications de l'objet json --
# cas 1 : ETAT
etat: str = data.get("etat", None)
etat = data.get("etat", None)
if etat is None:
errors.append("param 'etat': manquant")
elif not scu.EtatJustificatif.contains(etat):
errors.append("param 'etat': invalide")
etat: scu.EtatJustificatif = scu.EtatJustificatif.get(etat)
etat = scu.EtatJustificatif.get(etat)
# cas 2 : date_debut
date_debut: str = data.get("date_debut", None)
date_debut = data.get("date_debut", None)
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb: datetime = scu.is_iso_formated(date_debut, convert=True)
deb = scu.is_iso_formated(date_debut, convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 3 : date_fin
date_fin: str = data.get("date_fin", None)
date_fin = data.get("date_fin", None)
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin: datetime = scu.is_iso_formated(date_fin, convert=True)
fin = scu.is_iso_formated(date_fin, convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
@ -362,7 +250,7 @@ def _create_one(
raison: str = data.get("raison", None)
external_data: dict = data.get("external_data")
external_data = data.get("external_data")
if external_data is not None:
if not isinstance(external_data, dict):
errors.append("param 'external_data' : n'est pas un objet JSON")
@ -374,22 +262,16 @@ def _create_one(
# TOUT EST OK
try:
# On essaye de créer le justificatif
nouv_justificatif: Query = Justificatif.create_justificatif(
date_debut=deb,
date_fin=fin,
etat=etat,
etudiant=etud,
etud=etud,
raison=raison,
user_id=current_user.id,
external_data=external_data,
)
# Si tout s'est bien passé on ajoute l'assiduité à la session
# et on retourne un code 200 avec un objet possèdant le justif_id
# ainsi que les assiduités justifiées par le dit justificatif
# On renvoie aussi le justificatif créé pour pour le calcul total de fin
db.session.add(nouv_justificatif)
db.session.commit()
@ -402,7 +284,10 @@ def _create_one(
nouv_justificatif,
)
except ScoValueError as excp:
return (404, excp.args[0], None)
return (
404,
excp.args[0],
)
@bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@ -410,7 +295,7 @@ def _create_one(
@login_required
@scodoc
@as_json
@permission_required(Permission.AbsChange)
@permission_required(Permission.ScoAbsChange)
def justif_edit(justif_id: int):
"""
Edition d'un justificatif à partir de son id
@ -423,56 +308,53 @@ def justif_edit(justif_id: int):
"date_fin"?: str
}
"""
# Récupération du justificatif à modifier
justificatif_unique = Justificatif.get_justificatif(justif_id)
justificatif_unique: Query = Justificatif.query.filter_by(
id=justif_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
# Récupération des assiduités (id) précédemment justifiée par le justificatif
avant_ids: list[int] = scass.justifies(justificatif_unique)
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat: scu.EtatJustificatif = scu.EtatJustificatif.get(data.get("etat"))
etat = scu.EtatJustificatif.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
justificatif_unique.etat = etat
# Cas 2 : raison
raison: str = data.get("raison", False)
raison = data.get("raison", False)
if raison is not False:
justificatif_unique.raison = raison
deb, fin = None, None
# cas 3 : date_debut
date_debut: str = data.get("date_debut", False)
date_debut = data.get("date_debut", False)
if date_debut is not False:
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb: datetime = scu.is_iso_formated(date_debut.replace(" ", "+"), convert=True)
deb = scu.is_iso_formated(date_debut.replace(" ", "+"), convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 4 : date_fin
date_fin: str = data.get("date_fin", False)
date_fin = data.get("date_fin", False)
if date_fin is not False:
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin: datetime = scu.is_iso_formated(date_fin.replace(" ", "+"), convert=True)
fin = scu.is_iso_formated(date_fin.replace(" ", "+"), convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
# Récupération des dates précédentes si deb ou fin est None
# Mise à jour des dates
deb = deb if deb is not None else justificatif_unique.date_debut
fin = fin if fin is not None else justificatif_unique.date_fin
# Mise à jour de l'external data
external_data: dict = data.get("external_data")
external_data = data.get("external_data")
if external_data is not None:
if not isinstance(external_data, dict):
errors.append("param 'external_data' : n'est pas un objet JSON")
@ -482,7 +364,6 @@ def justif_edit(justif_id: int):
if fin <= deb:
errors.append("param 'dates' : Date de début après date de fin")
# Mise à jour des dates du justificatif
justificatif_unique.date_debut = deb
justificatif_unique.date_fin = fin
@ -490,28 +371,20 @@ def justif_edit(justif_id: int):
err: str = ", ".join(errors)
return json_error(404, err)
# Mise à jour du justificatif
justificatif_unique.dejustifier_assiduites()
db.session.add(justificatif_unique)
db.session.commit()
Scolog.logdb(
method="edit_justificatif",
etudid=justificatif_unique.etudiant.id,
msg=f"justificatif modif: {justificatif_unique}",
)
# Génération du dictionnaire de retour
# La couverture correspond
# - aux assiduités précédemment justifiées par le justificatif
# - aux assiduités qui sont justifiées par le justificatif modifié
retour = {
"couverture": {
"avant": avant_ids,
"apres": justificatif_unique.justifier_assiduites(),
"après": compute_assiduites_justified(
justificatif_unique.etudid,
[justificatif_unique],
False,
),
}
}
# Invalide le cache
scass.simple_invalidate_cache(justificatif_unique.to_dict())
return retour
@ -521,7 +394,7 @@ def justif_edit(justif_id: int):
@login_required
@scodoc
@as_json
@permission_required(Permission.AbsChange)
@permission_required(Permission.ScoAbsChange)
def justif_delete():
"""
Suppression d'un justificatif à partir de son id
@ -535,8 +408,6 @@ def justif_delete():
"""
# Récupération des justif_ids
justificatifs_list: list[int] = request.get_json(force=True)
if not isinstance(justificatifs_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
@ -544,7 +415,7 @@ def justif_delete():
output = {"errors": [], "success": []}
for i, ass in enumerate(justificatifs_list):
code, msg = _delete_one(ass)
code, msg = _delete_singular(ass, db)
if code == 404:
output["errors"].append({"indice": i, "message": msg})
else:
@ -555,40 +426,27 @@ def justif_delete():
return output
def _delete_one(justif_id: int) -> tuple[int, str]:
"""
_delete_one Supprime un justificatif
Args:
justif_id (int): l'identifiant du justificatif
Returns:
tuple[int, str]: code, message
code : 200 si réussi, 404 sinon
message : OK si réussi, message d'erreur sinon
"""
# Récupération du justificatif à supprimer
try:
justificatif_unique = Justificatif.get_justificatif(justif_id)
except NotFound:
def _delete_singular(justif_id: int, database):
justificatif_unique: Query = Justificatif.query.filter_by(id=justif_id).first()
if justificatif_unique is None:
return (404, "Justificatif non existant")
# Récupération de l'archive du justificatif
archive_name: str = justificatif_unique.fichier
if archive_name is not None:
# Si elle existe : on essaye de la supprimer
archiver: JustificatifArchiver = JustificatifArchiver()
try:
archiver.delete_justificatif(justificatif_unique.etudiant, archive_name)
archiver.delete_justificatif(justificatif_unique.etudid, archive_name)
except ValueError:
pass
# On invalide le cache
scass.simple_invalidate_cache(justificatif_unique.to_dict())
# On actualise les assiduités justifiées de l'étudiant concerné
justificatif_unique.dejustifier_assiduites()
# On supprime le justificatif
db.session.delete(justificatif_unique)
database.session.delete(justificatif_unique)
compute_assiduites_justified(
justificatif_unique.etudid,
Justificatif.query.filter_by(etudid=justificatif_unique.etudid).all(),
True,
)
return (200, "OK")
@ -599,86 +457,74 @@ def _delete_one(justif_id: int) -> tuple[int, str]:
@scodoc
@login_required
@as_json
@permission_required(Permission.AbsChange)
@permission_required(Permission.ScoAbsChange)
def justif_import(justif_id: int = None):
"""
Importation d'un fichier (création d'archive)
"""
# On vérifie qu'un fichier a bien été envoyé
if len(request.files) == 0:
return json_error(404, "Il n'y a pas de fichier joint")
file = list(request.files.values())[0]
if file.filename == "":
return json_error(404, "Il n'y a pas de fichier joint")
# On récupère le justificatif auquel on va importer le fichier
justificatif_unique = Justificatif.get_justificatif(justif_id)
query: Query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
# Récupération de l'archive si elle existe
archive_name: str = justificatif_unique.fichier
# Utilisation de l'archiver de justificatifs
archiver: JustificatifArchiver = JustificatifArchiver()
try:
# On essaye de sauvegarder le fichier
fname: str
archive_name, fname = archiver.save_justificatif(
justificatif_unique.etudiant,
etudid=justificatif_unique.etudid,
filename=file.filename,
data=file.stream.read(),
archive_name=archive_name,
user_id=current_user.id,
)
# On actualise l'archive du justificatif
justificatif_unique.fichier = archive_name
db.session.add(justificatif_unique)
db.session.commit()
return {"filename": fname}
except ScoValueError as exc:
# Si cela ne fonctionne pas on renvoie une erreur
return json_error(404, exc.args[0])
except ScoValueError as err:
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"])
@api_web_bp.route(
"/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"]
)
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
def justif_export(justif_id: int | None = None, filename: str | None = None):
@permission_required(Permission.ScoAbsChange)
def justif_export(justif_id: int = None, filename: str = None):
"""
Retourne un fichier d'une archive d'un justificatif.
La permission est ScoView + (AbsJustifView ou être l'auteur du justifcatif)
Retourne un fichier d'une archive d'un justificatif
"""
# On récupère le justificatif concerné
justificatif_unique = Justificatif.get_justificatif(justif_id)
# Vérification des permissions
if not (
current_user.has_permission(Permission.AbsJustifView)
or justificatif_unique.user_id == current_user.id
):
return json_error(401, "non autorisé à voir ce fichier")
query: Query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificaitf = query.first_or_404()
# On récupère l'archive concernée
archive_name: str = justificatif_unique.fichier
if archive_name is None:
# On retourne une erreur si le justificatif n'a pas de fichiers
return json_error(404, "le justificatif ne possède pas de fichier")
# On récupère le fichier et le renvoie en une réponse déjà formée
archiver: JustificatifArchiver = JustificatifArchiver()
try:
return archiver.get_justificatif_file(
archive_name, justificatif_unique.etudiant, filename
archive_name, justificatif_unique.etudid, filename
)
except ScoValueError as err:
# On retourne une erreur json si jamais il y a un problème
return json_error(404, err.args[0])
@ -687,10 +533,11 @@ def justif_export(justif_id: int | None = None, filename: str | None = None):
@scodoc
@login_required
@as_json
@permission_required(Permission.AbsChange)
@permission_required(Permission.ScoAbsChange)
def justif_remove(justif_id: int = None):
"""
Supression d'un fichier ou d'une archive
# TOTALK: Doc, expliquer les noms coté server
{
"remove": <"all"/"list">
@ -701,57 +548,47 @@ def justif_remove(justif_id: int = None):
}
"""
# On récupère le dictionnaire
data: dict = request.get_json(force=True)
# On récupère le justificatif concerné
justificatif_unique = Justificatif.get_justificatif(justif_id)
query: Query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
# On récupère l'archive
archive_name: str = justificatif_unique.fichier
if archive_name is None:
# On retourne une erreur si le justificatif n'a pas de fichiers
return json_error(404, "le justificatif ne possède pas de fichier")
# On regarde le type de suppression (all ou list)
# Si all : on supprime tous les fichiers
# Si list : on supprime les fichiers dont le nom est dans la liste
remove: str = data.get("remove")
if remove is None or remove not in ("all", "list"):
return json_error(404, "param 'remove': Valeur invalide")
# On récupère l'archiver et l'étudiant
archiver: JustificatifArchiver = JustificatifArchiver()
etud = justificatif_unique.etudiant
etudid: int = justificatif_unique.etudid
try:
if remove == "all":
# Suppression de toute l'archive du justificatif
archiver.delete_justificatif(etud, archive_name=archive_name)
archiver.delete_justificatif(etudid=etudid, archive_name=archive_name)
justificatif_unique.fichier = None
db.session.add(justificatif_unique)
db.session.commit()
else:
# Suppression des fichiers dont le nom se trouve dans la liste "filenames"
for fname in data.get("filenames", []):
archiver.delete_justificatif(
etud,
etudid=etudid,
archive_name=archive_name,
filename=fname,
)
# Si il n'y a plus de fichiers dans l'archive, on la supprime
if len(archiver.list_justificatifs(archive_name, etud)) == 0:
archiver.delete_justificatif(etud, archive_name)
if len(archiver.list_justificatifs(archive_name, etudid)) == 0:
archiver.delete_justificatif(etudid, archive_name)
justificatif_unique.fichier = None
db.session.add(justificatif_unique)
db.session.commit()
except ScoValueError as err:
# On retourne une erreur json si jamais il y a eu un problème
return json_error(404, err.args[0])
# On retourne une réponse "removed" si tout s'est bien passé
return {"response": "removed"}
@ -766,33 +603,29 @@ def justif_list(justif_id: int = None):
Liste les fichiers du justificatif
"""
# Récupération du justificatif concerné
justificatif_unique = Justificatif.get_justificatif(justif_id)
query: Query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
# Récupération de l'archive avec l'archiver
archive_name: str = justificatif_unique.fichier
filenames: list[str] = []
archiver: JustificatifArchiver = JustificatifArchiver()
if archive_name is not None:
filenames = archiver.list_justificatifs(
archive_name, justificatif_unique.etudiant
archive_name, justificatif_unique.etudid
)
# Préparation du retour
# - total : le nombre total de fichier du justificatif
# - filenames : le nom des fichiers visible par l'utilisateur
retour = {"total": len(filenames), "filenames": []}
# Pour chaque nom de fichier on vérifie
# - Si l'utilisateur qui a importé le fichier est le même que
# l'utilisateur qui a demandé la liste des fichiers
# - Ou si l'utilisateur qui a demandé la liste possède la permission AbsJustifView
# Si c'est le cas alors on ajoute à la liste des fichiers visibles
for filename in filenames:
if int(filename[1]) == current_user.id or current_user.has_permission(
Permission.AbsJustifView
for fi in filenames:
if int(fi[1]) == current_user.id or current_user.has_permission(
Permission.ScoJustifView
):
retour["filenames"].append(filename[0])
# On renvoie le total et la liste des fichiers visibles
retour["filenames"].append(fi[0])
return retour
@ -802,49 +635,50 @@ def justif_list(justif_id: int = None):
@scodoc
@login_required
@as_json
@permission_required(Permission.AbsChange)
@permission_required(Permission.ScoAbsChange)
def justif_justifies(justif_id: int = None):
"""
Liste assiduite_id justifiées par le justificatif
"""
# On récupère le justificatif concerné
justificatif_unique = Justificatif.get_justificatif(justif_id)
query: Query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
# On récupère la liste des assiduités justifiées par le justificatif
assiduites_list: list[int] = scass.justifies(justificatif_unique)
# On la renvoie
return assiduites_list
# -- Utils --
def _filter_manager(requested, justificatifs_query: Query):
def _filter_manager(requested, justificatifs_query):
"""
Retourne les justificatifs entrés filtrés en fonction de la request
et du département courant s'il y en a un
"""
# cas 1 : etat justificatif
etat: str = requested.args.get("etat")
etat = requested.args.get("etat")
if etat is not None:
justificatifs_query: Query = scass.filter_justificatifs_by_etat(
justificatifs_query = scass.filter_justificatifs_by_etat(
justificatifs_query, etat
)
# cas 2 : date de début
deb: str = requested.args.get("date_debut", "").replace(" ", "+")
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin: str = requested.args.get("date_fin", "").replace(" ", "+")
fin: datetime = scu.is_iso_formated(fin, True)
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if (deb, fin) != (None, None):
justificatifs_query: Query = scass.filter_by_date(
justificatifs_query, Justificatif, deb, fin
)
# cas 4 : user_id
user_id = requested.args.get("user_id", False)
if user_id is not False:
justificatifs_query: Query = scass.filter_by_user_id(
@ -854,49 +688,12 @@ def _filter_manager(requested, justificatifs_query: Query):
# cas 5 : formsemestre_id
formsemestre_id = requested.args.get("formsemestre_id")
if formsemestre_id not in [None, "", -1]:
if formsemestre_id is not None:
formsemestre: FormSemestre = None
try:
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
justificatifs_query = scass.filter_by_formsemestre(
justificatifs_query, Justificatif, formsemestre
)
except ValueError:
formsemestre = None
# cas 6 : order (retourne les justificatifs par ordre décroissant de date_debut)
order = requested.args.get("order", None)
if order is not None:
justificatifs_query: Query = justificatifs_query.order_by(
Justificatif.date_debut.desc()
)
# cas 7 : courant (retourne uniquement les justificatifs de l'année scolaire courante)
courant = requested.args.get("courant", None)
if courant is not None:
annee: int = scu.annee_scolaire()
justificatifs_query: Query = justificatifs_query.filter(
Justificatif.date_debut >= scu.date_debut_annee_scolaire(annee),
Justificatif.date_fin <= scu.date_fin_annee_scolaire(annee),
)
# cas 8 : group_id filtre les justificatifs d'un groupe d'étudiant
group_id = requested.args.get("group_id", None)
if group_id is not None:
try:
group_id = int(group_id)
etudids: list[int] = [etu["etudid"] for etu in get_group_members(group_id)]
justificatifs_query = justificatifs_query.filter(
Justificatif.etudid.in_(etudids)
)
except ValueError:
group_id = None
# Département
if g.scodoc_dept:
justificatifs_query = justificatifs_query.join(Identite).filter_by(
dept_id=g.scodoc_dept_id
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
justificatifs_query = scass.filter_by_formsemestre(
justificatifs_query, Justificatif, formsemestre
)
return justificatifs_query

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -8,14 +8,16 @@
ScoDoc 9 API : accès aux moduleimpl
"""
from flask import g
from flask_json import as_json
from flask_login import login_required
import app
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
from app.models import ModuleImpl
from app.scodoc import sco_liste_notes
from app.models import (
FormSemestre,
ModuleImpl,
)
from app.scodoc.sco_permissions import Permission
@ -60,58 +62,8 @@ def moduleimpl(moduleimpl_id: int):
}
}
"""
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)
@bp.route("/moduleimpl/<int:moduleimpl_id>/inscriptions")
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>/inscriptions")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def moduleimpl_inscriptions(moduleimpl_id: int):
"""Liste des inscriptions à ce moduleimpl
Exemple de résultat :
[
{
"id": 1,
"etudid": 666,
"moduleimpl_id": 1234,
},
...
]
"""
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
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 :
[
{
"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,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -176,7 +176,7 @@ def etud_in_group_query(group_id: int):
@api_web_bp.route("/group/<int:group_id>/set_etudiant/<int:etudid>", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def set_etud_group(etudid: int, group_id: int):
"""Affecte l'étudiant au groupe indiqué"""
@ -189,8 +189,6 @@ def set_etud_group(etudid: int, group_id: int):
group = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not group.partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
if etud.id not in {e.id for e in group.partition.formsemestre.etuds}:
return json_error(404, "etud non inscrit au formsemestre du groupe")
@ -209,7 +207,7 @@ def set_etud_group(etudid: int, group_id: int):
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def group_remove_etud(group_id: int, etudid: int):
"""Retire l'étudiant de ce groupe. S'il n'y est pas, ne fait rien."""
@ -222,8 +220,6 @@ def group_remove_etud(group_id: int, etudid: int):
group = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not group.partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
group.remove_etud(etud)
@ -238,7 +234,7 @@ def group_remove_etud(group_id: int, etudid: int):
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def partition_remove_etud(partition_id: int, etudid: int):
"""Enlève l'étudiant de tous les groupes de cette partition
@ -251,8 +247,7 @@ def partition_remove_etud(partition_id: int, etudid: int):
partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
db.session.execute(
sa.text(
"""DELETE FROM group_membership
@ -283,7 +278,7 @@ def partition_remove_etud(partition_id: int, etudid: int):
@api_web_bp.route("/partition/<int:partition_id>/group/create", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def group_create(partition_id: int): # partition-group-create
"""Création d'un groupe dans une partition
@ -301,28 +296,15 @@ def group_create(partition_id: int): # partition-group-create
return json_error(403, "formsemestre verrouillé")
if not partition.groups_editable:
return json_error(403, "partition non editable")
if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
args = request.get_json(force=True) # may raise 400 Bad Request
group_name = args.get("group_name")
if not isinstance(group_name, str):
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is None:
return json_error(API_CLIENT_ERROR, "missing group name or invalid data format")
args["group_name"] = args["group_name"].strip()
if not GroupDescr.check_name(partition, args["group_name"]):
if not GroupDescr.check_name(partition, group_name):
return json_error(API_CLIENT_ERROR, "invalid group_name")
group_name = group_name.strip()
# le numero est optionnel
numero = args.get("numero")
if numero is None:
numeros = [gr.numero or 0 for gr in partition.groups]
numero = (max(numeros) + 1) if numeros else 0
args["numero"] = numero
args["partition_id"] = partition_id
try:
group = GroupDescr(**args)
except TypeError:
return json_error(API_CLIENT_ERROR, "invalid arguments")
group = GroupDescr(group_name=group_name, partition_id=partition_id)
db.session.add(group)
db.session.commit()
log(f"created group {group}")
@ -335,7 +317,7 @@ def group_create(partition_id: int): # partition-group-create
@api_web_bp.route("/group/<int:group_id>/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def group_delete(group_id: int):
"""Suppression d'un groupe"""
@ -349,8 +331,6 @@ def group_delete(group_id: int):
return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable:
return json_error(403, "partition non editable")
if not group.partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
formsemestre_id = group.partition.formsemestre_id
log(f"deleting {group}")
db.session.delete(group)
@ -364,7 +344,7 @@ def group_delete(group_id: int):
@api_web_bp.route("/group/<int:group_id>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def group_edit(group_id: int):
"""Edit a group"""
@ -378,62 +358,28 @@ def group_edit(group_id: int):
return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable:
return json_error(403, "partition non editable")
if not group.partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
args = request.get_json(force=True) # may raise 400 Bad Request
if "group_name" in args:
if not isinstance(args["group_name"], str):
return json_error(API_CLIENT_ERROR, "invalid data format for group_name")
args["group_name"] = args["group_name"].strip() if args["group_name"] else ""
if not GroupDescr.check_name(
group.partition, args["group_name"], existing=True
):
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is not None:
group_name = group_name.strip()
if not GroupDescr.check_name(group.partition, group_name, existing=True):
return json_error(API_CLIENT_ERROR, "invalid group_name")
group.from_dict(args)
db.session.add(group)
db.session.commit()
log(f"modified {group}")
group.group_name = group_name
db.session.add(group)
db.session.commit()
log(f"modified {group}")
app.set_sco_dept(group.partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return group.to_dict(with_partition=True)
@bp.route("/group/<int:group_id>/set_edt_id/<string:edt_id>", methods=["POST"])
@api_web_bp.route("/group/<int:group_id>/set_edt_id/<string:edt_id>", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def group_set_edt_id(group_id: int, edt_id: str):
"""Set edt_id for this group.
Contrairement à /edit, peut-être changé pour toute partition
ou formsemestre non verrouillé.
"""
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
query = (
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group: GroupDescr = query.first_or_404()
if not group.partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
log(f"group_set_edt_id( {group_id}, '{edt_id}' )")
group.edt_id = edt_id
db.session.add(group)
db.session.commit()
return group.to_dict(with_partition=True)
@bp.route("/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"])
@api_web_bp.route(
"/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"]
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def partition_create(formsemestre_id: int):
"""Création d'une partition dans un semestre
@ -453,8 +399,6 @@ def partition_create(formsemestre_id: int):
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
if not formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
data = request.get_json(force=True) # may raise 400 Bad Request
partition_name = data.get("partition_name")
if partition_name is None:
@ -498,7 +442,7 @@ def partition_create(formsemestre_id: int):
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def formsemestre_order_partitions(formsemestre_id: int):
"""Modifie l'ordre des partitions du formsemestre
@ -510,8 +454,6 @@ def formsemestre_order_partitions(formsemestre_id: int):
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
if not formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
partition_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(partition_ids, int) and not all(
isinstance(x, int) for x in partition_ids
@ -527,7 +469,6 @@ def formsemestre_order_partitions(formsemestre_id: int):
db.session.commit()
app.set_sco_dept(formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(formsemestre_id)
log(f"formsemestre_order_partitions({partition_ids})")
return [
partition.to_dict()
for partition in formsemestre.partitions.order_by(Partition.numero)
@ -539,7 +480,7 @@ def formsemestre_order_partitions(formsemestre_id: int):
@api_web_bp.route("/partition/<int:partition_id>/groups/order", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def partition_order_groups(partition_id: int):
"""Modifie l'ordre des groupes de la partition
@ -551,8 +492,6 @@ def partition_order_groups(partition_id: int):
partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
group_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(group_ids, int) and not all(
isinstance(x, int) for x in group_ids
@ -576,7 +515,7 @@ def partition_order_groups(partition_id: int):
@api_web_bp.route("/partition/<int:partition_id>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def partition_edit(partition_id: int):
"""Modification d'une partition dans un semestre
@ -597,8 +536,6 @@ def partition_edit(partition_id: int):
partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
data = request.get_json(force=True) # may raise 400 Bad Request
modified = False
partition_name = data.get("partition_name")
@ -648,7 +585,7 @@ def partition_edit(partition_id: int):
@api_web_bp.route("/partition/<int:partition_id>/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def partition_delete(partition_id: int):
"""Suppression d'une partition (et de tous ses groupes).
@ -664,8 +601,6 @@ def partition_delete(partition_id: int):
partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
if not partition.partition_name:
return json_error(
API_CLIENT_ERROR, "ne peut pas supprimer la partition par défaut"

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -23,7 +23,7 @@
# @api_web_bp.route("/semset/set_periode/<int:semset_id>", methods=["POST"])
# @login_required
# @scodoc
# @permission_required(Permission.EditApogee)
# @permission_required(Permission.ScoEditApo)
# # TODO à modifier pour utiliser @as_json
# def semset_set_periode(semset_id: int):
# "Change la période d'un semset"

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : outils
@ -44,8 +44,4 @@ def get_etud(etudid=None, nip=None, ine=None) -> models.Identite:
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
etud = query.join(Admission).order_by(desc(Admission.annee)).first()
# dans de rares cas (bricolages manuels, bugs), l'étudiant n'a pas de données d'admission
if etud is None:
etud = query.first()
return etud
return query.join(Admission).order_by(desc(Admission.annee)).first()

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -8,20 +8,20 @@
ScoDoc 9 API : accès aux utilisateurs
"""
from flask import g, request
from flask_json import as_json
from flask_login import current_user, login_required
from app import db, log
from app import db
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_error
from app.auth.models import User, Role, UserRole
from app.auth.models import is_valid_password
from app.decorators import scodoc, permission_required
from app.models import Departement, ScoDocSiteConfig
from app.scodoc import sco_edt_cal
from app.models import Departement
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
from app.scodoc import sco_utils as scu
@ -29,7 +29,7 @@ from app.scodoc import sco_utils as scu
@api_web_bp.route("/user/<int:uid>")
@login_required
@scodoc
@permission_required(Permission.UsersView)
@permission_required(Permission.ScoUsersView)
@as_json
def user_info(uid: int):
"""
@ -39,7 +39,7 @@ def user_info(uid: int):
if user is None:
return json_error(404, "user not found")
if g.scodoc_dept:
allowed_depts = current_user.get_depts_with_permission(Permission.UsersView)
allowed_depts = current_user.get_depts_with_permission(Permission.ScoUsersView)
if (None not in allowed_depts) and (user.dept not in allowed_depts):
return json_error(404, "user not found")
@ -78,68 +78,51 @@ def users_info_query():
query.join(UserRole, (UserRole.dept == User.dept) | (UserRole.dept == None))
.filter(UserRole.user == current_user)
.join(Role, UserRole.role_id == Role.id)
.filter(Role.permissions.op("&")(Permission.UsersView) != 0)
.filter(Role.permissions.op("&")(Permission.ScoUsersView) != 0)
)
query = query.order_by(User.user_name)
return [user.to_dict() for user in query]
def _is_allowed_user_edit(args: dict) -> tuple[bool, str]:
"Vrai si on peut"
if "cas_id" in args and not current_user.has_permission(
Permission.UsersChangeCASId
):
return False, "non autorise a changer cas_id"
if not current_user.is_administrator():
for field in ("cas_allow_login", "cas_allow_scodoc_login"):
if field in args:
return False, f"non autorise a changer {field}"
return True, ""
@bp.route("/user/create", methods=["POST"])
@api_web_bp.route("/user/create", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.UsersAdmin)
@permission_required(Permission.ScoUsersAdmin)
@as_json
def user_create():
"""Création d'un utilisateur
The request content type should be "application/json":
{
"active":bool (default True),
"user_name": str,
"dept": str or null,
"nom": str,
"prenom": str,
"user_name": str,
...
"active":bool (default True)
}
"""
args = request.get_json(force=True) # may raise 400 Bad Request
user_name = args.get("user_name")
data = request.get_json(force=True) # may raise 400 Bad Request
user_name = data.get("user_name")
if not user_name:
return json_error(404, "empty user_name")
user = User.query.filter_by(user_name=user_name).first()
if user:
return json_error(404, f"user_create: user {user} already exists\n")
dept = args.get("dept")
dept = data.get("dept")
if dept == "@all":
dept = None
allowed_depts = current_user.get_depts_with_permission(Permission.UsersAdmin)
allowed_depts = current_user.get_depts_with_permission(Permission.ScoUsersAdmin)
if (None not in allowed_depts) and (dept not in allowed_depts):
return json_error(403, "user_create: departement non autorise")
if (dept is not None) and (
Departement.query.filter_by(acronym=dept).first() is None
):
return json_error(404, "user_create: departement inexistant")
args["dept"] = dept
ok, msg = _is_allowed_user_edit(args)
if not ok:
return json_error(403, f"user_create: {msg}")
user = User(user_name=user_name)
user.from_dict(args, new_user=True)
nom = data.get("nom")
prenom = data.get("prenom")
active = scu.to_bool(data.get("active", True))
user = User(user_name=user_name, active=active, dept=dept, nom=nom, prenom=prenom)
db.session.add(user)
db.session.commit()
return user.to_dict()
@ -149,7 +132,7 @@ def user_create():
@api_web_bp.route("/user/<int:uid>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.UsersAdmin)
@permission_required(Permission.ScoUsersAdmin)
@as_json
def user_edit(uid: int):
"""Modification d'un utilisateur
@ -159,18 +142,17 @@ def user_edit(uid: int):
"nom": str,
"prenom": str,
"active":bool
...
}
"""
args = request.get_json(force=True) # may raise 400 Bad Request
data = request.get_json(force=True) # may raise 400 Bad Request
user: User = User.query.get_or_404(uid)
# L'utilisateur doit avoir le droit dans le département de départ et celui d'arrivée
orig_dept = user.dept
dest_dept = args.get("dept", False)
dest_dept = data.get("dept", False)
if dest_dept is not False:
if dest_dept == "@all":
dest_dept = None
allowed_depts = current_user.get_depts_with_permission(Permission.UsersAdmin)
allowed_depts = current_user.get_depts_with_permission(Permission.ScoUsersAdmin)
if (None not in allowed_depts) and (
(orig_dept not in allowed_depts) or (dest_dept not in allowed_depts)
):
@ -182,11 +164,10 @@ def user_edit(uid: int):
return json_error(404, "user_edit: departement inexistant")
user.dept = dest_dept
ok, msg = _is_allowed_user_edit(args)
if not ok:
return json_error(403, f"user_edit: {msg}")
user.nom = data.get("nom", user.nom)
user.prenom = data.get("prenom", user.prenom)
user.active = scu.to_bool(data.get("active", user.active))
user.from_dict(args)
db.session.add(user)
db.session.commit()
return user.to_dict()
@ -196,7 +177,7 @@ def user_edit(uid: int):
@api_web_bp.route("/user/<int:uid>/password", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.UsersAdmin)
@permission_required(Permission.ScoUsersAdmin)
@as_json
def user_password(uid: int):
"""Modification du mot de passe d'un utilisateur
@ -213,7 +194,7 @@ def user_password(uid: int):
return json_error(404, "user_password: missing password")
if not is_valid_password(password):
return json_error(API_CLIENT_ERROR, "user_password: invalid password")
allowed_depts = current_user.get_depts_with_permission(Permission.UsersAdmin)
allowed_depts = current_user.get_depts_with_permission(Permission.ScoUsersAdmin)
if (None not in allowed_depts) and ((user.dept not in allowed_depts)):
return json_error(403, "user_password: departement non autorise")
user.set_password(password)
@ -237,7 +218,7 @@ def user_password(uid: int):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def user_role_add(uid: int, role_name: str, dept: str = None):
"""Add a role in the given dept to the user"""
"""Add a role to the user"""
user: User = User.query.get_or_404(uid)
role: Role = Role.query.filter_by(name=role_name).first_or_404()
if dept is not None: # check
@ -266,7 +247,7 @@ def user_role_add(uid: int, role_name: str, dept: str = None):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def user_role_remove(uid: int, role_name: str, dept: str = None):
"""Remove the role (in the given dept) from the user"""
"""Remove the role from the user"""
user: User = User.query.get_or_404(uid)
role: Role = Role.query.filter_by(name=role_name).first_or_404()
if dept is not None: # check
@ -290,7 +271,7 @@ def user_role_remove(uid: int, role_name: str, dept: str = None):
@api_web_bp.route("/permissions")
@login_required
@scodoc
@permission_required(Permission.UsersView)
@permission_required(Permission.ScoUsersView)
@as_json
def list_permissions():
"""Liste des noms de permissions définies"""
@ -301,7 +282,7 @@ def list_permissions():
@api_web_bp.route("/role/<string:role_name>")
@login_required
@scodoc
@permission_required(Permission.UsersView)
@permission_required(Permission.ScoUsersView)
@as_json
def list_role(role_name: str):
"""Un rôle"""
@ -312,7 +293,7 @@ def list_role(role_name: str):
@api_web_bp.route("/roles")
@login_required
@scodoc
@permission_required(Permission.UsersView)
@permission_required(Permission.ScoUsersView)
@as_json
def list_roles():
"""Tous les rôles définis"""
@ -340,7 +321,6 @@ def role_permission_add(role_name: str, perm_name: str):
role.add_permission(permission)
db.session.add(role)
db.session.commit()
log(f"role_permission_add({role_name}, {perm_name})")
return role.to_dict()
@ -365,7 +345,6 @@ def role_permission_remove(role_name: str, perm_name: str):
role.remove_permission(permission)
db.session.add(role)
db.session.commit()
log(f"role_permission_remove({role_name}, {perm_name})")
return role.to_dict()
@ -441,63 +420,3 @@ def role_delete(role_name: str):
db.session.delete(role)
db.session.commit()
return {"OK": True}
# @bp.route("/user/<int:uid>/edt")
# @api_web_bp.route("/user/<int:uid>/edt")
# @login_required
# @scodoc
# @permission_required(Permission.ScoView)
# @as_json
# def user_edt(uid: int):
# """L'emploi du temps de l'utilisateur.
# Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur.
# show_modules_titles affiche le titre complet du module (défaut), sinon juste le code.
# Il faut la permission ScoView + (UsersView ou bien être connecté comme l'utilisateur demandé)
# """
# if g.scodoc_dept is None: # route API non départementale
# if not current_user.has_permission(Permission.UsersView):
# return scu.json_error(403, "accès non autorisé")
# user: User = db.session.get(User, uid)
# if user is None:
# return json_error(404, "user not found")
# # Check permission
# if current_user.id != user.id:
# if g.scodoc_dept:
# allowed_depts = current_user.get_depts_with_permission(Permission.UsersView)
# if (None not in allowed_depts) and (user.dept not in allowed_depts):
# return json_error(404, "user not found")
# show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False))
# # Cherche ics
# if not user.edt_id:
# return json_error(404, "user not configured")
# ics_filename = sco_edt_cal.get_ics_user_edt_filename(user.edt_id)
# if not ics_filename:
# return json_error(404, "no calendar for this user")
# _, calendar = sco_edt_cal.load_calendar(ics_filename)
# # TODO:
# # - Construire mapping edt2modimpl: edt_id -> modimpl
# # pour cela, considérer tous les formsemestres de la période de l'edt
# # (soit on considère l'année scolaire du 1er event, ou celle courante,
# # soit on cherche min, max des dates des events)
# # - Modifier décodage des groupes dans convert_ics pour avoi run mapping
# # de groupe par semestre (retrouvé grâce au modimpl associé à l'event)
# raise NotImplementedError() # TODO XXX WIP
# events_scodoc, _ = sco_edt_cal.convert_ics(
# calendar,
# edt2group=edt2group,
# default_group=default_group,
# edt2modimpl=edt2modimpl,
# )
# edt_dict = sco_edt_cal.translate_calendar(
# events_scodoc, group_ids, show_modules_titles=show_modules_titles
# )
# return edt_dict

View File

@ -39,15 +39,6 @@ def after_cas_login():
"scodoc_cas_login_date"
] = datetime.datetime.now().isoformat()
user.cas_last_login = datetime.datetime.utcnow()
if flask.session.get("CAS_EDT_ID"):
# essaie de récupérer l'edt_id s'il est présent
# cet ID peut être renvoyé par le CAS et extrait par ScoDoc
# via l'expression `cas_edt_id_from_xml_regexp`
# voir flask_cas.routing
edt_id = flask.session.get("CAS_EDT_ID")
current_app.logger.info(f"""after_cas_login: storing edt_id for {
user.user_name}: '{edt_id}'""")
user.edt_id = edt_id
db.session.add(user)
db.session.commit()
return flask.redirect(url_for("scodoc.index"))

View File

@ -20,13 +20,14 @@ from werkzeug.security import generate_password_hash, check_password_hash
import jwt
from app import db, email, log, login
from app.models import Departement, ScoDocModel
from app.models import Departement
from app.models import SHORT_STR_LEN, USERNAME_STR_LEN
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
import app.scodoc.sco_utils as scu
from app.scodoc import sco_etud # a deplacer dans scu
VALID_LOGIN_EXP = re.compile(r"^[a-zA-Z0-9@\\\-_\.]+$")
@ -51,14 +52,13 @@ def is_valid_password(cleartxt) -> bool:
def invalid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is invalid"
return (
not user_name
or (len(user_name) < 2)
(len(user_name) < 2)
or (len(user_name) >= USERNAME_STR_LEN)
or not VALID_LOGIN_EXP.match(user_name)
)
class User(UserMixin, ScoDocModel):
class User(UserMixin, db.Model):
"""ScoDoc users, handled by Flask / SQLAlchemy"""
id = db.Column(db.Integer, primary_key=True)
@ -88,9 +88,8 @@ class User(UserMixin, ScoDocModel):
"""
cas_last_login = db.Column(db.DateTime, nullable=True)
"""date du dernier login via CAS"""
edt_id = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
password_hash = db.Column(db.Text()) # les hashs modernes peuvent être très longs
password_hash = db.Column(db.String(128))
password_scodoc7 = db.Column(db.String(42))
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
date_modif_passwd = db.Column(db.DateTime, default=datetime.utcnow)
@ -102,8 +101,6 @@ class User(UserMixin, ScoDocModel):
token = db.Column(db.Text(), index=True, unique=True)
token_expiration = db.Column(db.DateTime)
# Define the back reference from User to ModuleImpl
modimpls = db.relationship("ModuleImpl", back_populates="responsable")
roles = db.relationship("Role", secondary="user_role", viewonly=True)
Permission = Permission
@ -117,17 +114,12 @@ class User(UserMixin, ScoDocModel):
)
def __init__(self, **kwargs):
"user_name:str is mandatory"
self.roles = []
self.user_roles = []
# check login:
if not "user_name" in kwargs:
raise ValueError("missing user_name argument")
if invalid_user_name(kwargs["user_name"]):
if kwargs.get("user_name") and invalid_user_name(kwargs["user_name"]):
raise ValueError(f"invalid user_name: {kwargs['user_name']}")
kwargs["nom"] = kwargs.get("nom", "") or ""
kwargs["prenom"] = kwargs.get("prenom", "") or ""
super().__init__(**kwargs)
super(User, self).__init__(**kwargs)
# Ajoute roles:
if (
not self.roles
@ -180,8 +172,7 @@ class User(UserMixin, ScoDocModel):
return False
# if CAS activated and forced, allow only super-user and users with cas_allow_scodoc_login
cas_enabled = ScoDocSiteConfig.is_cas_enabled()
if cas_enabled and ScoDocSiteConfig.get("cas_force"):
if ScoDocSiteConfig.is_cas_enabled() and ScoDocSiteConfig.get("cas_force"):
if (not self.is_administrator()) and not self.cas_allow_scodoc_login:
return False
@ -216,7 +207,7 @@ class User(UserMixin, ScoDocModel):
@staticmethod
def verify_reset_password_token(token):
"Vérification du token de ré-initialisation du mot de passe"
"Vérification du token de reéinitialisation du mot de passe"
try:
token = jwt.decode(
token, current_app.config["SECRET_KEY"], algorithms=["HS256"]
@ -236,44 +227,33 @@ class User(UserMixin, ScoDocModel):
return None
return db.session.get(User, user_id)
def sort_key(self) -> tuple:
"sort key"
return (
(self.nom or "").upper(),
(self.prenom or "").upper(),
(self.user_name or "").upper(),
)
def to_dict(self, include_email=True):
"""l'utilisateur comme un dict, avec des champs supplémentaires"""
data = {
"date_expiration": (
self.date_expiration.isoformat() + "Z" if self.date_expiration else None
),
"date_modif_passwd": (
self.date_modif_passwd.isoformat() + "Z"
if self.date_modif_passwd
else None
),
"date_created": (
self.date_created.isoformat() + "Z" if self.date_created else None
),
"date_expiration": self.date_expiration.isoformat() + "Z"
if self.date_expiration
else None,
"date_modif_passwd": self.date_modif_passwd.isoformat() + "Z"
if self.date_modif_passwd
else None,
"date_created": self.date_created.isoformat() + "Z"
if self.date_created
else None,
"dept": self.dept,
"id": self.id,
"active": self.active,
"cas_id": self.cas_id,
"cas_allow_login": self.cas_allow_login,
"cas_allow_scodoc_login": self.cas_allow_scodoc_login,
"cas_last_login": (
self.cas_last_login.isoformat() + "Z" if self.cas_last_login else None
),
"edt_id": self.edt_id,
"cas_last_login": self.cas_last_login.isoformat() + "Z"
if self.cas_last_login
else None,
"status_txt": "actif" if self.active else "fermé",
"last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None,
"nom": self.nom or "",
"prenom": self.prenom or "",
"nom": (self.nom or ""), # sco8
"prenom": (self.prenom or ""), # sco8
"roles_string": self.get_roles_string(), # eg "Ens_RT, Ens_Info"
"user_name": self.user_name,
"user_name": self.user_name, # sco8
# Les champs calculés:
"nom_fmt": self.get_nom_fmt(),
"prenom_fmt": self.get_prenom_fmt(),
@ -287,54 +267,37 @@ class User(UserMixin, ScoDocModel):
data["email_institutionnel"] = self.email_institutionnel or ""
return data
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect.
args: dict with args in application.
returns: dict to store in model's db.
Convert boolean values to bools.
"""
args_dict = args
# Dates
if "date_expiration" in args:
date_expiration = args.get("date_expiration")
if isinstance(date_expiration, str):
args["date_expiration"] = (
datetime.datetime.fromisoformat(date_expiration)
if date_expiration
else None
)
# booléens:
for field in ("active", "cas_allow_login", "cas_allow_scodoc_login"):
if field in args:
args_dict[field] = scu.to_bool(args.get(field))
# chaines ne devant pas être NULLs
for field in ("nom", "prenom"):
if field in args:
args[field] = args[field] or ""
# chaines ne devant pas être vides mais au contraire null (unicité)
if "cas_id" in args:
args["cas_id"] = args["cas_id"] or None
return args_dict
def from_dict(self, data: dict, new_user=False):
"""Set users' attributes from given dict values.
- roles_string : roles, encoded like "Ens_RT, Secr_CJ"
- date_expiration is a dateime object.
Does not check permissions here.
Roles must be encoded as "roles_string", like "Ens_RT, Secr_CJ"
"""
for field in [
"nom",
"prenom",
"dept",
"active",
"email",
"email_institutionnel",
"date_expiration",
"cas_id",
]:
if field in data:
setattr(self, field, data[field] or None)
# required boolean fields
for field in [
"cas_allow_login",
"cas_allow_scodoc_login",
]:
setattr(self, field, scu.to_bool(data.get(field, False)))
if new_user:
if "user_name" in data:
# never change name of existing users
if invalid_user_name(data["user_name"]):
raise ValueError(f"invalid user_name: {data['user_name']}")
self.user_name = data["user_name"]
if "password" in data:
self.set_password(data["password"])
if invalid_user_name(self.user_name):
raise ValueError(f"invalid user_name: {self.user_name}")
# Roles: roles_string is "Ens_RT, Secr_RT, ..."
if "roles_string" in data:
self.user_roles = []
@ -343,15 +306,6 @@ class User(UserMixin, ScoDocModel):
role, dept = UserRole.role_dept_from_string(r_d)
self.add_role(role, dept)
super().from_dict(data, excluded={"user_name", "roles_string", "roles"})
# Set cas_id using regexp if configured:
exp = ScoDocSiteConfig.get("cas_uid_from_mail_regexp")
if exp and self.email_institutionnel:
cas_id = ScoDocSiteConfig.extract_cas_id(self.email_institutionnel)
if cas_id:
self.cas_id = cas_id
def get_token(self, expires_in=3600):
"Un jeton pour cet user. Stocké en base, non commité."
now = datetime.utcnow()
@ -392,8 +346,8 @@ class User(UserMixin, ScoDocModel):
return mails
# Permissions management:
def has_permission(self, perm: int, dept: str = False):
"""Check if user has permission `perm` in given `dept` (acronym).
def has_permission(self, perm: int, dept=False):
"""Check if user has permission `perm` in given `dept`.
Similar to Zope ScoDoc7 `has_permission``
Args:
@ -477,12 +431,12 @@ class User(UserMixin, ScoDocModel):
"""nomplogin est le nom en majuscules suivi du prénom et du login
e.g. Dupont Pierre (dupont)
"""
nom = scu.format_nom(self.nom) if self.nom else self.user_name.upper()
return f"{nom} {scu.format_prenom(self.prenom)} ({self.user_name})"
nom = sco_etud.format_nom(self.nom) if self.nom else self.user_name.upper()
return f"{nom} {sco_etud.format_prenom(self.prenom)} ({self.user_name})"
@staticmethod
def get_user_from_nomplogin(nomplogin: str) -> Optional["User"]:
"""Returns User instance from the string "Dupont Pierre (dupont)"
def get_user_id_from_nomplogin(nomplogin: str) -> Optional[int]:
"""Returns id from the string "Dupont Pierre (dupont)"
or None if user does not exist
"""
match = re.match(r".*\((.*)\)", nomplogin.strip())
@ -490,35 +444,35 @@ class User(UserMixin, ScoDocModel):
user_name = match.group(1)
u = User.query.filter_by(user_name=user_name).first()
if u:
return u
return u.id
return None
def get_nom_fmt(self):
"""Nom formaté: "Martin" """
if self.nom:
return scu.format_nom(self.nom, uppercase=False)
return sco_etud.format_nom(self.nom, uppercase=False)
else:
return self.user_name
def get_prenom_fmt(self):
"""Prénom formaté (minuscule capitalisées)"""
return scu.format_prenom(self.prenom)
return sco_etud.format_prenom(self.prenom)
def get_nomprenom(self):
"""Nom capitalisé suivi de l'initiale du prénom:
Viennet E.
"""
prenom_abbrv = scu.abbrev_prenom(scu.format_prenom(self.prenom))
prenom_abbrv = scu.abbrev_prenom(sco_etud.format_prenom(self.prenom))
return (self.get_nom_fmt() + " " + prenom_abbrv).strip()
def get_prenomnom(self):
"""L'initiale du prénom suivie du nom: "J.-C. Dupont" """
prenom_abbrv = scu.abbrev_prenom(scu.format_prenom(self.prenom))
prenom_abbrv = scu.abbrev_prenom(sco_etud.format_prenom(self.prenom))
return (prenom_abbrv + " " + self.get_nom_fmt()).strip()
def get_nomcomplet(self):
"Prénom et nom complets"
return scu.format_prenom(self.prenom) + " " + self.get_nom_fmt()
return sco_etud.format_prenom(self.prenom) + " " + self.get_nom_fmt()
# nomnoacc était le nom en minuscules sans accents (inutile)
@ -580,10 +534,6 @@ class Role(db.Model):
"Remove all permissions from role"
self.permissions = 0
def get_named_permissions(self) -> list[str]:
"List of the names of the permissions associated to this rôle"
return Permission.permissions_names(self.permissions)
def set_named_permissions(self, permission_names: list[str]):
"""Set permissions, given as a list of permissions names.
Raises ScoValueError if invalid permission."""
@ -603,19 +553,8 @@ class Role(db.Model):
"""Create default roles if missing, then, if reset_permissions,
reset their permissions to default values.
"""
Role.reset_roles_permissions(
SCO_ROLES_DEFAULTS, reset_permissions=reset_permissions
)
@staticmethod
def reset_roles_permissions(roles_perms: dict[str, tuple], reset_permissions=True):
"""Ajoute les permissions aux roles
roles_perms : { "role_name" : (permission, ...) }
reset_permissions : si vrai efface permissions déja existantes
Si le role n'existe pas, il est (re) créé.
"""
default_role = "Observateur"
for role_name, permissions in roles_perms.items():
for role_name, permissions in SCO_ROLES_DEFAULTS.items():
role = Role.query.filter_by(name=role_name).first()
if role is None:
role = Role(name=role_name)

View File

@ -21,9 +21,7 @@ from app.auth.forms import (
from app.auth.models import Role, User, invalid_user_name
from app.auth.email import send_password_reset_email
from app.decorators import admin_required
from app.forms.generic import SimpleConfirmationForm
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
from app.scodoc import sco_utils as scu
_ = lambda x: x # sans babel
@ -54,7 +52,6 @@ def _login_form():
title=_("Sign In"),
form=form,
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
is_cas_forced=ScoDocSiteConfig.is_cas_forced(),
)
@ -161,25 +158,9 @@ def reset_password(token):
@admin_required
def reset_standard_roles_permissions():
"Réinitialise (recrée au besoin) les rôles standards de ScoDoc et leurs permissions"
form = SimpleConfirmationForm()
if request.method == "POST" and form.cancel.data:
return redirect(url_for("scodoc.configuration"))
if form.validate_on_submit():
Role.reset_standard_roles_permissions()
flash("rôles standards réinitialisés !")
return redirect(url_for("scodoc.configuration"))
return render_template(
"form_confirmation.j2",
title="Réinitialiser les roles standards de ScoDoc ?",
form=form,
info_message=f"""<p>Les rôles standards seront recréés et leurs permissions
réinitialisées aux valeurs par défaut de ScoDoc.
</p>
<p>
Les rôles standards sont: <tt>{', '.join(SCO_ROLES_DEFAULTS.keys())}</tt>
</p>
""",
)
Role.reset_standard_roles_permissions()
flash("rôles standards réinitialisés !")
return redirect(url_for("scodoc.configuration"))
@bp.route("/cas_users_generate_excel_sample")
@ -209,3 +190,5 @@ def cas_users_import_config():
title=_("Importation configuration CAS utilisateurs"),
form=form,
)
return

View File

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

View File

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

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -18,7 +18,7 @@ Ces données sont des objets passés au template.
- `bul: dict` : le bulletin (dict, même structure que le json publié)
- `cursus: EtudCursusBUT`: infos sur le cursus BUT (niveaux validés etc)
- `decision_ues: dict`: `{ acronyme_ue : { 'code' : 'ADM' }}` accès aux décisions
de jury d'UE
de jury d'UE
- `ects_total` : nombre d'ECTS validées dans ce cursus
- `ue_validation_by_niveau : dict` : les validations d'UE de chaque niveau du cursus
"""
@ -36,7 +36,7 @@ from app.decorators import (
)
from app.models import FormSemestre, FormSemestreInscription, Identite
from app.scodoc.codes_cursus import UE_STANDARD
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
from app.scodoc.sco_logos import find_logo
from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu
@ -63,70 +63,16 @@ def bulletin_but(formsemestre_id: int, etudid: int = None, fmt="html"):
.filter_by(etudid=etudid)
.first_or_404()
)
if not formsemestre.formation.is_apc():
raise ScoValueError("formation non BUT")
args = _build_bulletin_but_infos(etud, formsemestre, fmt=fmt)
if fmt == "pdf":
filename = scu.bul_filename(formsemestre, etud, prefix="bul-but")
bul_pdf = bulletin_but_court_pdf.make_bulletin_but_court_pdf(args)
return scu.sendPDFFile(bul_pdf, filename + ".pdf")
return render_template(
"but/bulletin_court_page.j2",
datetime=datetime,
sco=ScoData(formsemestre=formsemestre, etud=etud),
time=time,
version="butcourt",
**args,
)
def bulletin_but_court_pdf_frag(
etud: Identite, formsemestre: FormSemestre, stand_alone=False
) -> bytes:
"""Le code PDF d'un bulletin BUT court, à intégrer dans un document
(pour les classeurs de tous les bulletins)
"""
args = _build_bulletin_but_infos(etud, formsemestre)
return bulletin_but_court_pdf.make_bulletin_but_court_pdf(
args, stand_alone=stand_alone
)
def _build_bulletin_but_infos(
etud: Identite, formsemestre: FormSemestre, fmt="pdf"
) -> dict:
"""Réuni toutes les information pour le contenu d'un bulletin BUT court.
On indique le format ("html" ou "pdf") car il y a moins d'infos en HTML.
"""
bulletins_sem = BulletinBUT(formsemestre)
if fmt == "pdf":
bul: dict = bulletins_sem.bulletin_etud_complet(etud)
filigranne = bul["filigranne"]
else: # la même chose avec un peu moins d'infos
bul: dict = bulletins_sem.bulletin_etud(etud, force_publishing=True)
filigranne = ""
decision_ues = (
{x["acronyme"]: x for x in bul["semestre"]["decision_ue"]}
if "semestre" in bul and "decision_ue" in bul["semestre"]
else {}
)
if "ues" not in bul:
raise ScoValueError("Aucune UE à afficher")
bul: dict = bulletins_sem.bulletin_etud(etud)
decision_ues = {x["acronyme"]: x for x in bul["semestre"]["decision_ue"]}
cursus = cursus_but.EtudCursusBUT(etud, formsemestre.formation)
refcomp = formsemestre.formation.referentiel_competence
if refcomp is None:
raise ScoNoReferentielCompetences(formation=formsemestre.formation)
warn_html = cursus_but.formsemestre_warning_apc_setup(
formsemestre, bulletins_sem.res
)
if warn_html:
raise ScoValueError(
"<b>Formation mal configurée pour le BUT</b>" + warn_html, safe=True
)
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
refcomp, etud
)
@ -134,23 +80,29 @@ def _build_bulletin_but_infos(
logo = find_logo(logoname="header", dept_id=g.scodoc_dept_id)
ue_acronyms = bul["ues"].keys()
args = {
"bul": bul,
"cursus": cursus,
"decision_ues": decision_ues,
"ects_total": ects_total,
"etud": etud,
"filigranne": filigranne,
"formsemestre": formsemestre,
"logo": logo,
"prefs": bulletins_sem.prefs,
"title": f"Bul. {etud.nom_disp()} BUT (court)",
"ue_validation_by_niveau": ue_validation_by_niveau,
"ues_acronyms": [
ue.acronyme
for ue in bulletins_sem.res.ues
if ue.type == UE_STANDARD and ue.acronyme in ue_acronyms
ue.acronyme for ue in bulletins_sem.res.ues if ue.type == UE_STANDARD
],
}
return args
if fmt == "pdf":
filename = scu.bul_filename(formsemestre, etud, prefix="bul-but")
bul_pdf = bulletin_but_court_pdf.make_bulletin_but_court_pdf(**args)
return scu.sendPDFFile(bul_pdf, filename)
return render_template(
"but/bulletin_court_page.j2",
datetime=datetime,
sco=ScoData(formsemestre=formsemestre, etud=etud),
time=time,
**args,
)

View File

@ -1,12 +1,12 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Génération bulletin BUT PDF synthétique en une page
On génère du PDF avec reportLab en utilisant les classes
On génère du PDF avec reportLab en utilisant les classes
ScoDoc BulletinGenerator et GenTable.
"""
@ -16,7 +16,7 @@ from flask_login import current_user
from reportlab.lib import styles
from reportlab.lib.colors import black, white, Color
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.units import mm
from reportlab.lib.units import cm, mm
from reportlab.platypus import Paragraph, Spacer, Table
from app.but import cursus_but
@ -30,38 +30,27 @@ from app.models import (
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc.sco_logos import Logo
from app.scodoc.sco_pdf import PDFLOCK, SU
from app.scodoc.sco_preferences import SemPreferences
from app.scodoc import sco_utils as scu
def make_bulletin_but_court_pdf(
args: dict,
stand_alone: bool = True,
bul: dict = None,
cursus: cursus_but.EtudCursusBUT = None,
decision_ues: dict = None,
ects_total: float = 0.0,
etud: Identite = None,
formsemestre: FormSemestre = None,
logo: Logo = None,
title: str = "",
ue_validation_by_niveau: dict[tuple[int, str], ScolarFormSemestreValidation] = None,
ues_acronyms: list[str] = None,
) -> bytes:
"""génère le bulletin court BUT en pdf.
Si stand_alone, génère un doc pdf complet (une page ici),
sinon un morceau (fragment) à intégrer dans un autre document.
args donne toutes les infos du contenu du bulletin:
bul: dict = None,
cursus: cursus_but.EtudCursusBUT = None,
decision_ues: dict = None,
ects_total: float = 0.0,
etud: Identite = None,
formsemestre: FormSemestre = None,
filigranne=""
logo: Logo = None,
prefs: SemPreferences = None,
title: str = "",
ue_validation_by_niveau: dict[tuple[int, str], ScolarFormSemestreValidation] = None,
ues_acronyms: list[str] = None,
"""
"génère le bulletin court BUT en pdf"
# A priori ce verrou n'est plus nécessaire avec Flask (multi-process)
# mais...
try:
PDFLOCK.acquire()
bul_generator = BulletinGeneratorBUTCourt(**args)
bul_pdf = bul_generator.generate(fmt="pdf", stand_alone=stand_alone)
bul_generator = BulletinGeneratorBUTCourt(**locals())
bul_pdf = bul_generator.generate(format="pdf")
finally:
PDFLOCK.release()
return bul_pdf
@ -88,17 +77,15 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
decision_ues: dict = None,
ects_total: float = 0.0,
etud: Identite = None,
filigranne="",
formsemestre: FormSemestre = None,
logo: Logo = None,
prefs: SemPreferences = None,
title: str = "",
ue_validation_by_niveau: dict[
tuple[int, str], ScolarFormSemestreValidation
] = None,
ues_acronyms: list[str] = None,
):
super().__init__(bul, authuser=current_user, filigranne=filigranne)
super().__init__(bul, authuser=current_user)
self.bul = bul
self.cursus = cursus
self.decision_ues = decision_ues
@ -106,7 +93,6 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
self.etud = etud
self.formsemestre = formsemestre
self.logo = logo
self.prefs = prefs
self.title = title
self.ue_validation_by_niveau = ue_validation_by_niveau
self.ues_acronyms = ues_acronyms # sans UEs sport
@ -116,15 +102,13 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
self.style_base = styles.ParagraphStyle("style_base")
self.style_base.fontName = "Helvetica"
self.style_base.fontSize = 9
self.style_base.firstLineIndent = 0
# écrase style defaut des bulletins
self.style_field = self.style_base
# Le nom/prénom de l'étudiant:
self.style_nom = styles.ParagraphStyle("style_nom", self.style_base)
self.style_nom.fontSize = 11
self.style_nom.fontName = "Helvetica-Bold"
self.style_field = self.style_base # écrase style defaut buleltins
self.style_cell = styles.ParagraphStyle("style_cell", self.style_base)
self.style_cell.fontSize = 7
self.style_cell.leading = 7
@ -140,7 +124,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
self.style_niveaux.firstLineIndent = 0
self.style_niveaux.leftIndent = 1
self.style_niveaux.rightIndent = 1
self.style_niveaux.borderWidth = 0.5
self.style_niveaux.borderWidth = 1
self.style_niveaux.borderPadding = 2
self.style_niveaux.borderRadius = 2
self.style_niveaux_top = styles.ParagraphStyle(
@ -178,13 +162,12 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
) # espace les lignes
self.style_assiduite = self.style_cell
self.style_signature = self.style_appreciations
# Géométrie page
self.width_page_avail = 185 * mm # largeur utilisable
# Géométrie tableaux
self.width_col_ue = 18 * mm
self.width_col_ue_titres = 16.5 * mm
self.width_col_ue_titres = 15 * mm
# Modules
self.width_col_code = self.width_col_ue
# Niveaux
@ -195,7 +178,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
"""Génère la partie "titre" du bulletin de notes.
Renvoie une liste d'objets platypus
"""
# comme les bulletins standards, mais avec notre préférence
# comme les bulletins standard, mais avec notre préférence
return super().bul_title_pdf(preference_field=preference_field)
def bul_part_below(self, fmt="pdf") -> list:
@ -206,16 +189,10 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
appreciations = BulAppreciations.get_appreciations_list(
self.formsemestre.id, self.etud.id
)
return (
[
Spacer(1, 3 * mm),
self.bul_appreciations_pdf(
appreciations, style=self.style_appreciations
),
]
if appreciations
else []
)
return [
Spacer(1, 3 * mm),
self.bul_appreciations_pdf(appreciations, style=self.style_appreciations),
]
def bul_table(self, fmt=None) -> list:
"""Génère la table centrale du bulletin de notes
@ -273,36 +250,29 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
[""] + self.ues_acronyms,
["Moyenne"]
+ [bul["ues"][ue]["moyenne"]["value"] for ue in self.ues_acronyms],
["dont bonus"]
["Bonus"]
+ [
bul["ues"][ue]["bonus"] if bul["ues"][ue]["bonus"] != "00.00" else ""
for ue in self.ues_acronyms
],
["et malus"]
["Malus"]
+ [
bul["ues"][ue]["malus"] if bul["ues"][ue]["malus"] != "00.00" else ""
for ue in self.ues_acronyms
],
["Rang"]
["Rang"] + [bul["ues"][ue]["moyenne"]["rang"] for ue in self.ues_acronyms],
["Effectif"]
+ [bul["ues"][ue]["moyenne"]["total"] for ue in self.ues_acronyms],
["ECTS"]
+ [
f'{bul["ues"][ue]["moyenne"]["rang"]} / {bul["ues"][ue]["moyenne"]["total"]}'
f'{self.decision_ues[ue]["ects"]:g}' if ue in self.decision_ues else ""
for ue in self.ues_acronyms
],
]
if self.prefs["bul_show_ects"]:
rows += [
["ECTS"]
+ [
f'{bul["ues"][ue]["ECTS"]["acquis"]:g} /{bul["ues"][ue]["ECTS"]["total"]:g}'
for ue in self.ues_acronyms
]
]
rows += [
["Jury"]
+ [
self.decision_ues[ue]["code"] if ue in self.decision_ues else ""
for ue in self.ues_acronyms
]
],
]
blue_bg = Color(183 / 255.0, 235 / 255.0, 255 / 255.0)
table_style = [
@ -344,11 +314,9 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
for mod in self.bul[mod_type]:
row = [mod, bul[mod_type][mod]["titre"]]
row += [
(
bul["ues"][ue][mod_type][mod]["moyenne"]
if mod in bul["ues"][ue][mod_type]
else ""
)
bul["ues"][ue][mod_type][mod]["moyenne"]
if mod in bul["ues"][ue][mod_type]
else ""
for ue in self.ues_acronyms
]
rows.append(row)
@ -409,8 +377,6 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
def boite_identite(self) -> list:
"Les informations sur l'identité et l'inscription de l'étudiant"
parcour = self.formsemestre.etuds_inscriptions[self.etud.id].parcour
return [
Paragraph(
SU(f"""{self.etud.nomprenom}"""),
@ -421,8 +387,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
f"""
<b>{self.bul["demission"]}</b><br/>
Formation: {self.formsemestre.titre_num()}<br/>
{'Parcours ' + parcour.code + '<br/>' if parcour else ''}
Année universitaire: {self.formsemestre.annee_scolaire_str()}<br/>
Année scolaire: {self.formsemestre.annee_scolaire_str()}<br/>
"""
),
style=self.style_base,
@ -522,18 +487,17 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
def boite_decisions_jury(self):
"""La boite en bas à droite avec jury"""
txt = f"""ECTS acquis en BUT : <b>{self.ects_total:g}</b><br/>"""
if self.bul["semestre"].get("decision_annee", None):
txt = f"""ECTS acquis : {self.ects_total:g}<br/>"""
if self.bul["semestre"]["decision_annee"]:
txt += f"""
Décision saisie le {
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime(scu.DATE_FMT)
}, année BUT{self.bul["semestre"]["decision_annee"]["ordre"]}
<b>{self.bul["semestre"]["decision_annee"]["code"]}</b>.
Jury tenu le {
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime("%d/%m/%Y à %H:%M")
}, année BUT <b>{self.bul["semestre"]["decision_annee"]["code"]}</b>.
<br/>
"""
if self.bul["semestre"].get("autorisation_inscription", None):
if self.bul["semestre"]["autorisation_inscription"]:
txt += (
"<br/>Autorisé à s'inscrire en <b>"
"Autorisé à s'inscrire en <b>"
+ ", ".join(
[
f"S{aut['semestre_id']}"

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -9,12 +9,12 @@
La génération du bulletin PDF suit le chemin suivant:
- vue formsemestre_bulletinetud -> sco_bulletins.formsemestre_bulletinetud
bul_dict = bulletin_but.BulletinBUT(formsemestre).bulletin_etud_complet(etud)
- sco_bulletins_generator.make_formsemestre_bulletin_etud()
- instance de BulletinGeneratorStandardBUT
- BulletinGeneratorStandardBUT.generate(fmt="pdf")
- BulletinGeneratorStandardBUT.generate(format="pdf")
sco_bulletins_generator.BulletinGenerator.generate()
.generate_pdf()
.bul_table() (ci-dessous)
@ -24,7 +24,6 @@ from reportlab.lib.colors import blue
from reportlab.lib.units import cm, mm
from reportlab.platypus import Paragraph, Spacer
from app.models import Evaluation, ScoDocSiteConfig
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc import gen_tables
from app.scodoc.codes_cursus import UE_SPORT
@ -49,8 +48,6 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
- en HTML: une chaine
- en PDF: une liste d'objets PLATYPUS (eg instance de Table).
"""
if fmt == "pdf" and ScoDocSiteConfig.is_bul_pdf_disabled():
return [Paragraph("<p>Export des PDF interdit par l'administrateur</p>")]
tables_infos = [
# ---- TABLE SYNTHESE UES
self.but_table_synthese_ues(),
@ -73,9 +70,8 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
html_class="notes_bulletin",
html_class_ignore_default=True,
html_with_td_classes=True,
table_id="bul-table",
)
table_objects = table.gen(fmt=fmt)
table_objects = table.gen(format=fmt)
objects += table_objects
# objects += [KeepInFrame(0, 0, table_objects, mode="shrink")]
if i != 2:
@ -270,7 +266,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
date_capitalisation = ue.get("date_capitalisation")
if date_capitalisation:
fields_bmr.append(
f"""Capitalisée le {date_capitalisation.strftime(scu.DATE_FMT)}"""
f"""Capitalisée le {date_capitalisation.strftime("%d/%m/%Y")}"""
)
t = {
"titre": " - ".join(fields_bmr),
@ -423,22 +419,16 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
def evaluations_rows(self, rows, evaluations: list[dict], ue_acros=()):
"lignes des évaluations"
for e in evaluations:
coef = (
e["coef"]
if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE
else "*"
)
note_value = e["note"].get("value", "")
coef = e["coef"] if e["evaluation_type"] == scu.EVALUATION_NORMALE else "*"
t = {
"titre": f"{e['description'] or ''}",
"moyenne": note_value,
"_moyenne_pdf": Paragraph(f"""<para align=right>{note_value}</para>"""),
"moyenne": e["note"]["value"],
"_moyenne_pdf": Paragraph(
f"""<para align=right>{e["note"]["value"]}</para>"""
),
"coef": coef,
"_coef_pdf": Paragraph(
f"""<para align=right fontSize={self.small_fontsize}><i>{
coef if e["evaluation_type"] != Evaluation.EVALUATION_BONUS
else "bonus"
}</i></para>"""
f"<para align=right fontSize={self.small_fontsize}><i>{coef}</i></para>"
),
"_pdf_style": [
(

View File

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

View File

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

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -23,21 +23,29 @@ from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences,
)
from app.models.ues import UEParcours
from app.models.but_validations import ApcValidationRCUE
from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
)
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
from app.scodoc import sco_cursus_dut
@ -111,15 +119,8 @@ class EtudCursusBUT:
self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
validation_rcue: ApcValidationRCUE
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau()
if niveau is None:
raise ScoValueError(
"""UE d'un RCUE non associée à un niveau de compétence.
Vérifiez la formation et les associations de ses UEs.
"""
)
if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get(
@ -435,38 +436,15 @@ def formsemestre_warning_apc_setup(
"""
if not formsemestre.formation.is_apc():
return ""
url_formation = url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=formsemestre.formation.id,
semestre_idx=formsemestre.semestre_id,
)
if formsemestre.formation.referentiel_competence is None:
return f"""<div class="formsemestre_status_warning">
La <a class="stdlink" href="{url_formation}">formation
n'est pas associée à un référentiel de compétence.</a>
La <a class="stdlink" href="{
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
}">formation n'est pas associée à un référentiel de compétence.</a>
</div>
"""
H = []
# Le semestre n'a pas de parcours, mais les UE ont des parcours ?
if not formsemestre.parcours:
nb_ues_sans_parcours = len(
formsemestre.formation.query_ues_parcour(None)
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
.all()
)
nb_ues_tot = (
UniteEns.query.filter_by(formation=formsemestre.formation, type=UE_STANDARD)
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
.count()
)
if nb_ues_sans_parcours != nb_ues_tot:
H.append(
"""Le semestre n'est associé à aucun parcours,
mais les UEs de la formation ont des parcours
"""
)
# Vérifie les niveaux de chaque parcours
H = []
for parcour in formsemestre.parcours or [None]:
annee = (formsemestre.semestre_id + 1) // 2
niveaux_ids = {
@ -483,7 +461,7 @@ def formsemestre_warning_apc_setup(
}
if niveaux_ids != ues_niveaux_ids:
H.append(
f"""Parcours {parcour.code if parcour else "Tronc commun"} :
f"""Parcours {parcour.code if parcour else "Tronc commun"} :
{len(ues_niveaux_ids)} UE avec niveaux
mais {len(niveaux_ids)} niveaux à valider !
"""
@ -491,12 +469,11 @@ def formsemestre_warning_apc_setup(
if not H:
return ""
return f"""<div class="formsemestre_status_warning">
Problème dans la
<a class="stdlink" href="{url_formation}">configuration de la formation</a>:
Problème dans la configuration de la formation:
<ul>
<li>{ '</li><li>'.join(H) }</li>
</ul>
<p class="help">Vérifiez les parcours cochés pour ce semestre,
<p class="help">Vérifiez les parcours cochés pour ce semestre,
et les associations entre UE et niveaux <a class="stdlink" href="{
url_for("notes.parcour_formation", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
}">dans la formation.</a>
@ -505,79 +482,6 @@ def formsemestre_warning_apc_setup(
"""
def formation_semestre_niveaux_warning(formation: Formation, semestre_idx: int) -> str:
"""Vérifie que tous les niveaux de compétences de cette année de formation
ont bien des UEs.
Afin de ne pas générer trop de messages, on ne considère que les parcours
du référentiel de compétences pour lesquels au moins une UE a été associée.
Renvoie fragment de html
"""
annee = (semestre_idx - 1) // 2 + 1 # année BUT
ref_comp: ApcReferentielCompetences = formation.referentiel_competence
if not ref_comp:
return "" # détecté ailleurs...
niveaux_sans_ue_by_parcour = {} # { parcour.code : [ ue ... ] }
parcours_ids = {
uep.parcours_id
for uep in UEParcours.query.join(UniteEns).filter_by(
formation_id=formation.id, type=UE_STANDARD
)
}
for parcour in ref_comp.parcours:
if parcour.id not in parcours_ids:
continue # saute parcours associés à aucune UE (tous semestres)
niveaux_sans_ue = []
niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp)
# print(f"\n# Parcours {parcour.code} : {len(niveaux)} niveaux")
for niveau in niveaux:
ues = [ue for ue in formation.ues if ue.niveau_competence_id == niveau.id]
if not ues:
niveaux_sans_ue.append(niveau)
# print( niveau.competence.titre + " " + str(niveau.ordre) + "\t" + str(ue) )
if niveaux_sans_ue:
niveaux_sans_ue_by_parcour[parcour.code] = niveaux_sans_ue
#
H = []
for parcour_code, niveaux in niveaux_sans_ue_by_parcour.items():
H.append(
f"""<li>Parcours {parcour_code} : {
len(niveaux)} niveaux sans UEs&nbsp;:
<span class="niveau-nom"><span>
{ '</span>, <span>'.join( f'{niveau.competence.titre} {niveau.ordre}'
for niveau in niveaux
)
}
</span>
</li>
"""
)
# Combien de compétences de tronc commun ?
_, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee)
nb_niveaux_tc = len(niveaux_by_parcours["TC"])
nb_ues_tc = len(
formation.query_ues_parcour(None)
.filter(UniteEns.semestre_idx == semestre_idx)
.all()
)
if nb_niveaux_tc != nb_ues_tc:
H.append(
f"""<li>{nb_niveaux_tc} niveaux de compétences de tronc commun,
mais {nb_ues_tc} UEs de tronc commun ! (c'est normal si
vous avez des UEs différenciées par parcours)</li>"""
)
if H:
return f"""<div class="formation_semestre_niveaux_warning">
<div>Problèmes détectés à corriger :</div>
<ul>
{"".join(H)}
</ul>
</div>
"""
return "" # no problem detected
def ue_associee_au_niveau_du_parcours(
ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
) -> UniteEns:
@ -593,9 +497,7 @@ def ue_associee_au_niveau_du_parcours(
return ues[0] if ues else None
def parcour_formation_competences(
parcour: ApcParcours, formation: Formation
) -> tuple[list[dict], float]:
def parcour_formation_competences(parcour: ApcParcours, formation: Formation) -> list:
"""
[
{
@ -612,8 +514,7 @@ def parcour_formation_competences(
}
}
}
],
ects_parcours (somme des ects des UEs associées)
]
"""
refcomp: ApcReferentielCompetences = formation.referentiel_competence
@ -637,7 +538,7 @@ def parcour_formation_competences(
if len(niveaux) > 0:
if len(niveaux) > 1:
log(
f"""_niveau_ues: plus d'un niveau pour {competence}
f"""_niveau_ues: plus d'un niveau pour {competence}
annee {annee} {("parcours " + parcour.code) if parcour else ""}"""
)
niveau = niveaux[0]
@ -697,17 +598,4 @@ def parcour_formation_competences(
else refcomp.competences.order_by(ApcCompetence.numero)
)
]
ects_parcours = sum(
sum(
(ni["ue_impair"].ects or 0) if ni["ue_impair"] else 0
for ni in cp["niveaux"].values()
)
for cp in competences
) + sum(
sum(
(ni["ue_pair"].ects or 0) if ni["ue_pair"] else 0
for ni in cp["niveaux"].values()
)
for cp in competences
)
return competences, ects_parcours
return competences

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -16,8 +16,8 @@ from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but(
formsemestre: FormSemestre, only_adm: bool = True, dry_run=False
) -> tuple[int, list[jury_but.DecisionsProposeesAnnee]]:
formsemestre: FormSemestre, only_adm: bool = True
) -> int:
"""Calcul automatique des décisions de jury sur une "année" BUT.
- N'enregistre jamais de décisions de l'année scolaire précédente, même
@ -27,22 +27,16 @@ def formsemestre_validation_auto_but(
En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc
(mode à n'utiliser que pour les tests unitaires vérifiant la saisie des jurys)
Returns:
- En mode normal, (nombre d'étudiants pour lesquels on a enregistré au moins un code, []])
- En mode dry_run, (0, list[DecisionsProposeesAnnee])
Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code.
"""
if not formsemestre.formation.is_apc():
raise ScoValueError("fonction réservée aux formations BUT")
nb_etud_modif = 0
decas = []
with sco_cache.DeferredSemCacheManager():
for etudid in formsemestre.etuds_inscriptions:
etud = Identite.get_etud(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if not dry_run:
nb_etud_modif += deca.record_all(only_validantes=only_adm)
else:
decas.append(deca)
nb_etud_modif += deca.record_all(only_validantes=only_adm)
db.session.commit()
ScolarNews.add(
@ -55,4 +49,4 @@ def formsemestre_validation_auto_but(
formsemestre_id=formsemestre.id,
),
)
return nb_etud_modif, decas
return nb_etud_modif

View File

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

View File

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

View File

@ -1,11 +1,12 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury BUT: un RCUE, ou Regroupe Cohérent d'UEs
"""
from typing import Union
from flask_sqlalchemy.query import Query
from app.comp.res_but import ResultatsSemestreBUT
@ -75,7 +76,7 @@ class RegroupementCoherentUE:
else None
)
# Autres validations pour les UEs paire/impaire
# Autres validations pour l'UE paire
self.validation_ue_best_pair = best_autre_ue_validation(
etud.id,
niveau.id,
@ -101,24 +102,14 @@ class RegroupementCoherentUE:
"résultats formsemestre de l'UE si elle est courante, None sinon"
self.ue_status_impair = None
if self.ue_cur_impair:
# UE courante
ue_status = res_impair.get_etud_ue_status(etud.id, self.ue_cur_impair.id)
self.moy_ue_1 = ue_status["moy"] if ue_status else None # avec capitalisée
self.ue_1 = self.ue_cur_impair
self.res_impair = res_impair
self.ue_status_impair = ue_status
elif self.validation_ue_best_impair:
# UE capitalisée
self.moy_ue_1 = self.validation_ue_best_impair.moy_ue
self.ue_1 = self.validation_ue_best_impair.ue
if (
res_impair
and self.validation_ue_best_impair
and self.validation_ue_best_impair.ue
):
self.ue_status_impair = res_impair.get_etud_ue_status(
etud.id, self.validation_ue_best_impair.ue.id
)
else:
self.moy_ue_1, self.ue_1 = None, None
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
@ -214,7 +205,7 @@ class RegroupementCoherentUE:
self.moy_rcue > codes_cursus.BUT_BARRE_RCUE
)
def code_valide(self) -> ApcValidationRCUE | None:
def code_valide(self) -> Union[ApcValidationRCUE, None]:
"Si ce RCUE est ADM, CMP ou ADJ, la validation. Sinon, None"
validation = self.query_validations().first()
if (validation is not None) and (

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -43,20 +43,16 @@ def validation_rcues(etud: Identite, formsemestre: FormSemestre, edit: bool = Fa
raise ScoNoReferentielCompetences(formation=formation)
parcour = formsemestre.etuds_inscriptions[etud.id].parcour
# Si non inscrit à un parcours, prend toutes les compétences
competences_parcour, ects_parcours = cursus_but.parcour_formation_competences(
parcour, formation
)
competences_parcour = cursus_but.parcour_formation_competences(parcour, formation)
ue_validation_by_niveau = get_ue_validation_by_niveau(refcomp, etud)
rcue_validation_by_niveau = get_rcue_validation_by_niveau(refcomp, etud)
ects_acquis = sum((v.ects() for v in ue_validation_by_niveau.values()))
ects_total = sum((v.ects() for v in ue_validation_by_niveau.values()))
return render_template(
"but/validation_rcues.j2",
competences_parcour=competences_parcour,
edit=edit,
ects_acquis=ects_acquis,
ects_parcours=ects_parcours,
ects_total=ects_total,
formation=formation,
parcour=parcour,
rcue_validation_by_niveau=rcue_validation_by_niveau,

View File

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

View File

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

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -27,7 +27,6 @@
"""caches pour tables APC
"""
from flask import g
from app.scodoc import sco_cache
@ -48,27 +47,3 @@ class EvaluationsPoidsCache(sco_cache.ScoDocCache):
"""
prefix = "EPC"
@classmethod
def invalidate_all(cls):
"delete all cached evaluations poids (in current dept)"
from app.models.formsemestre import FormSemestre
from app.models.moduleimpls import ModuleImpl
moduleimpl_ids = [
mi.id
for mi in ModuleImpl.query.join(FormSemestre).filter_by(
dept_id=g.scodoc_dept_id
)
]
cls.delete_many(moduleimpl_ids)
@classmethod
def invalidate_sem(cls, formsemestre_id):
"delete cached evaluations poids for this formsemestre from cache"
from app.models.moduleimpls import ModuleImpl
moduleimpl_ids = [
mi.id for mi in ModuleImpl.query.filter_by(formsemestre_id=formsemestre_id)
]
cls.delete_many(moduleimpl_ids)

View File

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

View File

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

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -35,6 +35,7 @@ moyenne générale d'une UE.
"""
import dataclasses
from dataclasses import dataclass
import numpy as np
import pandas as pd
import sqlalchemy as sa
@ -45,6 +46,7 @@ from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoBugCatcher
from app.scodoc.sco_utils import ModuleType
@ -54,7 +56,6 @@ class EvaluationEtat:
evaluation_id: int
nb_attente: int
nb_notes: int # nb notes d'étudiants inscrits au semestre et au modimpl
is_complete: bool
def to_dict(self):
@ -71,15 +72,7 @@ class ModuleImplResults:
les caches sont gérés par ResultatsSemestre.
"""
def __init__(
self, moduleimpl: ModuleImpl, etudids: list[int], etudids_actifs: set[int]
):
"""
Args:
- etudids : liste des etudids, qui donne l'index du dataframe
(doit être tous les étudiants inscrits au semestre incluant les DEM et DEF)
- etudids_actifs l'ensemble des étudiants inscrits au semestre, non DEM/DEF.
"""
def __init__(self, moduleimpl: ModuleImpl):
self.moduleimpl_id = moduleimpl.id
self.module_id = moduleimpl.module.id
self.etudids = None
@ -112,23 +105,14 @@ class ModuleImplResults:
"""
self.evals_etudids_sans_note = {}
"""dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
self.evals_type = {}
"""Type de chaque eval { evaluation.id : evaluation.evaluation_type }"""
self.load_notes(etudids, etudids_actifs)
self.load_notes()
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module vient de la session2"""
self.etuds_use_rattrapage = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module utilise la note de rattrapage"""
def load_notes(
self, etudids: list[int], etudids_actifs: set[int]
): # ré-écriture de df_load_modimpl_notes
def load_notes(self): # ré-écriture de df_load_modimpl_notes
"""Charge toutes les notes de toutes les évaluations du module.
Args:
- etudids : liste des etudids, qui donne l'index du dataframe
(doit être tous les étudiants inscrits au semestre incluant les DEM et DEF)
- etudids_actifs l'ensemble des étudiants inscrits au semestre, non DEM/DEF.
Dataframe evals_notes
colonnes: le nom de la colonne est l'evaluation_id (int)
index (lignes): etudid (int)
@ -151,12 +135,12 @@ class ModuleImplResults:
qui ont des notes ATT.
"""
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
self.etudids = etudids
self.etudids = self._etudids()
# --- Calcul nombre d'inscrits pour déterminer les évaluations "completes":
# on prend les inscrits au module ET au semestre (donc sans démissionnaires)
inscrits_module = {ins.etud.id for ins in moduleimpl.inscriptions}.intersection(
etudids_actifs
moduleimpl.formsemestre.etudids_actifs
)
self.nb_inscrits_module = len(inscrits_module)
@ -164,24 +148,19 @@ class ModuleImplResults:
evals_notes = pd.DataFrame(index=self.etudids, dtype=float)
self.evaluations_completes = []
self.evaluations_completes_dict = {}
self.etudids_attente = set() # empty
self.evals_type = {}
evaluation: Evaluation
for evaluation in moduleimpl.evaluations:
self.evals_type[evaluation.id] = evaluation.evaluation_type
eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi
# tous les inscrits (non dem) au module ont une note
# ou évaluation déclarée "à prise en compte immédiate"
# ou rattrapage, 2eme session, bonus
# ET pas bloquée par date (is_blocked)
is_blocked = evaluation.is_blocked()
# is_complete ssi tous les inscrits (non dem) au semestre ont une note
# ou évaluation déclarée "à prise en compte immédiate"
# Les évaluations de rattrapage et 2eme session sont toujours complètes
etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem.
is_complete = (
(evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE)
(evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE)
or (evaluation.evaluation_type == scu.EVALUATION_SESSION2)
or (evaluation.publish_incomplete)
or (not etudids_sans_note)
) and not is_blocked
)
self.evaluations_completes.append(is_complete)
self.evaluations_completes_dict[evaluation.id] = is_complete
self.evals_etudids_sans_note[evaluation.id] = etudids_sans_note
@ -189,39 +168,25 @@ class ModuleImplResults:
# NULL en base => ABS (= -999)
eval_df.fillna(scu.NOTES_ABSENCE, inplace=True)
# Ce merge ne garde que les étudiants inscrits au module
# et met à NULL (NaN) les notes non présentes
# et met à NULL les notes non présentes
# (notes non saisies ou etuds non inscrits au module):
evals_notes = evals_notes.merge(
eval_df, how="left", left_index=True, right_index=True
)
# Notes en attente: (ne prend en compte que les inscrits, non démissionnaires)
eval_notes_inscr = evals_notes[str(evaluation.id)][list(inscrits_module)]
# Nombre de notes (non vides, incluant ATT etc) des inscrits:
nb_notes = eval_notes_inscr.notna().sum()
if is_blocked:
eval_etudids_attente = set()
else:
# Etudiants avec notes en attente:
# = ceux avec note ATT
eval_etudids_attente = set(
eval_notes_inscr.iloc[
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
].index
)
if evaluation.publish_incomplete:
# et en "immédiat", tous ceux sans note
eval_etudids_attente |= etudids_sans_note
# Synthèse pour état du module:
eval_etudids_attente = set(
eval_notes_inscr.iloc[
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
].index
)
self.etudids_attente |= eval_etudids_attente
self.evaluations_etat[evaluation.id] = EvaluationEtat(
evaluation_id=evaluation.id,
nb_attente=len(eval_etudids_attente),
nb_notes=int(nb_notes),
is_complete=is_complete,
)
# au moins une note en attente (ATT ou manquante en mode "immédiat") dans ce modimpl:
# au moins une note en ATT dans ce modimpl:
self.en_attente = bool(self.etudids_attente)
# Force columns names to integers (evaluation ids)
@ -254,44 +219,36 @@ class ModuleImplResults:
eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)])
return eval_df
def get_evaluations_coefs(self, modimpl: ModuleImpl) -> np.array:
def _etudids(self):
"""L'index du dataframe est la liste de tous les étudiants inscrits au semestre
(incluant les DEM et DEF)
"""
return [
inscr.etudid
for inscr in db.session.get(
ModuleImpl, self.moduleimpl_id
).formsemestre.inscriptions
]
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
"""Coefficients des évaluations.
Les coefs des évals incomplètes, rattrapage, session 2, bonus sont forcés à zéro.
Les coefs des évals incomplètes et non "normales" (session 2, rattrapage)
sont zéro.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[
(
e.coefficient
if e.evaluation_type == Evaluation.EVALUATION_NORMALE
else 0.0
)
for e in modimpl.evaluations
e.coefficient
if e.evaluation_type == scu.EVALUATION_NORMALE
else 0.0
for e in moduleimpl.evaluations
],
dtype=float,
)
* self.evaluations_completes
).reshape(-1, 1)
def get_evaluations_special_coefs(
self, modimpl: ModuleImpl, evaluation_type=Evaluation.EVALUATION_SESSION2
) -> np.array:
"""Coefficients des évaluations de session 2 ou rattrapage.
Les évals de session 2 et rattrapage sont réputées "complètes": elles sont toujours
prises en compte mais seules les notes numériques et ABS sont utilisées.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[
(e.coefficient if e.evaluation_type == evaluation_type else 0.0)
for e in modimpl.evaluations
],
dtype=float,
)
).reshape(-1, 1)
# was _list_notes_evals_titles
def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"Liste des évaluations complètes"
@ -309,7 +266,7 @@ class ModuleImplResults:
) / [e.note_max / 20.0 for e in moduleimpl.evaluations]
def get_eval_notes_dict(self, evaluation_id: int) -> dict:
"""Notes d'une évaluation, brutes, sous forme d'un dict
"""Notes d'une évaulation, brutes, sous forme d'un dict
{ etudid : valeur }
avec les valeurs float, ou "ABS" ou EXC
"""
@ -318,55 +275,44 @@ class ModuleImplResults:
for (etudid, x) in self.evals_notes[evaluation_id].items()
}
def get_evaluations_rattrapage(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations de rattrapage de ce module.
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl):
"""L'évaluation de rattrapage de ce module, ou None s'il n'en a pas.
Rattrapage: la moyenne du module est la meilleure note entre moyenne
des autres évals et la moyenne des notes de rattrapage.
des autres évals et la note eval rattrapage.
"""
return [
eval_list = [
e
for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE
if e.evaluation_type == scu.EVALUATION_RATTRAPAGE
]
if eval_list:
return eval_list[0]
return None
def get_evaluations_session2(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations de deuxième session de ce module, ou None s'il n'en a pas.
La moyenne des notes de Session 2 remplace la note de moyenne des autres évals.
def get_evaluation_session2(self, moduleimpl: ModuleImpl):
"""L'évaluation de deuxième session de ce module, ou None s'il n'en a pas.
Session 2: remplace la note de moyenne des autres évals.
"""
return [
eval_list = [
e
for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_SESSION2
]
def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations bonus 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
]
def get_evaluations_bonus_idx(self, modimpl: ModuleImpl) -> list[int]:
"""Les indices des évaluations bonus"""
return [
i
for (i, e) in enumerate(modimpl.evaluations)
if e.evaluation_type == Evaluation.EVALUATION_BONUS
if e.evaluation_type == scu.EVALUATION_SESSION2
]
if eval_list:
return eval_list[0]
return None
class ModuleImplResultsAPC(ModuleImplResults):
"Calcul des moyennes de modules à la mode BUT"
def compute_module_moy(
self, evals_poids_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
self,
evals_poids_df: pd.DataFrame,
) -> pd.DataFrame:
"""Calcule les moyennes des étudiants dans ce module
Argument:
evals_poids: DataFrame, colonnes: UEs, lignes: EVALs
modimpl_coefs_df: DataFrame, colonnes: modimpl_id, lignes: ue_id
Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs
Résultat: DataFrame, colonnes UE, lignes etud
= la note de l'étudiant dans chaque UE pour ce module.
@ -387,7 +333,6 @@ class ModuleImplResultsAPC(ModuleImplResults):
return pd.DataFrame(index=[], columns=evals_poids_df.columns)
if nb_ues == 0:
return pd.DataFrame(index=self.evals_notes.index, columns=[])
# coefs des évals complètes normales (pas rattr., session 2 ni bonus):
evals_coefs = self.get_evaluations_coefs(modimpl)
evals_poids = evals_poids_df.values * evals_coefs
# -> evals_poids shape : (nb_evals, nb_ues)
@ -401,7 +346,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
# et dans dans evals_poids_etuds
# (rappel: la comparaison est toujours false face à un NaN)
# shape: (nb_etuds, nb_evals, nb_ues)
poids_stacked = np.stack([evals_poids] * nb_etuds) # nb_etuds, nb_evals, nb_ues
poids_stacked = np.stack([evals_poids] * nb_etuds)
evals_poids_etuds = np.where(
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
poids_stacked,
@ -409,61 +354,51 @@ class ModuleImplResultsAPC(ModuleImplResults):
)
# Calcule la moyenne pondérée sur les notes disponibles:
evals_notes_stacked = np.stack([evals_notes_20] * nb_ues, axis=2)
# evals_notes_stacked shape: nb_etuds, nb_evals, nb_ues
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module = np.sum(
evals_poids_etuds * evals_notes_stacked, axis=1
) / np.sum(evals_poids_etuds, axis=1)
# etuds_moy_module shape: nb_etuds x nb_ues
evals_session2 = self.get_evaluations_session2(modimpl)
evals_rat = self.get_evaluations_rattrapage(modimpl)
if evals_session2:
# Session2 : quand elle existe, remplace la note de module
# Calcul moyenne notes session2 et remplace (si la note session 2 existe)
etuds_moy_module_s2 = self._compute_moy_special(
modimpl,
evals_notes_stacked,
evals_poids_df,
Evaluation.EVALUATION_SESSION2,
)
# Vrai si toutes les UEs avec coef non nul ont bien une note de session 2 calculée:
mod_coefs = modimpl_coefs_df[modimpl.id]
etuds_use_session2 = np.all(
np.isfinite(etuds_moy_module_s2[:, mod_coefs != 0]), axis=1
)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
notes_session2 = self.evals_notes[eval_session2.id].values
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
etuds_moy_module = np.where(
etuds_use_session2[:, np.newaxis],
etuds_moy_module_s2,
np.tile(
(notes_session2 / (eval_session2.note_max / 20.0))[:, np.newaxis],
nb_ues,
),
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
elif evals_rat:
etuds_moy_module_rat = self._compute_moy_special(
modimpl,
evals_notes_stacked,
evals_poids_df,
Evaluation.EVALUATION_RATTRAPAGE,
)
etuds_ue_use_rattrapage = (
etuds_moy_module_rat > etuds_moy_module
) # etud x UE
etuds_moy_module = np.where(
etuds_ue_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
np.any(etuds_ue_use_rattrapage, axis=1), index=self.evals_notes.index
)
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_poids_df,
evals_notes_stacked,
)
else:
# Rattrapage: remplace la note de module ssi elle est supérieure
eval_rat = self.get_evaluation_rattrapage(modimpl)
if eval_rat:
notes_rat = self.evals_notes[eval_rat.id].values
# remplace les notes invalides (ATT, EXC...) par des NaN
notes_rat = np.where(
notes_rat > scu.NOTES_ABSENCE,
notes_rat / (eval_rat.note_max / 20.0),
np.nan,
)
# "Étend" le rattrapage sur les UE: la note de rattrapage est la même
# pour toutes les UE mais ne remplace que là où elle est supérieure
notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1)
# prend le max
etuds_use_rattrapage = notes_rat_ues > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
)
# Serie indiquant que l'étudiant utilise une note de rattrapage sur l'une des UE:
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
)
self.etuds_moy_module = pd.DataFrame(
etuds_moy_module,
index=self.evals_notes.index,
@ -471,58 +406,6 @@ class ModuleImplResultsAPC(ModuleImplResults):
)
return self.etuds_moy_module
def _compute_moy_special(
self,
modimpl: ModuleImpl,
evals_notes_stacked: np.array,
evals_poids_df: pd.DataFrame,
evaluation_type: int,
) -> np.array:
"""Calcul moyenne APC sur évals rattrapage ou session2"""
nb_etuds = self.evals_notes.shape[0]
nb_ues = evals_poids_df.shape[1]
evals_coefs_s2 = self.get_evaluations_special_coefs(
modimpl, evaluation_type=evaluation_type
)
evals_poids_s2 = evals_poids_df.values * evals_coefs_s2
poids_stacked_s2 = np.stack(
[evals_poids_s2] * nb_etuds
) # nb_etuds, nb_evals, nb_ues
evals_poids_etuds_s2 = np.where(
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
poids_stacked_s2,
0,
)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module_s2 = np.sum(
evals_poids_etuds_s2 * evals_notes_stacked, axis=1
) / np.sum(evals_poids_etuds_s2, axis=1)
return etuds_moy_module_s2
def apply_bonus(
self,
etuds_moy_module: pd.DataFrame,
modimpl: ModuleImpl,
evals_poids_df: pd.DataFrame,
evals_notes_stacked: np.ndarray,
):
"""Ajoute les points des évaluations bonus.
Il peut y avoir un nb quelconque d'évaluations bonus.
Les points sont directement ajoutés (ils peuvent être négatifs).
"""
evals_bonus = self.get_evaluations_bonus(modimpl)
if not evals_bonus:
return etuds_moy_module
poids_stacked = np.stack([evals_poids_df.values] * len(etuds_moy_module))
for evaluation in evals_bonus:
eval_idx = evals_poids_df.index.get_loc(evaluation.id)
etuds_moy_module += (
evals_notes_stacked[:, eval_idx, :] * poids_stacked[:, eval_idx, :]
)
# Clip dans [0,20]
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
return etuds_moy_module
def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
"""Charge poids des évaluations d'un module et retourne un dataframe
@ -536,10 +419,11 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
Résultat: (evals_poids, liste de UEs du semestre sauf le sport)
"""
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
modimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id)
evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all()
ues = modimpl.formsemestre.get_ues(with_sport=False)
ue_ids = [ue.id for ue in ues]
evaluation_ids = [evaluation.id for evaluation in modimpl.evaluations]
evaluation_ids = [evaluation.id for evaluation in evaluations]
evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float)
if (
modimpl.module.module_type == ModuleType.RESSOURCE
@ -550,7 +434,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
).filter_by(moduleimpl_id=moduleimpl_id):
try:
evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids
except KeyError:
except KeyError as exc:
pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre...
# Initialise poids non enregistrés:
@ -571,7 +455,6 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
return evals_poids, ues
# appelé par ModuleImpl.check_apc_conformity()
def moduleimpl_is_conforme(
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> bool:
@ -593,12 +476,12 @@ def moduleimpl_is_conforme(
if len(modimpl_coefs_df) != nb_ues:
# il arrive (#bug) que le cache ne soit pas à jour...
sco_cache.invalidate_formsemestre()
return app.critical_error("moduleimpl_is_conforme: err 1")
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent")
if moduleimpl.id not in modimpl_coefs_df:
# soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre()
return app.critical_error("moduleimpl_is_conforme: err 2")
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
@ -640,87 +523,42 @@ class ModuleImplResultsClassic(ModuleImplResults):
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1)
evals_session2 = self.get_evaluations_session2(modimpl)
evals_rat = self.get_evaluations_rattrapage(modimpl)
if evals_session2:
# Session2 : quand elle existe, remplace la note de module
# Calcule la moyenne des évaluations de session2
etuds_moy_module_s2 = self._compute_moy_special(
modimpl, evals_notes_20, Evaluation.EVALUATION_SESSION2
)
etuds_use_session2 = np.isfinite(etuds_moy_module_s2)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
notes_session2 = self.evals_notes[eval_session2.id].values
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
etuds_moy_module = np.where(
etuds_use_session2,
etuds_moy_module_s2,
notes_session2 / (eval_session2.note_max / 20.0),
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
elif evals_rat:
else:
# Rattrapage: remplace la note de module ssi elle est supérieure
# Calcule la moyenne des évaluations de rattrapage
etuds_moy_module_rat = self._compute_moy_special(
modimpl, evals_notes_20, Evaluation.EVALUATION_RATTRAPAGE
)
etuds_use_rattrapage = etuds_moy_module_rat > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage, index=self.evals_notes.index
)
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_notes_20,
)
eval_rat = self.get_evaluation_rattrapage(modimpl)
if eval_rat:
notes_rat = self.evals_notes[eval_rat.id].values
# remplace les notes invalides (ATT, EXC...) par des NaN
notes_rat = np.where(
notes_rat > scu.NOTES_ABSENCE,
notes_rat / (eval_rat.note_max / 20.0),
np.nan,
)
# prend le max
etuds_use_rattrapage = notes_rat > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage, index=self.evals_notes.index
)
self.etuds_moy_module = pd.Series(
etuds_moy_module,
index=self.evals_notes.index,
)
return self.etuds_moy_module
def _compute_moy_special(
self, modimpl: ModuleImpl, evals_notes_20: np.array, evaluation_type: int
) -> np.array:
"""Calcul moyenne sur évals rattrapage ou session2"""
# n'utilise que les notes valides et ABS (0).
# Même calcul que pour les évals normales, mais avec seulement les
# coefs des évals de session 2 ou rattrapage:
nb_etuds = self.evals_notes.shape[0]
evals_coefs = self.get_evaluations_special_coefs(
modimpl, evaluation_type=evaluation_type
).reshape(-1)
coefs_stacked = np.stack([evals_coefs] * nb_etuds)
# zéro partout sauf si une note ou ABS:
evals_coefs_etuds = np.where(
self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked, 0
)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module = np.sum(
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1)
return etuds_moy_module # array 1d (nb_etuds)
def apply_bonus(
self,
etuds_moy_module: np.ndarray,
modimpl: ModuleImpl,
evals_notes_20: np.ndarray,
):
"""Ajoute les points des évaluations bonus.
Il peut y avoir un nb quelconque d'évaluations bonus.
Les points sont directement ajoutés (ils peuvent être négatifs).
"""
evals_bonus_idx = self.get_evaluations_bonus_idx(modimpl)
if not evals_bonus_idx:
return etuds_moy_module
for eval_idx in evals_bonus_idx:
etuds_moy_module += evals_notes_20[:, eval_idx]
# Clip dans [0,20]
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
return etuds_moy_module

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -89,7 +89,7 @@ def compute_sem_moys_apc_using_ects(
flash(
Markup(
f"""Calcul moyenne générale impossible: ECTS des UE manquants !<br>
(formation: <a href="{url_for("notes.ue_table",
(formation: <a href="{url_for("notes.ue_table",
scodoc_dept=g.scodoc_dept, formation_id=formation_id)}">{formation.get_titre_version()}</a>)"""
)
)
@ -100,7 +100,7 @@ def compute_sem_moys_apc_using_ects(
def comp_ranks_series(notes: pd.Series) -> tuple[pd.Series, pd.Series]:
"""Calcul rangs à partir d'une série ("vecteur") de notes (index etudid, valeur
"""Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur
numérique) en tenant compte des ex-aequos.
Result: couple (tuple)

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -99,11 +99,9 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
# sur toutes les UE)
default_poids = {
mod.id: (
1.0
if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT)
else 0.0
)
mod.id: 1.0
if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT)
else 0.0
for mod in modules
}
@ -150,12 +148,10 @@ def df_load_modimpl_coefs(
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
# sur toutes les UE)
default_poids = {
modimpl.id: (
1.0
if (modimpl.module.module_type == ModuleType.STANDARD)
and (modimpl.module.ue.type == UE_SPORT)
else 0.0
)
modimpl.id: 1.0
if (modimpl.module.module_type == ModuleType.STANDARD)
and (modimpl.module.ue.type == UE_SPORT)
else 0.0
for modimpl in formsemestre.modimpls_sorted
}
@ -183,9 +179,7 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray:
return modimpls_notes.swapaxes(0, 1)
def notes_sem_load_cube(
formsemestre: FormSemestre, modimpl_coefs_df: pd.DataFrame
) -> tuple:
def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
"""Construit le "cube" (tenseur) des notes du semestre.
Charge toutes les notes (sql), calcule les moyennes des modules
et assemble le cube.
@ -206,11 +200,10 @@ def notes_sem_load_cube(
modimpls_results = {}
modimpls_evals_poids = {}
modimpls_notes = []
etudids, etudids_actifs = formsemestre.etudids_actifs()
for modimpl in formsemestre.modimpls_sorted:
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs)
evals_poids = modimpl.get_evaluations_poids()
etuds_moy_module = mod_results.compute_module_moy(evals_poids, modimpl_coefs_df)
mod_results = moy_mod.ModuleImplResultsAPC(modimpl)
evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
etuds_moy_module = mod_results.compute_module_moy(evals_poids)
modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids
modimpls_notes.append(etuds_moy_module)
@ -351,12 +344,8 @@ def compute_ue_moys_classic(
pd.Series(
[val] * len(modimpl_inscr_df.index), index=modimpl_inscr_df.index
),
pd.DataFrame(
columns=[ue.id for ue in ues], index=modimpl_inscr_df.index, dtype=float
),
pd.DataFrame(
columns=[ue.id for ue in ues], index=modimpl_inscr_df.index, dtype=float
),
pd.DataFrame(columns=[ue.id for ue in ues], index=modimpl_inscr_df.index),
pd.DataFrame(columns=[ue.id for ue in ues], index=modimpl_inscr_df.index),
)
# Restreint aux modules sélectionnés:
sem_matrix = sem_matrix[:, modimpl_mask]
@ -411,7 +400,6 @@ def compute_ue_moys_classic(
},
index=modimpl_inscr_df.index,
columns=[ue.id for ue in ues],
dtype=float,
)
# remplace NaN par zéros dans les moyennes d'UE
etud_moy_ue_df_no_nan = etud_moy_ue_df.fillna(0.0, inplace=False)
@ -427,7 +415,6 @@ def compute_ue_moys_classic(
coefs.sum(axis=2).T,
index=modimpl_inscr_df.index, # etudids
columns=[ue.id for ue in ues],
dtype=float,
)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etud_moy_gen = np.sum(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -50,7 +50,7 @@ def load_formsemestre_validations(formsemestre: FormSemestre) -> ValidationsSeme
If not in cache, build it and cache it (in g).
"""
if not hasattr(g, "formsemestre_validation_cache"):
g.formsemestre_validations_cache = {}
g.formsemestre_validations_cache = {} # pylint: disable=C0237
else:
if formsemestre.id in g.formsemestre_validations_cache:
return g.formsemestre_validations_cache[formsemestre.id]

View File

@ -44,7 +44,6 @@ def scodoc(func):
Set `g.scodoc_dept` and `g.scodoc_dept_id` if `scodoc_dept` is present
in the argument (for routes like
`/<scodoc_dept>/Scolarite/sco_exemple`).
Else set scodoc_dept=None, scodoc_dept_id=-1.
"""
@wraps(func)
@ -187,10 +186,7 @@ def scodoc7func(func):
arg_names = argspec.args
for arg_name in arg_names: # pour chaque arg de la fonction vue
# peut produire une KeyError s'il manque un argument attendu:
try:
v = req_args[arg_name]
except KeyError as exc:
raise ScoValueError(f"argument {arg_name} manquant") from exc
v = req_args[arg_name]
# try to convert all arguments to INTEGERS
# necessary for db ids and boolean values
try:

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -131,7 +131,7 @@ def check_offre_depts(depts: list, offre_depts: list):
"""
Retourne vrai si l'utilisateur a le droit de visibilité sur l'offre
"""
if current_user.has_permission(Permission.RelationsEntrepEdit, None):
if current_user.has_permission(Permission.RelationsEntreprisesChange, None):
return True
for offre_dept in offre_depts:
if offre_dept.dept_id in depts:

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -62,7 +62,7 @@ from config import Config
@bp.route("/", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntrepView)
@permission_required(Permission.RelationsEntreprisesView)
def index():
"""
Permet d'afficher une page avec la liste des entreprises (visible et active) et une liste des dernières opérations
@ -98,7 +98,7 @@ def index():
@bp.route("/logs", methods=["GET"])
@permission_required(Permission.RelationsEntrepView)
@permission_required(Permission.RelationsEntreprisesView)
def logs():
"""
Permet d'afficher les logs (toutes les entreprises)
@ -115,7 +115,7 @@ def logs():
@bp.route("/correspondants", methods=["GET"])
@permission_required(Permission.RelationsEntrepViewCorrs)
@permission_required(Permission.RelationsEntreprisesCorrespondants)
def correspondants():
"""
Permet d'afficher une page avec la liste des correspondants des entreprises visibles et une liste des dernières opérations
@ -141,7 +141,7 @@ def correspondants():
@bp.route("/validation", methods=["GET"])
@permission_required(Permission.RelationsEntrepValidate)
@permission_required(Permission.RelationsEntreprisesValidate)
def validation():
"""
Permet d'afficher une page avec la liste des entreprises a valider (non visible)
@ -155,7 +155,7 @@ def validation():
@bp.route("/fiche_entreprise_validation/<int:entreprise_id>", methods=["GET"])
@permission_required(Permission.RelationsEntrepValidate)
@permission_required(Permission.RelationsEntreprisesValidate)
def fiche_entreprise_validation(entreprise_id):
"""
Permet d'afficher la fiche entreprise d'une entreprise a valider
@ -176,7 +176,7 @@ def fiche_entreprise_validation(entreprise_id):
"/fiche_entreprise_validation/<int:entreprise_id>/validate_entreprise",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepValidate)
@permission_required(Permission.RelationsEntreprisesValidate)
def validate_entreprise(entreprise_id):
"""
Permet de valider une entreprise
@ -214,7 +214,7 @@ def validate_entreprise(entreprise_id):
"/fiche_entreprise_validation/<int:entreprise_id>/delete_validation_entreprise",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepValidate)
@permission_required(Permission.RelationsEntreprisesValidate)
def delete_validation_entreprise(entreprise_id):
"""
Permet de supprimer une entreprise en attente de validation avec une formulaire de validation
@ -241,7 +241,7 @@ def delete_validation_entreprise(entreprise_id):
flash("L'entreprise a été supprimée de la liste des entreprises à valider.")
return redirect(url_for("entreprises.validation"))
return render_template(
"form_confirmation.j2",
"entreprises/form_confirmation.j2",
title="Supression entreprise",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -249,7 +249,7 @@ def delete_validation_entreprise(entreprise_id):
@bp.route("/offres_recues", methods=["GET"])
@permission_required(Permission.RelationsEntrepView)
@permission_required(Permission.RelationsEntreprisesView)
def offres_recues():
"""
Permet d'afficher la page où l'on peut voir les offres reçues
@ -290,7 +290,7 @@ def offres_recues():
@bp.route(
"/offres_recues/delete_offre_recue/<int:envoi_offre_id>", methods=["GET", "POST"]
)
@permission_required(Permission.RelationsEntrepView)
@permission_required(Permission.RelationsEntreprisesView)
def delete_offre_recue(envoi_offre_id):
"""
Permet de supprimer une offre reçue
@ -304,7 +304,7 @@ def delete_offre_recue(envoi_offre_id):
@bp.route("/preferences", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntrepValidate)
@permission_required(Permission.RelationsEntreprisesValidate)
def preferences():
"""
Permet d'afficher la page des préférences du module gestion des relations entreprises
@ -327,7 +327,7 @@ def preferences():
@bp.route("/add_entreprise", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def add_entreprise():
"""
Permet d'ajouter une entreprise dans la base avec un formulaire
@ -338,11 +338,9 @@ def add_entreprise():
if form.validate_on_submit():
entreprise = Entreprise(
nom=form.nom_entreprise.data.strip(),
siret=(
form.siret.data.strip()
if form.siret.data.strip()
else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}"
), # siret provisoire
siret=form.siret.data.strip()
if form.siret.data.strip()
else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}", # siret provisoire
siret_provisoire=False if form.siret.data.strip() else True,
association=form.association.data,
adresse=form.adresse.data.strip(),
@ -354,7 +352,7 @@ def add_entreprise():
db.session.add(entreprise)
db.session.commit()
db.session.refresh(entreprise)
except Exception:
except:
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
@ -387,7 +385,7 @@ def add_entreprise():
notes=form.notes.data.strip(),
)
db.session.add(correspondant)
if current_user.has_permission(Permission.RelationsEntrepValidate, None):
if current_user.has_permission(Permission.RelationsEntreprisesValidate, None):
entreprise.visible = True
lien_entreprise = f"<a href='{url_for('entreprises.fiche_entreprise', entreprise_id=entreprise.id)}'>{entreprise.nom}</a>"
log = EntrepriseHistorique(
@ -416,7 +414,7 @@ def add_entreprise():
@bp.route("/fiche_entreprise/<int:entreprise_id>", methods=["GET"])
@permission_required(Permission.RelationsEntrepView)
@permission_required(Permission.RelationsEntreprisesView)
def fiche_entreprise(entreprise_id):
"""
Permet d'afficher la fiche entreprise d'une entreprise avec une liste des dernières opérations et
@ -458,7 +456,7 @@ def fiche_entreprise(entreprise_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/logs", methods=["GET"])
@permission_required(Permission.RelationsEntrepView)
@permission_required(Permission.RelationsEntreprisesView)
def logs_entreprise(entreprise_id):
"""
Permet d'afficher les logs d'une entreprise
@ -481,7 +479,7 @@ def logs_entreprise(entreprise_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/offres_expirees")
@permission_required(Permission.RelationsEntrepView)
@permission_required(Permission.RelationsEntreprisesView)
def offres_expirees(entreprise_id):
"""
Permet d'afficher la liste des offres expirés d'une entreprise
@ -501,7 +499,7 @@ def offres_expirees(entreprise_id):
@bp.route(
"/fiche_entreprise/<int:entreprise_id>/edit_entreprise", methods=["GET", "POST"]
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def edit_entreprise(entreprise_id):
"""
Permet de modifier une entreprise de la base avec un formulaire
@ -582,7 +580,7 @@ def edit_entreprise(entreprise_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/desactiver", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def fiche_entreprise_desactiver(entreprise_id):
"""
Permet de désactiver une entreprise
@ -611,7 +609,7 @@ def fiche_entreprise_desactiver(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"form_confirmation.j2",
"entreprises/form_confirmation.j2",
title="Désactiver entreprise",
form=form,
info_message="Cliquez sur le bouton Modifier pour confirmer la désactivation",
@ -619,7 +617,7 @@ def fiche_entreprise_desactiver(entreprise_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/activer", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def fiche_entreprise_activer(entreprise_id):
"""
Permet d'activer une entreprise
@ -647,7 +645,7 @@ def fiche_entreprise_activer(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"form_confirmation.j2",
"entreprises/form_confirmation.j2",
title="Activer entreprise",
form=form,
info_message="Cliquez sur le bouton Modifier pour confirmer l'activaction",
@ -776,7 +774,7 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id):
url_for("entreprises.fiche_entreprise", entreprise_id=taxe.entreprise_id)
)
return render_template(
"form_confirmation.j2",
"entreprises/form_confirmation.j2",
title="Supprimer taxe apprentissage",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -784,7 +782,7 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/add_offre", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def add_offre(entreprise_id):
"""
Permet d'ajouter une offre a une entreprise
@ -806,9 +804,9 @@ def add_offre(entreprise_id):
missions=form.missions.data.strip(),
duree=form.duree.data.strip(),
expiration_date=form.expiration_date.data,
correspondant_id=(
form.correspondant.data if form.correspondant.data != "" else None
),
correspondant_id=form.correspondant.data
if form.correspondant.data != ""
else None,
)
db.session.add(offre)
db.session.commit()
@ -856,7 +854,7 @@ def add_offre(entreprise_id):
"/fiche_entreprise/<int:entreprise_id>/edit_offre/<int:offre_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def edit_offre(entreprise_id, offre_id):
"""
Permet de modifier une offre
@ -932,7 +930,7 @@ def edit_offre(entreprise_id, offre_id):
"/fiche_entreprise/<int:entreprise_id>/delete_offre/<int:offre_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def delete_offre(entreprise_id, offre_id):
"""
Permet de supprimer une offre
@ -972,7 +970,7 @@ def delete_offre(entreprise_id, offre_id):
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
)
return render_template(
"form_confirmation.j2",
"entreprises/form_confirmation.j2",
title="Supression offre",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -983,7 +981,7 @@ def delete_offre(entreprise_id, offre_id):
"/fiche_entreprise/<int:entreprise_id>/expired/<int:offre_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def expired(entreprise_id, offre_id):
"""
Permet de rendre expirée et non expirée une offre
@ -1008,7 +1006,7 @@ def expired(entreprise_id, offre_id):
"/fiche_entreprise/<int:entreprise_id>/add_site",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def add_site(entreprise_id):
"""
Permet d'ajouter un site a une entreprise
@ -1109,7 +1107,7 @@ def edit_site(entreprise_id, site_id):
"/fiche_entreprise/<int:entreprise_id>/site/<int:site_id>/add_correspondant",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def add_correspondant(entreprise_id, site_id):
"""
Permet d'ajouter un correspondant a une entreprise
@ -1165,7 +1163,7 @@ def add_correspondant(entreprise_id, site_id):
"/fiche_entreprise/<int:entreprise_id>/site/<int:site_id>/edit_correspondant/<int:correspondant_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def edit_correspondant(entreprise_id, site_id, correspondant_id):
"""
Permet de modifier un correspondant
@ -1245,7 +1243,7 @@ def edit_correspondant(entreprise_id, site_id, correspondant_id):
"/fiche_entreprise/<int:entreprise_id>/site/<int:site_id>/delete_correspondant/<int:correspondant_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def delete_correspondant(entreprise_id, site_id, correspondant_id):
"""
Permet de supprimer un correspondant
@ -1291,7 +1289,7 @@ def delete_correspondant(entreprise_id, site_id, correspondant_id):
)
)
return render_template(
"form_confirmation.j2",
"entreprises/form_confirmation.j2",
title="Supression correspondant",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1299,7 +1297,7 @@ def delete_correspondant(entreprise_id, site_id, correspondant_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/contacts")
@permission_required(Permission.RelationsEntrepView)
@permission_required(Permission.RelationsEntreprisesView)
def contacts(entreprise_id):
"""
Permet d'afficher une page avec la liste des contacts d'une entreprise
@ -1320,7 +1318,7 @@ def contacts(entreprise_id):
"/fiche_entreprise/<int:entreprise_id>/contacts/add_contact",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def add_contact(entreprise_id):
"""
Permet d'ajouter un contact avec une entreprise
@ -1330,11 +1328,9 @@ def add_contact(entreprise_id):
).first_or_404(description=f"entreprise {entreprise_id} inconnue")
form = ContactCreationForm(
date=f"{datetime.now().strftime('%Y-%m-%dT%H:%M')}",
utilisateur=(
f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
if current_user.nom and current_user.prenom
else ""
),
utilisateur=f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
if current_user.nom and current_user.prenom
else "",
)
if request.method == "POST" and form.cancel.data:
return redirect(url_for("entreprises.contacts", entreprise_id=entreprise_id))
@ -1378,7 +1374,7 @@ def add_contact(entreprise_id):
"/fiche_entreprise/<int:entreprise_id>/contacts/edit_contact/<int:contact_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def edit_contact(entreprise_id, contact_id):
"""
Permet d'editer un contact avec une entreprise
@ -1434,7 +1430,7 @@ def edit_contact(entreprise_id, contact_id):
"/fiche_entreprise/<int:entreprise_id>/contacts/delete_contact/<int:contact_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def delete_contact(entreprise_id, contact_id):
"""
Permet de supprimer un contact
@ -1462,7 +1458,7 @@ def delete_contact(entreprise_id, contact_id):
url_for("entreprises.contacts", entreprise_id=contact.entreprise)
)
return render_template(
"form_confirmation.j2",
"entreprises/form_confirmation.j2",
title="Supression contact",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1473,7 +1469,7 @@ def delete_contact(entreprise_id, contact_id):
"/fiche_entreprise/<int:entreprise_id>/add_stage_apprentissage",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def add_stage_apprentissage(entreprise_id):
"""
Permet d'ajouter un étudiant ayant réalisé un stage ou alternance
@ -1500,9 +1496,9 @@ def add_stage_apprentissage(entreprise_id):
date_debut=form.date_debut.data,
date_fin=form.date_fin.data,
formation_text=formation.formsemestre.titre if formation else None,
formation_scodoc=(
formation.formsemestre.formsemestre_id if formation else None
),
formation_scodoc=formation.formsemestre.formsemestre_id
if formation
else None,
notes=form.notes.data.strip(),
)
db.session.add(stage_apprentissage)
@ -1532,7 +1528,7 @@ def add_stage_apprentissage(entreprise_id):
"/fiche_entreprise/<int:entreprise_id>/edit_stage_apprentissage/<int:stage_apprentissage_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
"""
Permet de modifier un étudiant ayant réalisé un stage ou alternance sur la fiche de l'entreprise
@ -1584,8 +1580,8 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
)
)
elif request.method == "GET":
form.etudiant.data = f"""{scu.format_nom(etudiant.nom)} {
scu.format_prenom(etudiant.prenom)}"""
form.etudiant.data = f"""{sco_etud.format_nom(etudiant.nom)} {
sco_etud.format_prenom(etudiant.prenom)}"""
form.etudid.data = etudiant.id
form.type_offre.data = stage_apprentissage.type_offre
form.date_debut.data = stage_apprentissage.date_debut
@ -1602,7 +1598,7 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
"/fiche_entreprise/<int:entreprise_id>/delete_stage_apprentissage/<int:stage_apprentissage_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def delete_stage_apprentissage(entreprise_id, stage_apprentissage_id):
"""
Permet de supprimer un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise
@ -1633,7 +1629,7 @@ def delete_stage_apprentissage(entreprise_id, stage_apprentissage_id):
)
)
return render_template(
"form_confirmation.j2",
"entreprises/form_confirmation.j2",
title="Supression stage/apprentissage",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1644,7 +1640,7 @@ def delete_stage_apprentissage(entreprise_id, stage_apprentissage_id):
"/fiche_entreprise/<int:entreprise_id>/envoyer_offre/<int:offre_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepSend)
@permission_required(Permission.RelationsEntreprisesSend)
def envoyer_offre(entreprise_id, offre_id):
"""
Permet d'envoyer une offre à un utilisateur ScoDoc
@ -1690,7 +1686,7 @@ def envoyer_offre(entreprise_id, offre_id):
@bp.route("/etudiants")
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
@as_json
def json_etudiants():
"""
@ -1703,7 +1699,7 @@ def json_etudiants():
list = []
for etudiant in etudiants:
content = {}
value = f"{scu.format_nom(etudiant.nom)} {scu.format_prenom(etudiant.prenom)}"
value = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}"
if etudiant.inscription_courante() is not None:
content = {
"id": f"{etudiant.id}",
@ -1721,7 +1717,7 @@ def json_etudiants():
@bp.route("/responsables")
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def json_responsables():
"""
Permet de récuperer un JSON avec tous les utilisateurs ScoDoc
@ -1747,7 +1743,7 @@ def json_responsables():
@bp.route("/export_donnees")
@permission_required(Permission.RelationsEntrepExport)
@permission_required(Permission.RelationsEntreprisesExport)
def export_donnees():
"""
Permet d'exporter la liste des entreprises sous format excel (.xlsx)
@ -1763,7 +1759,7 @@ def export_donnees():
@bp.route("/import_donnees/get_file_sample")
@permission_required(Permission.RelationsEntrepExport)
@permission_required(Permission.RelationsEntreprisesExport)
def import_donnees_get_file_sample():
"""
Permet de récupérer un fichier exemple vide pour pouvoir importer des entreprises
@ -1775,7 +1771,7 @@ def import_donnees_get_file_sample():
@bp.route("/import_donnees", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntrepExport)
@permission_required(Permission.RelationsEntreprisesExport)
def import_donnees():
"""
Permet d'importer des entreprises à partir d'un fichier excel (.xlsx)
@ -1806,7 +1802,7 @@ def import_donnees():
db.session.add(entreprise)
db.session.commit()
db.session.refresh(entreprise)
except Exception:
except:
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
@ -1854,7 +1850,7 @@ def import_donnees():
@bp.route(
"/fiche_entreprise/<int:entreprise_id>/offre/<int:offre_id>/get_offre_file/<string:filedir>/<string:filename>"
)
@permission_required(Permission.RelationsEntrepView)
@permission_required(Permission.RelationsEntreprisesView)
def get_offre_file(entreprise_id, offre_id, filedir, filename):
"""
Permet de télécharger un fichier d'une offre
@ -1888,7 +1884,7 @@ def get_offre_file(entreprise_id, offre_id, filedir, filename):
"/fiche_entreprise/<int:entreprise_id>/offre/<int:offre_id>/add_offre_file",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def add_offre_file(entreprise_id, offre_id):
"""
Permet d'ajouter un fichier à une offre
@ -1931,7 +1927,7 @@ def add_offre_file(entreprise_id, offre_id):
"/fiche_entreprise/<int:entreprise_id>/offre/<int:offre_id>/delete_offre_file/<string:filedir>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntrepEdit)
@permission_required(Permission.RelationsEntreprisesChange)
def delete_offre_file(entreprise_id, offre_id, filedir):
"""
Permet de supprimer un fichier d'une offre
@ -1963,7 +1959,7 @@ def delete_offre_file(entreprise_id, offre_id, filedir):
)
)
return render_template(
"form_confirmation.j2",
"entreprises/form_confirmation.j2",
title="Suppression fichier d'une offre",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",

View File

@ -1,210 +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)
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=[
("", "Choisir..."), # Placeholder
(scu.EtatJustificatif.ATTENTE.value, "En attente de validation"),
(scu.EtatJustificatif.NON_VALIDE.value, "Non valide"),
(scu.EtatJustificatif.MODIFIE.value, "Modifié"),
(scu.EtatJustificatif.VALIDE.value, "Valide"),
],
validators=[DataRequired(message="This field is required.")],
)
fichiers = MultipleFileField(label="Ajouter des fichiers")
class ChoixDateForm(FlaskForm):
"""
Formulaire de choix de date
(utilisé par la page de choix de date
si la date courante n'est pas dans le semestre)
"""
def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs)
self.ok = True
self.error_messages: list[str] = [] # used to report our errors
def set_error(self, err_msg, field=None):
"Set error message both in form and field"
self.ok = False
self.error_messages.append(err_msg)
if field:
field.errors.append(err_msg)
date = StringField(
"Date",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "date",
},
)
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

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

View File

@ -4,7 +4,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -43,7 +43,6 @@ def gen_formsemestre_change_formation_form(
formations: list[Formation],
) -> FormSemestreChangeFormationForm:
"Create our dynamical form"
# see https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition
class F(FormSemestreChangeFormationForm):
pass

View File

@ -1,52 +0,0 @@
"""
Formulaire configuration des codes Apo et EDT des modimps d'un formsemestre
"""
from flask_wtf import FlaskForm
from wtforms import validators
from wtforms.fields.simple import StringField, SubmitField
from app.models import FormSemestre, ModuleImpl
class _EditModimplsCodesForm(FlaskForm):
"form. définition des liens personnalisés"
# construit dynamiquement ci-dessous
def EditModimplsCodesForm(formsemestre: FormSemestre) -> _EditModimplsCodesForm:
"Création d'un formulaire pour éditer les codes"
# Formulaire dynamique, on créé une classe ad-hoc
class F(_EditModimplsCodesForm):
pass
def _gen_mod_form(modimpl: ModuleImpl):
field = StringField(
modimpl.module.code,
validators=[
validators.Optional(),
validators.Length(min=1, max=80),
],
default="",
render_kw={"size": 32},
)
setattr(F, f"modimpl_apo_{modimpl.id}", field)
field = StringField(
"",
validators=[
validators.Optional(),
validators.Length(min=1, max=80),
],
default="",
render_kw={"size": 12},
)
setattr(F, f"modimpl_edt_{modimpl.id}", field)
for modimpl in formsemestre.modimpls_sorted:
_gen_mod_form(modimpl)
F.submit = SubmitField("Valider")
F.cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
return F()

View File

@ -1,17 +0,0 @@
# -*- coding: utf-8 -*-
"""Formulaires génériques
"""
from flask_wtf import FlaskForm
from wtforms import (
SubmitField,
)
SUBMIT_MARGE = {"style": "margin-bottom: 10px;"}
class SimpleConfirmationForm(FlaskForm):
"bête dialogue de confirmation"
submit = SubmitField("OK", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)

View File

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

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -28,163 +28,61 @@
"""
Formulaire configuration Module Assiduités
"""
import datetime
import re
from flask_wtf import FlaskForm
from wtforms import DecimalField, SubmitField, ValidationError
from wtforms import SubmitField, DecimalField
from wtforms.fields.simple import StringField
from wtforms.validators import Optional, Length
from wtforms.widgets import TimeInput
import datetime
def check_tick_time(form, field):
"""Le tick_time doit être entre 0 et 60 minutes"""
if field.data < 1 or field.data > 59:
raise ValidationError("Valeur de granularité invalide (entre 1 et 59)")
class TimeField(StringField):
"""HTML5 time input."""
widget = TimeInput()
def check_ics_path(form, field):
"""Vérifie que le chemin est bien un chemin absolu
et qu'il contient edt_id
"""
data = field.data.strip()
if not data:
return
if not data.startswith("/"):
raise ValidationError("Le chemin vers les ics doit commencer par /")
if not "{edt_id}" in data:
raise ValidationError("Le chemin vers les ics doit utiliser {edt_id}")
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 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 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])
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
if len(components) == 3:
seconds = int(components[2])
else:
raise ValueError
self.data = datetime.time(hour, minutes, seconds)
except ValueError:
self.data = None
raise ValueError(self.gettext("Not a valid time string"))
class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduité"
assi_morning_time = StringField(
"Début de la journée",
default="",
validators=[Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_morning_time",
},
)
assi_lunch_time = StringField(
"Heure de midi (date pivot entre matin et après-midi)",
default="",
validators=[Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_lunch_time",
},
)
assi_afternoon_time = StringField(
"Fin de la journée",
validators=[Length(max=5)],
default="",
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_afternoon_time",
},
)
"Formulaire paramétrage Module Assiduités"
assi_tick_time = DecimalField(
"Granularité de la timeline (temps en minutes)",
places=0,
validators=[check_tick_time],
)
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")
edt_ics_path = StringField(
label="Chemin vers les ics",
description="""Chemin absolu unix sur le serveur vers le fichier ics donnant l'emploi
du temps d'un semestre. La balise <tt>{edt_id}</tt> sera remplacée par l'edt_id du
semestre (par défaut, son code étape Apogée).
Si ce champ n'est pas renseigné, les emplois du temps ne seront pas utilisés.""",
validators=[Optional(), check_ics_path],
)
edt_ics_user_path = StringField(
label="Chemin vers les ics des utilisateurs (enseignants)",
description="""Optionnel. Chemin absolu unix sur le serveur vers le fichier ics donnant l'emploi
du temps d'un enseignant. La balise <tt>{edt_id}</tt> sera remplacée par l'edt_id du
de l'utilisateur.
Dans certains cas (XXX), ScoDoc peut générer ces fichiers et les écrira suivant
ce chemin (avec edt_id).
""",
validators=[Optional(), check_ics_path],
)
tick_time = DecimalField("Granularité de la Time Line (temps en minutes)", places=0)
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 doit
sera le titre de l'évènement affcihé dans le calendrier ScoDoc.
Exemple: <tt>Matière : \w+ - ([\w\.\s']+)</tt>
""",
validators=[Optional(), check_ics_regexp],
)
edt_ics_group_field = StringField(
label="Champ contenant le groupe",
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
validators=[Optional(), check_ics_field],
)
edt_ics_group_regexp = StringField(
label="Extraction du groupe",
description=r"""expression régulière python dont le premier groupe doit
correspondre à l'identifiant de groupe de l'emploi du temps.
Exemple: <tt>.*- ([\w\s]+)$</tt>
""",
validators=[Optional(), check_ics_regexp],
)
edt_ics_mod_field = StringField(
label="Champ contenant le module",
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
validators=[Optional(), check_ics_field],
)
edt_ics_mod_regexp = StringField(
label="Extraction du module",
description=r"""expression régulière python dont le premier groupe doit
correspondre à l'identifiant (code) du module de l'emploi du temps.
Exemple: <tt>Matière : ([A-Z][A-Z0-9]+)</tt>
""",
validators=[Optional(), check_ics_regexp],
)
edt_ics_uid_field = StringField(
label="Champ contenant les enseignants",
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
validators=[Optional(), check_ics_field],
)
edt_ics_uid_regexp = StringField(
label="Extraction des enseignants",
description=r"""expression régulière python permettant d'extraire les
identifiants des enseignants associés à l'évènement.
(contrairement aux autres champs, il peut y avoir plusieurs enseignants par évènement.)
Exemple: <tt>[0-9]+</tt>
""",
validators=[Optional(), check_ics_regexp],
)
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -30,23 +30,8 @@ Formulaire configuration CAS
"""
from flask_wtf import FlaskForm
from wtforms import BooleanField, SubmitField, ValidationError
from wtforms import BooleanField, SubmitField
from wtforms.fields.simple import FileField, StringField
from wtforms.validators import Optional
from app.models import ScoDocSiteConfig
def check_cas_uid_from_mail_regexp(form, field):
"Vérifie la regexp fournie pour l'extraction du CAS id"
if not ScoDocSiteConfig.cas_uid_from_mail_regexp_is_valid(field.data):
raise ValidationError("expression régulière invalide")
def check_cas_edt_id_from_xml_regexp(form, field):
"Vérifie la regexp fournie pour l'extraction du CAS id"
if not ScoDocSiteConfig.cas_edt_id_from_xml_regexp_is_valid(field.data):
raise ValidationError("expression régulière pour edt_id invalide")
class ConfigCASForm(FlaskForm):
@ -55,60 +40,33 @@ class ConfigCASForm(FlaskForm):
cas_force = BooleanField(
"Forcer l'utilisation de CAS (tous les utilisateurs seront redirigés vers le CAS)"
)
cas_allow_for_new_users = BooleanField(
"Par défaut, autoriser le CAS aux nouveaux utilisateurs"
)
cas_server = StringField(
label="URL du serveur CAS",
description="""url complète. Commence en général par <tt>https://</tt>.""",
)
cas_login_route = StringField(
label="Optionnel: route du login CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas</tt>
(si commence par <tt>/</tt>, part de la racine)""",
label="Route du login CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas</tt> (si commence par <tt>/</tt>, part de la racine)""",
default="/cas",
)
cas_logout_route = StringField(
label="Optionnel: route du logout CAS",
label="Route du logout CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas/logout</tt>""",
default="/cas/logout",
)
cas_validate_route = StringField(
label="Optionnel: route de validation CAS",
label="Route de validation CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas/serviceValidate</tt>""",
default="/cas/serviceValidate",
)
cas_attribute_id = StringField(
label="Attribut CAS utilisé comme id (laissez vide pour prendre l'id par défaut)",
description="""Le champ CAS qui sera considéré comme l'id unique des
description="""Le champs CAS qui sera considéré comme l'id unique des
comptes utilisateurs.""",
)
cas_uid_from_mail_regexp = StringField(
label="Optionnel: expression pour extraire l'identifiant utilisateur",
description="""regexp python appliquée au mail institutionnel de l'utilisateur,
dont le premier groupe doit donner l'identifiant CAS.
Si non fournie, le super-admin devra saisir cet identifiant pour chaque compte.
Par exemple, <tt>(.*)@</tt> indique que le mail sans le domaine (donc toute
la partie avant le <tt>@</tt>) est l'identifiant.
Pour prendre le mail complet, utiliser <tt>(.*)</tt>.
""",
validators=[Optional(), check_cas_uid_from_mail_regexp],
)
cas_edt_id_from_xml_regexp = StringField(
label="Optionnel: expression pour extraire l'identifiant edt",
description="""regexp python appliquée à la réponse XML du serveur CAS pour
retrouver l'id de l'utilisateur sur le SI de l'institution, et notamment sur les
calendrier d'emploi du temps. Par exemple, si cet id est renvoyé dans le champ
<b>supannEmpId</b>, utiliser:
<tt>&lt;cas:supannEmpId&gt;(.*?)&lt;/cas:supannEmpId&gt;</tt>
""",
validators=[Optional(), check_cas_edt_id_from_xml_regexp],
)
cas_ssl_verify = BooleanField("Vérification du certificat SSL")
cas_ssl_certificate_file = FileField(
label="Certificat (PEM)",

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -28,7 +28,6 @@
"""
Formulaires configuration Exports Apogée (codes)
"""
import time
from flask import flash, url_for, redirect, request, render_template
from flask_wtf import FlaskForm
@ -48,15 +47,13 @@ class BonusConfigurationForm(FlaskForm):
for (name, displayed_name) in ScoDocSiteConfig.get_bonus_sport_class_list()
],
)
submit_bonus = SubmitField("Enregistrer ce bonus")
submit_bonus = SubmitField("Valider")
cancel_bonus = SubmitField("Annuler", render_kw={"formnovalidate": True})
class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration avancée"
disable_passerelle = BooleanField( # disable car par défaut activée
"""cacher les fonctions liées à une passerelle de publication des résultats vers les étudiants ("œil"). N'affecte pas l'API, juste la présentation."""
)
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
month_debut_annee_scolaire = SelectField(
label="Mois de début des années scolaires",
description="""Date pivot. En France métropolitaine, août.
@ -79,13 +76,7 @@ class ScoDocConfigurationForm(FlaskForm):
Attention: si ce champ peut aussi être défini dans chaque département.""",
validators=[Optional(), Email()],
)
user_require_email_institutionnel = BooleanField(
"imposer la saisie du mail institutionnel dans le formulaire de création utilisateur"
)
disable_bul_pdf = BooleanField(
"interdire les exports des bulletins en PDF (déconseillé)"
)
submit_scodoc = SubmitField("Enregistrer ces paramètres")
submit_scodoc = SubmitField("Valider")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
@ -100,12 +91,9 @@ def configuration():
form_scodoc = ScoDocConfigurationForm(
data={
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
"disable_passerelle": ScoDocSiteConfig.is_passerelle_disabled(),
"month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(),
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
"email_from_addr": ScoDocSiteConfig.get("email_from_addr"),
"disable_bul_pdf": ScoDocSiteConfig.is_bul_pdf_disabled(),
"user_require_email_institutionnel": ScoDocSiteConfig.is_user_require_email_institutionnel_enabled(),
}
)
if request.method == "POST" and (
@ -126,12 +114,12 @@ def configuration():
flash("Fonction bonus inchangée.")
return redirect(url_for("scodoc.index"))
elif form_scodoc.submit_scodoc.data and form_scodoc.validate():
if ScoDocSiteConfig.disable_passerelle(
disabled=form_scodoc.data["disable_passerelle"]
if ScoDocSiteConfig.enable_entreprises(
enabled=form_scodoc.data["enable_entreprises"]
):
flash(
"Fonction passerelle "
+ ("cachées" if form_scodoc.data["disable_passerelle"] else "montrées")
"Module entreprise "
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
)
if ScoDocSiteConfig.set_month_debut_annee_scolaire(
int(form_scodoc.data["month_debut_annee_scolaire"])
@ -151,33 +139,12 @@ def configuration():
)
if ScoDocSiteConfig.set("email_from_addr", form_scodoc.data["email_from_addr"]):
flash("Adresse email origine enregistrée")
if ScoDocSiteConfig.disable_bul_pdf(
enabled=form_scodoc.data["disable_bul_pdf"]
):
flash(
"Exports PDF "
+ ("désactivés" if form_scodoc.data["disable_bul_pdf"] else "réactivés")
)
if ScoDocSiteConfig.set(
"user_require_email_institutionnel",
"on" if form_scodoc.data["user_require_email_institutionnel"] else "",
):
flash(
(
"impose"
if form_scodoc.data["user_require_email_institutionnel"]
else "n'impose pas"
)
+ " la saisie du mail institutionnel des utilisateurs"
)
return redirect(url_for("scodoc.index"))
return render_template(
"configuration.j2",
is_entreprises_enabled=ScoDocSiteConfig.is_entreprises_enabled(),
form_bonus=form_bonus,
form_scodoc=form_scodoc,
scu=scu,
time=time,
title="Configuration",
)

View File

@ -2,8 +2,9 @@
Formulaire configuration liens personalisés (menu "Liens")
"""
from flask import g, url_for
from flask_wtf import FlaskForm
from wtforms import validators
from wtforms import FieldList, Form, validators
from wtforms.fields.simple import BooleanField, StringField, SubmitField
from app.models import ScoDocSiteConfig
@ -28,7 +29,7 @@ def PersonalizedLinksForm() -> _PersonalizedLinksForm:
F,
f"link_{idx}",
StringField(
"Titre",
f"Titre",
validators=[
validators.Optional(),
validators.Length(min=1, max=80),
@ -41,7 +42,7 @@ def PersonalizedLinksForm() -> _PersonalizedLinksForm:
F,
f"link_url_{idx}",
StringField(
"URL",
f"URL",
description="adresse, incluant le http.",
validators=[
validators.Optional(),
@ -55,7 +56,7 @@ def PersonalizedLinksForm() -> _PersonalizedLinksForm:
F,
f"link_with_args_{idx}",
BooleanField(
"ajouter arguments",
f"ajouter arguments",
description="query string avec ids",
),
)

View File

@ -1,49 +0,0 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire configuration RGPD
"""
from flask_wtf import FlaskForm
from wtforms import SubmitField
from wtforms.fields.simple import TextAreaField
class ConfigRGPDForm(FlaskForm):
"Formulaire paramétrage RGPD"
rgpd_coordonnees_dpo = TextAreaField(
label="Optionnel: coordonnées du DPO",
description="""Le délégué à la protection des données (DPO) est chargé de mettre en œuvre
la conformité au règlement européen sur la protection des données (RGPD) au sein de lorganisme.
Indiquer ici les coordonnées (format libre) qui seront affichées aux utilisateurs de ScoDoc.
""",
render_kw={"rows": 5, "cols": 72},
)
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -1,66 +0,0 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire création de ticket de bug
"""
from flask_wtf import FlaskForm
from wtforms import SubmitField, validators
from wtforms.fields.simple import StringField, TextAreaField, BooleanField
from app.scodoc import sco_preferences
class CreateBugReport(FlaskForm):
"""Formulaire permettant la création d'un ticket de bug"""
title = StringField(
label="Titre du ticket",
validators=[
validators.DataRequired("titre du ticket requis"),
],
)
message = TextAreaField(
label="Message",
id="ticket_message",
validators=[
validators.DataRequired("message du ticket requis"),
],
)
etab = StringField(label="Etablissement")
include_dump = BooleanField(
"""Inclure une copie anonymisée de la base de données ?
Ces données faciliteront le traitement du problème et resteront strictement confidentielles.
""",
default=False,
)
submit = SubmitField("Envoyer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
def __init__(self, *args, **kwargs):
super(CreateBugReport, self).__init__(*args, **kwargs)
self.etab.data = sco_preferences.get_preference("InstituteName") or ""

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -1,58 +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
#
##############################################################################
"""
Formulaires création département
"""
from flask_wtf import FlaskForm
from wtforms import SubmitField, validators
from wtforms.fields.simple import StringField, BooleanField
from app.models import SHORT_STR_LEN
class CreateRoleForm(FlaskForm):
"""Formulaire création rôle"""
name = StringField(
label="Nom du rôle",
validators=[
validators.regexp(
r"^[a-zA-Z0-9]*$",
message="Ne doit comporter que lettres et des chiffres",
),
validators.Length(
max=SHORT_STR_LEN,
message=f"Le nom ne doit pas dépasser {SHORT_STR_LEN} caractères",
),
validators.DataRequired("vous devez spécifier le nom du rôle"),
],
)
submit = SubmitField("Créer ce rôle")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

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

@ -4,7 +4,6 @@
"""
import sqlalchemy
from app import db
CODE_STR_LEN = 16 # chaine pour les codes
SHORT_STR_LEN = 32 # courtes chaine, eg acronymes
@ -22,101 +21,6 @@ convention = {
metadata_obj = sqlalchemy.MetaData(naming_convention=convention)
class ScoDocModel(db.Model):
"""Superclass for our models. Add some useful methods for editing, cloning, etc.
- clone() : clone object and add copy to session, do not commit.
- create_from_dict() : create instance from given dict, applying conversions.
- convert_dict_fields() : convert dict values, called before instance creation.
By default, do nothing.
- from_dict() : update object using data from dict. data is first converted.
- edit() : update from wtf form.
"""
__abstract__ = True # declare an abstract class for SQLAlchemy
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain flush ou commit.
"""
d = dict(self.__dict__)
d.pop("id", None) # get rid of id
d.pop("_sa_instance_state", None) # get rid of SQLAlchemy special attr
for k in not_copying:
d.pop(k, None)
copy = self.__class__(**d)
db.session.add(copy)
return copy
@classmethod
def create_from_dict(cls, data: dict) -> "ScoDocModel":
"""Create a new instance of the model with attributes given in dict.
The instance is added to the session (but not flushed nor committed).
Use only relevant attributes for the given model and ignore others.
"""
if data:
args = cls.convert_dict_fields(cls.filter_model_attributes(data))
if args:
obj = cls(**args)
else:
obj = cls()
else:
obj = cls()
db.session.add(obj)
return obj
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
Add 'id' to excluded."""
excluded = excluded or set()
excluded.add("id") # always exclude id
# Les attributs du modèle qui sont des variables: (élimine les __ et les alias comme adm_id)
my_attributes = [
a
for a in dir(cls)
if isinstance(
getattr(cls, a), sqlalchemy.orm.attributes.InstrumentedAttribute
)
]
# Filtre les arguments utiles
return {
k: v for k, v in data.items() if k in my_attributes and k not in excluded
}
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields from the given dict to model's attributes values. No side effect.
By default, do nothing, but is overloaded by some subclasses.
args: dict with args in application.
returns: dict to store in model's db.
"""
# virtual, by default, do nothing
return args
def from_dict(self, args: dict, excluded: set[str] | None = None) -> bool:
"""Update object's fields given in dict. Add to session but don't commit.
True if modification.
"""
args_dict = self.convert_dict_fields(
self.filter_model_attributes(args, excluded=excluded)
)
modified = False
for key, value in args_dict.items():
if hasattr(self, key) and value != getattr(self, key):
setattr(self, key, value)
modified = True
db.session.add(self)
return modified
def edit_from_form(self, form) -> bool:
"""Generic edit method for updating model instance.
True if modification.
"""
args = {field.name: field.data for field in form}
return self.from_dict(args)
from app.models.absences import Absence, AbsenceNotification, BilletAbsence
from app.models.departements import Departement
from app.models.etudiants import (
@ -160,6 +64,7 @@ from app.models.notes import (
NotesNotesLog,
)
from app.models.validations import (
ScolarEvent,
ScolarFormSemestreValidation,
ScolarAutorisationInscription,
)
@ -178,4 +83,3 @@ from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.config import ScoDocSiteConfig
from app.models.assiduites import Assiduite, Justificatif
from app.models.scolar_event import ScolarEvent

View File

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

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 models : Référentiel Compétence BUT 2021
@ -8,19 +8,16 @@
from datetime import datetime
import functools
from operator import attrgetter
import yaml
from flask import g
from flask_sqlalchemy.query import Query
from sqlalchemy.orm import class_mapper
import sqlalchemy
from app import db, log
from app import db
from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
REFCOMP_EQUIVALENCE_FILENAME = "ressources/referentiels/equivalences.yaml"
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
@ -100,18 +97,12 @@ class ApcReferentielCompetences(db.Model, XMLModel):
validations_annee = db.relationship(
"ApcValidationAnnee",
backref="referentiel_competence",
cascade="all, delete-orphan", # cascade at ORM level
lazy="dynamic",
)
def __repr__(self):
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
def get_title(self) -> str:
"Titre affichable"
# utilise type_titre (B.U.T.), spécialité, version
return f"{self.type_titre} {self.specialite} {self.get_version()}"
def get_version(self) -> str:
"La version, normalement sous forme de date iso yyy-mm-dd"
if not self.version_orebut:
@ -132,11 +123,9 @@ class ApcReferentielCompetences(db.Model, XMLModel):
"type_departement": self.type_departement,
"type_titre": self.type_titre,
"version_orebut": self.version_orebut,
"scodoc_date_loaded": (
self.scodoc_date_loaded.isoformat() + "Z"
if self.scodoc_date_loaded
else ""
),
"scodoc_date_loaded": self.scodoc_date_loaded.isoformat() + "Z"
if self.scodoc_date_loaded
else "",
"scodoc_orig_filename": self.scodoc_orig_filename,
"competences": {
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
@ -244,92 +233,6 @@ class ApcReferentielCompetences(db.Model, XMLModel):
return parcours_info
def equivalents(self) -> set["ApcReferentielCompetences"]:
"""Ensemble des référentiels du même département
qui peuvent être considérés comme "équivalents", au sens
une formation de ce référentiel pourrait changer vers un équivalent,
en ignorant les apprentissages critiques.
Pour cela, il faut avoir le même type, etc et les mêmes compétences,
niveaux et parcours (voir map_to_other_referentiel).
"""
candidats = ApcReferentielCompetences.query.filter_by(
dept_id=self.dept_id
).filter(ApcReferentielCompetences.id != self.id)
return {
referentiel
for referentiel in candidats
if not isinstance(self.map_to_other_referentiel(referentiel), str)
}
def map_to_other_referentiel(
self, other: "ApcReferentielCompetences"
) -> str | tuple[dict[int, int], dict[int, int], dict[int, int]]:
"""Build mapping between this referentiel and ref2.
If successful, returns 3 dicts mapping self ids to other ids.
Else return a string, error message.
"""
if self.type_structure != other.type_structure:
return "type_structure mismatch"
if self.type_departement != other.type_departement:
return "type_departement mismatch"
# Table d'équivalences entre refs:
equiv = self._load_config_equivalences()
# mê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
"""
try:
with open(REFCOMP_EQUIVALENCE_FILENAME, encoding="utf-8") as f:
doc = yaml.safe_load(f.read())
except FileNotFoundError:
log(f"_load_config_equivalences: {REFCOMP_EQUIVALENCE_FILENAME} not found")
return {}
except yaml.parser.ParserError as exc:
raise ScoValueError(
f"erreur dans le fichier {REFCOMP_EQUIVALENCE_FILENAME}"
) from exc
return doc.get(self.specialite, {})
class ApcCompetence(db.Model, XMLModel):
"Compétence"
@ -470,11 +373,9 @@ class ApcNiveau(db.Model, XMLModel):
"libelle": self.libelle,
"annee": self.annee,
"ordre": self.ordre,
"app_critiques": (
{x.code: x.to_dict() for x in self.app_critiques}
if with_app_critiques
else {}
),
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
if with_app_critiques
else {},
}
def to_dict_bul(self):
@ -562,9 +463,9 @@ class ApcNiveau(db.Model, XMLModel):
return []
if competence is None:
parcour_niveaux: list[ApcParcoursNiveauCompetence] = (
annee_parcour.niveaux_competences
)
parcour_niveaux: list[
ApcParcoursNiveauCompetence
] = annee_parcour.niveaux_competences
niveaux: list[ApcNiveau] = [
pn.competence.niveaux.filter_by(ordre=pn.niveau).first()
for pn in parcour_niveaux

View File

@ -7,10 +7,9 @@ from app import db
from app.models import CODE_STR_LEN
from app.models.but_refcomp import ApcNiveau
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
class ApcValidationRCUE(db.Model):
@ -39,12 +38,8 @@ class ApcValidationRCUE(db.Model):
)
"formsemestre origine du RCUE (celui d'où a été émis la validation)"
# Les deux UE associées à ce niveau:
ue1_id = db.Column(
db.Integer, db.ForeignKey("notes_ue.id", ondelete="CASCADE"), nullable=False
)
ue2_id = db.Column(
db.Integer, db.ForeignKey("notes_ue.id", ondelete="CASCADE"), nullable=False
)
ue1_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
# optionnel, le parcours dans lequel se trouve la compétence:
parcours_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="set null"), nullable=True
@ -64,25 +59,24 @@ class ApcValidationRCUE(db.Model):
def __str__(self):
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
self.code} enregistrée le {self.date.strftime(scu.DATE_FMT)}"""
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
def html(self) -> str:
"description en HTML"
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
<b>{self.code}</b>
<em>enregistrée le {self.date.strftime(scu.DATEATIME_FMT)}</em>"""
<em>enregistrée le {self.date.strftime("%d/%m/%Y")}
à {self.date.strftime("%Hh%M")}</em>"""
def annee(self) -> str:
"""l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """
niveau = self.niveau()
return niveau.annee if niveau else None
def niveau(self) -> ApcNiveau | None:
def niveau(self) -> ApcNiveau:
"""Le niveau de compétence associé à cet RCUE."""
# Par convention, il est donné par la seconde UE
# à défaut (si l'UE a été désacciée entre temps), la première
# et à défaut, renvoie None
return self.ue2.niveau_competence or self.ue1.niveau_competence
return self.ue2.niveau_competence
def to_dict(self):
"as a dict"
@ -164,7 +158,7 @@ class ApcValidationAnnee(db.Model):
def html(self) -> str:
"Affichage html"
date_str = (
f"""le {self.date.strftime(scu.DATEATIME_FMT)}"""
f"""le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}"""
if self.date
else "(sans date)"
)
@ -220,19 +214,14 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
decisions["decision_rcue"] = []
decisions["descr_decisions_rcue"] = ""
decisions["descr_decisions_niveaux"] = ""
# --- Année: prend la validation pour l'année scolaire et l'ordre de ce semestre
if sco_preferences.get_preference("bul_but_code_annuel", formsemestre.id):
annee_but = (formsemestre.semestre_id + 1) // 2
validation = ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
ordre=annee_but,
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
).first()
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
else:
decisions["decision_annee"] = None
# --- Année: prend la validation pour l'année scolaire de ce semestre
validation = ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
).first()
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
else:
decisions["decision_annee"] = None
return decisions

View File

@ -4,7 +4,6 @@
"""
import json
import re
import urllib.parse
from flask import flash
@ -13,6 +12,8 @@ from app.comp import bonus_spo
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu
from datetime import time
from app.scodoc.codes_cursus import (
ABAN,
ABL,
@ -92,11 +93,8 @@ class ScoDocSiteConfig(db.Model):
"INSTITUTION_CITY": str,
"DEFAULT_PDF_FOOTER_TEMPLATE": str,
"enable_entreprises": bool,
"disable_passerelle": bool, # remplace pref. bul_display_publication
"month_debut_annee_scolaire": int,
"month_debut_periode2": int,
"disable_bul_pdf": bool,
"user_require_email_institutionnel": bool,
# CAS
"cas_enable": bool,
"cas_server": str,
@ -104,9 +102,7 @@ class ScoDocSiteConfig(db.Model):
"cas_logout_route": str,
"cas_validate_route": str,
"cas_attribute_id": str,
"cas_uid_from_mail_regexp": str,
"cas_edt_id_from_xml_regexp": str,
# Assiduité
# Assiduités
"morning_time": str,
"lunch_time": str,
"afternoon_time": str,
@ -175,7 +171,7 @@ class ScoDocSiteConfig(db.Model):
klass = bonus_spo.get_bonus_class_dict().get(class_name)
if klass is None:
flash(
f"""Fonction de calcul bonus sport inexistante: {class_name}.
f"""Fonction de calcul bonus sport inexistante: {class_name}.
Changez ou contactez votre administrateur local."""
)
return klass
@ -233,12 +229,6 @@ class ScoDocSiteConfig(db.Model):
cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first()
return cfg is not None and cfg.value
@classmethod
def is_cas_forced(cls) -> bool:
"""True si CAS forcé"""
cfg = ScoDocSiteConfig.query.filter_by(name="cas_force").first()
return cfg is not None and cfg.value
@classmethod
def is_entreprises_enabled(cls) -> bool:
"""True si on doit activer le module entreprise"""
@ -246,39 +236,20 @@ class ScoDocSiteConfig(db.Model):
return cfg is not None and cfg.value
@classmethod
def is_passerelle_disabled(cls):
"""True si on doit cacher les fonctions passerelle ("oeil")."""
cfg = ScoDocSiteConfig.query.filter_by(name="disable_passerelle").first()
return cfg is not None and cfg.value
@classmethod
def is_user_require_email_institutionnel_enabled(cls) -> bool:
"""True si impose saisie email_institutionnel"""
cfg = ScoDocSiteConfig.query.filter_by(
name="user_require_email_institutionnel"
).first()
return cfg is not None and cfg.value
@classmethod
def is_bul_pdf_disabled(cls) -> bool:
"""True si on interdit les exports PDF des bulltins"""
cfg = ScoDocSiteConfig.query.filter_by(name="disable_bul_pdf").first()
return cfg is not None and cfg.value
@classmethod
def enable_entreprises(cls, enabled: bool = True) -> bool:
def enable_entreprises(cls, enabled=True) -> bool:
"""Active (ou déactive) le module entreprises. True si changement."""
return cls.set("enable_entreprises", "on" if enabled else "")
@classmethod
def disable_passerelle(cls, disabled: bool = True) -> bool:
"""Désactive (ou active) les fonctions liées à la présence d'une passerelle. True si changement."""
return cls.set("disable_passerelle", "on" if disabled else "")
@classmethod
def disable_bul_pdf(cls, enabled=True) -> bool:
"""Interdit (ou autorise) les exports PDF. True si changement."""
return cls.set("disable_bul_pdf", "on" if enabled else "")
if enabled != ScoDocSiteConfig.is_entreprises_enabled():
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
if cfg is None:
cfg = ScoDocSiteConfig(
name="enable_entreprises", value="on" if enabled else ""
)
else:
cfg.value = "on" if enabled else ""
db.session.add(cfg)
db.session.commit()
return True
return False
@classmethod
def get(cls, name: str, default: str = "") -> str:
@ -291,16 +262,15 @@ class ScoDocSiteConfig(db.Model):
@classmethod
def set(cls, name: str, value: str) -> bool:
"Set parameter, returns True if change. Commit session."
value_str = str(value or "").strip()
value_str = str(value or "")
if (cls.get(name) or "") != value_str:
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if cfg is None:
cfg = ScoDocSiteConfig(name=name, value=value_str)
else:
cfg.value = value_str
cfg.value = str(value or "")
current_app.logger.info(
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}{
'...' if len(cfg.value)>32 else ''}'"""
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}...'"""
)
db.session.add(cfg)
db.session.commit()
@ -309,7 +279,7 @@ class ScoDocSiteConfig(db.Model):
@classmethod
def _get_int_field(cls, name: str, default=None) -> int:
"""Valeur d'un champ integer"""
"""Valeur d'un champs integer"""
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if (cfg is None) or cfg.value is None:
return default
@ -323,7 +293,7 @@ class ScoDocSiteConfig(db.Model):
default=None,
range_values: tuple = (),
) -> bool:
"""Set champ integer. True si changement."""
"""Set champs integer. True si changement."""
if value != cls._get_int_field(name, default=default):
if not isinstance(value, int) or (
range_values and (value < range_values[0]) or (value > range_values[1])
@ -390,7 +360,7 @@ class ScoDocSiteConfig(db.Model):
cls.set("personalized_links", "")
raise ScoValueError(
"Attention: liens personnalisés erronés: ils ont été effacés."
) from exc
)
return [PersonalizedLink(**item) for item in links_dict]
@classmethod
@ -402,69 +372,6 @@ class ScoDocSiteConfig(db.Model):
data_links = json.dumps(links_dict)
cls.set("personalized_links", data_links)
@classmethod
def extract_cas_id(cls, email_addr: str) -> str | None:
"Extract cas_id from maill, using regexp in config. None if not possible."
exp = cls.get("cas_uid_from_mail_regexp")
if not exp or not email_addr:
return None
try:
match = re.search(exp, email_addr)
except re.error:
log("error extracting CAS id from '{email_addr}' using regexp '{exp}'")
return None
if not match:
log("no match extracting CAS id from '{email_addr}' using regexp '{exp}'")
return None
try:
cas_id = match.group(1)
except IndexError:
log(
"no group found extracting CAS id from '{email_addr}' using regexp '{exp}'"
)
return None
return cas_id
@classmethod
def cas_uid_from_mail_regexp_is_valid(cls, exp: str) -> bool:
"True si l'expression régulière semble valide"
# check that it compiles
try:
pattern = re.compile(exp)
except re.error:
return False
# and returns at least one group on a simple cannonical address
match = pattern.search("emmanuel@exemple.fr")
return match is not None and len(match.groups()) > 0
@classmethod
def cas_edt_id_from_xml_regexp_is_valid(cls, exp: str) -> bool:
"True si l'expression régulière semble valide"
# check that it compiles
try:
_ = re.compile(exp)
except re.error:
return False
return True
@classmethod
def assi_get_rounded_time(cls, label: str, default: str) -> float:
"Donne l'heure stockée dans la config globale sous label, en float arrondi au quart d'heure"
return _round_time_str_to_quarter(cls.get(label, default))
def _round_time_str_to_quarter(string: str) -> float:
"""Prend une heure iso '12:20:23', et la converti en un nombre d'heures
en arrondissant au quart d'heure: (les secondes sont ignorées)
"12:20:00" -> 12.25
"12:29:00" -> 12.25
"12:30:00" -> 12.5
"""
parts = [*map(float, string.split(":"))]
hour = parts[0]
minutes = round(parts[1] / 60 * 4) / 4
return hour + minutes
class PersonalizedLink:
def __init__(self, title: str = "", url: str = "", with_args: bool = False):

View File

@ -26,17 +26,7 @@ class Departement(db.Model):
) # sur page d'accueil
# entreprises = db.relationship("Entreprise", lazy="dynamic", backref="departement")
etudiants = db.relationship(
"Identite",
back_populates="departement",
cascade="all,delete-orphan",
lazy="dynamic",
)
# This means if a Departement is deleted, all related Identite instances are also
# deleted (all,delete). The orphan part means that if an Identite instance becomes
# detached from its parent Departement (for example, by setting my_identite.departement = None),
# it will be deleted.
etudiants = db.relationship("Identite", lazy="dynamic", backref="departement")
formations = db.relationship("Formation", lazy="dynamic", backref="departement")
formsemestres = db.relationship(
"FormSemestre", lazy="dynamic", backref="departement"
@ -90,6 +80,8 @@ class Departement(db.Model):
def create_dept(acronym: str, visible=True) -> Departement:
"Create new departement"
from app.models import ScoPreference
if Departement.invalid_dept_acronym(acronym):
raise ScoValueError("acronyme departement invalide")
existing = Departement.query.filter_by(acronym=acronym).count()

View File

@ -15,57 +15,45 @@ from sqlalchemy import desc, text
from app import db, log
from app import models
from app.models.departements import Departement
from app.models.scolar_event import ScolarEvent
from app.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat
from app.scodoc.sco_exceptions import ScoGenError, ScoInvalidParamError, ScoValueError
from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
import app.scodoc.sco_utils as scu
class Identite(models.ScoDocModel):
class Identite(db.Model):
"""étudiant"""
__tablename__ = "identite"
__table_args__ = (
db.UniqueConstraint("dept_id", "code_nip"),
db.UniqueConstraint("dept_id", "code_ine"),
db.CheckConstraint("civilite IN ('M', 'F', 'X')"),
db.CheckConstraint("civilite_etat_civil IN ('M', 'F', 'X')"),
)
id = db.Column(db.Integer, primary_key=True)
etudid = db.synonym("id")
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
# ForeignKey ondelete set the cascade at the database level
admission_id = db.Column(
db.Integer, db.ForeignKey("admissions.id", ondelete="CASCADE"), nullable=True
)
admission = db.relationship(
"Admission",
back_populates="etud",
uselist=False,
cascade="all,delete", # cascade also defined at ORM level
single_parent=True,
)
dept_id = db.Column(
db.Integer, db.ForeignKey("departement.id"), index=True, nullable=False
)
departement = db.relationship("Departement", back_populates="etudiants")
nom = db.Column(db.Text())
prenom = db.Column(db.Text())
nom_usuel = db.Column(db.Text())
"optionnel (si present, affiché à la place du nom)"
civilite = db.Column(db.String(1), nullable=False)
# données d'état-civil. Si présent (non null) remplace les données d'usage dans les documents
# données d'état-civil. Si présent remplace les données d'usage dans les documents
# officiels (bulletins, PV): voir nomprenom_etat_civil()
civilite_etat_civil = db.Column(db.String(1), nullable=True)
prenom_etat_civil = db.Column(db.Text(), nullable=True)
civilite_etat_civil = db.Column(db.String(1), nullable=False, server_default="X")
prenom_etat_civil = db.Column(db.Text(), nullable=False, server_default="")
date_naissance = db.Column(db.Date)
lieu_naissance = db.Column(db.Text())
dept_naissance = db.Column(db.Text())
nationalite = db.Column(db.Text())
statut = db.Column(db.Text())
boursier = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
"True si boursier"
boursier = db.Column(db.Boolean()) # True si boursier ('O' en ScoDoc7)
photo_filename = db.Column(db.Text())
# Codes INE et NIP pas unique car le meme etud peut etre ds plusieurs dept
code_nip = db.Column(db.Text(), index=True)
@ -73,42 +61,11 @@ class Identite(models.ScoDocModel):
# Ancien id ScoDoc7 pour les migrations de bases anciennes
# ne pas utiliser après migrate_scodoc7_dept_archives
scodoc7_id = db.Column(db.Text(), nullable=True)
# ----- Contraintes
__table_args__ = (
# Define a unique constraint on (dept_id, code_nip) when code_nip is not NULL
db.UniqueConstraint("dept_id", "code_nip", name="unique_dept_nip_except_null"),
db.Index(
"unique_dept_nip_except_null",
"dept_id",
"code_nip",
unique=True,
postgresql_where=(code_nip.isnot(None)),
),
# Define a unique constraint on (dept_id, code_ine) when code_ine is not NULL
db.UniqueConstraint("dept_id", "code_ine", name="unique_dept_ine_except_null"),
db.Index(
"unique_dept_ine_except_null",
"dept_id",
"code_ine",
unique=True,
postgresql_where=(code_ine.isnot(None)),
),
db.CheckConstraint("civilite IN ('M', 'F', 'X')"), # non nullable
db.CheckConstraint("civilite_etat_civil IN ('M', 'F', 'X')"), # nullable
)
# ----- Relations
adresses = db.relationship(
"Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic"
)
annotations = db.relationship(
"EtudAnnotation",
backref="etudiant",
cascade="all, delete-orphan",
lazy="dynamic",
)
#
adresses = db.relationship("Adresse", lazy="dynamic", backref="etud")
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
#
admission = db.relationship("Admission", backref="identite", lazy="dynamic")
dispense_ues = db.relationship(
"DispenseUE",
back_populates="etud",
@ -117,75 +74,21 @@ class Identite(models.ScoDocModel):
)
# Relations avec les assiduites et les justificatifs
assiduites = db.relationship(
"Assiduite", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
)
assiduites = db.relationship("Assiduite", back_populates="etudiant", lazy="dynamic")
justificatifs = db.relationship(
"Justificatif", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
"Justificatif", back_populates="etudiant", lazy="dynamic"
)
# Champs "protégés" par ViewEtudData (RGPD)
protected_attrs = {"boursier", "nationalite"}
def __repr__(self):
return (
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
)
def clone(self, not_copying=(), new_dept_id: int = None):
"""Clone, not copying the given attrs
Clone aussi les adresses et infos d'admission.
Si new_dept_id est None, le nouvel étudiant n'a pas de département.
Attention: la copie n'a pas d'id avant le prochain flush ou commit.
"""
if new_dept_id == self.dept_id:
raise ScoValueError(
"clonage étudiant: le département destination est identique à celui de départ"
)
# Vérifie les contraintes d'unicité
# ("dept_id", "code_nip") et ("dept_id", "code_ine")
if (
self.code_nip is not None
and Identite.query.filter_by(
dept_id=new_dept_id, code_nip=self.code_nip
).count()
> 0
) or (
self.code_ine is not None
and Identite.query.filter_by(
dept_id=new_dept_id, code_ine=self.code_ine
).count()
> 0
):
raise ScoValueError(
"""clonage étudiant: un étudiant de même code existe déjà
dans le département destination"""
)
d = dict(self.__dict__)
d.pop("id", None) # get rid of id
d.pop("_sa_instance_state", None) # get rid of SQLAlchemy special attr
d.pop("departement", None) # relationship
d["dept_id"] = new_dept_id
for k in not_copying:
d.pop(k, None)
copy = self.__class__(**d)
db.session.add(copy)
copy.adresses = [adr.clone() for adr in self.adresses]
copy.admission = self.admission.clone()
log(
f"cloning etud <{self.id} {self.nom!r} {self.prenom!r}> in dept_id={new_dept_id}"
)
return copy
def html_link_fiche(self) -> str:
"lien vers la fiche"
return f"""<a class="etudlink" href="{self.url_fiche()}">{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
)
return f"""<a class="stdlink" href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id)
}">{self.nomprenom}</a>"""
@classmethod
def from_request(cls, etudid=None, code_nip=None) -> "Identite":
@ -206,90 +109,41 @@ class Identite(models.ScoDocModel):
return cls.query.filter_by(id=etudid).first_or_404()
@classmethod
def create_etud(cls, **args) -> "Identite":
"""Crée un étudiant, avec admission et adresse vides.
(added to session but not flushed nor commited)
"""
return cls.create_from_dict(args)
@classmethod
def create_from_dict(cls, args) -> "Identite":
"""Crée un étudiant à partir d'un dict, avec admission et adresse vides.
If required dept_id or dept are not specified, set it to the current dept.
args: dict with args in application.
Les clés adresses et admission ne SONT PAS utilisées.
(added to session but not flushed nor commited)
"""
if not "dept_id" in args:
if "dept" in args:
departement = Departement.query.filter_by(acronym=args["dept"]).first()
if departement:
args["dept_id"] = departement.id
if not "dept_id" in args:
args["dept_id"] = g.scodoc_dept_id
etud: Identite = super().create_from_dict(args)
if args.get("admission_id", None) is None:
etud.admission = Admission()
def create_etud(cls, **args):
"Crée un étudiant, avec admission et adresse vides."
etud: Identite = cls(**args)
etud.adresses.append(Adresse(typeadresse="domicile"))
db.session.flush()
event = ScolarEvent(etud=etud, event_type="CREATION")
db.session.add(event)
log(f"Identite.create {etud}")
etud.admission.append(Admission())
return etud
def from_dict(self, args, **kwargs) -> bool:
"""Check arguments, then modify.
Add to session but don't commit.
True if modification.
"""
check_etud_duplicate_code(args, "code_nip")
check_etud_duplicate_code(args, "code_ine")
return super().from_dict(args, **kwargs)
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded."""
return super().filter_model_attributes(
data,
excluded=(excluded or set()) | {"adresses", "admission", "departement"},
)
@property
def civilite_str(self) -> str:
"""returns civilité usuelle: 'M.' ou 'Mme' ou '' (pour le genre neutre,
def civilite_str(self):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
personnes ne souhaitant pas d'affichage).
"""
return {"M": "M.", "F": "Mme", "X": ""}[self.civilite]
@property
def civilite_etat_civil_str(self) -> str:
"""returns 'M.' ou 'Mme', selon état civil officiel.
La France ne reconnait pas le genre neutre dans l'état civil:
si cette donnée état civil est précisée, elle est utilisée,
sinon on renvoie la civilité usuelle.
def civilite_etat_civil_str(self):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
personnes ne souhaitant pas d'affichage).
"""
return (
{"M": "M.", "F": "Mme"}.get(self.civilite_etat_civil, "")
if self.civilite_etat_civil
else self.civilite_str
)
return {"M": "M.", "F": "Mme", "X": ""}[self.civilite_etat_civil]
def sex_nom(self, no_accents=False) -> str:
"'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'. Civilité usuelle."
"'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'"
s = f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()}"
if no_accents:
return scu.suppress_accents(s)
return s
@property
def e(self) -> str:
"terminaison en français: 'ne', '', 'ou '(e)', selon la civilité usuelle"
def e(self):
"terminaison en français: 'ne', '', 'ou '(e)'"
return {"M": "", "F": "e"}.get(self.civilite, "(e)")
def nom_disp(self) -> str:
"""Nom à afficher.
Note: le nom est stocké en base en majuscules."""
"Nom à afficher"
if self.nom_usuel:
return (
(self.nom_usuel + " (" + self.nom + ")") if self.nom else self.nom_usuel
@ -297,24 +151,18 @@ class Identite(models.ScoDocModel):
else:
return self.nom
@property
@cached_property
def nomprenom(self, reverse=False) -> str:
"""DEPRECATED
Civilité/prénom/nom pour affichages: "M. Pierre Dupont"
"""Civilité/nom/prenom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
Prend l'identité courante et non celle de l'état civil si elles diffèrent.
"""
nom = self.nom_usuel or self.nom
prenom = self.prenom_str
if reverse:
return f"{nom} {prenom}".strip()
return f"{self.civilite_str} {prenom} {nom}".strip()
def nom_prenom(self) -> str:
"""Civilite NOM Prénom
Prend l'identité courante et non celle de l'état civil si elles diffèrent.
"""
return f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()} {self.prenom_str}"
fields = (nom, prenom)
else:
fields = (self.civilite_str, prenom, nom)
return " ".join([x for x in fields if x])
@property
def prenom_str(self):
@ -329,11 +177,12 @@ class Identite(models.ScoDocModel):
return " ".join(r)
@property
def etat_civil(self) -> str:
"M. PRÉNOM NOM, utilisant les données état civil si présentes, usuelles sinon."
return f"""{self.civilite_etat_civil_str} {
self.prenom_etat_civil or self.prenom or ''} {
self.nom or ''}""".strip()
def etat_civil(self):
if self.prenom_etat_civil:
civ = {"M": "M.", "F": "Mme", "X": ""}[self.civilite_etat_civil]
return f"{civ} {self.prenom_etat_civil} {self.nom}"
else:
return self.nomprenom
@property
def nom_short(self):
@ -341,14 +190,14 @@ class Identite(models.ScoDocModel):
return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}."
@cached_property
def sort_key(self) -> str:
def sort_key(self) -> tuple:
"clé pour tris par ordre alphabétique"
# Note: scodoc7 utilisait sco_etud.etud_sort_key, à mettre à jour
# si on modifie cette méthode.
return scu.sanitize_string(
(self.nom_usuel or self.nom or "") + ";" + (self.prenom or ""),
remove_spaces=False,
).lower()
return (
scu.sanitize_string(
self.nom_usuel or self.nom or "", remove_spaces=False
).lower(),
scu.sanitize_string(self.prenom or "", remove_spaces=False).lower(),
)
def get_first_email(self, field="email") -> str:
"Le mail associé à la première adresse de l'étudiant, ou None"
@ -364,49 +213,10 @@ class Identite(models.ScoDocModel):
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
@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.
"""
# Les champs qui sont toujours stockés en majuscules:
fs_uppercase = {"nom", "nom_usuel", "prenom", "prenom_etat_civil"}
"Convert fields in the given dict. No other side effect"
fs_uppercase = {"nom", "prenom", "prenom_etat_civil"}
fs_empty_stored_as_nulls = {
"nom",
"prenom",
@ -428,19 +238,27 @@ class Identite(models.ScoDocModel):
value = None
if key in fs_uppercase and value:
value = value.upper()
if key == "civilite": # requis
if key == "civilite" or key == "civilite_etat_civil":
value = input_civilite(value)
elif key == "civilite_etat_civil":
value = input_civilite_etat_civil(value)
elif key == "boursier":
value = scu.to_bool(value)
value = bool(value)
elif key == "date_naissance":
value = ndb.DateDMYtoISO(value)
args_dict[key] = value
return args_dict
def from_dict(self, args: dict):
"update fields given in dict. Add to session but don't commit."
args_dict = Identite.convert_dict_fields(args)
args_dict.pop("id", None)
args_dict.pop("etudid", None)
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.add(self)
def to_dict_short(self) -> dict:
"""Les champs essentiels (aucune donnée perso protégée)"""
"""Les champs essentiels"""
return {
"id": self.id,
"civilite": self.civilite,
@ -455,25 +273,21 @@ class Identite(models.ScoDocModel):
"prenom_etat_civil": self.prenom_etat_civil,
}
def to_dict_scodoc7(self, restrict=False, with_inscriptions=False) -> dict:
def to_dict_scodoc7(self) -> dict:
"""Représentation dictionnaire,
compatible ScoDoc7 mais sans infos admission.
Si restrict, cache les infos "personnelles" si pas permission ViewEtudData
Si with_inscriptions, inclut les champs "inscription"
compatible ScoDoc7 mais sans infos admission
"""
e_dict = self.__dict__.copy() # dict(self.__dict__)
e_dict.pop("_sa_instance_state", None)
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators: (backward compat)
e_dict["etudid"] = self.id
e_dict["date_naissance"] = ndb.DateISOtoDMY(e_dict.get("date_naissance", ""))
e_dict["ne"] = self.e
e_dict["nomprenom"] = self.nomprenom
e["etudid"] = self.id
e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"])
e["ne"] = self.e
e["nomprenom"] = self.nomprenom
adresse = self.adresses.first()
if adresse:
e_dict.update(adresse.to_dict(restrict=restrict))
if with_inscriptions:
e_dict.update(self.inscription_descr())
return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty
e.update(adresse.to_dict())
return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty
def to_dict_bul(self, include_urls=True):
"""Infos exportées dans les bulletins
@ -487,11 +301,9 @@ class Identite(models.ScoDocModel):
"civilite": self.civilite,
"code_ine": self.code_ine or "",
"code_nip": self.code_nip or "",
"date_naissance": (
self.date_naissance.strftime(scu.DATE_FMT)
if self.date_naissance
else ""
),
"date_naissance": self.date_naissance.strftime("%d/%m/%Y")
if self.date_naissance
else "",
"dept_acronym": self.departement.acronym,
"dept_id": self.dept_id,
"dept_naissance": self.dept_naissance or "",
@ -509,7 +321,7 @@ class Identite(models.ScoDocModel):
if include_urls and has_request_context():
# test request context so we can use this func in tests under the flask shell
d["fiche_url"] = url_for(
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=self.id
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id
)
d["photo_url"] = sco_photos.get_etud_photo_url(self.id)
adresse = self.adresses.first()
@ -518,37 +330,22 @@ class Identite(models.ScoDocModel):
d["id"] = self.id # a été écrasé par l'id de adresse
return d
def to_dict_api(self, restrict=False, with_annotations=False) -> dict:
"""Représentation dictionnaire pour export API, avec adresses et admission.
Si restrict, supprime les infos "personnelles" (boursier)
"""
def to_dict_api(self) -> dict:
"""Représentation dictionnaire pour export API, avec adresses et admission."""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
admission = self.admission
admission = self.admission.first()
e["admission"] = admission.to_dict() if admission is not None else None
e["adresses"] = [adr.to_dict(restrict=restrict) for adr in self.adresses]
e["adresses"] = [adr.to_dict() for adr in self.adresses]
e["dept_acronym"] = self.departement.acronym
e.pop("departement", None)
e["sort_key"] = self.sort_key
if with_annotations:
e["annotations"] = (
[
annot.to_dict()
for annot in EtudAnnotation.query.filter_by(
etudid=self.id
).order_by(desc(EtudAnnotation.date))
]
if not restrict
else []
)
if restrict:
# Met à None les attributs protégés:
for attr in self.protected_attrs:
e[attr] = None
return e
def inscriptions(self) -> list["FormSemestreInscription"]:
"Liste des inscriptions à des formsemestres, triée, la plus récente en tête"
from app.models.formsemestre import FormSemestre, FormSemestreInscription
return (
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
.filter(
@ -558,7 +355,7 @@ class Identite(models.ScoDocModel):
.all()
)
def inscription_courante(self) -> "FormSemestreInscription | None":
def inscription_courante(self):
"""La première inscription à un formsemestre _actuellement_ en cours.
None s'il n'y en a pas (ou plus, ou pas encore).
"""
@ -574,6 +371,8 @@ class Identite(models.ScoDocModel):
(il est rare qu'il y en ai plus d'une, mais c'est possible).
Triées par date de début de semestre décroissante (le plus récent en premier).
"""
from app.models.formsemestre import FormSemestre, FormSemestreInscription
return (
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
.filter(
@ -596,9 +395,7 @@ class Identite(models.ScoDocModel):
return r[0] if r else None
def inscription_descr(self) -> dict:
"""Description de l'état d'inscription
avec champs compatibles templates ScoDoc7
"""
"""Description de l'état d'inscription"""
inscription_courante = self.inscription_courante()
if inscription_courante:
titre_sem = inscription_courante.formsemestre.titre_mois()
@ -609,7 +406,7 @@ class Identite(models.ScoDocModel):
else:
inscr_txt = "Inscrit en"
result = {
return {
"etat_in_cursem": inscription_courante.etat,
"inscription_courante": inscription_courante,
"inscription": titre_sem,
@ -632,20 +429,15 @@ class Identite(models.ScoDocModel):
inscription = "ancien"
situation = "ancien élève"
else:
inscription = "non inscrit"
inscription = ("non inscrit",)
situation = inscription
result = {
return {
"etat_in_cursem": "?",
"inscription_courante": None,
"inscription": inscription,
"inscription_str": inscription,
"situation": situation,
}
# aliases pour compat templates ScoDoc7
result["etatincursem"] = result["etat_in_cursem"]
result["inscriptionstr"] = result["inscription_str"]
return result
def inscription_etat(self, formsemestre_id: int) -> str:
"""État de l'inscription de cet étudiant au semestre:
@ -742,7 +534,7 @@ class Identite(models.ScoDocModel):
"""
if with_paragraph:
return f"""{self.etat_civil}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
self.date_naissance.strftime(scu.DATE_FMT) if self.date_naissance else ""}{
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
line_sep}à {self.lieu_naissance or ""}"""
return self.etat_civil
@ -766,58 +558,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><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)
def make_etud_args(
etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True
) -> dict:
@ -860,13 +600,11 @@ def make_etud_args(
return args
def input_civilite(s: str) -> str:
def input_civilite(s):
"""Converts external representation of civilite to internal:
'M', 'F', or 'X' (and nothing else).
Raises ScoValueError if conversion fails.
"""
if not isinstance(s, str):
raise ScoValueError("valeur invalide pour la civilité (chaine attendue)")
s = s.upper().strip()
if s in ("M", "M.", "MR", "H"):
return "M"
@ -877,11 +615,6 @@ def input_civilite(s: str) -> str:
raise ScoValueError(f"valeur invalide pour la civilité: {s}")
def input_civilite_etat_civil(s: str) -> str | None:
"""Same as input_civilite, but empty gives None (null)"""
return input_civilite(s) if s and s.strip() else None
PIVOT_YEAR = 70
@ -898,7 +631,7 @@ def pivot_year(y) -> int:
return y
class Adresse(models.ScoDocModel):
class Adresse(db.Model):
"""Adresse d'un étudiant
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule)
"""
@ -907,10 +640,10 @@ class Adresse(models.ScoDocModel):
id = db.Column(db.Integer, primary_key=True)
adresse_id = db.synonym("id")
etudid = db.Column(db.Integer, db.ForeignKey("identite.id"), nullable=False)
# Relationship to Identite
etud = db.relationship("Identite", back_populates="adresses")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
)
email = db.Column(db.Text()) # mail institutionnel
emailperso = db.Column(db.Text) # email personnel (exterieur)
domicile = db.Column(db.Text)
@ -925,42 +658,26 @@ class Adresse(models.ScoDocModel):
)
description = db.Column(db.Text)
# Champs "protégés" par ViewEtudData (RGPD)
protected_attrs = {
"emailperso",
"domicile",
"codepostaldomicile",
"villedomicile",
"telephone",
"telephonemobile",
"fax",
}
def to_dict(self, convert_nulls_to_str=False, restrict=False):
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
def to_dict(self, convert_nulls_to_str=False):
"""Représentation dictionnaire,"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
if convert_nulls_to_str:
e = {k: v or "" for k, v in e.items()}
if restrict:
e = {k: v for (k, v) in e.items() if k not in self.protected_attrs}
return {k: e[k] or "" for k in e}
return e
class Admission(models.ScoDocModel):
class Admission(db.Model):
"""Informations liées à l'admission d'un étudiant"""
__tablename__ = "admissions"
id = db.Column(db.Integer, primary_key=True)
adm_id = db.synonym("id")
# obsoleted by migration 497ba81343f7_identite_admission.py:
# etudid = db.Column(
# db.Integer,
# db.ForeignKey("identite.id", ondelete="CASCADE"),
# )
etud = db.relationship("Identite", back_populates="admission", uselist=False)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
)
# Anciens champs de ScoDoc7, à revoir pour être plus générique et souple
# notamment dans le cadre du bac 2021
# de plus, certaines informations liées à APB ne sont plus disponibles
@ -970,9 +687,9 @@ class Admission(models.ScoDocModel):
specialite = db.Column(db.Text)
annee_bac = db.Column(db.Integer)
math = db.Column(db.Text)
physique = db.Column(db.Text)
anglais = db.Column(db.Text)
francais = db.Column(db.Text)
physique = db.Column(db.Float)
anglais = db.Column(db.Float)
francais = db.Column(db.Float)
# Rang dans les voeux du candidat (inconnu avec APB et PS)
rang = db.Column(db.Integer)
# Qualité et décision du jury d'admission (ou de l'examinateur)
@ -998,16 +715,12 @@ class Admission(models.ScoDocModel):
# classement (1..Ngr) par le jury dans le groupe APB
apb_classement_gr = db.Column(db.Integer)
# Tous les champs sont "protégés" par ViewEtudData (RGPD)
# sauf:
not_protected_attrs = {"bac", "specialite", "anne_bac"}
def get_bac(self) -> Baccalaureat:
"Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères."
return Baccalaureat(self.bac, specialite=self.specialite)
def to_dict(self, no_nulls=False, restrict=False):
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
def to_dict(self, no_nulls=False):
"""Représentation dictionnaire,"""
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
if no_nulls:
@ -1022,16 +735,11 @@ class Admission(models.ScoDocModel):
d[key] = 0
elif isinstance(col_type, sqlalchemy.Boolean):
d[key] = False
if restrict:
d = {k: v for (k, v) in d.items() if k in self.not_protected_attrs}
return d
@classmethod
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 fields in the given dict. No other side effect"
fs_uppercase = {"bac", "specialite"}
args_dict = {}
for key, value in args.items():
@ -1042,6 +750,8 @@ class Admission(models.ScoDocModel):
value = None
if key in fs_uppercase and value:
value = value.upper()
if key == "civilite" or key == "civilite_etat_civil":
value = input_civilite(value)
elif key == "annee" or key == "annee_bac":
value = pivot_year(value)
elif key == "classement" or key == "apb_classement_gr":
@ -1049,6 +759,16 @@ class Admission(models.ScoDocModel):
args_dict[key] = value
return args_dict
def from_dict(self, args: dict): # TODO à refactoriser dans une super-classe
"update fields given in dict. Add to session but don't commit."
args_dict = Admission.convert_dict_fields(args)
args_dict.pop("adm_id", None)
args_dict.pop("id", None)
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.add(self)
# Suivi scolarité / débouchés
class ItemSuivi(db.Model):
@ -1091,16 +811,6 @@ class EtudAnnotation(db.Model):
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
etudid = db.Column(db.Integer, db.ForeignKey(Identite.id))
etudid = db.Column(db.Integer) # sans contrainte (compat ScoDoc 7))
author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user
comment = db.Column(db.Text)
def to_dict(self):
"""Représentation dictionnaire."""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
return e
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -10,22 +10,18 @@
"""ScoDoc models: formsemestre
"""
from collections import defaultdict
import datetime
from functools import cached_property
from itertools import chain
from operator import attrgetter
from flask_login import current_user
from flask import abort, flash, g, url_for
from flask import flash, g, url_for
from sqlalchemy.sql import text
from sqlalchemy import func
import app.scodoc.sco_utils as scu
from app import db, log
from app.auth.models import User
from app import models
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models.but_refcomp import (
ApcParcours,
@ -33,16 +29,11 @@ from app.models.but_refcomp import (
parcours_formsemestre,
)
from app.models.config import ScoDocSiteConfig
from app.models.departements import Departement
from app.models.etudiants import Identite
from app.models.evaluations import Evaluation
from app.models.formations import Formation
from app.models.groups import GroupDescr, Partition
from app.models.moduleimpls import (
ModuleImpl,
ModuleImplInscription,
notes_modules_enseignants,
)
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
from app.models.modules import Module
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
@ -52,10 +43,12 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric
from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.sco_utils import translate_assiduites_metric
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
class FormSemestre(models.ScoDocModel):
class FormSemestre(db.Model):
"""Mise en oeuvre d'un semestre de formation"""
__tablename__ = "notes_formsemestre"
@ -69,9 +62,7 @@ class FormSemestre(models.ScoDocModel):
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
titre = db.Column(db.Text(), nullable=False)
date_debut = db.Column(db.Date(), nullable=False)
date_fin = db.Column(db.Date(), nullable=False) # jour inclus
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
date_fin = db.Column(db.Date(), nullable=False)
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
"False si verrouillé"
modalite = db.Column(
@ -85,7 +76,7 @@ class FormSemestre(models.ScoDocModel):
bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
"ne publie pas le bulletin sur l'API"
"ne publie pas le bulletin XML ou JSON"
block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
@ -94,10 +85,6 @@ class FormSemestre(models.ScoDocModel):
db.Boolean(), nullable=False, default=False, server_default="false"
)
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
mode_calcul_moyennes = db.Column(
db.Integer, nullable=False, default=0, server_default="0"
)
"pour usage futur"
gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
@ -151,7 +138,6 @@ class FormSemestre(models.ScoDocModel):
secondary="notes_formsemestre_responsables",
lazy=True,
backref=db.backref("formsemestres", lazy=True),
order_by=func.upper(User.nom),
)
partitions = db.relationship(
"Partition",
@ -183,32 +169,22 @@ class FormSemestre(models.ScoDocModel):
def html_link_status(self, label=None, title=None) -> str:
"html link to status page"
return f"""<a class="stdlink" href="{
url_for("notes.formsemestre_status", scodoc_dept=self.departement.acronym,
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
formsemestre_id=self.id,)
}" title="{title or ''}">{label or self.titre_mois()}</a>
"""
@classmethod
def get_formsemestre(
cls, formsemestre_id: int | str, dept_id: int = None
) -> "FormSemestre":
"""FormSemestre ou 404, cherche uniquement dans le département spécifié
ou le courant (g.scodoc_dept)"""
if not isinstance(formsemestre_id, int):
try:
formsemestre_id = int(formsemestre_id)
except (TypeError, ValueError):
abort(404, "formsemestre_id invalide")
def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre":
""" "FormSemestre ou 404, cherche uniquement dans le département courant"""
if g.scodoc_dept:
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
if dept_id is not None:
return cls.query.filter_by(
id=formsemestre_id, dept_id=dept_id
id=formsemestre_id, dept_id=g.scodoc_dept_id
).first_or_404()
return cls.query.filter_by(id=formsemestre_id).first_or_404()
def sort_key(self) -> tuple:
"""clé pour tris par ordre de date_debut, le plus ancien en tête
"""clé pour tris par ordre alphabétique
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
return (self.date_debut, self.semestre_id)
@ -219,17 +195,16 @@ class FormSemestre(models.ScoDocModel):
"""
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
d.pop("groups_auto_assignment_data", None)
# ScoDoc7 output_formators: (backward compat)
d["formsemestre_id"] = self.id
d["titre_num"] = self.titre_num()
if self.date_debut:
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
d["date_debut_iso"] = self.date_debut.isoformat()
else:
d["date_debut"] = d["date_debut_iso"] = ""
if self.date_fin:
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
d["date_fin_iso"] = self.date_fin.isoformat()
else:
d["date_fin"] = d["date_fin_iso"] = ""
@ -247,20 +222,18 @@ class FormSemestre(models.ScoDocModel):
def to_dict_api(self):
"""
Un dict avec les informations sur le semestre destinées à l'api
Un dict avec les informations sur le semestre destiné à l'api
"""
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
d.pop("groups_auto_assignment_data", None)
d["annee_scolaire"] = self.annee_scolaire()
d["bul_hide_xml"] = self.bul_hide_xml
if self.date_debut:
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
d["date_debut_iso"] = self.date_debut.isoformat()
else:
d["date_debut"] = d["date_debut_iso"] = ""
if self.date_fin:
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
d["date_fin_iso"] = self.date_fin.isoformat()
else:
d["date_fin"] = d["date_fin_iso"] = ""
@ -276,27 +249,6 @@ class FormSemestre(models.ScoDocModel):
d["session_id"] = self.session_id()
return d
def get_default_group(self) -> GroupDescr:
"""default ('tous') group.
Le groupe par défaut contient tous les étudiants et existe toujours.
C'est l'unique groupe de la partition sans nom.
"""
default_partition = self.partitions.filter_by(partition_name=None).first()
if default_partition:
return default_partition.groups.first()
raise ScoValueError("Le semestre n'a pas de groupe par défaut")
def get_edt_ids(self) -> list[str]:
"""Les ids pour l'emploi du temps: à défaut, les codes étape Apogée.
Les edt_id de formsemestres ne sont pas normalisés afin de contrôler
précisément l'accès au fichier ics.
"""
return (
scu.split_id(self.edt_id)
or [e.etape_apo.strip() for e in self.etapes if e.etape_apo]
or []
)
def get_infos_dict(self) -> dict:
"""Un dict avec des informations sur le semestre
pour les bulletins et autres templates
@ -399,80 +351,6 @@ class FormSemestre(models.ScoDocModel):
_cache[key] = ues
return ues
@classmethod
def get_user_formsemestres_annee_by_dept(
cls, user: User
) -> tuple[
defaultdict[int, list["FormSemestre"]], defaultdict[int, list[ModuleImpl]]
]:
"""Liste des formsemestres de l'année scolaire
dans lesquels user intervient (comme resp., resp. de module ou enseignant),
ainsi que la liste des modimpls concernés dans chaque formsemestre
Attention: les semestres et modimpls peuvent être de différents départements !
Résultat:
{ dept_id : [ formsemestre, ... ] },
{ formsemestre_id : [ modimpl, ... ]}
"""
debut_annee_scolaire = scu.date_debut_annee_scolaire()
fin_annee_scolaire = scu.date_fin_annee_scolaire()
query = FormSemestre.query.filter(
FormSemestre.date_fin >= debut_annee_scolaire,
FormSemestre.date_debut < fin_annee_scolaire,
)
# responsable ?
formsemestres_resp = query.join(notes_formsemestre_responsables).filter_by(
responsable_id=user.id
)
# Responsable d'un modimpl ?
modimpls_resp = (
ModuleImpl.query.filter_by(responsable_id=user.id)
.join(FormSemestre)
.filter(
FormSemestre.date_fin >= debut_annee_scolaire,
FormSemestre.date_debut < fin_annee_scolaire,
)
)
# Enseignant dans un modimpl ?
modimpls_ens = (
ModuleImpl.query.join(notes_modules_enseignants)
.filter_by(ens_id=user.id)
.join(FormSemestre)
.filter(
FormSemestre.date_fin >= debut_annee_scolaire,
FormSemestre.date_debut < fin_annee_scolaire,
)
)
# Liste les modimpls, uniques
modimpls = modimpls_resp.all()
ids = {modimpl.id for modimpl in modimpls}
for modimpl in modimpls_ens:
if modimpl.id not in ids:
modimpls.append(modimpl)
ids.add(modimpl.id)
# Liste les formsemestres et modimpls associés
modimpls_by_formsemestre = defaultdict(lambda: [])
formsemestres = formsemestres_resp.all()
ids = {formsemestre.id for formsemestre in formsemestres}
for modimpl in chain(modimpls_resp, modimpls_ens):
if modimpl.formsemestre_id not in ids:
formsemestres.append(modimpl.formsemestre)
ids.add(modimpl.formsemestre_id)
modimpls_by_formsemestre[modimpl.formsemestre_id].append(modimpl)
# Tris et organisation par département
formsemestres_by_dept = defaultdict(lambda: [])
formsemestres.sort(key=lambda x: (x.departement.acronym,) + x.sort_key())
for formsemestre in formsemestres:
formsemestres_by_dept[formsemestre.dept_id].append(formsemestre)
modimpls = modimpls_by_formsemestre[formsemestre.id]
if formsemestre.formation.is_apc():
key = lambda x: x.module.sort_key_apc()
else:
key = lambda x: x.module.sort_key()
modimpls.sort(key=key)
return formsemestres_by_dept, modimpls_by_formsemestre
def get_evaluations(self) -> list[Evaluation]:
"Liste de toutes les évaluations du semestre, triées par module/numero"
return (
@ -483,7 +361,7 @@ class FormSemestre(models.ScoDocModel):
Module.numero,
Module.code,
Evaluation.numero,
Evaluation.date_debut,
Evaluation.date_debut.desc(),
)
.all()
)
@ -493,7 +371,6 @@ class FormSemestre(models.ScoDocModel):
"""Liste des modimpls du semestre (y compris bonus)
- triée par type/numéro/code en APC
- triée par numéros d'UE/matières/modules pour les formations standard.
Hors APC, élimine les modules de type ressources et SAEs.
"""
modimpls = self.modimpls.all()
if self.formation.is_apc():
@ -505,14 +382,6 @@ class FormSemestre(models.ScoDocModel):
)
)
else:
modimpls = [
mi
for mi in modimpls
if (
mi.module.module_type
not in (scu.ModuleType.RESSOURCE, scu.ModuleType.SAE)
)
]
modimpls.sort(
key=lambda m: (
m.module.ue.numero or 0,
@ -547,7 +416,7 @@ class FormSemestre(models.ScoDocModel):
def can_be_edited_by(self, user):
"""Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)"""
if not user.has_permission(Permission.EditFormSemestre): # pas chef
if not user.has_permission(Permission.ScoImplement): # pas chef
if not self.resp_can_edit or user.id not in [
resp.id for resp in self.responsables
]:
@ -624,7 +493,7 @@ class FormSemestre(models.ScoDocModel):
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
jour_pivot_annee=1,
jour_pivot_periode=1,
) -> tuple[int, int]:
):
"""Calcule la session associée à un formsemestre commençant en date_debut
sous la forme (année, période)
année: première année de l'année scolaire
@ -674,26 +543,6 @@ class FormSemestre(models.ScoDocModel):
mois_pivot_periode=ScoDocSiteConfig.get_month_debut_periode2(),
)
@classmethod
def get_dept_formsemestres_courants(
cls, dept: Departement, date_courante: datetime.datetime | None = None
) -> db.Query:
"""Liste (query) ordonnée des formsemestres courants, c'est
à dire contenant la date courant (si None, la date actuelle)"""
date_courante = date_courante or db.func.current_date()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,
FormSemestre.date_debut <= date_courante,
FormSemestre.date_fin >= date_courante,
)
return formsemestres.order_by(
FormSemestre.date_debut.desc(),
FormSemestre.modalite,
FormSemestre.semestre_id,
FormSemestre.titre,
)
def etapes_apo_vdi(self) -> list[ApoEtapeVDI]:
"Liste des vdis"
# was read_formsemestre_etapes
@ -750,18 +599,18 @@ class FormSemestre(models.ScoDocModel):
def est_chef_or_diretud(self, user: User = None):
"Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre"
user = user or current_user
return user.has_permission(Permission.EditFormSemestre) or self.est_responsable(
return user.has_permission(Permission.ScoImplement) or self.est_responsable(
user
)
def can_change_groups(self, user: User = None) -> bool:
"""Vrai si l'utilisateur (par def. current) peut changer les groupes dans
ce semestre: vérifie permission et verrouillage (mais pas si la partition est éditable).
ce semestre: vérifie permission et verrouillage.
"""
if not self.etat:
return False # semestre verrouillé
user = user or current_user
if user.has_permission(Permission.EtudChangeGroups):
if user.has_permission(Permission.ScoEtudChangeGroups):
return True # typiquement admin, chef dept
return self.est_responsable(user)
@ -775,9 +624,9 @@ class FormSemestre(models.ScoDocModel):
def can_edit_pv(self, user: User = None):
"Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre"
user = user or current_user
# Autorise les secrétariats, repérés via la permission EtudChangeAdr
# Autorise les secrétariats, repérés via la permission ScoEtudChangeAdr
return self.est_chef_or_diretud(user) or user.has_permission(
Permission.EtudChangeAdr
Permission.ScoEtudChangeAdr
)
def annee_scolaire(self) -> int:
@ -876,9 +725,9 @@ class FormSemestre(models.ScoDocModel):
descr_sem += " " + self.modalite
return descr_sem
def get_abs_count(self, etudid) -> tuple[int, int, int]:
def get_abs_count(self, etudid):
"""Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs non just, nb abs justifiées, nb abs total)
tuple (nb abs, nb abs justifiées)
Utilise un cache.
"""
from app.scodoc import sco_assiduites
@ -918,34 +767,12 @@ class FormSemestre(models.ScoDocModel):
etuds.sort(key=lambda e: e.sort_key)
return etuds
def get_partitions_list(
self, with_default=True, only_listed=False
) -> list[Partition]:
"""Liste des partitions pour ce semestre (list of dicts),
triées par numéro, avec la partition par défaut en fin de liste.
"""
if only_listed:
partitions = [
p
for p in self.partitions
if p.partition_name is not None and p.show_in_lists
]
else:
partitions = [p for p in self.partitions if p.partition_name is not None]
if with_default:
partitions += [p for p in self.partitions if p.partition_name is None]
return partitions
@cached_property
def etudids_actifs(self) -> set:
"Set des etudids inscrits non démissionnaires et non défaillants"
return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT}
def etudids_actifs(self) -> tuple[list[int], set[int]]:
"""Liste les etudids inscrits (incluant DEM et DEF),
qui ser al'index des dataframes de notes
et donne l'ensemble des inscrits non DEM ni DEF.
"""
return [inscr.etudid for inscr in self.inscriptions], {
ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT
}
@property
@cached_property
def etuds_inscriptions(self) -> dict:
"""Map { etudid : inscription } (incluant DEM et DEF)"""
return {ins.etud.id: ins for ins in self.inscriptions}
@ -1169,33 +996,6 @@ class FormSemestre(models.ScoDocModel):
nb_recorded += 1
return nb_recorded
def change_formation(self, formation_dest: Formation):
"""Associe ce formsemestre à une autre formation.
Ce n'est possible que si la formation destination possède des modules de
même code que ceux utilisés dans la formation d'origine du formsemestre.
S'il manque un module, l'opération est annulée.
Commit (or rollback) session.
"""
ok = True
for mi in self.modimpls:
dest_modules = formation_dest.modules.filter_by(code=mi.module.code).all()
match len(dest_modules):
case 1:
mi.module = dest_modules[0]
db.session.add(mi)
case 0:
print(f"Argh ! no module found with code={mi.module.code}")
ok = False
case _:
print(f"Arg ! several modules found with code={mi.module.code}")
ok = False
if ok:
self.formation_id = formation_dest.id
db.session.commit()
else:
db.session.rollback()
# Association id des utilisateurs responsables (aka directeurs des etudes) du semestre
notes_formsemestre_responsables = db.Table(

View File

@ -1,7 +1,7 @@
# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -11,15 +11,13 @@ from operator import attrgetter
from sqlalchemy.exc import IntegrityError
from app import db, log
from app.models import ScoDocModel, GROUPNAME_STR_LEN, SHORT_STR_LEN
from app.models.etudiants import Identite
from app.models.events import Scolog
from app.models import Scolog, GROUPNAME_STR_LEN, SHORT_STR_LEN
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
class Partition(ScoDocModel):
class Partition(db.Model):
"""Partition: découpage d'une promotion en groupes"""
__table_args__ = (db.UniqueConstraint("formsemestre_id", "partition_name"),)
@ -52,7 +50,7 @@ class Partition(ScoDocModel):
backref=db.backref("partition", lazy=True),
lazy="dynamic",
cascade="all, delete-orphan",
order_by="GroupDescr.numero, GroupDescr.group_name",
order_by="GroupDescr.numero",
)
def __init__(self, **kwargs):
@ -181,7 +179,7 @@ class Partition(ScoDocModel):
"Crée un groupe dans cette partition"
if not self.formsemestre.can_change_groups():
raise AccessDenied(
"""Vous n'avez pas le droit d'effectuer cette opération,
"""Vous n'avez pas le droit d'effectuer cette opération,
ou bien le semestre est verrouillé !"""
)
if group_name:
@ -205,7 +203,7 @@ class Partition(ScoDocModel):
return group
class GroupDescr(ScoDocModel):
class GroupDescr(db.Model):
"""Description d'un groupe d'une partition"""
__tablename__ = "group_descr"
@ -214,12 +212,10 @@ class GroupDescr(ScoDocModel):
id = db.Column(db.Integer, primary_key=True)
group_id = db.synonym("id")
partition_id = db.Column(db.Integer, db.ForeignKey("partition.id"))
# "A", "C2", ... (NULL for 'all'):
group_name = db.Column(db.String(GROUPNAME_STR_LEN))
"""nom du groupe: "A", "C2", ... (NULL for 'all')"""
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
# Numero = ordre de presentation
numero = db.Column(db.Integer, nullable=False, default=0)
"Numero = ordre de presentation"
etuds = db.relationship(
"Identite",
@ -232,46 +228,18 @@ class GroupDescr(ScoDocModel):
f"""<{self.__class__.__name__} {self.id} "{self.group_name or '(tous)'}">"""
)
def get_nom_with_part(self, default="-") -> str:
"""Nom avec partition: 'TD A'
Si groupe par défaut (tous), utilise default ou "-"
"""
if self.partition.partition_name is None:
return default
def get_nom_with_part(self) -> str:
"Nom avec partition: 'TD A'"
return f"{self.partition.partition_name or ''} {self.group_name or '-'}"
def to_dict(self, with_partition=True) -> dict:
"""as a dict, with or without partition"""
if with_partition:
partition_dict = self.partition.to_dict(with_groups=False)
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
if with_partition:
d["partition"] = partition_dict
d["partition"] = self.partition.to_dict(with_groups=False)
return d
def get_edt_ids(self) -> list[str]:
"les ids normalisés pour l'emploi du temps: à défaut, le nom scodoc du groupe"
return [
scu.normalize_edt_id(x)
for x in scu.split_id(self.edt_id) or [self.group_name] or []
]
def get_nb_inscrits(self) -> int:
"""Nombre inscrits à ce group et au formsemestre.
C'est nécessaire car lors d'une désinscription, on conserve l'appartenance
aux groupes pour faciliter une éventuelle -inscription.
"""
from app.models.formsemestre import FormSemestreInscription
return (
Identite.query.join(group_membership)
.filter_by(group_id=self.id)
.join(FormSemestreInscription)
.filter_by(formsemestre_id=self.partition.formsemestre.id)
.count()
)
@classmethod
def check_name(
cls, partition: "Partition", group_name: str, existing=False, default=False
@ -288,46 +256,6 @@ class GroupDescr(ScoDocModel):
return False
return True
def set_name(self, group_name: str, dest_url: str = None):
"""Set group name, and optionally edt_id.
Check permission (partition must be groups_editable)
and invalidate caches. Commit session.
dest_url is used for error messages.
"""
if not self.partition.formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
if self.group_name is None:
raise ValueError("can't set a name to default group")
if not self.partition.groups_editable:
raise AccessDenied("Partition non éditable")
if group_name:
group_name = group_name.strip()
if not group_name:
raise ScoValueError("nom de groupe vide !", dest_url=dest_url)
if group_name != self.group_name and not GroupDescr.check_name(
self.partition, group_name
):
raise ScoValueError(
"Le nom de groupe existe déjà dans la partition", dest_url=dest_url
)
self.group_name = group_name
db.session.add(self)
db.session.commit()
sco_cache.invalidate_formsemestre(
formsemestre_id=self.partition.formsemestre_id
)
def set_edt_id(self, edt_id: str):
"Set edt_id. Check permission. Commit session."
if not self.partition.formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
if isinstance(edt_id, str):
edt_id = edt_id.strip() or None
self.edt_id = edt_id
db.session.add(self)
db.session.commit()
def remove_etud(self, etud: "Identite"):
"Enlève l'étudiant de ce groupe s'il en fait partie (ne fait rien sinon)"
if etud in self.etuds:

View File

@ -2,34 +2,25 @@
"""ScoDoc models: moduleimpls
"""
import pandas as pd
from flask import abort, g
from flask_login import current_user
from flask_sqlalchemy.query import Query
import app
from app import db
from app.auth.models import User
from app.comp import df_cache
from app.models import APO_CODE_STR_LEN, ScoDocModel
from app.models.etudiants import Identite
from app.models.evaluations import Evaluation
from app.models.modules import Module
from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
class ModuleImpl(ScoDocModel):
class ModuleImpl(db.Model):
"""Mise en oeuvre d'un module pour une annee/semestre"""
__tablename__ = "notes_moduleimpl"
__table_args__ = (db.UniqueConstraint("formsemestre_id", "module_id"),)
id = db.Column(db.Integer, primary_key=True)
code_apogee = db.Column(db.String(APO_CODE_STR_LEN), index=True, nullable=True)
"id de l'element pedagogique Apogee correspondant"
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
moduleimpl_id = db.synonym("id")
module_id = db.Column(db.Integer, db.ForeignKey("notes_modules.id"), nullable=False)
formsemestre_id = db.Column(
@ -38,50 +29,27 @@ class ModuleImpl(ScoDocModel):
index=True,
nullable=False,
)
responsable_id = db.Column(
"responsable_id", db.Integer, db.ForeignKey("user.id", ondelete="SET NULL")
)
responsable = db.relationship("User", back_populates="modimpls")
responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id"))
# formule de calcul moyenne:
computation_expr = db.Column(db.Text())
evaluations = db.relationship(
"Evaluation",
lazy="dynamic",
backref="moduleimpl",
order_by=(Evaluation.numero, Evaluation.date_debut),
)
"évaluations, triées par numéro et dates croissants, donc la plus ancienne d'abord."
evaluations = db.relationship("Evaluation", lazy="dynamic", backref="moduleimpl")
enseignants = db.relationship(
"User",
secondary="notes_modules_enseignants",
lazy="dynamic",
backref="moduleimpl",
viewonly=True,
)
"enseignants du module (sans le responsable)"
def __init__(self, **kwargs):
super(ModuleImpl, self).__init__(**kwargs)
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} module={repr(self.module)}>"
def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée (codés en base comme "VRT1,VRT2").
(si non renseigné, ceux du module)
"""
if self.code_apogee:
return {x.strip() for x in self.code_apogee.split(",") if x}
return self.module.get_codes_apogee()
def get_edt_ids(self) -> list[str]:
"les ids pour l'emploi du temps: à défaut, les codes Apogée"
return [
scu.normalize_edt_id(x)
for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee)
] or self.module.get_edt_ids()
def get_evaluations_poids(self) -> pd.DataFrame:
"""Les poids des évaluations vers les UEs (accès via cache redis).
Toutes les évaluations sont considérées (normales, bonus, rattr., etc.)
"""
"""Les poids des évaluations vers les UE (accès via cache)"""
evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id)
if evaluations_poids is None:
from app.comp import moy_mod
@ -90,58 +58,24 @@ class ModuleImpl(ScoDocModel):
df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids)
return evaluations_poids
@classmethod
def get_modimpl(cls, moduleimpl_id: int | str, dept_id: int = None) -> "ModuleImpl":
"""ModuleImpl ou 404, cherche uniquement dans le département spécifié ou le courant."""
from app.models.formsemestre import FormSemestre
if not isinstance(moduleimpl_id, int):
try:
moduleimpl_id = int(moduleimpl_id)
except (TypeError, ValueError):
abort(404, "moduleimpl_id invalide")
if g.scodoc_dept:
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
query = cls.query.filter_by(id=moduleimpl_id)
if dept_id is not None:
query = query.join(FormSemestre).filter_by(dept_id=dept_id)
return query.first_or_404()
def invalidate_evaluations_poids(self):
"""Invalide poids cachés"""
df_cache.EvaluationsPoidsCache.delete(self.id)
def check_apc_conformity(
self, res: "ResultatsSemestreBUT", evaluation_type=Evaluation.EVALUATION_NORMALE
) -> bool:
"""true si les poids des évaluations du type indiqué (normales par défaut)
du module permettent de satisfaire les coefficients du PN.
def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool:
"""true si les poids des évaluations du module permettent de satisfaire
les coefficients du PN.
"""
# appelé par formsemestre_status, liste notes, et moduleimpl_status
if not self.module.formation.get_cursus().APC_SAE or (
self.module.module_type
not in {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE}
self.module.module_type != scu.ModuleType.RESSOURCE
and self.module.module_type != scu.ModuleType.SAE
):
return True # Non BUT, toujours conforme
from app.comp import moy_mod
mod_results = res.modimpls_results.get(self.id)
if mod_results is None:
app.critical_error("check_apc_conformity: err 1")
selected_evaluations_ids = [
eval_id
for eval_id, eval_type in mod_results.evals_type.items()
if eval_type == evaluation_type
]
if not selected_evaluations_ids:
return True # conforme si pas d'évaluations
selected_evaluations_poids = self.get_evaluations_poids().loc[
selected_evaluations_ids
]
return moy_mod.moduleimpl_is_conforme(
self,
selected_evaluations_poids,
self.get_evaluations_poids(),
res.modimpl_coefs_df,
)
@ -165,7 +99,6 @@ class ModuleImpl(ScoDocModel):
d["module"] = self.module.to_dict(convert_objects=convert_objects)
else:
d.pop("module", None)
d["code_apogee"] = d["code_apogee"] or "" # pas de None
return d
def can_edit_evaluation(self, user) -> bool:
@ -174,7 +107,7 @@ class ModuleImpl(ScoDocModel):
"""
# acces pour resp. moduleimpl et resp. form semestre (dir etud)
if (
user.has_permission(Permission.EditAllEvals)
user.has_permission(Permission.ScoEditAllEvals)
or user.id == self.responsable_id
or user.id in (r.id for r in self.formsemestre.responsables)
):
@ -198,7 +131,7 @@ class ModuleImpl(ScoDocModel):
if not self.formsemestre.etat:
return False # semestre verrouillé
is_dir_etud = user.id in (u.id for u in self.formsemestre.responsables)
can_edit_all_notes = user.has_permission(Permission.EditAllNotes)
can_edit_all_notes = user.has_permission(Permission.ScoEditAllNotes)
if sco_cursus_dut.formsemestre_has_decisions(self.formsemestre_id):
# il y a des décisions de jury dans ce semestre !
return can_edit_all_notes or is_dir_etud
@ -211,7 +144,7 @@ class ModuleImpl(ScoDocModel):
return allow_ens and user.id in (ens.id for ens in self.enseignants)
return True
def can_change_responsable(self, user: User, raise_exc=False) -> bool:
def can_change_ens_by(self, user: User, raise_exc=False) -> bool:
"""Check if user can modify module resp.
If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.
= Admin, et dir des etud. (si option l'y autorise)
@ -222,7 +155,7 @@ class ModuleImpl(ScoDocModel):
return False
# -- check access
# admin ou resp. semestre avec flag resp_can_change_resp
if user.has_permission(Permission.EditFormSemestre):
if user.has_permission(Permission.ScoImplement):
return True
if (
user.id in [resp.id for resp in self.formsemestre.responsables]
@ -232,32 +165,11 @@ class ModuleImpl(ScoDocModel):
raise AccessDenied(f"Modification impossible pour {user}")
return False
def can_change_ens(self, user: User | None = None, raise_exc=True) -> bool:
"""check if user can modify ens list (raise exception if not)"
if user is None, current user.
"""
user = current_user if user is None else user
if not self.formsemestre.etat:
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")
return False
# -- check access
# admin, resp. module ou resp. semestre
if (
user.id != self.responsable_id
and not user.has_permission(Permission.EditFormSemestre)
and user.id not in (u.id for u in self.formsemestre.responsables)
):
if raise_exc:
raise AccessDenied(f"Modification impossible pour {user}")
return False
return True
def est_inscrit(self, etud: Identite) -> bool:
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre).
(lent, pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df).
Retourne Vrai si inscrit au module, faux sinon.
Vérifie si l'étudiant est bien inscrit au moduleimpl
Retourne Vrai si c'est le cas, faux sinon
"""
is_module: int = (
@ -307,14 +219,6 @@ class ModuleImplInscription(db.Model):
backref=db.backref("inscriptions", cascade="all, delete-orphan"),
)
def to_dict(self) -> dict:
"dict repr."
return {
"id": self.id,
"etudid": self.etudid,
"moduleimpl_id": self.moduleimpl_id,
}
@classmethod
def etud_modimpls_in_ue(
cls, formsemestre_id: int, etudid: int, ue_id: int

View File

@ -1,24 +1,17 @@
"""ScoDoc 9 models : Modules
"""
from flask import current_app, g
from flask import current_app
from app import db
from app import models
from app.models import APO_CODE_STR_LEN
from app.models.but_refcomp import (
ApcParcours,
ApcReferentielCompetences,
app_critiques_modules,
parcours_modules,
)
from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import ModuleType
class Module(models.ScoDocModel):
class Module(db.Model):
"""Module"""
__tablename__ = "notes_modules"
@ -29,7 +22,6 @@ class Module(models.ScoDocModel):
abbrev = db.Column(db.Text()) # nom court
# certains départements ont des codes infiniment longs: donc Text !
code = db.Column(db.Text(), nullable=False)
"code module, chaine non nullable"
heures_cours = db.Column(db.Float)
heures_td = db.Column(db.Float)
heures_tp = db.Column(db.Float)
@ -42,10 +34,8 @@ class Module(models.ScoDocModel):
# note: en APC, le semestre qui fait autorité est celui de l'UE
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
# id de l'element pedagogique Apogee correspondant:
code_apogee = db.Column(db.String(APO_CODE_STR_LEN))
"id de l'element pedagogique Apogee correspondant"
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
# Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)
module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0")
# Relations:
@ -83,55 +73,6 @@ class Module(models.ScoDocModel):
return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name
} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"""
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect.
returns: dict to store in model's db.
"""
# s'assure que ects etc est non ''
fs_empty_stored_as_nulls = {
"coefficient",
"ects",
"heures_cours",
"heures_td",
"heures_tp",
}
args_dict = {}
for key, value in args.items():
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
if key in fs_empty_stored_as_nulls and value == "":
value = None
args_dict[key] = value
return args_dict
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
Add 'id' to excluded."""
# on ne peut pas affecter directement parcours
return super().filter_model_attributes(data, (excluded or set()) | {"parcours"})
@classmethod
def create_from_dict(cls, data: dict) -> "Module":
"""Create from given dict, add parcours"""
mod = super().create_from_dict(data)
for p in data.get("parcours", []) or []:
if isinstance(p, ApcParcours):
parcour: ApcParcours = p
else:
pid = int(p)
query = ApcParcours.query.filter_by(id=pid)
if g.scodoc_dept:
query = query.join(ApcReferentielCompetences).filter_by(
dept_id=g.scodoc_dept_id
)
parcour: ApcParcours = query.first()
if parcour is None:
raise ScoValueError("Parcours invalide")
mod.parcours.append(parcour)
return mod
def clone(self):
"""Create a new copy of this module."""
mod = Module(
@ -216,10 +157,6 @@ class Module(models.ScoDocModel):
"Identifiant du module à afficher : abbrev ou titre ou code"
return self.abbrev or self.titre or self.code
def sort_key(self) -> tuple:
"""Clé de tri pour formations classiques"""
return self.numero or 0, self.code
def sort_key_apc(self) -> tuple:
"""Clé de tri pour avoir
présentation par type (res, sae), parcours, type, numéro
@ -245,8 +182,8 @@ class Module(models.ScoDocModel):
Les coefs nuls (zéro) ne sont pas stockés: la relation est supprimée.
"""
if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logger.info(
"set_ue_coef_dict: locked formation, ignoring request"
current_app.logguer.info(
f"set_ue_coef_dict: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
changed = False
@ -276,8 +213,8 @@ class Module(models.ScoDocModel):
def update_ue_coef_dict(self, ue_coef_dict: dict):
"""update coefs vers UE (ajoute aux existants)"""
if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logger.info(
"update_ue_coef_dict: locked formation, ignoring request"
current_app.logguer.info(
f"update_ue_coef_dict: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
current = self.get_ue_coef_dict()
@ -295,7 +232,7 @@ class Module(models.ScoDocModel):
def delete_ue_coef(self, ue):
"""delete coef"""
if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logger.info(
current_app.logguer.info(
"delete_ue_coef: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
@ -340,34 +277,12 @@ class Module(models.ScoDocModel):
# Liste seulement les coefs définis:
return [(c.ue, c.coef) for c in self.get_ue_coefs_sorted()]
def get_ue_coefs_descr(self) -> str:
"""Description des coefficients vers les UEs (APC)"""
coefs_descr = ", ".join(
[
f"{ue.acronyme}: {co}"
for ue, co in self.ue_coefs_list()
if isinstance(co, float) and co > 0
]
)
if coefs_descr:
descr = "Coefs: " + coefs_descr
else:
descr = "(pas de coefficients) "
return descr
def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée (codés en base comme "VRT1,VRT2")"""
if self.code_apogee:
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
def get_edt_ids(self) -> list[str]:
"les ids pour l'emploi du temps: à défaut, le 1er code Apogée"
return [
scu.normalize_edt_id(x)
for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) or []
]
def get_parcours(self) -> list[ApcParcours]:
"""Les parcours utilisant ce module.
Si tous les parcours, liste vide (!).
@ -381,14 +296,6 @@ class Module(models.ScoDocModel):
return []
return self.parcours
def add_tag(self, tag: "NotesTag"):
"""Add tag to module. Check if already has it."""
if tag.id in {t.id for t in self.tags}:
return
self.tags.append(tag)
db.session.add(self)
db.session.flush()
class ModuleUECoef(db.Model):
"""Coefficients des modules vers les UE (APC, BUT)
@ -451,19 +358,6 @@ class NotesTag(db.Model):
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
title = db.Column(db.Text(), nullable=False)
@classmethod
def get_or_create(cls, title: str, dept_id: int | None = None) -> "NotesTag":
"""Get tag, or create it if it doesn't yet exists.
If dept_id unspecified, use current dept.
"""
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
tag = NotesTag.query.filter_by(dept_id=dept_id, title=title).first()
if tag is None:
tag = NotesTag(dept_id=dept_id, title=title)
db.session.add(tag)
db.session.flush()
return tag
# Association tag <-> module
notes_modules_tags = db.Table(

View File

@ -1,48 +0,0 @@
"""évènements scolaires dans la vie d'un étudiant(inscription, ...)
"""
from app import db
from app.models import SHORT_STR_LEN
class ScolarEvent(db.Model):
"""Evenement dans le parcours scolaire d'un étudiant"""
__tablename__ = "scolar_events"
id = db.Column(db.Integer, primary_key=True)
event_id = db.synonym("id")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
)
event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id", ondelete="SET NULL"),
)
ue_id = db.Column(
db.Integer,
db.ForeignKey("notes_ue.id", ondelete="SET NULL"),
)
# 'CREATION', 'INSCRIPTION', 'DEMISSION',
# 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM'
# 'ECHEC_SEM'
# 'UTIL_COMPENSATION'
event_type = db.Column(db.String(SHORT_STR_LEN))
# Semestre compensé par formsemestre_id:
comp_formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
)
etud = db.relationship("Identite", lazy="select", backref="events", uselist=False)
formsemestre = db.relationship(
"FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
)
def to_dict(self) -> dict:
"as a dict"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
return d
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.event_type}, {self.event_date.isoformat()}, {self.formsemestre})"

View File

@ -5,7 +5,6 @@ from flask import g
import pandas as pd
from app import db, log
from app import models
from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN
from app.models.but_refcomp import ApcNiveau, ApcParcours
@ -13,7 +12,7 @@ from app.models.modules import Module
from app.scodoc import sco_utils as scu
class UniteEns(models.ScoDocModel):
class UniteEns(db.Model):
"""Unité d'Enseignement (UE)"""
__tablename__ = "notes_ue"
@ -82,7 +81,7 @@ class UniteEns(models.ScoDocModel):
'EXTERNE' if self.is_external else ''})>"""
def clone(self):
"""Create a new copy of this ue, add to session.
"""Create a new copy of this ue.
Ne copie pas le code, ni le code Apogée, ni les liens au réf. de comp.
(parcours et niveau).
"""
@ -101,26 +100,8 @@ class UniteEns(models.ScoDocModel):
coef_rcue=self.coef_rcue,
color=self.color,
)
db.session.add(ue)
return ue
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields from the given dict to model's attributes values. No side effect.
args: dict with args in application.
returns: dict to store in model's db.
"""
args = args.copy()
if "type" in args:
args["type"] = int(args["type"] or 0)
if "is_external" in args:
args["is_external"] = scu.to_bool(args["is_external"])
if "ects" in args:
args["ects"] = float(args["ects"])
return args
def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
"""as a dict, with the same conversions as in ScoDoc7.
If convert_objects, convert all attributes to native types
@ -409,14 +390,6 @@ class UniteEns(models.ScoDocModel):
Renvoie (True, "") si ok, sinon (False, error_message)
"""
msg = ""
# Safety check
if self.formation.referentiel_competence is None:
return False, "pas de référentiel de compétence"
# Si tous les parcours, aucun (tronc commun)
if {p.id for p in parcours} == {
p.id for p in self.formation.referentiel_competence.parcours
}:
parcours = []
# Le niveau est-il dans tous ces parcours ? Sinon, l'enlève
prev_niveau = self.niveau_competence
if (
@ -432,7 +405,6 @@ class UniteEns(models.ScoDocModel):
self.niveau_competence, parcours
)
if not ok:
self.formation.invalidate_cached_sems()
self.niveau_competence = prev_niveau # restore
return False, error_message

View File

@ -1,6 +1,6 @@
# -*- coding: UTF-8 -*
"""Notes, décisions de jury
"""Notes, décisions de jury, évènements scolaires
"""
from app import db
@ -72,7 +72,7 @@ class ScolarFormSemestreValidation(db.Model):
return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id
} ({self.ue_id}): {self.code}"""
return f"""décision sur semestre {self.formsemestre.titre_mois()} du {
self.event_date.strftime(scu.DATE_FMT)}"""
self.event_date.strftime("%d/%m/%Y")}"""
def delete(self):
"Efface cette validation"
@ -113,20 +113,20 @@ class ScolarFormSemestreValidation(db.Model):
if self.ue.parcours else ""}
{("émise par " + link)}
: <b>{self.code}</b>{moyenne}
le {self.event_date.strftime(scu.DATEATIME_FMT)}
"""
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
"""
else:
return f"""Validation du semestre S{
self.formsemestre.semestre_id if self.formsemestre else "?"}
{self.formsemestre.html_link_status() if self.formsemestre else ""}
: <b>{self.code}</b>
le {self.event_date.strftime(scu.DATEATIME_FMT)}
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
"""
def ects(self) -> float:
"Les ECTS acquis par cette validation. (0 si ce n'est pas une validation d'UE)"
return (
self.ue.ects or 0.0
self.ue.ects
if (self.ue is not None) and (self.code in CODES_UE_VALIDES)
else 0.0
)
@ -175,8 +175,8 @@ class ScolarAutorisationInscription(db.Model):
)
return f"""Autorisation de passage vers <b>S{self.semestre_id}</b> émise par
{link}
le {self.date.strftime(scu.DATEATIME_FMT)}
"""
le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}
"""
@classmethod
def autorise_etud(
@ -218,3 +218,47 @@ class ScolarAutorisationInscription(db.Model):
msg=f"Passage vers S{autorisation.semestre_id}: effacé",
)
db.session.flush()
class ScolarEvent(db.Model):
"""Evenement dans le parcours scolaire d'un étudiant"""
__tablename__ = "scolar_events"
id = db.Column(db.Integer, primary_key=True)
event_id = db.synonym("id")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
)
event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id", ondelete="SET NULL"),
)
ue_id = db.Column(
db.Integer,
db.ForeignKey("notes_ue.id", ondelete="SET NULL"),
)
# 'CREATION', 'INSCRIPTION', 'DEMISSION',
# 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM'
# 'ECHEC_SEM'
# 'UTIL_COMPENSATION'
event_type = db.Column(db.String(SHORT_STR_LEN))
# Semestre compensé par formsemestre_id:
comp_formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
)
etud = db.relationship("Identite", lazy="select", backref="events", uselist=False)
formsemestre = db.relationship(
"FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
)
def to_dict(self) -> dict:
"as a dict"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
return d
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.event_type}, {self.event_date.isoformat()}, {self.formsemestre})"

View File

@ -1,5 +1,8 @@
# Module "Avis de poursuite d'étude"
# Module "Avis de poursuite d'étude"
Conçu et développé sur ScoDoc 7 par Cléo Baras (IUT de Grenoble) pour le DUT.
Actuellement non opérationnel dans ScoDoc 9.

View File

@ -1,342 +0,0 @@
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Thu Sep 8 09:36:33 2016
@author: barasc
"""
import pandas as pd
import numpy as np
from app.models import Identite
from app.pe import pe_affichage
from app.pe.moys import pe_tabletags, pe_moy, pe_moytag, pe_sxtag
from app.pe.rcss import pe_rcs
import app.pe.pe_comp as pe_comp
from app.scodoc.sco_utils import ModuleType
class InterClassTag(pe_tabletags.TableTag):
"""
Interclasse l'ensemble des étudiants diplômés à une année
donnée (celle du jury), pour un RCS donné (par ex: 'S2', '3S'), qu'il soit
de type SemX ou RCSemX,
en reportant les moyennes obtenues sur à la version tagguée
du RCS (de type SxTag ou RCSTag).
Sont ensuite calculés les classements (uniquement)
sur les étudiants diplômes.
Args:
nom_rcs: Le nom de l'aggrégat
type_interclassement: Le type d'interclassement (par UE ou par compétences)
etudiants_diplomes: L'identité des étudiants diplômés
rcss: Un dictionnaire {(nom_rcs, fid_final): RCS} donnant soit
les SemX soit les RCSemX recencés par le jury PE
rcstag: Un dictionnaire {(nom_rcs, fid_final): RCSTag} donnant
soit les SxTag (associés aux SemX)
soit les RCSTags (associés au RCSemX) calculés par le jury PE
suivis: Un dictionnaire associé à chaque étudiant son rcss
(de la forme ``{etudid: {nom_rcs: RCS_suivi}}``)
"""
def __init__(
self,
nom_rcs: str,
type_interclassement: str,
etudiants_diplomes: dict[int, Identite],
rcss: dict[(str, int) : pe_rcs.RCS],
rcstags: dict[(str, int) : pe_tabletags.TableTag],
suivis: dict[int:dict],
):
pe_tabletags.TableTag.__init__(self)
self.nom_rcs: str = nom_rcs
"""Le nom du RCS interclassé"""
# Le type d'interclassement
self.type = type_interclassement
pe_affichage.pe_print(
f"*** Interclassement par 🗂️{type_interclassement} pour le RCS ⏯️{nom_rcs}"
)
# Les informations sur les étudiants diplômés
self.etuds: list[Identite] = list(etudiants_diplomes.values())
"""Identités des étudiants diplômés"""
self.add_etuds(self.etuds)
self.diplomes_ids = set(etudiants_diplomes.keys())
"""Etudids des étudiants diplômés"""
# Les RCS de l'aggrégat (SemX ou RCSemX)
self.rcss: dict[(str, int), pe_rcs.RCS] = {}
"""Ensemble des SemX ou des RCSemX associés à l'aggrégat"""
for (nom, fid), rcs in rcss.items():
if nom == nom_rcs:
self.rcss[(nom, fid)] = rcss
# Les données tagguées
self.rcstags: dict[(str, int), pe_tabletags.TableTag] = {}
"""Ensemble des SxTag ou des RCSTags associés à l'aggrégat"""
for rcs_id in self.rcss:
self.rcstags[rcs_id] = rcstags[rcs_id]
# Les RCS (SemX ou RCSemX) suivis par les étudiants du jury,
# en ne gardant que ceux associés aux diplomés
self.suivis: dict[int, pe_rcs.RCS] = {}
"""Association entre chaque étudiant et le SxTag ou RCSTag à prendre
pour l'aggrégat"""
for etudid in self.diplomes_ids:
self.suivis[etudid] = suivis[etudid][nom_rcs]
# Les données sur les tags
self.tags_sorted = self._do_taglist()
"""Liste des tags (triés par ordre alphabétique)"""
aff = pe_affichage.repr_tags(self.tags_sorted)
pe_affichage.pe_print(f"--> Tags : {aff}")
# Les données sur les UEs (si SxTag) ou compétences (si RCSTag)
self.champs_sorted = self._do_ues_ou_competences_list()
"""Les champs (UEs ou compétences) de l'interclassement"""
if self.type == pe_moytag.CODE_MOY_UE:
pe_affichage.pe_print(
f"--> UEs : {pe_affichage.aff_UEs(self.champs_sorted)}"
)
else:
pe_affichage.pe_print(
f"--> Compétences : {pe_affichage.aff_competences(self.champs_sorted)}"
)
# Etudids triés
self.etudids_sorted = sorted(list(self.diplomes_ids))
self.nom = self.get_repr()
"""Représentation textuelle de l'interclassement"""
# Synthétise les moyennes/classements par tag
self.moyennes_tags: dict[str, pe_moytag.MoyennesTag] = {}
for tag in self.tags_sorted:
# Les moyennes tous modules confondus
notes_gen = self.compute_notes_matrice(tag)
# Les coefficients de la moyenne générale
coeffs = self.compute_coeffs_matrice(tag)
aff = pe_affichage.repr_profil_coeffs(coeffs, with_index=True)
pe_affichage.pe_print(f"--> Moyenne 👜{tag} avec coeffs: {aff} ")
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
tag,
self.type,
notes_gen,
coeffs, # limite les moyennes aux étudiants de la promo
)
def get_repr(self) -> str:
"""Une représentation textuelle"""
return f"{self.nom_rcs} par {self.type}"
def _do_taglist(self):
"""Synthétise les tags à partir des TableTags (SXTag ou RCSTag)
Returns:
Une liste de tags triés par ordre alphabétique
"""
tags = []
for rcstag in self.rcstags.values():
tags.extend(rcstag.tags_sorted)
return sorted(set(tags))
def compute_notes_matrice(self, tag) -> pd.DataFrame:
"""Construit la matrice de notes (etudids x champs) en
reportant les moyennes obtenues par les étudiants
aux semestres de l'aggrégat pour le tag visé.
Les champs peuvent être des acronymes d'UEs ou des compétences.
Args:
tag: Le tag visé
Return:
Le dataFrame (etudids x champs)
reportant les moyennes des étudiants aux champs
"""
# etudids_sorted: Les etudids des étudiants (diplômés) triés
# champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
# Partant d'un dataframe vierge
df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted)
for rcstag in self.rcstags.values():
# Charge les moyennes au tag d'un RCStag
if tag in rcstag.moyennes_tags:
moytag = rcstag.moyennes_tags[tag]
notes = moytag.matrice_notes_gen # dataframe etudids x ues
# Etudiants/Champs communs entre le RCSTag et les données interclassées
(
etudids_communs,
champs_communs,
) = pe_comp.find_index_and_columns_communs(df, notes)
# Injecte les notes par tag
df.loc[etudids_communs, champs_communs] = notes.loc[
etudids_communs, champs_communs
]
return df
def compute_coeffs_matrice(self, tag) -> pd.DataFrame:
"""Idem que compute_notes_matrices mais pour les coeffs
Args:
tag: Le tag visé
Return:
Le dataFrame (etudids x champs)
reportant les moyennes des étudiants aux champs
"""
# etudids_sorted: Les etudids des étudiants (diplômés) triés
# champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
# Partant d'un dataframe vierge
df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted)
for rcstag in self.rcstags.values():
if tag in rcstag.moyennes_tags:
# Charge les coeffs au tag d'un RCStag
coeffs: pd.DataFrame = rcstag.moyennes_tags[tag].matrice_coeffs_moy_gen
# Etudiants/Champs communs entre le RCSTag et les données interclassées
(
etudids_communs,
champs_communs,
) = pe_comp.find_index_and_columns_communs(df, coeffs)
# Injecte les coeffs par tag
df.loc[etudids_communs, champs_communs] = coeffs.loc[
etudids_communs, champs_communs
]
return df
def _do_ues_ou_competences_list(self) -> list[str]:
"""Synthétise les champs (UEs ou compétences) sur lesquels
sont calculés les moyennes.
Returns:
Un dictionnaire {'acronyme_ue' : 'compétences'}
"""
dict_champs = []
for rcstag in self.rcstags.values():
if isinstance(rcstag, pe_sxtag.SxTag):
champs = rcstag.acronymes_sorted
else: # pe_rcstag.RCSTag
champs = rcstag.competences_sorted
dict_champs.extend(champs)
return sorted(set(dict_champs))
def has_tags(self):
"""Indique si l'interclassement a des tags (cas d'un
interclassement sur un S5 qui n'a pas eu lieu)
"""
return len(self.tags_sorted) > 0
def _un_rcstag_significatif(self, rcsstags: dict[(str, int):pe_tabletags]):
"""Renvoie un rcstag significatif (ayant des tags et des notes aux tags)
parmi le dictionnaire de rcsstags"""
for rcstag_id, rcstag in rcsstags.items():
moystags: pe_moytag.MoyennesTag = rcstag.moyennes_tags
for tag, moystag in moystags.items():
tags_tries = moystag.get_all_significant_tags()
if tags_tries:
return moystag
return None
def compute_df_synthese_moyennes_tag(
self, tag, aggregat=None, type_colonnes=False, options={"min_max_moy": True}
) -> pd.DataFrame:
"""Construit le dataframe retraçant pour les données des moyennes
pour affichage dans la synthèse du jury PE. (cf. to_df())
Args:
etudids_sorted: Les etudids des étudiants (diplômés) triés
champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
Return:
Le dataFrame (etudids x champs)
reportant les moyennes des étudiants aux champs
"""
if aggregat:
assert (
aggregat == self.nom_rcs
), "L'interclassement ciblé ne correspond pas à l'aggrégat visé"
etudids_sorted = sorted(list(self.diplomes_ids))
if not self.rcstags:
return None
# Partant d'un dataframe vierge
initialisation = False
df = pd.DataFrame()
# Pour chaque rcs (suivi) associe la liste des etudids l'ayant suivi
asso_rcs_etudids = {}
for etudid in etudids_sorted:
rcs = self.suivis[etudid]
if rcs:
if rcs.rcs_id not in asso_rcs_etudids:
asso_rcs_etudids[rcs.rcs_id] = []
asso_rcs_etudids[rcs.rcs_id].append(etudid)
for rcs_id, etudids in asso_rcs_etudids.items():
# Charge ses moyennes au RCSTag suivi
rcstag = self.rcstags[rcs_id] # Le SxTag ou RCSTag
# Charge la moyenne
if tag in rcstag.moyennes_tags:
moytag: pd.DataFrame = rcstag.moyennes_tags[tag]
df_moytag = moytag.to_df(
aggregat=aggregat, cohorte="Groupe", options=options
)
# Modif les colonnes au regard du 1er df_moytag significatif lu
if not initialisation:
df = pd.DataFrame(
np.nan, index=etudids_sorted, columns=df_moytag.columns
)
colonnes = list(df_moytag.columns)
for col in colonnes:
if col.endswith("rang"):
df[col] = df[col].astype(str)
initialisation = True
# Injecte les notes des étudiants
df.loc[etudids, :] = df_moytag.loc[etudids, :]
return df

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