Merge pull request 'Update opolka/ScoDoc from ScoDoc/ScoDoc' (#2) from ScoDoc/ScoDoc:master into master

Reviewed-on: https://scodoc.org/git/opolka/ScoDoc/pulls/2
This commit is contained in:
Alexis Opolka 2024-05-27 09:11:03 +02:00
commit c392f56bf6
656 changed files with 73596 additions and 29662 deletions

3
.flake8 Normal file
View File

@ -0,0 +1,3 @@
[flake8]
max-line-length = 88
ignore = E203,W503

3
.gitignore vendored
View File

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

2
.mypy.ini Normal file
View File

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

View File

@ -1,10 +1,24 @@
[MASTER]
load-plugins=pylint_flask_sqlalchemy,pylint_flask
[MESSAGES CONTROL]
# pylint and black disagree...
disable=bad-continuation
# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=pylint_flask
[TYPECHECK]
ignored-classes=Permission,SQLObject,Registrant,scoped_session
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=Permission,
SQLObject,
Registrant,
scoped_session,
func
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis). It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=entreprises
good-names=d,df,e,f,i,j,k,n,nt,t,u,ue,v,x,y,z,H,F

156
README.md
View File

@ -1,8 +1,8 @@
# ScoDoc - Gestion de la scolarité - Version ScoDoc 9
(c) Emmanuel Viennet 1999 - 2022 (voir LICENCE.txt).
(c) Emmanuel Viennet 1999 - 2024 (voir LICENCE.txt).
Installation: voir instructions à jour sur <https://scodoc.org/GuideInstallDebian11>
Installation: voir instructions à jour sur <https://scodoc.org/GuideInstallDebian12>
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 [https://scodoc.org/GuideConfig](le guide de configuration).
Voir [le guide de configuration](https://scodoc.org/GuideConfig).
## Organisation des fichiers
@ -41,45 +41,41 @@ 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/GuideInstallDebian11)).
Installer ScoDoc 9 normalement ([voir la doc](https://scodoc.org/GuideInstallDebian12)).
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éé !)
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
# Donner ce répertoire à l'utilisateur scodoc:
chown -R scodoc:scodoc /opt/scodoc
```
Il faut ensuite installer l'environnement et le fichier de configuration:
# Le plus simple est de piquer le virtualenv configuré par l'installeur:
mv /opt/off-scodoc/venv /opt/scodoc
```bash
# Le plus simple est de piquer le virtualenv configuré par l'installeur:
mv /opt/off-scodoc/venv /opt/scodoc
```
Et la config:
ln -s /opt/scodoc-data/.env /opt/scodoc
```bash
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`.
@ -88,11 +84,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:
./tools/create_database.sh SCODOC_TEST
export FLASK_ENV=test
flask db upgrade
```bash
./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.
@ -100,17 +96,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:
flask delete-dept -fy TEST00 && flask create-dept TEST00
```bash
flask delete-dept -fy TEST00 && flask create-dept TEST00
```
Puis dérouler les tests unitaires:
pytest tests/unit
```bash
pytest tests/unit
```
Ou avec couverture (`pip install pytest-cov`)
pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/*
```bash
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
@ -119,43 +115,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:
export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
```bash
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:
pytest tests/unit/test_sco_basic.py
```bash
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:
flask user-password admin
```bash
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.
flask db migrate -m "message explicatif....."
flask db upgrade
```bash
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)
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
# 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.
@ -163,23 +159,23 @@ positionner à la bonne étape.
### Profiling
Sur une machine de DEV, lancer
flask profile --host 0.0.0.0 --length 32 --profile-dir /opt/scodoc-data
```bash
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:
pip install snakeviz
```bash
pip install snakeviz
```
puis
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
# Paquet Debian 11
```bash
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

53
app/__init__.py Normal file → Executable file
View File

@ -19,24 +19,20 @@ 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
import werkzeug.debug
from app.scodoc.sco_exceptions import (
AccessDenied,
@ -44,6 +40,7 @@ from app.scodoc.sco_exceptions import (
ScoException,
ScoGenError,
ScoInvalidCSRF,
ScoPDFFormatError,
ScoValueError,
APIInvalidParams,
)
@ -59,8 +56,6 @@ 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(
@ -74,6 +69,7 @@ cache = Cache(
def handle_sco_value_error(exc):
"page d'erreur avec message"
return render_template("sco_value_error.j2", exc=exc), 404
@ -90,6 +86,11 @@ 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 internal_server_error(exc):
"""Bugs scodoc, erreurs 500"""
# note that we set the 500 status explicitly
@ -148,7 +149,7 @@ def handle_invalid_usage(error):
# JSON ENCODING
# used by some internal finctions
# the API is now using flask_son, NOT THIS ENCODER
# the API is now using flask_json, NOT THIS ENCODER
class ScoDocJSONEncoder(json.JSONEncoder):
def default(self, o): # pylint: disable=E0202
if isinstance(o, (datetime.date, datetime.datetime)):
@ -260,7 +261,13 @@ def create_app(config_class=DevConfig):
CAS(app, url_prefix="/cas", configuration_function=cas.set_cas_configuration)
app.wsgi_app = ReverseProxied(app.wsgi_app)
FlaskJSON(app)
app_json = FlaskJSON(app)
@app_json.encoder
def scodoc_json_encoder(o):
"Overide default date encoding (RFC 822) and use ISO"
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat()
# Pour conserver l'ordre des objets dans les JSON:
# e.g. l'ordre des UE dans les bulletins
@ -293,8 +300,6 @@ 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
@ -304,6 +309,7 @@ def create_app(config_class=DevConfig):
app.register_error_handler(ScoValueError, handle_sco_value_error)
app.register_error_handler(ScoBugCatcher, handle_sco_bug)
app.register_error_handler(ScoInvalidCSRF, handle_invalid_csrf)
app.register_error_handler(ScoPDFFormatError, handle_pdf_format_error)
app.register_error_handler(AccessDenied, handle_access_denied)
app.register_error_handler(500, internal_server_error)
app.register_error_handler(503, postgresql_server_error)
@ -322,11 +328,19 @@ def create_app(config_class=DevConfig):
from app.views import notes_bp
from app.views import users_bp
from app.views import absences_bp
from app.views import assiduites_bp
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)
@ -340,6 +354,9 @@ def create_app(config_class=DevConfig):
app.register_blueprint(
absences_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Absences"
)
app.register_blueprint(
assiduites_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Assiduites"
)
app.register_blueprint(api_bp, url_prefix="/ScoDoc/api")
app.register_blueprint(api_web_bp, url_prefix="/ScoDoc/<scodoc_dept>/api")
@ -534,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
@ -620,14 +637,12 @@ def critical_error(msg):
import app.scodoc.sco_utils as scu
log(f"\n*** CRITICAL ERROR: {msg}")
send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
subject = f"CRITICAL ERROR: {msg}".strip()[:68]
send_scodoc_alarm(subject, msg)
clear_scodoc_cache()
raise ScoValueError(
f"""
Une erreur est survenue.
Si le problème persiste, merci de contacter le support ScoDoc via
{scu.SCO_DISCORD_ASSISTANCE}
Une erreur est survenue, veuillez -essayer.
{msg}
"""

View File

@ -1,10 +1,13 @@
"""api.__init__
"""
from flask_json import as_json
from flask import Blueprint
from flask import request
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 ScoException
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__)
@ -14,12 +17,24 @@ API_CLIENT_ERROR = 400 # erreur dans les paramètres fournis par le client
@api_bp.errorhandler(ScoException)
@api_web_bp.errorhandler(ScoException)
@api_bp.errorhandler(404)
def api_error_handler(e):
"erreurs API => json"
return scu.json_error(404, message=str(e))
@api_bp.errorhandler(AccessDenied)
@api_web_bp.errorhandler(AccessDenied)
def permission_denied_error_handler(exc):
"""
Renvoie message d'erreur pour l'erreur 403
"""
return scu.json_error(
403, f"operation non autorisee ({exc.args[0] if exc.args else ''})"
)
def requested_format(default_format="json", allowed_formats=None):
"""Extract required format from query string.
* default value is json. A list of allowed formats may be provided
@ -34,9 +49,41 @@ def requested_format(default_format="json", allowed_formats=None):
return None
@as_json
def get_model_api_object(
model_cls: db.Model,
model_id: int,
join_cls: db.Model = None,
restrict: bool | None = None,
):
"""
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]"
Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemestre) -> 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()
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)
from app.api import tokens
from app.api import (
absences,
assiduites,
billets_absences,
departements,
etudiants,
@ -44,7 +91,9 @@ from app.api import (
formations,
formsemestres,
jury,
justificatifs,
logos,
moduleimpl,
partitions,
semset,
users,

View File

@ -1,262 +0,0 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Absences
"""
from flask_json import as_json
from app.api import api_bp as bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required
from app.models import Identite
from app.scodoc import notesdb as ndb
from app.scodoc import sco_abs
from app.scodoc.sco_groups import get_group_members
from app.scodoc.sco_permissions import Permission
# TODO XXX revoir routes web API et calcul des droits
@bp.route("/absences/etudid/<int:etudid>", methods=["GET"])
@scodoc
@permission_required(Permission.ScoView)
@as_json
def absences(etudid: int = None):
"""
Liste des absences de cet étudiant
Exemple de résultat:
[
{
"jour": "2022-04-15",
"matin": true,
"estabs": true,
"estjust": true,
"description": "",
"begin": "2022-04-15 08:00:00",
"end": "2022-04-15 11:59:59"
},
{
"jour": "2022-04-15",
"matin": false,
"estabs": true,
"estjust": false,
"description": "",
"begin": "2022-04-15 12:00:00",
"end": "2022-04-15 17:59:59"
}
]
"""
etud = Identite.query.get(etudid)
if etud is None:
return json_error(404, message="etudiant inexistant")
# Absences de l'étudiant
ndb.open_db_connection()
abs_list = sco_abs.list_abs_date(etud.id)
for absence in abs_list:
absence["jour"] = absence["jour"].isoformat()
return abs_list
@bp.route("/absences/etudid/<int:etudid>/just", methods=["GET"])
@scodoc
@permission_required(Permission.ScoView)
@as_json
def absences_just(etudid: int = None):
"""
Retourne la liste des absences justifiées d'un étudiant donné
etudid : l'etudid d'un étudiant
nip: le code nip d'un étudiant
ine : le code ine d'un étudiant
Exemple de résultat :
[
{
"jour": "2022-04-15",
"matin": true,
"estabs": true,
"estjust": true,
"description": "",
"begin": "2022-04-15 08:00:00",
"end": "2022-04-15 11:59:59"
},
{
"jour": "Fri, 15 Apr 2022 00:00:00 GMT",
"matin": false,
"estabs": true,
"estjust": true,
"description": "",
"begin": "2022-04-15 12:00:00",
"end": "2022-04-15 17:59:59"
}
]
"""
etud = Identite.query.get(etudid)
if etud is None:
return json_error(404, message="etudiant inexistant")
# Absences justifiées de l'étudiant
abs_just = [
absence for absence in sco_abs.list_abs_date(etud.id) if absence["estjust"]
]
for absence in abs_just:
absence["jour"] = absence["jour"].isoformat()
return abs_just
@bp.route(
"/absences/abs_group_etat/<int:group_id>",
methods=["GET"],
)
@bp.route(
"/absences/abs_group_etat/group_id/<int:group_id>/date_debut/<string:date_debut>/date_fin/<string:date_fin>",
methods=["GET"],
)
@scodoc
@permission_required(Permission.ScoView)
@as_json
def abs_groupe_etat(group_id: int, date_debut=None, date_fin=None):
"""
Liste des absences d'un groupe (possibilité de choisir entre deux dates)
group_id = l'id du groupe
date_debut = None par défaut, sinon la date ISO du début de notre filtre
date_fin = None par défaut, sinon la date ISO de la fin de notre filtre
Exemple de résultat :
[
{
"etudid": 1,
"list_abs": []
},
{
"etudid": 2,
"list_abs": [
{
"jour": "Fri, 15 Apr 2022 00:00:00 GMT",
"matin": true,
"estabs": true,
"estjust": true,
"description": "",
"begin": "2022-04-15 08:00:00",
"end": "2022-04-15 11:59:59"
},
{
"jour": "Fri, 15 Apr 2022 00:00:00 GMT",
"matin": false,
"estabs": true,
"estjust": false,
"description": "",
"begin": "2022-04-15 12:00:00",
"end": "2022-04-15 17:59:59"
},
]
},
...
]
"""
members = get_group_members(group_id)
data = []
# Filtre entre les deux dates renseignées
for member in members:
absence = {
"etudid": member["etudid"],
"list_abs": sco_abs.list_abs_date(member["etudid"], date_debut, date_fin),
}
data.append(absence)
return data
# XXX TODO EV: A REVOIR (data json dans le POST + modifier les routes)
# @bp.route(
# "/absences/etudid/<int:etudid>/list_abs/<string:list_abs>/reset_etud_abs",
# methods=["POST"],
# defaults={"just_or_not": 0},
# )
# @bp.route(
# "/absences/etudid/<int:etudid>/list_abs/<string:list_abs>/reset_etud_abs/only_not_just",
# methods=["POST"],
# defaults={"just_or_not": 1},
# )
# @bp.route(
# "/absences/etudid/<int:etudid>/list_abs/<string:list_abs>/reset_etud_abs/only_just",
# methods=["POST"],
# defaults={"just_or_not": 2},
# )
# @token_auth.login_required
# @token_permission_required(Permission.APIAbsChange)
# def reset_etud_abs(etudid: int, list_abs: str, just_or_not: int = 0):
# """
# Set la liste des absences d'un étudiant sur tout un semestre.
# (les absences existant pour cet étudiant sur cette période sont effacées)
# etudid : l'id d'un étudiant
# list_abs : json d'absences
# just_or_not : 0 (pour les absences justifiées et non justifiées),
# 1 (pour les absences justifiées),
# 2 (pour les absences non justifiées)
# """
# # Toutes les absences
# if just_or_not == 0:
# # suppression des absences et justificatif déjà existant pour éviter les doublons
# for abs in list_abs:
# # Récupération de la date au format iso
# jour = abs["jour"].isoformat()
# if abs["matin"] is True:
# annule_absence(etudid, jour, True)
# annule_justif(etudid, jour, True)
# else:
# annule_absence(etudid, jour, False)
# annule_justif(etudid, jour, False)
# # Ajout de la liste d'absences en base
# add_abslist(list_abs)
# # Uniquement les absences justifiées
# elif just_or_not == 1:
# list_abs_not_just = []
# # Trie des absences justifiées
# for abs in list_abs:
# if abs["estjust"] is False:
# list_abs_not_just.append(abs)
# # suppression des absences et justificatif déjà existant pour éviter les doublons
# for abs in list_abs:
# # Récupération de la date au format iso
# jour = abs["jour"].isoformat()
# if abs["matin"] is True:
# annule_absence(etudid, jour, True)
# annule_justif(etudid, jour, True)
# else:
# annule_absence(etudid, jour, False)
# annule_justif(etudid, jour, False)
# # Ajout de la liste d'absences en base
# add_abslist(list_abs_not_just)
# # Uniquement les absences non justifiées
# elif just_or_not == 2:
# list_abs_just = []
# # Trie des absences non justifiées
# for abs in list_abs:
# if abs["estjust"] is True:
# list_abs_just.append(abs)
# # suppression des absences et justificatif déjà existant pour éviter les doublons
# for abs in list_abs:
# # Récupération de la date au format iso
# jour = abs["jour"].isoformat()
# if abs["matin"] is True:
# annule_absence(etudid, jour, True)
# annule_justif(etudid, jour, True)
# else:
# annule_absence(etudid, jour, False)
# annule_justif(etudid, jour, False)
# # Ajout de la liste d'absences en base
# add_abslist(list_abs_just)

1263
app/api/assiduites.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 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.ScoAbsAddBillet)
@permission_required(Permission.AbsAddBillet)
@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.ScoAbsAddBillet)
@permission_required(Permission.AbsAddBillet)
@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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 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,17 +271,13 @@ def dept_formsemestres_courants(acronym: str):
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
date_courante = request.args.get("date_courante")
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = app.db.func.now()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,
FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= test_date,
)
return [d.to_dict_api() for d in formsemestres]
date_courante = datetime.fromisoformat(date_courante) if date_courante else None
return [
formsemestre.to_dict_api()
for formsemestre in FormSemestre.get_dept_formsemestres_courants(
dept, date_courante
)
]
@bp.route("/departement/id/<int:dept_id>/formsemestres_courants")
@ -299,7 +295,7 @@ def dept_formsemestres_courants_by_id(dept_id: int):
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = app.db.func.now()
test_date = db.func.current_date()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,

353
app/api/etudiants.py Normal file → Executable file
View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -10,7 +10,7 @@
from datetime import datetime
from operator import attrgetter
from flask import g, request
from flask import g, request, Response
from flask_json import as_json
from flask_login import current_user
from flask_login import login_required
@ -18,22 +18,29 @@ 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>")
@ -48,6 +55,32 @@ from app.scodoc.sco_utils import json_error, suppress_accents
#
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})
@ -98,7 +131,10 @@ def etudiants_courants(long=False):
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
if long:
data = [etud.to_dict_api() for etud in etuds]
restrict = not current_user.has_permission(Permission.ViewEtudData)
data = [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in etuds
]
else:
data = [etud.to_dict_short() for etud in etuds]
return data
@ -132,8 +168,83 @@ 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")
@bp.route("/etudiant/nip/<string:nip>/photo")
@bp.route("/etudiant/ine/<string:ine>/photo")
@api_web_bp.route("/etudiant/etudid/<int:etudid>/photo")
@api_web_bp.route("/etudiant/nip/<string:nip>/photo")
@api_web_bp.route("/etudiant/ine/<string:ine>/photo")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
"""
Retourne la photo de l'étudiant
correspondant ou un placeholder si non existant.
etudid : l'etudid de l'étudiant
nip : le code nip de l'étudiant
ine : le code ine de l'étudiant
"""
etud = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
if not etudid:
filename = sco_photos.UNKNOWN_IMAGE_PATH
size = request.args.get("size", "orig")
filename = sco_photos.photo_pathname(etud.photo_filename, size=size)
if not filename:
filename = sco_photos.UNKNOWN_IMAGE_PATH
res = sco_photos.build_image_response(filename)
return res
@bp.route("/etudiant/etudid/<int:etudid>/photo", methods=["POST"])
@api_web_bp.route("/etudiant/etudid/<int:etudid>/photo", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.EtudChangeAdr)
@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)
query = Identite.query.filter_by(id=etudid)
if not None in allowed_depts:
# restreint aux départements autorisés:
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
if g.scodoc_dept is not None:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first()
if etud is None:
return json_error(404, message="etudiant inexistant")
# Récupère l'image
if len(request.files) == 0:
return json_error(404, "Il n'y a pas de fichier joint")
file = list(request.files.values())[0]
if not file.filename:
return json_error(404, "Il n'y a pas de fichier joint")
data = file.stream.read()
status, err_msg = sco_photos.store_photo(etud, data, file.filename)
if status:
return {"etudid": etud.id, "message": "recorded photo"}
return json_error(
404,
message=f"Erreur: {err_msg}",
)
@bp.route("/etudiants/etudid/<int:etudid>", methods=["GET"])
@ -170,7 +281,10 @@ 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)
)
return [etud.to_dict_api() for etud in query]
restrict = not current_user.has_permission(Permission.ViewEtudData)
return [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in query
]
@bp.route("/etudiants/name/<string:start>")
@ -197,7 +311,11 @@ 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:
return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))]
restrict = not current_user.has_permission(Permission.ViewEtudData)
return [
etud.to_dict_api(restrict=restrict)
for etud in sorted(etuds, key=attrgetter("sort_key"))
]
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@ -278,17 +396,17 @@ def bulletin(
code_type: str = "etudid",
code: str = None,
formsemestre_id: int = None,
version: str = "long",
version: str = "selectedevals",
pdf: bool = False,
with_img_signatures_pdf: bool = True,
):
"""
Retourne le bulletin d'un étudiant en fonction de son id et d'un semestre donné
Retourne le bulletin d'un étudiant dans un formsemestre.
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, "long"): short, long, long_mat
version : type de bulletin (par défaut, "selectedevals"): short, long, selectedevals, butcourt
pdf : si spécifié, bulletin au format PDF (et non JSON).
Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin
@ -296,34 +414,36 @@ def bulletin(
if version == "pdf":
version = "long"
pdf = True
# return f"{code_type}={code}, version={version}, pdf={pdf}"
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
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
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", as_response=True)
return json_error(404, "formsemestre inexistant")
app.set_sco_dept(dept.acronym)
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")
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 pdf:
pdf_response, _ = do_formsemestre_bulletinetud(
formsemestre,
etud,
version=version,
format="pdf",
fmt="pdf",
with_img_signatures_pdf=with_img_signatures_pdf,
)
return pdf_response
@ -332,9 +452,9 @@ def bulletin(
)
@bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups",
methods=["GET"],
@bp.route("/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups")
@api_web_bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups"
)
@scodoc
@permission_required(Permission.ScoView)
@ -372,7 +492,6 @@ 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)
@ -389,3 +508,173 @@ 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,23 +1,23 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
ScoDoc 9 API : accès aux évaluations
"""
from flask import g, request
from flask_json import as_json
from flask_login import login_required
from flask_login import current_user, login_required
import app
from app import log, db
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
from app.models import Evaluation, ModuleImpl, FormSemestre
from app.scodoc import sco_evaluation_db, sco_saisie_notes
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu
@ -28,7 +28,7 @@ import app.scodoc.sco_utils as scu
@scodoc
@permission_required(Permission.ScoView)
@as_json
def evaluation(evaluation_id: int):
def get_evaluation(evaluation_id: int):
"""Description d'une évaluation.
{
@ -47,7 +47,7 @@ def evaluation(evaluation_id: int):
'UE1.3': 1.0
},
'publish_incomplete': False,
'visi_bulletin': True
'visibulletin': True
}
"""
query = Evaluation.query.filter_by(id=evaluation_id)
@ -67,7 +67,7 @@ def evaluation(evaluation_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def evaluations(moduleimpl_id: int):
def moduleimpl_evaluations(moduleimpl_id: int):
"""
Retourne la liste des évaluations d'un moduleimpl
@ -75,14 +75,8 @@ def evaluations(moduleimpl_id: int):
Exemple de résultat : voir /evaluation
"""
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]
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
return [evaluation.to_dict_api() for evaluation in modimpl.evaluations]
@bp.route("/evaluation/<int:evaluation_id>/notes")
@ -146,9 +140,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.ScoEnsView)
@permission_required(Permission.EnsView)
@as_json
def evaluation_set_notes(evaluation_id: int):
def evaluation_set_notes(evaluation_id: int): # evaluation-notes-set
"""Écriture de notes dans une évaluation.
The request content type should be "application/json",
and contains:
@ -181,3 +175,97 @@ def evaluation_set_notes(evaluation_id: int):
return sco_saisie_notes.save_notes(
evaluation, notes, comment=data.get("comment", "")
)
@bp.route("/moduleimpl/<int:moduleimpl_id>/evaluation/create", methods=["POST"])
@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
@as_json
def evaluation_create(moduleimpl_id: int):
"""Création d'une évaluation.
The request content type should be "application/json",
and contains:
{
"description" : str,
"evaluation_type" : int, // {0,1,2} default 0 (normale)
"date_debut" : date_iso, // optionnel
"date_fin" : date_iso, // optionnel
"note_max" : float, // si non spécifié, 20.0
"numero" : int, // ordre de présentation, default tri sur date
"visibulletin" : boolean , //default true
"publish_incomplete" : boolean , //default false
"coefficient" : float, // si non spécifié, 1.0
"poids" : { ue_id : poids } // optionnel
}
Result: l'évaluation créée.
"""
moduleimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id)
if not moduleimpl.can_edit_evaluation(current_user):
return scu.json_error(403, "opération non autorisée")
data = request.get_json(force=True) # may raise 400 Bad Request
try:
evaluation = Evaluation.create(moduleimpl=moduleimpl, **data)
except ValueError:
return scu.json_error(400, "paramètre incorrect")
except ScoValueError as exc:
return scu.json_error(
400, f"paramètre de type incorrect ({exc.args[0] if exc.args else ''})"
)
db.session.add(evaluation)
db.session.commit()
# Les poids vers les UEs:
poids = data.get("poids")
if poids is not None:
if not isinstance(poids, dict):
log("API error: canceling evaluation creation")
db.session.delete(evaluation)
db.session.commit()
return scu.json_error(
400, "paramètre de type incorrect (poids must be a dict)"
)
try:
evaluation.set_ue_poids_dict(data["poids"])
except ScoValueError as exc:
log("API error: canceling evaluation creation")
db.session.delete(evaluation)
db.session.commit()
return scu.json_error(
400,
f"erreur enregistrement des poids ({exc.args[0] if exc.args else ''})",
)
db.session.commit()
return evaluation.to_dict_api()
@bp.route("/evaluation/<int:evaluation_id>/delete", methods=["POST"])
@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
@as_json
def evaluation_delete(evaluation_id: int):
"""Suppression d'une évaluation.
Efface aussi toutes ses notes
"""
query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept:
query = (
query.join(ModuleImpl)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
)
evaluation = query.first_or_404()
dept = evaluation.moduleimpl.formsemestre.departement
app.set_sco_dept(dept.acronym)
if not evaluation.moduleimpl.can_edit_evaluation(current_user):
raise AccessDenied("evaluation_delete")
sco_saisie_notes.evaluation_suppress_alln(
evaluation_id=evaluation_id, dialog_confirmed=True
)
evaluation.delete()
return "ok"

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -21,8 +21,6 @@ from app.models import (
ApcNiveau,
ApcParcours,
Formation,
FormSemestre,
ModuleImpl,
UniteEns,
)
from app.scodoc import sco_formations
@ -54,7 +52,8 @@ def formations():
@as_json
def formations_ids():
"""
Retourne la liste de toutes les id de formations (tous départements)
Retourne la liste de toutes les id de formations
(tous départements, ou du département indiqué dans la route)
Exemple de résultat : [ 17, 99, 32 ]
"""
@ -249,59 +248,11 @@ def referentiel_competences(formation_id: int):
return formation.referentiel_competence.to_dict()
@bp.route("/moduleimpl/<int:moduleimpl_id>")
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def moduleimpl(moduleimpl_id: int):
"""
Retourne un moduleimpl en fonction de son id
moduleimpl_id : l'id d'un moduleimpl
Exemple de résultat :
{
"id": 1,
"formsemestre_id": 1,
"module_id": 1,
"responsable_id": 2,
"moduleimpl_id": 1,
"ens": [],
"module": {
"heures_tp": 0,
"code_apogee": "",
"titre": "Initiation aux réseaux informatiques",
"coefficient": 1,
"module_type": 2,
"id": 1,
"ects": null,
"abbrev": "Init aux réseaux informatiques",
"ue_id": 1,
"code": "R101",
"formation_id": 1,
"heures_cours": 0,
"matiere_id": 1,
"heures_td": 0,
"semestre_id": 1,
"numero": 10,
"module_id": 1
}
}
"""
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("/set_ue_parcours/<int:ue_id>", methods=["POST"])
@api_web_bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoChangeFormation)
@permission_required(Permission.EditFormation)
@as_json
def set_ue_parcours(ue_id: int):
"""Associe UE et parcours BUT.
@ -336,7 +287,7 @@ def set_ue_parcours(ue_id: int):
)
@login_required
@scodoc
@permission_required(Permission.ScoChangeFormation)
@permission_required(Permission.EditFormation)
@as_json
def assoc_ue_niveau(ue_id: int, niveau_id: int):
"""Associe l'UE au niveau de compétence"""
@ -365,7 +316,7 @@ def assoc_ue_niveau(ue_id: int, niveau_id: int):
)
@login_required
@scodoc
@permission_required(Permission.ScoChangeFormation)
@permission_required(Permission.EditFormation)
@as_json
def desassoc_ue_niveau(ue_id: int):
"""Désassocie cette UE de son niveau de compétence
@ -378,6 +329,8 @@ 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 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 login_required
from flask_login import current_user, login_required
import sqlalchemy as sa
import app
from app import db
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
@ -33,11 +33,12 @@ 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
from app.tables.recap import TableRecap, RowRecap
@bp.route("/formsemestre/<int:formsemestre_id>")
@ -99,18 +100,20 @@ def formsemestre_infos(formsemestre_id: int):
def formsemestres_query():
"""
Retourne les formsemestres filtrés par
étape Apogée ou année scolaire ou département (acronyme ou id)
étape Apogée ou année scolaire ou département (acronyme ou id) ou état ou code étudiant
etape_apo : un code étape apogée
annee_scolaire : année de début de l'année scolaire
dept_acronym : acronyme du département (eg "RT")
dept_id : id du département
ine ou nip: code d'un étudiant: ramène alors tous les semestres auxquels il est inscrit.
etat: 0 si verrouillé, 1 sinon
"""
etape_apo = request.args.get("etape_apo")
annee_scolaire = request.args.get("annee_scolaire")
dept_acronym = request.args.get("dept_acronym")
dept_id = request.args.get("dept_id")
etat = request.args.get("etat")
nip = request.args.get("nip")
ine = request.args.get("ine")
formsemestres = FormSemestre.query
@ -121,11 +124,17 @@ 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_anne_scolaire(annee_scolaire_int)
fin_annee = scu.date_fin_anne_scolaire(annee_scolaire_int)
debut_annee = scu.date_debut_annee_scolaire(annee_scolaire_int)
fin_annee = scu.date_fin_annee_scolaire(annee_scolaire_int)
formsemestres = formsemestres.filter(
FormSemestre.date_fin >= debut_annee, FormSemestre.date_debut <= fin_annee
)
if etat is not None:
try:
etat = bool(int(etat))
except ValueError:
return json_error(404, "invalid etat: integer expected")
formsemestres = formsemestres.filter_by(etat=etat)
if dept_acronym is not None:
formsemestres = formsemestres.join(Departement).filter_by(acronym=dept_acronym)
if dept_id is not None:
@ -151,7 +160,53 @@ def formsemestres_query():
formsemestres = formsemestres.join(FormSemestreInscription).join(Identite)
formsemestres = formsemestres.filter_by(code_ine=ine)
return [formsemestre.to_dict_api() for formsemestre in formsemestres]
return [
formsemestre.to_dict_api()
for formsemestre in formsemestres.order_by(
FormSemestre.date_debut.desc(),
FormSemestre.modalite,
FormSemestre.semestre_id,
FormSemestre.titre,
)
]
@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")
@ -196,7 +251,7 @@ def bulletins(formsemestre_id: int, version: str = "long"):
@as_json
def formsemestre_programme(formsemestre_id: int):
"""
Retourne la liste des Ues, ressources et SAE d'un semestre
Retourne la liste des UEs, ressources et SAEs d'un semestre
formsemestre_id : l'id d'un formsemestre
@ -343,7 +398,8 @@ def formsemestre_etudiants(
inscriptions = formsemestre.inscriptions
if long:
etuds = [ins.etud.to_dict_api() for ins in inscriptions]
restrict = not current_user.has_permission(Permission.ViewEtudData)
etuds = [ins.etud.to_dict_api(restrict=restrict) for ins in inscriptions]
else:
etuds = [ins.etud.to_dict_short() for ins in inscriptions]
# Ajout des groupes de chaque étudiants
@ -408,7 +464,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)
modimpl_dict = modimpl.to_dict(convert_objects=True, with_module=False)
list_eval = []
for evaluation_id in modimpl_results.evaluations_etat:
@ -450,13 +506,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)
@ -487,16 +543,30 @@ 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)
table = TableRecap(
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:
# 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"], {})
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,
)
rows = table.to_list()
# for row in rows:
# row["partitions"] = etud_groups.get(row["etudid"], {})
return rows
@ -539,3 +609,27 @@ 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -8,25 +8,32 @@
ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions
"""
from flask import g, url_for
import datetime
from flask import g, request, url_for
from flask_json import as_json
from flask_login import current_user, login_required
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp, tools
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR, tools
from app.decorators import scodoc, permission_required
from app.scodoc.sco_exceptions import ScoException
from app.but import jury_but_results
from app.models import (
ApcParcours,
ApcValidationAnnee,
ApcValidationRCUE,
Formation,
FormSemestre,
Identite,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
ScolarNews,
Scolog,
UniteEns,
)
from app.scodoc import codes_cursus
from app.scodoc import sco_cache
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@ -41,7 +48,12 @@ from app.scodoc.sco_utils import json_error
def decisions_jury(formsemestre_id: int):
"""Décisions du jury des étudiants du formsemestre."""
# APC, pair:
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
if formsemestre is None:
return json_error(
404,
message="formsemestre inconnu",
)
if formsemestre.formation.is_apc():
app.set_sco_dept(formsemestre.departement.acronym)
rows = jury_but_results.get_jury_but_results(formsemestre)
@ -54,7 +66,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.ficheEtud", scodoc_dept=etud.departement.acronym, etudid=etud.id
"scolar.fiche_etud", scodoc_dept=etud.departement.acronym, etudid=etud.id
)
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
@ -113,17 +125,17 @@ 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.ScoEtudInscrit):
return json_error(403, "validation_delete: non autorise")
if not current_user.has_permission(Permission.EtudInscrit):
return json_error(403, "opération non autorisée (117)")
else:
if validation.formsemestre:
if (
validation.formsemestre.dept_id != g.scodoc_dept_id
) or not validation.formsemestre.can_edit_jury():
return json_error(403, "validation_delete: non autorise")
elif not current_user.has_permission(Permission.ScoEtudInscrit):
return json_error(403, "opération non autorisée (123)")
elif not current_user.has_permission(Permission.EtudInscrit):
# Validation non rattachée à un semestre: on doit être chef
return json_error(403, "validation_delete: non autorise")
return json_error(403, "opération non autorisée (126)")
log(f"validation_ue_delete: etuid={etudid} {validation}")
db.session.delete(validation)
@ -143,7 +155,7 @@ def _validation_ue_delete(etudid: int, validation_id: int):
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudInscrit)
@permission_required(Permission.EtudInscrit)
@as_json
def autorisation_inscription_delete(etudid: int, validation_id: int):
"Efface cette validation"
@ -161,6 +173,133 @@ def autorisation_inscription_delete(etudid: int, validation_id: int):
return "ok"
@bp.route(
"/etudiant/<int:etudid>/jury/validation_rcue/record",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_rcue/record",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def validation_rcue_record(etudid: int):
"""Enregistre une validation de RCUE.
Si une validation existe déjà pour ce RCUE, la remplace.
The request content type should be "application/json":
{
"code" : str,
"ue1_id" : int,
"ue2_id" : int,
// Optionnel:
"formsemestre_id" : int,
"date" : date_iso, // si non spécifié, now()
"parcours_id" :int,
}
"""
etud = tools.get_etud(etudid)
if etud is None:
return json_error(404, "étudiant inconnu")
data = request.get_json(force=True) # may raise 400 Bad Request
code = data.get("code")
if code is None:
return json_error(API_CLIENT_ERROR, "missing argument: code")
if code not in codes_cursus.CODES_JURY_RCUE:
return json_error(API_CLIENT_ERROR, "invalid code value")
ue1_id = data.get("ue1_id")
if ue1_id is None:
return json_error(API_CLIENT_ERROR, "missing argument: ue1_id")
try:
ue1_id = int(ue1_id)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid value for ue1_id")
ue2_id = data.get("ue2_id")
if ue2_id is None:
return json_error(API_CLIENT_ERROR, "missing argument: ue2_id")
try:
ue2_id = int(ue2_id)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid value for ue2_id")
formsemestre_id = data.get("formsemestre_id")
date_validation_str = data.get("date", datetime.datetime.now().isoformat())
parcours_id = data.get("parcours_id")
#
query = UniteEns.query.filter_by(id=ue1_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue1: UniteEns = query.first_or_404()
query = UniteEns.query.filter_by(id=ue2_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue2: UniteEns = query.first_or_404()
if ue1.niveau_competence_id != ue2.niveau_competence_id:
return json_error(
API_CLIENT_ERROR, "UEs non associees au meme niveau de competence"
)
if formsemestre_id is not None:
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()
if (formsemestre.formation_id != ue1.formation_id) or (
formsemestre.formation_id != ue2.formation_id
):
return json_error(
API_CLIENT_ERROR, "ues et semestre ne sont pas de la meme formation"
)
else:
formsemestre = None
try:
date_validation = datetime.datetime.fromisoformat(date_validation_str)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid date string")
if parcours_id is not None:
parcours: ApcParcours = ApcParcours.query.get_or_404(parcours_id)
if parcours.referentiel_id != ue1.niveau_competence.competence.referentiel_id:
return json_error(API_CLIENT_ERROR, "niveau et parcours incompatibles")
# Une validation pour ce niveau de compétence existe-elle ?
validation = (
ApcValidationRCUE.query.filter_by(etudid=etudid)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
.filter_by(niveau_competence_id=ue2.niveau_competence_id)
.first()
)
if validation:
validation.code = code
validation.date = date_validation
validation.formsemestre_id = formsemestre_id
validation.parcours_id = parcours_id
validation.ue1_id = ue1_id
validation.ue2_id = ue2_id
operation = "update"
else:
validation = ApcValidationRCUE(
code=code,
date=date_validation,
etudid=etudid,
formsemestre_id=formsemestre_id,
parcours_id=parcours_id,
ue1_id=ue1_id,
ue2_id=ue2_id,
)
operation = "record"
db.session.add(validation)
# invalider bulletins (les autres résultats ne dépendent pas des RCUEs):
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
Scolog.logdb(
method="validation_rcue_record",
etudid=etudid,
msg=f"Enregistrement {validation}",
commit=True,
)
log(f"{operation} {validation}")
return validation.to_dict()
@bp.route(
"/etudiant/<int:etudid>/jury/validation_rcue/<int:validation_id>/delete",
methods=["POST"],
@ -171,7 +310,7 @@ def autorisation_inscription_delete(etudid: int, validation_id: int):
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudInscrit)
@permission_required(Permission.EtudInscrit)
@as_json
def validation_rcue_delete(etudid: int, validation_id: int):
"Efface cette validation"
@ -199,7 +338,7 @@ def validation_rcue_delete(etudid: int, validation_id: int):
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudInscrit)
@permission_required(Permission.EtudInscrit)
@as_json
def validation_annee_but_delete(etudid: int, validation_id: int):
"Efface cette validation"

902
app/api/justificatifs.py Normal file
View File

@ -0,0 +1,902 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Justificatifs
"""
from datetime import datetime
from flask_json import as_json
from flask import g, request
from flask_login import login_required, current_user
from flask_sqlalchemy.query import Query
from werkzeug.exceptions import NotFound
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db, set_sco_dept
from app.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.assiduites import (
get_formsemestre_from_data,
)
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
# Partie Modèle
@bp.route("/justificatif/<int:justif_id>")
@api_web_bp.route("/justificatif/<int:justif_id>")
@scodoc
@permission_required(Permission.ScoView)
def justificatif(justif_id: int = None):
"""Retourne un objet justificatif à partir de son id
Exemple de résultat:
{
"justif_id": 1,
"etudid": 2,
"date_debut": "2022-10-31T08:00+01:00",
"date_fin": "2022-10-31T10:00+01:00",
"etat": "valide",
"fichier": "archive_id",
"raison": "une raison", // VIDE si pas le droit
"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),
)
# 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}
)
# nip
@bp.route("/justificatifs/nip/<nip>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/nip/<nip>", defaults={"with_query": False})
@bp.route("/justificatifs/nip/<nip>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/nip/<nip>/query", defaults={"with_query": True})
# ine
@bp.route("/justificatifs/ine/<ine>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/ine/<ine>", defaults={"with_query": False})
@bp.route("/justificatifs/ine/<ine>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/ine/<ine>/query", defaults={"with_query": True})
#
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /justificatifs/<int:etudid>
Un filtrage peut être donné avec une query
chemin : /justificatifs/<int:etudid>/query?
Les différents filtres :
Etat (etat du justificatif -> validé, non validé, modifé, en attente):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=validé,modifié
Date debut
(date de début du justificatif, sont affichés les justificatifs
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin du justificatif, sont affichés les justificatifs
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
user_id (l'id de l'auteur du justificatif)
query?user_id=[int]
ex query?user_id=3
"""
# Récupération de l'étudiant
etud: Identite = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
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_set.append(data)
return data_set
@api_web_bp.route("/justificatifs/dept/<int:dept_id>", defaults={"with_query": False})
@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)
"""
# 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]
# 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)
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)
data_set.append(data)
return data_set
@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"])
# nip
@bp.route("/justificatif/nip/<nip>/create", methods=["POST"])
@api_web_bp.route("/justificatif/nip/<nip>/create", methods=["POST"])
# ine
@bp.route("/justificatif/ine/<ine>/create", methods=["POST"])
@api_web_bp.route("/justificatif/ine/<ine>/create", methods=["POST"])
@scodoc
@login_required
@as_json
@permission_required(Permission.AbsChange)
def justif_create(etudid: int = None, nip=None, ine=None):
"""
Création d'un justificatif pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
"date_fin": str,
"etat": str,
},
{
"date_debut": str,
"date_fin": str,
"etat": str,
"raison":str,
}
...
]
"""
# 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",
)
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
for i, data in enumerate(create_list):
code, obj, justi = _create_one(data, etud)
code: int
obj: str | dict
justi: Justificatif | None
if code == 404:
errors.append({"indice": i, "message": obj})
else:
success.append({"indice": i, "message": obj})
justi.justifier_assiduites()
scass.simple_invalidate_cache(data, etud.id)
return {"errors": errors, "success": success}
def _create_one(
data: dict,
etud: Identite,
) -> tuple[int, object, Justificatif]:
errors: list[str] = []
# -- vérifications de l'objet json --
# cas 1 : ETAT
etat: str = 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)
# cas 2 : date_debut
date_debut: str = 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)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 3 : date_fin
date_fin: str = 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)
if fin is None:
errors.append("param 'date_fin': format invalide")
# cas 4 : raison
raison: str = data.get("raison", None)
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")
if errors:
err: str = ", ".join(errors)
return (404, err, None)
# 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,
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()
return (
200,
{
"justif_id": nouv_justificatif.id,
"couverture": scass.justifies(nouv_justificatif),
},
nouv_justificatif,
)
except ScoValueError as excp:
return (404, excp.args[0], None)
@bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@login_required
@scodoc
@as_json
@permission_required(Permission.AbsChange)
def justif_edit(justif_id: int):
"""
Edition d'un justificatif à partir de son id
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"raison"?: str
"date_debut"?: str
"date_fin"?: str
}
"""
# Récupération du justificatif à modifier
justificatif_unique = Justificatif.get_justificatif(justif_id)
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"))
if etat is None:
errors.append("param 'etat': invalide")
else:
justificatif_unique.etat = etat
# Cas 2 : raison
raison: str = 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)
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)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 4 : date_fin
date_fin: str = 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)
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
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")
if external_data is not None:
if not isinstance(external_data, dict):
errors.append("param 'external_data' : n'est pas un objet JSON")
else:
justificatif_unique.external_data = external_data
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
if errors:
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(),
}
}
# Invalide le cache
scass.simple_invalidate_cache(justificatif_unique.to_dict())
return retour
@bp.route("/justificatif/delete", methods=["POST"])
@api_web_bp.route("/justificatif/delete", methods=["POST"])
@login_required
@scodoc
@as_json
@permission_required(Permission.AbsChange)
def justif_delete():
"""
Suppression d'un justificatif à partir de son id
Forme des données envoyées :
[
<justif_id:int>,
...
]
"""
# 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")
output = {"errors": [], "success": []}
for i, ass in enumerate(justificatifs_list):
code, msg = _delete_one(ass)
if code == 404:
output["errors"].append({"indice": i, "message": msg})
else:
output["success"].append({"indice": i, "message": "OK"})
db.session.commit()
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:
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)
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)
return (200, "OK")
# Partie archivage
@bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
@scodoc
@login_required
@as_json
@permission_required(Permission.AbsChange)
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)
# 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,
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])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"])
@api_web_bp.route(
"/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"]
)
@scodoc
@login_required
@permission_required(Permission.ScoView)
def justif_export(justif_id: int | None = None, filename: str | None = None):
"""
Retourne un fichier d'une archive d'un justificatif.
La permission est ScoView + (AbsJustifView ou être l'auteur du justifcatif)
"""
# 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")
# 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
)
except ScoValueError as err:
# On retourne une erreur json si jamais il y a un problème
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
@scodoc
@login_required
@as_json
@permission_required(Permission.AbsChange)
def justif_remove(justif_id: int = None):
"""
Supression d'un fichier ou d'une archive
{
"remove": <"all"/"list">
"filenames"?: [
<filename:str>,
...
]
}
"""
# On récupère le dictionnaire
data: dict = request.get_json(force=True)
# On récupère le justificatif concerné
justificatif_unique = Justificatif.get_justificatif(justif_id)
# 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
try:
if remove == "all":
# Suppression de toute l'archive du justificatif
archiver.delete_justificatif(etud, 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,
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)
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"}
@bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoView)
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)
# 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
)
# 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
):
retour["filenames"].append(filename[0])
# On renvoie le total et la liste des fichiers visibles
return retour
# Partie justification
@bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@scodoc
@login_required
@as_json
@permission_required(Permission.AbsChange)
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)
# 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):
"""
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")
if etat is not None:
justificatifs_query: Query = scass.filter_justificatifs_by_etat(
justificatifs_query, etat
)
# cas 2 : date de début
deb: str = 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)
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(
justificatifs_query, user_id
)
# cas 5 : formsemestre_id
formsemestre_id = requested.args.get("formsemestre_id")
if formsemestre_id not in [None, "", -1]:
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
)
return justificatifs_query

View File

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

117
app/api/moduleimpl.py Normal file
View File

@ -0,0 +1,117 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
ScoDoc 9 API : accès aux moduleimpl
"""
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.scodoc.sco_permissions import Permission
@bp.route("/moduleimpl/<int:moduleimpl_id>")
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def moduleimpl(moduleimpl_id: int):
"""
Retourne un moduleimpl en fonction de son id
moduleimpl_id : l'id d'un moduleimpl
Exemple de résultat :
{
"id": 1,
"formsemestre_id": 1,
"module_id": 1,
"responsable_id": 2,
"moduleimpl_id": 1,
"ens": [],
"module": {
"heures_tp": 0,
"code_apogee": "",
"titre": "Initiation aux réseaux informatiques",
"coefficient": 1,
"module_type": 2,
"id": 1,
"ects": null,
"abbrev": "Init aux réseaux informatiques",
"ue_id": 1,
"code": "R101",
"formation_id": 1,
"heures_cours": 0,
"matiere_id": 1,
"heures_td": 0,
"semestre_id": 1,
"numero": 10,
"module_id": 1
}
}
"""
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
return modimpl.to_dict(convert_objects=True)
@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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -12,6 +12,8 @@ from operator import attrgetter
from flask import g, request
from flask_json import as_json
from flask_login import login_required
import sqlalchemy as sa
from sqlalchemy.exc import IntegrityError
import app
from app import db, log
@ -23,6 +25,7 @@ from app.models import GroupDescr, Partition, Scolog
from app.models.groups import group_membership
from app.scodoc import sco_cache
from app.scodoc import sco_groups
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
@ -107,7 +110,7 @@ def formsemestre_partitions(formsemestre_id: int):
def etud_in_group(group_id: int):
"""
Retourne la liste des étudiants dans un groupe
(inscrits au groupe et inscrits au semestre).
group_id : l'id d'un groupe
Exemple de résultat :
@ -130,7 +133,15 @@ def etud_in_group(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group = query.first_or_404()
return [etud.to_dict_short() for etud in group.etuds]
query = (
Identite.query.join(group_membership)
.filter_by(group_id=group_id)
.join(FormSemestreInscription)
.filter_by(formsemestre_id=group.partition.formsemestre_id)
)
return [etud.to_dict_short() for etud in query]
@bp.route("/group/<int:group_id>/etudiants/query")
@ -158,7 +169,6 @@ def etud_in_group_query(group_id: int):
query = query.filter_by(etat=etat)
query = query.join(group_membership).filter_by(group_id=group_id)
return [etud.to_dict_short() for etud in query]
@ -166,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.ScoEtudChangeGroups)
@permission_required(Permission.ScoView)
@as_json
def set_etud_group(etudid: int, group_id: int):
"""Affecte l'étudiant au groupe indiqué"""
@ -179,13 +189,17 @@ 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")
sco_groups.change_etud_group_in_partition(
etudid, group_id, group.partition.to_dict()
)
try:
sco_groups.change_etud_group_in_partition(etudid, group)
except ScoValueError as exc:
return json_error(404, exc.args[0])
except IntegrityError:
return json_error(404, "échec de l'enregistrement")
return {"group_id": group_id, "etudid": etudid}
@ -195,7 +209,7 @@ def set_etud_group(etudid: int, group_id: int):
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@permission_required(Permission.ScoView)
@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."""
@ -208,18 +222,11 @@ 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 etud in group.etuds:
group.etuds.remove(etud)
db.session.commit()
Scolog.logdb(
method="group_remove_etud",
etudid=etud.id,
msg=f"Retrait du groupe {group.group_name} de {group.partition.partition_name}",
commit=True,
)
# Update parcours
group.partition.formsemestre.update_inscriptions_parcours_from_groups()
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
if not group.partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
group.remove_etud(etud)
return {"group_id": group_id, "etudid": etudid}
@ -231,7 +238,7 @@ def group_remove_etud(group_id: int, etudid: int):
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@permission_required(Permission.ScoView)
@as_json
def partition_remove_etud(partition_id: int, etudid: int):
"""Enlève l'étudiant de tous les groupes de cette partition
@ -244,22 +251,29 @@ 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é")
groups = (
GroupDescr.query.filter_by(partition_id=partition_id)
.join(group_membership)
.filter_by(etudid=etudid)
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
WHERE etudid=:etudid
and group_id IN (
SELECT id FROM group_descr WHERE partition_id = :partition_id
);
"""
),
{"etudid": etudid, "partition_id": partition_id},
)
Scolog.logdb(
method="partition_remove_etud",
etudid=etud.id,
msg=f"Retrait de la partition {partition.partition_name}",
commit=False,
)
for group in groups:
group.etuds.remove(etud)
Scolog.logdb(
method="partition_remove_etud",
etudid=etud.id,
msg=f"Retrait du groupe {group.group_name} de {group.partition.partition_name}",
commit=True,
)
db.session.commit()
# Update parcours
partition.formsemestre.update_inscriptions_parcours_from_groups()
partition.formsemestre.update_inscriptions_parcours_from_groups(etudid=etudid)
app.set_sco_dept(partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(partition.formsemestre_id)
return {"partition_id": partition_id, "etudid": etudid}
@ -269,9 +283,9 @@ 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.ScoEtudChangeGroups)
@permission_required(Permission.ScoView)
@as_json
def group_create(partition_id: int):
def group_create(partition_id: int): # partition-group-create
"""Création d'un groupe dans une partition
The request content type should be "application/json":
@ -287,15 +301,28 @@ def group_create(partition_id: int):
return json_error(403, "formsemestre verrouillé")
if not partition.groups_editable:
return json_error(403, "partition non editable")
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")
if not GroupDescr.check_name(partition, group_name):
return json_error(API_CLIENT_ERROR, "invalid group_name")
group_name = group_name.strip()
if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
group = GroupDescr(group_name=group_name, partition_id=partition_id)
args = request.get_json(force=True) # may raise 400 Bad Request
group_name = args.get("group_name")
if not isinstance(group_name, str):
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"]):
return json_error(API_CLIENT_ERROR, "invalid group_name")
# 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")
db.session.add(group)
db.session.commit()
log(f"created group {group}")
@ -308,7 +335,7 @@ def group_create(partition_id: int):
@api_web_bp.route("/group/<int:group_id>/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@permission_required(Permission.ScoView)
@as_json
def group_delete(group_id: int):
"""Suppression d'un groupe"""
@ -322,6 +349,8 @@ 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)
@ -335,7 +364,7 @@ def group_delete(group_id: int):
@api_web_bp.route("/group/<int:group_id>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@permission_required(Permission.ScoView)
@as_json
def group_edit(group_id: int):
"""Edit a group"""
@ -349,28 +378,62 @@ 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")
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):
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
):
return json_error(API_CLIENT_ERROR, "invalid group_name")
group.group_name = group_name
db.session.add(group)
db.session.commit()
log(f"modified {group}")
group.from_dict(args)
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.ScoEtudChangeGroups)
@permission_required(Permission.ScoView)
@as_json
def partition_create(formsemestre_id: int):
"""Création d'une partition dans un semestre
@ -390,6 +453,8 @@ 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:
@ -433,7 +498,7 @@ def partition_create(formsemestre_id: int):
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@permission_required(Permission.ScoView)
@as_json
def formsemestre_order_partitions(formsemestre_id: int):
"""Modifie l'ordre des partitions du formsemestre
@ -445,6 +510,8 @@ 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
@ -460,6 +527,7 @@ def formsemestre_order_partitions(formsemestre_id: int):
db.session.commit()
app.set_sco_dept(formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(formsemestre_id)
log(f"formsemestre_order_partitions({partition_ids})")
return [
partition.to_dict()
for partition in formsemestre.partitions.order_by(Partition.numero)
@ -471,7 +539,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.ScoEtudChangeGroups)
@permission_required(Permission.ScoView)
@as_json
def partition_order_groups(partition_id: int):
"""Modifie l'ordre des groupes de la partition
@ -483,6 +551,8 @@ 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
@ -506,7 +576,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.ScoEtudChangeGroups)
@permission_required(Permission.ScoView)
@as_json
def partition_edit(partition_id: int):
"""Modification d'une partition dans un semestre
@ -527,6 +597,8 @@ 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")
@ -576,7 +648,7 @@ def partition_edit(partition_id: int):
@api_web_bp.route("/partition/<int:partition_id>/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@permission_required(Permission.ScoView)
@as_json
def partition_delete(partition_id: int):
"""Suppression d'une partition (et de tous ses groupes).
@ -592,6 +664,8 @@ 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 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.ScoEditApo)
# @permission_required(Permission.EditApogee)
# # 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : outils
@ -44,4 +44,8 @@ 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)
)
return query.join(Admission).order_by(desc(Admission.annee)).first()
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

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 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
from app import db, log
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
from app.models import Departement, ScoDocSiteConfig
from app.scodoc import sco_edt_cal
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
from app.scodoc import sco_utils as scu
@ -29,17 +29,17 @@ from app.scodoc import sco_utils as scu
@api_web_bp.route("/user/<int:uid>")
@login_required
@scodoc
@permission_required(Permission.ScoUsersView)
@permission_required(Permission.UsersView)
@as_json
def user_info(uid: int):
"""
Info sur un compte utilisateur scodoc
"""
user: User = User.query.get(uid)
user: User = db.session.get(User, uid)
if user is None:
return json_error(404, "user not found")
if g.scodoc_dept:
allowed_depts = current_user.get_depts_with_permission(Permission.ScoUsersView)
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")
@ -78,51 +78,68 @@ 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.ScoUsersView) != 0)
.filter(Role.permissions.op("&")(Permission.UsersView) != 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.ScoUsersAdmin)
@permission_required(Permission.UsersAdmin)
@as_json
def user_create():
"""Création d'un utilisateur
The request content type should be "application/json":
{
"user_name": str,
"active":bool (default True),
"dept": str or null,
"nom": str,
"prenom": str,
"active":bool (default True)
"user_name": str,
...
}
"""
data = request.get_json(force=True) # may raise 400 Bad Request
user_name = data.get("user_name")
args = request.get_json(force=True) # may raise 400 Bad Request
user_name = args.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 = data.get("dept")
dept = args.get("dept")
if dept == "@all":
dept = None
allowed_depts = current_user.get_depts_with_permission(Permission.ScoUsersAdmin)
allowed_depts = current_user.get_depts_with_permission(Permission.UsersAdmin)
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")
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)
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)
db.session.add(user)
db.session.commit()
return user.to_dict()
@ -132,7 +149,7 @@ def user_create():
@api_web_bp.route("/user/<int:uid>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoUsersAdmin)
@permission_required(Permission.UsersAdmin)
@as_json
def user_edit(uid: int):
"""Modification d'un utilisateur
@ -142,17 +159,18 @@ def user_edit(uid: int):
"nom": str,
"prenom": str,
"active":bool
...
}
"""
data = request.get_json(force=True) # may raise 400 Bad Request
args = 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 = data.get("dept", False)
dest_dept = args.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.ScoUsersAdmin)
allowed_depts = current_user.get_depts_with_permission(Permission.UsersAdmin)
if (None not in allowed_depts) and (
(orig_dept not in allowed_depts) or (dest_dept not in allowed_depts)
):
@ -164,10 +182,11 @@ def user_edit(uid: int):
return json_error(404, "user_edit: departement inexistant")
user.dept = dest_dept
user.nom = data.get("nom", user.nom)
user.prenom = data.get("prenom", user.prenom)
user.active = scu.to_bool(data.get("active", user.active))
ok, msg = _is_allowed_user_edit(args)
if not ok:
return json_error(403, f"user_edit: {msg}")
user.from_dict(args)
db.session.add(user)
db.session.commit()
return user.to_dict()
@ -177,7 +196,7 @@ def user_edit(uid: int):
@api_web_bp.route("/user/<int:uid>/password", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoUsersAdmin)
@permission_required(Permission.UsersAdmin)
@as_json
def user_password(uid: int):
"""Modification du mot de passe d'un utilisateur
@ -194,7 +213,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.ScoUsersAdmin)
allowed_depts = current_user.get_depts_with_permission(Permission.UsersAdmin)
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)
@ -218,7 +237,7 @@ def user_password(uid: int):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def user_role_add(uid: int, role_name: str, dept: str = None):
"""Add a role to the user"""
"""Add a role in the given dept to the user"""
user: User = User.query.get_or_404(uid)
role: Role = Role.query.filter_by(name=role_name).first_or_404()
if dept is not None: # check
@ -247,7 +266,7 @@ def user_role_add(uid: int, role_name: str, dept: str = None):
@permission_required(Permission.ScoSuperAdmin)
@as_json
def user_role_remove(uid: int, role_name: str, dept: str = None):
"""Remove the role from the user"""
"""Remove the role (in the given dept) from the user"""
user: User = User.query.get_or_404(uid)
role: Role = Role.query.filter_by(name=role_name).first_or_404()
if dept is not None: # check
@ -271,7 +290,7 @@ def user_role_remove(uid: int, role_name: str, dept: str = None):
@api_web_bp.route("/permissions")
@login_required
@scodoc
@permission_required(Permission.ScoUsersView)
@permission_required(Permission.UsersView)
@as_json
def list_permissions():
"""Liste des noms de permissions définies"""
@ -282,7 +301,7 @@ def list_permissions():
@api_web_bp.route("/role/<string:role_name>")
@login_required
@scodoc
@permission_required(Permission.ScoUsersView)
@permission_required(Permission.UsersView)
@as_json
def list_role(role_name: str):
"""Un rôle"""
@ -293,7 +312,7 @@ def list_role(role_name: str):
@api_web_bp.route("/roles")
@login_required
@scodoc
@permission_required(Permission.ScoUsersView)
@permission_required(Permission.UsersView)
@as_json
def list_roles():
"""Tous les rôles définis"""
@ -321,6 +340,7 @@ 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()
@ -345,6 +365,7 @@ 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()
@ -420,3 +441,63 @@ 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,6 +39,15 @@ 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

@ -9,7 +9,7 @@ from flask import current_app, g, redirect, request, url_for
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
import flask_login
from app import login
from app import db, login
from app.auth.models import User
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_utils import json_error
@ -39,7 +39,7 @@ def basic_auth_error(status):
@login.user_loader
def load_user(uid: str) -> User:
"flask-login: accès à un utilisateur"
return User.query.get(int(uid))
return db.session.get(User, int(uid))
@token_auth.verify_token

View File

@ -20,14 +20,13 @@ from werkzeug.security import generate_password_hash, check_password_hash
import jwt
from app import db, email, log, login
from app.models import Departement
from app.models import Departement, ScoDocModel
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@\\\-_\.]+$")
@ -52,13 +51,14 @@ def is_valid_password(cleartxt) -> bool:
def invalid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is invalid"
return (
(len(user_name) < 2)
not user_name
or (len(user_name) < 2)
or (len(user_name) >= USERNAME_STR_LEN)
or not VALID_LOGIN_EXP.match(user_name)
)
class User(UserMixin, db.Model):
class User(UserMixin, ScoDocModel):
"""ScoDoc users, handled by Flask / SQLAlchemy"""
id = db.Column(db.Integer, primary_key=True)
@ -88,8 +88,9 @@ class User(UserMixin, db.Model):
"""
cas_last_login = db.Column(db.DateTime, nullable=True)
"""date du dernier login via CAS"""
password_hash = db.Column(db.String(128))
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_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)
@ -101,6 +102,8 @@ class User(UserMixin, db.Model):
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
@ -114,12 +117,17 @@ class User(UserMixin, db.Model):
)
def __init__(self, **kwargs):
"user_name:str is mandatory"
self.roles = []
self.user_roles = []
# check login:
if kwargs.get("user_name") and invalid_user_name(kwargs["user_name"]):
if not "user_name" in kwargs:
raise ValueError("missing user_name argument")
if invalid_user_name(kwargs["user_name"]):
raise ValueError(f"invalid user_name: {kwargs['user_name']}")
super(User, self).__init__(**kwargs)
kwargs["nom"] = kwargs.get("nom", "") or ""
kwargs["prenom"] = kwargs.get("prenom", "") or ""
super().__init__(**kwargs)
# Ajoute roles:
if (
not self.roles
@ -172,7 +180,8 @@ class User(UserMixin, db.Model):
return False
# if CAS activated and forced, allow only super-user and users with cas_allow_scodoc_login
if ScoDocSiteConfig.is_cas_enabled() and ScoDocSiteConfig.get("cas_force"):
cas_enabled = ScoDocSiteConfig.is_cas_enabled()
if cas_enabled and ScoDocSiteConfig.get("cas_force"):
if (not self.is_administrator()) and not self.cas_allow_scodoc_login:
return False
@ -207,7 +216,7 @@ class User(UserMixin, db.Model):
@staticmethod
def verify_reset_password_token(token):
"Vérification du token de reéinitialisation du mot de passe"
"Vérification du token de ré-initialisation du mot de passe"
try:
token = jwt.decode(
token, current_app.config["SECRET_KEY"], algorithms=["HS256"]
@ -225,35 +234,46 @@ class User(UserMixin, db.Model):
return None
except (TypeError, KeyError):
return None
return User.query.get(user_id)
return db.session.get(User, user_id)
def sort_key(self) -> tuple:
"sort key"
return (
(self.nom or "").upper(),
(self.prenom or "").upper(),
(self.user_name or "").upper(),
)
def to_dict(self, include_email=True):
"""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,
"cas_last_login": (
self.cas_last_login.isoformat() + "Z" if self.cas_last_login else None
),
"edt_id": self.edt_id,
"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 ""), # sco8
"prenom": (self.prenom or ""), # sco8
"nom": self.nom or "",
"prenom": self.prenom or "",
"roles_string": self.get_roles_string(), # eg "Ens_RT, Ens_Info"
"user_name": self.user_name, # sco8
"user_name": self.user_name,
# Les champs calculés:
"nom_fmt": self.get_nom_fmt(),
"prenom_fmt": self.get_prenom_fmt(),
@ -267,37 +287,54 @@ class User(UserMixin, db.Model):
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 must be encoded as "roles_string", like "Ens_RT, Secr_CJ"
- roles_string : roles, encoded like "Ens_RT, Secr_CJ"
- date_expiration is a dateime object.
Does not check permissions here.
"""
for field in [
"nom",
"prenom",
"dept",
"active",
"email",
"email_institutionnel",
"date_expiration",
"cas_id",
]:
if field in data:
setattr(self, field, data[field] or None)
# required boolean fields
for field in [
"cas_allow_login",
"cas_allow_scodoc_login",
]:
setattr(self, field, scu.to_bool(data.get(field, False)))
if new_user:
if "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 = []
@ -306,6 +343,15 @@ class User(UserMixin, db.Model):
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()
@ -346,8 +392,8 @@ class User(UserMixin, db.Model):
return mails
# Permissions management:
def has_permission(self, perm: int, dept=False):
"""Check if user has permission `perm` in given `dept`.
def has_permission(self, perm: int, dept: str = False):
"""Check if user has permission `perm` in given `dept` (acronym).
Similar to Zope ScoDoc7 `has_permission``
Args:
@ -376,7 +422,9 @@ class User(UserMixin, db.Model):
"""
if not isinstance(role, Role):
raise ScoValueError("add_role: rôle invalide")
self.user_roles.append(UserRole(user=self, role=role, dept=dept))
user_role = UserRole(user=self, role=role, dept=dept)
db.session.add(user_role)
self.user_roles.append(user_role)
def add_roles(self, roles: "list[Role]", dept: str):
"""Add roles to this user.
@ -429,12 +477,12 @@ class User(UserMixin, db.Model):
"""nomplogin est le nom en majuscules suivi du prénom et du login
e.g. Dupont Pierre (dupont)
"""
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})"
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})"
@staticmethod
def get_user_id_from_nomplogin(nomplogin: str) -> Optional[int]:
"""Returns id from the string "Dupont Pierre (dupont)"
def get_user_from_nomplogin(nomplogin: str) -> Optional["User"]:
"""Returns User instance from the string "Dupont Pierre (dupont)"
or None if user does not exist
"""
match = re.match(r".*\((.*)\)", nomplogin.strip())
@ -442,35 +490,35 @@ class User(UserMixin, db.Model):
user_name = match.group(1)
u = User.query.filter_by(user_name=user_name).first()
if u:
return u.id
return u
return None
def get_nom_fmt(self):
"""Nom formaté: "Martin" """
if self.nom:
return sco_etud.format_nom(self.nom, uppercase=False)
return scu.format_nom(self.nom, uppercase=False)
else:
return self.user_name
def get_prenom_fmt(self):
"""Prénom formaté (minuscule capitalisées)"""
return sco_etud.format_prenom(self.prenom)
return scu.format_prenom(self.prenom)
def get_nomprenom(self):
"""Nom capitalisé suivi de l'initiale du prénom:
Viennet E.
"""
prenom_abbrv = scu.abbrev_prenom(sco_etud.format_prenom(self.prenom))
prenom_abbrv = scu.abbrev_prenom(scu.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(sco_etud.format_prenom(self.prenom))
prenom_abbrv = scu.abbrev_prenom(scu.format_prenom(self.prenom))
return (prenom_abbrv + " " + self.get_nom_fmt()).strip()
def get_nomcomplet(self):
"Prénom et nom complets"
return sco_etud.format_prenom(self.prenom) + " " + self.get_nom_fmt()
return scu.format_prenom(self.prenom) + " " + self.get_nom_fmt()
# nomnoacc était le nom en minuscules sans accents (inutile)
@ -532,6 +580,10 @@ 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."""
@ -551,8 +603,19 @@ 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 SCO_ROLES_DEFAULTS.items():
for role_name, permissions in roles_perms.items():
role = Role.query.filter_by(name=role_name).first()
if role is None:
role = Role(name=role_name)

View File

@ -21,7 +21,9 @@ 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
@ -52,6 +54,7 @@ def _login_form():
title=_("Sign In"),
form=form,
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
is_cas_forced=ScoDocSiteConfig.is_cas_forced(),
)
@ -158,9 +161,25 @@ 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"
Role.reset_standard_roles_permissions()
flash("rôles standards réinitialisés !")
return redirect(url_for("scodoc.configuration"))
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>
""",
)
@bp.route("/cas_users_generate_excel_sample")
@ -190,5 +209,3 @@ def cas_users_import_config():
title=_("Importation configuration CAS utilisateurs"),
form=form,
)
return

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 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="ue_advanced">
return f"""<div class="scobox 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,24 +31,33 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
H = [
"""
<div class="ue_advanced">
<h3>Parcours du BUT</h3>
<div class="scobox ue_advanced">
<div class="scobox-title">Parcours du BUT</div>
"""
]
# Choix des parcours
ue_pids = [p.id for p in ue.parcours]
H.append("""<form id="choix_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;">
"""
)
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",
@ -62,7 +71,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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -9,11 +9,14 @@
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
from app.models import Evaluation, FormSemestre, Identite, ModuleImpl
from app.models.groups import GroupDescr
from app.models.ues import UniteEns
from app.scodoc import sco_bulletins, sco_utils as scu
@ -23,6 +26,7 @@ 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
@ -102,9 +106,11 @@ 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),
@ -158,7 +164,7 @@ class BulletinBUT:
[etud.id]
].iterrows():
if codes_cursus.code_ue_validant(ue_capitalisee.code):
ue = UniteEns.query.get(ue_capitalisee.ue_id) # XXX cacher ?
ue = db.session.get(UniteEns, ue_capitalisee.ue_id) # XXX cacher ?
# déjà capitalisé ? montre la meilleure
if ue.acronyme in d:
moy_cap = d[ue.acronyme]["moyenne_num"] or 0.0
@ -179,14 +185,16 @@ 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": {},
}
@ -223,15 +231,17 @@ class BulletinBUT:
if res.modimpl_inscr_df[modimpl.id][etud.id]: # si inscrit
d[modimpl.module.code] = {
"id": modimpl.id,
"titre": modimpl.module.titre,
"titre": modimpl.module.titre_str(),
"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)
@ -240,63 +250,115 @@ class BulletinBUT:
# "max": fmt_note(moyennes_etuds.max()),
# "moy": fmt_note(moyennes_etuds.mean()),
},
"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"]
"evaluations": (
self.etud_list_modimpl_evaluations(
etud, modimpl, modimpl_results, version
)
]
if version != "short"
else [],
if version != "short"
else []
),
}
return d
def etud_eval_results(self, etud, e: Evaluation) -> dict:
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:
"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[e.moduleimpl_id]
modimpls_evals_poids = self.res.modimpls_evals_poids[evaluation.moduleimpl_id]
try:
etud_ues_ids = self.res.etud_ues_ids(etud.id)
poids = {
ue.acronyme: modimpls_evals_poids[ue.id][e.id]
ue.acronyme: modimpls_evals_poids[ue.id][evaluation.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": e.id,
"coef": fmt_note(e.coefficient)
if e.evaluation_type == scu.EVALUATION_NORMALE
else None,
"date": e.jour.isoformat() if e.jour else None,
"description": e.description,
"evaluation_type": e.evaluation_type,
"heure_debut": e.heure_debut.strftime("%H:%M") if e.heure_debut else None,
"heure_fin": e.heure_fin.strftime("%H:%M") if e.heure_debut else None,
"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),
},
"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 {}
),
"poids": poids,
"url": url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.id,
)
if has_request_context()
else "na",
"url": (
url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=evaluation.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
),
}
return d
@ -327,7 +389,6 @@ class BulletinBUT:
def bulletin_etud(
self,
etud: Identite,
formsemestre: FormSemestre,
force_publishing=False,
version="long",
) -> dict:
@ -337,22 +398,18 @@ 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).
(bulletins non publiés sur la passerelle).
"""
if version not in scu.BULLETINS_VERSIONS_BUT:
raise ScoValueError("bulletin_etud: version de bulletin demandée invalide")
res = self.res
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)
formsemestre = res.formsemestre
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,
@ -361,15 +418,21 @@ class BulletinBUT:
"titre": formsemestre.formation.titre,
},
"formsemestre_id": formsemestre.id,
"etat_inscription": etat_inscription,
"options": sco_preferences.bulletin_option_affichage(
formsemestre, self.prefs
),
}
if not published:
published = (not formsemestre.bul_hide_xml) or force_publishing
if not published or d["etat_inscription"] is False:
return d
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
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)
etud_groups = sco_groups.get_etud_formsemestre_groups(
etud, formsemestre, only_to_show=True
)
@ -384,8 +447,13 @@ class BulletinBUT:
}
if self.prefs["bul_show_abs"]:
semestre_infos["absences"] = {
"injustifie": nbabs - nbabsjust,
"injustifie": nbabsnj,
"total": nbabs,
"metrique": {
"H.": "Heure(s)",
"J.": "Journée(s)",
"1/2 J.": "1/2 Jour.",
}.get(sco_preferences.get_preference("assi_metrique")),
}
decisions_ues = self.res.get_etud_decisions_ue(etud.id) or {}
if self.prefs["bul_show_ects"]:
@ -396,7 +464,7 @@ class BulletinBUT:
semestre_infos.update(
sco_bulletins_json.dict_decision_jury(etud, formsemestre)
)
if etat_inscription == scu.INSCRIT:
if d["etat_inscription"] == scu.INSCRIT:
# moyenne des moyennes générales du semestre
semestre_infos["notes"] = {
"value": fmt_note(res.etud_moy_gen[etud.id]),
@ -478,19 +546,15 @@ class BulletinBUT:
(pas utilisé pour json/html)
Résultat compatible avec celui de sco_bulletins.formsemestre_bulletinetud_dict
"""
d = self.bulletin_etud(
etud, self.res.formsemestre, version=version, force_publishing=True
)
d = self.bulletin_etud(etud, version=version, force_publishing=True)
d["etudid"] = etud.id
d["etud"] = d["etudiant"]
d["etud"]["nomprenom"] = etud.nomprenom
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(
etud_etat,
self.prefs,
decision_sem=d["semestre"].get("decision"),
d["filigranne"] = sco_bulletins_pdf.get_filigranne_apc(
etud_etat, self.prefs, etud.id, res=self.res
)
if etud_etat == scu.DEMISSION:
d["demission"] = "(Démission)"
@ -500,13 +564,13 @@ class BulletinBUT:
d["demission"] = ""
# --- Absences
d["nbabs"], d["nbabsjust"] = self.res.formsemestre.get_abs_count(etud.id)
_, d["nbabsjust"], d["nbabs"] = self.res.formsemestre.get_abs_count(etud.id)
# --- Decision Jury
infos, dpv = sco_bulletins.etud_descr_situation_semestre(
infos, _ = sco_bulletins.etud_descr_situation_semestre(
etud.id,
self.res.formsemestre,
format="html",
fmt="html",
show_date_inscr=self.prefs["bul_show_date_inscr"],
show_decisions=self.prefs["bul_show_decision"],
show_uevalid=self.prefs["bul_show_uevalid"],
@ -515,15 +579,11 @@ 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"]
# --- Appréciations
d.update(
sco_bulletins.get_appreciations_list(self.res.formsemestre.id, etud.id)
)
d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"]))
return d

View File

@ -0,0 +1,156 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Génération bulletin BUT HTML synthétique en une page
On génère du HTML à partir d'un template Jinja.
## Données
Ces données sont des objets passés au template.
- `etud: Identite` : l'étudiant
- `formsemestre: FormSemestre` : le formsemestre d'où est émis ce bulletin
- `bulletins_sem: BulletinBUT` les données bulletins pour tous les étudiants
- `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
- `ects_total` : nombre d'ECTS validées dans ce cursus
- `ue_validation_by_niveau : dict` : les validations d'UE de chaque niveau du cursus
"""
import datetime
import time
from flask import render_template
from flask import g
from app.but.bulletin_but import BulletinBUT
from app.but import bulletin_but_court_pdf, cursus_but, validations_view
from app.decorators import (
scodoc,
permission_required,
)
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_logos import find_logo
from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu
from app.views import notes_bp as bp
from app.views import ScoData
@bp.route(
"/bulletin_but/<int:formsemestre_id>/<int:etudid>", endpoint="bulletin_but_html"
)
@bp.route(
"/bulletin_but/<int:formsemestre_id>/<int:etudid>/pdf",
defaults={"fmt": "pdf"},
endpoint="bulletin_but_pdf",
)
@scodoc
@permission_required(Permission.ScoView)
def bulletin_but(formsemestre_id: int, etudid: int = None, fmt="html"):
"""Page HTML affichant le bulletin BUT simplifié"""
etud: Identite = Identite.query.get_or_404(etudid)
formsemestre: FormSemestre = (
FormSemestre.query.filter_by(id=formsemestre_id)
.join(FormSemestreInscription)
.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")
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
)
ects_total = sum((v.ects() for v in ue_validation_by_niveau.values()))
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
],
}
return args

View File

@ -0,0 +1,546 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 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
ScoDoc BulletinGenerator et GenTable.
"""
import datetime
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.platypus import Paragraph, Spacer, Table
from app.but import cursus_but
from app.models import (
BulAppreciations,
FormSemestre,
Identite,
ScolarFormSemestreValidation,
)
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,
) -> 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,
"""
# 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)
finally:
PDFLOCK.release()
return bul_pdf
class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
"""Ce générateur de bulletin BUT court est assez différent des autres bulletins.
Ne génére que du PDF.
Il reprend la mise en page et certains éléments (pied de page, signature).
"""
# spécialisation du BulletinGeneratorStandard, ne pas présenter à l'utilisateur:
list_in_menu = False
scale_table_in_page = True # pas de mise à l'échelle pleine page auto
multi_pages = False # une page par bulletin
small_fontsize = "8"
color_blue_bg = Color(0, 153 / 255, 204 / 255)
color_gray_bg = Color(0.86, 0.86, 0.86)
def __init__(
self,
bul: dict = None,
cursus: cursus_but.EtudCursusBUT = None,
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)
self.bul = bul
self.cursus = cursus
self.decision_ues = decision_ues
self.ects_total = ects_total
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
self.nb_ues = len(self.ues_acronyms)
# Styles PDF
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_cell = styles.ParagraphStyle("style_cell", self.style_base)
self.style_cell.fontSize = 7
self.style_cell.leading = 7
self.style_cell_bold = styles.ParagraphStyle("style_cell_bold", self.style_cell)
self.style_cell_bold.fontName = "Helvetica-Bold"
self.style_head = styles.ParagraphStyle("style_head", self.style_cell_bold)
self.style_head.fontSize = 9
self.style_niveaux = styles.ParagraphStyle("style_niveaux", self.style_cell)
self.style_niveaux.alignment = TA_CENTER
self.style_niveaux.leading = 9
self.style_niveaux.firstLineIndent = 0
self.style_niveaux.leftIndent = 1
self.style_niveaux.rightIndent = 1
self.style_niveaux.borderWidth = 0.5
self.style_niveaux.borderPadding = 2
self.style_niveaux.borderRadius = 2
self.style_niveaux_top = styles.ParagraphStyle(
"style_niveaux_top", self.style_niveaux
)
self.style_niveaux_top.fontName = "Helvetica-Bold"
self.style_niveaux_top.fontSize = 8
self.style_niveaux_titre = styles.ParagraphStyle(
"style_niveaux_titre", self.style_niveaux
)
self.style_niveaux_titre.textColor = white
self.style_niveaux_titre.backColor = self.color_blue_bg
self.style_niveaux_titre.borderColor = self.color_blue_bg
self.style_niveaux_code = styles.ParagraphStyle(
"style_niveaux_code", self.style_niveaux
)
self.style_niveaux_code.borderColor = black
#
self.style_jury = styles.ParagraphStyle("style_jury", self.style_base)
self.style_jury.fontSize = 9
self.style_jury.leading = self.style_jury.fontSize * 1.4 # espace les lignes
self.style_jury.backColor = self.color_gray_bg
self.style_jury.borderColor = black
self.style_jury.borderWidth = 1
self.style_jury.borderPadding = 2
self.style_jury.borderRadius = 2
self.style_appreciations = styles.ParagraphStyle(
"style_appreciations", self.style_base
)
self.style_appreciations.fontSize = 9
self.style_appreciations.leading = (
self.style_jury.fontSize * 1.4
) # 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
# Modules
self.width_col_code = self.width_col_ue
# Niveaux
self.width_col_niveaux_titre = 24 * mm
self.width_col_niveaux_code = 14 * mm
def bul_title_pdf(self, preference_field="bul_but_pdf_title") -> list:
"""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
return super().bul_title_pdf(preference_field=preference_field)
def bul_part_below(self, fmt="pdf") -> list:
"""Génère les informations placées sous la table
Dans le cas du bul. court BUT pdf, seulement les appréciations.
fmt est ignoré ici.
"""
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 []
)
def bul_table(self, fmt=None) -> list:
"""Génère la table centrale du bulletin de notes
Renvoie: une liste d'objets PLATYPUS (eg instance de Table).
L'argument fmt est ici ignoré (toujours en PDF)
"""
style_table_2cols = [
("ALIGN", (0, -1), (0, -1), "LEFT"),
("ALIGN", (-1, -1), (-1, -1), "RIGHT"),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
]
# Ligne avec boite assiduité et table UEs
table_abs_ues = Table(
[
[
self.boite_identite() + [Spacer(1, 3 * mm), self.boite_assiduite()],
self.table_ues(),
],
],
style=style_table_2cols,
)
table_abs_ues.hAlign = "RIGHT"
# Ligne (en bas) avec table cursus et boite jury
table_cursus_jury = Table(
[
[
self.table_cursus_but(),
[Spacer(1, 8 * mm), self.boite_decisions_jury()],
]
],
colWidths=(self.width_page_avail - 84 * mm, 84 * mm),
style=style_table_2cols,
)
return [
table_abs_ues,
Spacer(0, 3 * mm),
self.table_ressources(),
Spacer(0, 3 * mm),
self.table_saes(),
Spacer(0, 5 * mm),
table_cursus_jury,
]
def table_ues(self) -> Table:
"""Table avec les résultats d'UE du semestre courant"""
bul = self.bul
rows = [
[
f"Unités d'enseignement du semestre {self.formsemestre.semestre_id}",
],
[""] + self.ues_acronyms,
["Moyenne"]
+ [bul["ues"][ue]["moyenne"]["value"] for ue in self.ues_acronyms],
["dont bonus"]
+ [
bul["ues"][ue]["bonus"] if bul["ues"][ue]["bonus"] != "00.00" else ""
for ue in self.ues_acronyms
],
["et malus"]
+ [
bul["ues"][ue]["malus"] if bul["ues"][ue]["malus"] != "00.00" else ""
for ue in self.ues_acronyms
],
["Rang"]
+ [
f'{bul["ues"][ue]["moyenne"]["rang"]} / {bul["ues"][ue]["moyenne"]["total"]}'
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 = [
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOX", (0, 0), (-1, -1), 1.0, black), # ajoute cadre extérieur
("INNERGRID", (0, 0), (-1, -1), 0.25, black),
("LEADING", (0, 1), (-1, -1), 5),
("SPAN", (0, 0), (self.nb_ues, 0)),
("BACKGROUND", (0, 0), (self.nb_ues, 0), blue_bg),
]
col_widths = [self.width_col_ue_titres] + [self.width_col_ue] * self.nb_ues
rows_styled = [[Paragraph(SU(str(cell)), self.style_head) for cell in rows[0]]]
rows_styled += [
[Paragraph(SU(str(cell)), self.style_cell_bold) for cell in rows[1]]
]
rows_styled += [
[Paragraph(SU(str(cell)), self.style_cell) for cell in row]
for row in rows[2:-1]
]
rows_styled += [
[Paragraph(SU(str(cell)), self.style_cell_bold) for cell in rows[-1]]
]
table = Table(
rows_styled,
colWidths=col_widths,
style=table_style,
)
table.hAlign = "RIGHT"
return table
def _table_modules(self, mod_type: str = "ressources", title: str = "") -> Table:
"génère table des modules: resources ou SAEs"
bul = self.bul
rows = [
["", "", "Unités d'enseignement"] + [""] * (self.nb_ues - 1),
[title, ""] + self.ues_acronyms,
]
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 ""
)
for ue in self.ues_acronyms
]
rows.append(row)
title_bg = (
Color(255 / 255, 192 / 255, 0)
if mod_type == "ressources"
else Color(176 / 255, 255 / 255, 99 / 255)
)
table_style = [
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOX", (0, 0), (-1, -1), 1.0, black), # ajoute cadre extérieur
("INNERGRID", (0, 0), (-1, -1), 0.25, black),
("LEADING", (0, 1), (-1, -1), 5),
# 1ère ligne titre
("SPAN", (0, 0), (1, 0)),
("SPAN", (2, 0), (self.nb_ues, 0)),
# 2ème ligne titre
("SPAN", (0, 1), (1, 1)),
("BACKGROUND", (0, 1), (1, 1), title_bg),
]
# Estime l'espace horizontal restant pour les titres de modules
width_col_titre_module = (
self.width_page_avail
- self.width_col_code
- self.width_col_ue * self.nb_ues
)
col_widths = [self.width_col_code, width_col_titre_module] + [
self.width_col_ue
] * self.nb_ues
rows_styled = [
[Paragraph(SU(str(cell)), self.style_cell_bold) for cell in row]
for row in rows[:2]
]
rows_styled += [
[Paragraph(SU(str(cell)), self.style_cell) for cell in row]
for row in rows[2:]
]
table = Table(
rows_styled,
colWidths=col_widths,
style=table_style,
)
table.hAlign = "RIGHT"
return table
def table_ressources(self) -> Table:
"La table des ressources"
return self._table_modules("ressources", "Ressources")
def table_saes(self) -> Table:
"La table des SAEs"
return self._table_modules(
"saes", "Situations d'Apprentissage et d'Évaluation (SAÉ)"
)
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}"""),
style=self.style_nom,
),
Paragraph(
SU(
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/>
"""
),
style=self.style_base,
),
]
def boite_assiduite(self) -> Table:
"Les informations sur l'assiduité"
if not self.bul["options"]["show_abs"]:
return Paragraph("") # empty
color_bg = Color(245 / 255, 237 / 255, 200 / 255)
rows = [
["Absences", ""],
[f'({self.bul["semestre"]["absences"]["metrique"]})', ""],
["Non justifiées", self.bul["semestre"]["absences"]["injustifie"]],
["Total", self.bul["semestre"]["absences"]["total"]],
]
rows_styled = [
[Paragraph(SU(str(cell)), self.style_head) for cell in row]
for row in rows[:1]
]
rows_styled += [
[Paragraph(SU(str(cell)), self.style_assiduite) for cell in row]
for row in rows[1:]
]
table = Table(
rows_styled,
# [topLeft, topRight, bottomLeft bottomRight]
cornerRadii=[2 * mm] * 4,
style=[
("BACKGROUND", (0, 0), (-1, -1), color_bg),
("SPAN", (0, 0), (1, 0)),
("SPAN", (0, 1), (1, 1)),
("VALIGN", (0, 0), (-1, -1), "TOP"),
],
colWidths=(25 * mm, 10 * mm),
)
table.hAlign = "LEFT"
return table
def table_cursus_but(self) -> Table:
"La table avec niveaux et validations BUT1, BUT2, BUT3"
rows = [
["", "BUT 1", "BUT 2", "BUT 3"],
]
for competence_id in self.cursus.to_dict():
row = [self.cursus.competences[competence_id].titre]
for annee in ("BUT1", "BUT2", "BUT3"):
validation = self.cursus.validation_par_competence_et_annee.get(
competence_id, {}
).get(annee)
has_niveau = self.cursus.competence_annee_has_niveau(
competence_id, annee
)
txt = ""
if validation:
txt = validation.code
elif has_niveau:
txt = "-"
row.append(txt)
rows.append(row)
rows_styled = [
[Paragraph(SU(str(cell)), self.style_niveaux_top) for cell in rows[0]]
] + [
[Paragraph(SU(str(row[0])), self.style_niveaux_titre)]
+ [
Paragraph(SU(str(cell)), self.style_niveaux_code) if cell else ""
for cell in row[1:]
]
for row in rows[1:]
]
table = Table(
rows_styled,
colWidths=[
self.width_col_niveaux_titre,
self.width_col_niveaux_code,
self.width_col_niveaux_code,
self.width_col_niveaux_code,
],
style=[
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 5),
("TOPPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
# sert de séparateur entre les lignes:
("LINEABOVE", (0, 1), (-1, -1), 3, white),
# séparateur colonne
("LINEBEFORE", (1, 1), (-1, -1), 5, white),
],
)
table.hAlign = "LEFT"
return table
def boite_decisions_jury(self):
"""La boite en bas à droite avec jury"""
txt = f"""ECTS acquis en BUT : <b>{self.ects_total:g}</b><br/>"""
if self.bul["semestre"].get("decision_annee", None):
txt += f"""
Décision saisie le {
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime(scu.DATE_FMT)
}, année BUT{self.bul["semestre"]["decision_annee"]["ordre"]}
<b>{self.bul["semestre"]["decision_annee"]["code"]}</b>.
<br/>
"""
if self.bul["semestre"].get("autorisation_inscription", None):
txt += (
"<br/>Autorisé à s'inscrire en <b>"
+ ", ".join(
[
f"S{aut['semestre_id']}"
for aut in self.bul["semestre"]["autorisation_inscription"]
]
)
+ "</b>."
)
return Paragraph(txt, style=self.style_jury)

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 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_bulletinetud(infos)
- instance de BulletinGeneratorStandardBUT(infos)
- BulletinGeneratorStandardBUT.generate(format="pdf")
- sco_bulletins_generator.make_formsemestre_bulletin_etud()
- instance de BulletinGeneratorStandardBUT
- BulletinGeneratorStandardBUT.generate(fmt="pdf")
sco_bulletins_generator.BulletinGenerator.generate()
.generate_pdf()
.bul_table() (ci-dessous)
@ -24,6 +24,7 @@ from reportlab.lib.colors import blue
from reportlab.lib.units import cm, mm
from reportlab.platypus import Paragraph, Spacer
from app.models import Evaluation, ScoDocSiteConfig
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc import gen_tables
from app.scodoc.codes_cursus import UE_SPORT
@ -42,12 +43,14 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
multi_pages = True # plusieurs pages par bulletins
small_fontsize = "8"
def bul_table(self, format="html"):
def bul_table(self, fmt="html"):
"""Génère la table centrale du bulletin de notes
Renvoie:
- 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(),
@ -70,8 +73,9 @@ 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(format=format)
table_objects = table.gen(fmt=fmt)
objects += table_objects
# objects += [KeepInFrame(0, 0, table_objects, mode="shrink")]
if i != 2:
@ -189,7 +193,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
moy_ue = moy_ue.get("value", "-") if moy_ue is not None else "-"
t = {
"titre": f"{ue_acronym} - {ue['titre']}",
"moyenne": Paragraph(f"""<para align=right><b>{moy_ue}</b></para>"""),
"moyenne": Paragraph(
f"""<para align=right><b>{moy_ue or "-"}</b></para>"""
),
"_css_row_class": "note_bold",
"_pdf_row_markup": ["b"],
"_pdf_style": [
@ -210,6 +216,35 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
else:
self.ue_std_rows(rows, ue, title_bg)
@staticmethod
def affichage_bonus_malus(ue: dict) -> list[str]:
"liste de chaînes affichant les bonus et malus"
fields_bmr = []
# lecture des bonus sport culture et malus (ou bonus autre) (0 si valeur non numérique)
try:
bonus_sc = float(ue.get("bonus", 0.0)) or 0
except ValueError:
bonus_sc = 0
try:
malus = float(ue.get("malus", 0.0)) or 0
except ValueError:
malus = 0
# Calcul de l affichage
if malus < 0:
if bonus_sc > 0:
fields_bmr.append(f"Bonus sport/culture: {bonus_sc}")
fields_bmr.append(f"Bonus autres: {-malus}")
else:
fields_bmr.append(f"Bonus: {-malus}")
elif malus > 0:
if bonus_sc > 0:
fields_bmr.append(f"Bonus: {bonus_sc}")
fields_bmr.append(f"Malus: {malus}")
else:
if bonus_sc > 0:
fields_bmr.append(f"Bonus: {bonus_sc}")
return fields_bmr
def ue_std_rows(self, rows: list, ue: dict, title_bg: tuple):
"Lignes décrivant une UE standard dans la table de synthèse"
# 2eme ligne titre UE (bonus/malus/ects)
@ -218,20 +253,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
else:
ects_txt = ""
# case Bonus/Malus/Rang "bmr"
fields_bmr = []
try:
value = float(ue.get("bonus", 0.0))
if value != 0:
fields_bmr.append(f"Bonus: {ue['bonus']}")
except ValueError:
pass
try:
value = float(ue.get("malus", 0.0))
if value != 0:
fields_bmr.append(f"Malus: {ue['malus']}")
except ValueError:
pass
fields_bmr = BulletinGeneratorStandardBUT.affichage_bonus_malus(ue)
moy_ue = ue.get("moyenne", "-")
if isinstance(moy_ue, dict): # UE non capitalisées
if self.preferences["bul_show_ue_rangs"]:
@ -248,7 +270,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
date_capitalisation = ue.get("date_capitalisation")
if date_capitalisation:
fields_bmr.append(
f"""Capitalisée le {date_capitalisation.strftime("%d/%m/%Y")}"""
f"""Capitalisée le {date_capitalisation.strftime(scu.DATE_FMT)}"""
)
t = {
"titre": " - ".join(fields_bmr),
@ -401,16 +423,22 @@ 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"] == scu.EVALUATION_NORMALE else "*"
coef = (
e["coef"]
if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE
else "*"
)
note_value = e["note"].get("value", "")
t = {
"titre": f"{e['description'] or ''}",
"moyenne": e["note"]["value"],
"_moyenne_pdf": Paragraph(
f"""<para align=right>{e["note"]["value"]}</para>"""
),
"moyenne": note_value,
"_moyenne_pdf": Paragraph(f"""<para align=right>{note_value}</para>"""),
"coef": coef,
"_coef_pdf": Paragraph(
f"<para align=right fontSize={self.small_fontsize}><i>{coef}</i></para>"
f"""<para align=right fontSize={self.small_fontsize}><i>{
coef if e["evaluation_type"] != Evaluation.EVALUATION_BONUS
else "bonus"
}</i></para>"""
),
"_pdf_style": [
(

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# 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
@ -38,14 +38,11 @@ import datetime
from xml.etree import ElementTree
from xml.etree.ElementTree import Element
from app import log
from app import db, log
from app.but import bulletin_but
from app.models import FormSemestre, Identite
from app.models import BulAppreciations, FormSemestre, Identite, UniteEns
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
@ -202,19 +199,24 @@ def bulletin_but_xml_compat(
if e.visibulletin or version == "long":
x_eval = Element(
"evaluation",
jour=e.jour.isoformat() if e.jour else "",
heure_debut=e.heure_debut.isoformat()
if e.heure_debut
else "",
heure_fin=e.heure_fin.isoformat()
if e.heure_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),
description=quote_xml_attr(e.description),
# 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 ""
),
heure_debut=e.heure_debut(),
heure_fin=e.heure_fin(),
)
x_mod.append(x_eval)
try:
@ -239,7 +241,7 @@ def bulletin_but_xml_compat(
# --- Absences
if sco_preferences.get_preference("bul_show_abs", formsemestre_id):
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
_, nbabsjust, nbabs = 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 ---------
@ -253,7 +255,7 @@ def bulletin_but_xml_compat(
infos, dpv = sco_bulletins.etud_descr_situation_semestre(
etudid,
formsemestre,
format="xml",
fmt="xml",
show_uevalid=sco_preferences.get_preference(
"bul_show_uevalid", formsemestre_id
),
@ -289,17 +291,18 @@ 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 = 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"],
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"],
)
)
)
for aut in decision["autorisations"]:
doc.append(
@ -310,16 +313,13 @@ def bulletin_but_xml_compat(
else:
doc.append(Element("decision", code="", etat="DEM"))
# --- Appreciations
cnx = ndb.GetDBConnexion()
apprecs = sco_etud.appreciations_list(
cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id}
)
for appr in apprecs:
appreciations = BulAppreciations.get_appreciations_list(formsemestre.id, etudid)
for appreciation in appreciations:
x_appr = Element(
"appreciation",
date=ndb.DateDMYtoISO(appr["date"]),
date=appreciation.date.isoformat() if appreciation.date else "",
)
x_appr.text = quote_xml_attr(appr["comment"])
x_appr.text = quote_xml_attr(appreciation.comment_safe())
doc.append(x_appr)
if is_appending:

92
app/but/change_refcomp.py Normal file
View File

@ -0,0 +1,92 @@
##############################################################################
# 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -15,39 +15,29 @@ Classe raccordant avec ScoDoc 7:
"""
import collections
from operator import attrgetter
from typing import Union
from flask import g, url_for
from app import db
from app import log
from app import db, log
from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences,
)
from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
RegroupementCoherentUE,
)
from app.models.ues import UEParcours
from app.models.but_validations import ApcValidationRCUE
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.formsemestre import FormSemestre
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, RED, UE_STANDARD
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
from app.scodoc import sco_cursus_dut
@ -103,7 +93,7 @@ class EtudCursusBUT:
"Liste des inscriptions aux sem. de la formation, triées par indice et chronologie"
self.parcour: ApcParcours = self.inscriptions[-1].parcour
"Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
self.niveaux_by_annee = {}
self.niveaux_by_annee: dict[int, list[ApcNiveau]] = {}
"{ annee:int : liste des niveaux à valider }"
self.niveaux: dict[int, ApcNiveau] = {}
"cache les niveaux"
@ -121,8 +111,15 @@ 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(
@ -253,7 +250,9 @@ class FormSemestreCursusBUT:
parcour = None
else:
if parcour_id not in self.parcours_by_id:
self.parcours_by_id[parcour_id] = ApcParcours.query.get(parcour_id)
self.parcours_by_id[parcour_id] = db.session.get(
ApcParcours, parcour_id
)
parcour = self.parcours_by_id[parcour_id]
return self.get_niveaux_parcours_by_annee(parcour)
@ -365,10 +364,33 @@ class FormSemestreCursusBUT:
"cache { competence_id : competence }"
def but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float:
"""Nombre d'ECTS validés par etud dans le BUT de référentiel indiqué.
Ne prend que les UE associées à des niveaux de compétences,
et ne les compte qu'une fois même en cas de redoublement avec re-validation.
"""
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.filter(ScolarFormSemestreValidation.ue_id != None)
.join(UniteEns)
.join(ApcNiveau)
.join(ApcCompetence)
.filter_by(referentiel_id=referentiel_competence_id)
)
ects_dict = {}
for v in validations:
key = (v.ue.semestre_idx, v.ue.niveau_competence.id)
if v.code in CODES_UE_VALIDES:
ects_dict[key] = v.ue.ects
return sum(ects_dict.values()) if ects_dict else 0.0
def etud_ues_de_but1_non_validees(
etud: Identite, formation: Formation, parcour: ApcParcours
) -> list[UniteEns]:
"""Vrai si cet étudiant a validé toutes ses UEs de S1 et S2, dans son parcours"""
"""Liste des UEs de S1 et S2 non validées, dans son parcours"""
# Les UEs avec décisions, dans les S1 ou S2 d'une formation de même code:
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
@ -378,9 +400,9 @@ def etud_ues_de_but1_non_validees(
.join(Formation)
.filter_by(formation_code=formation.formation_code)
)
codes_validations_by_ue = collections.defaultdict(list)
codes_validations_by_ue_code = collections.defaultdict(list)
for v in validations:
codes_validations_by_ue[v.ue_id].append(v.code)
codes_validations_by_ue_code[v.ue.ue_code].append(v.code)
# Les UEs du parcours en S1 et S2:
ues = formation.query_ues_parcour(parcour).filter(
@ -391,8 +413,11 @@ def etud_ues_de_but1_non_validees(
[
ue
for ue in ues
if any(
(not code_ue_validant(code) for code in codes_validations_by_ue[ue.id])
if not any(
(
code_ue_validant(code)
for code in codes_validations_by_ue_code[ue.ue_code]
)
)
],
key=attrgetter("numero", "acronyme"),
@ -410,15 +435,38 @@ 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_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>
La <a class="stdlink" href="{url_formation}">formation
n'est pas associée à un référentiel de compétence.</a>
</div>
"""
# Vérifie les niveaux de chaque parcours
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
for parcour in formsemestre.parcours or [None]:
annee = (formsemestre.semestre_id + 1) // 2
niveaux_ids = {
@ -435,7 +483,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 !
"""
@ -443,14 +491,223 @@ def formsemestre_warning_apc_setup(
if not H:
return ""
return f"""<div class="formsemestre_status_warning">
Problème dans la configuration de la formation:
Problème dans la
<a class="stdlink" href="{url_formation}">configuration de la formation</a>:
<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>
</p>
</div>
"""
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:
"L'UE associée à ce niveau, ou None"
ues = [ue for ue in ues_possibles if ue.niveau_competence_id == niveau.id]
if len(ues) > 1:
# plusieurs UEs associées à ce niveau: élimine celles sans parcours
ues_pair_avec_parcours = [ue for ue in ues if ue.parcours]
if ues_pair_avec_parcours:
ues = ues_pair_avec_parcours
if len(ues) > 1:
log(f"_niveau_ues: {len(ues)} associées au niveau {niveau} / {sem_name}")
return ues[0] if ues else None
def parcour_formation_competences(
parcour: ApcParcours, formation: Formation
) -> tuple[list[dict], float]:
"""
[
{
'competence' : ApcCompetence,
'niveaux' : {
1 : { ... },
2 : { ... },
3 : {
'niveau' : ApcNiveau,
'ue_impair' : UniteEns, # actuellement associée
'ues_impair' : list[UniteEns], # choix possibles
'ue_pair' : UniteEns,
'ues_pair' : list[UniteEns],
}
}
}
],
ects_parcours (somme des ects des UEs associées)
"""
refcomp: ApcReferentielCompetences = formation.referentiel_competence
def _niveau_ues(competence: ApcCompetence, annee: int) -> dict:
"""niveau et ues pour cette compétence de cette année du parcours.
Si parcour est None, les niveaux du tronc commun
"""
if parcour is not None:
# L'étudiant est inscrit à un parcours: cherche les niveaux
niveaux = ApcNiveau.niveaux_annee_de_parcours(
parcour, annee, competence=competence
)
else:
# sans parcours, on cherche les niveaux du Tronc Commun de cette année
niveaux = [
niveau
for niveau in refcomp.get_niveaux_by_parcours(annee)[1]["TC"]
if niveau.competence_id == competence.id
]
if len(niveaux) > 0:
if len(niveaux) > 1:
log(
f"""_niveau_ues: plus d'un niveau pour {competence}
annee {annee} {("parcours " + parcour.code) if parcour else ""}"""
)
niveau = niveaux[0]
elif len(niveaux) == 0:
return {
"niveau": None,
"ue_pair": None,
"ue_impair": None,
"ues_pair": [],
"ues_impair": [],
}
# Toutes les UEs de la formation dans ce parcours ou tronc commun
ues = [
ue
for ue in formation.ues
if (
(not ue.parcours)
or (parcour is not None and (parcour.id in (p.id for p in ue.parcours)))
)
and ue.type == UE_STANDARD
]
ues_pair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee)]
ues_impair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee - 1)]
# UE associée au niveau dans ce parcours
ue_pair = ue_associee_au_niveau_du_parcours(
ues_pair_possibles, niveau, f"S{2*annee}"
)
ue_impair = ue_associee_au_niveau_du_parcours(
ues_impair_possibles, niveau, f"S{2*annee-1}"
)
return {
"niveau": niveau,
"ue_pair": ue_pair,
"ues_pair": [
ue
for ue in ues_pair_possibles
if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
],
"ue_impair": ue_impair,
"ues_impair": [
ue
for ue in ues_impair_possibles
if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
],
}
competences = [
{
"competence": competence,
"niveaux": {annee: _niveau_ues(competence, annee) for annee in (1, 2, 3)},
}
for competence in (
parcour.query_competences()
if parcour
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

View File

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

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -10,9 +10,11 @@
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 :"
)
@ -21,6 +23,7 @@ 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"
)
@ -47,3 +50,12 @@ 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
from xml.etree import ElementTree
@ -23,9 +23,12 @@ 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):
def orebut_import_refcomp(
xml_data: str, dept_id: int, orig_filename=None
) -> ApcReferentielCompetences:
"""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é:
@ -33,7 +36,7 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
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."""
)
@ -41,7 +44,7 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
try:
root = ElementTree.XML(xml_data)
except ElementTree.ParseError as exc:
raise ScoFormatError(f"fichier XML Orébut invalide (2): {exc.args}")
raise ScoFormatError(f"fichier XML Orébut invalide (2): {exc.args}") from exc
if root.tag != "referentiel_competence":
raise ScoFormatError("élément racine 'referentiel_competence' manquant")
args = ApcReferentielCompetences.attr_from_xml(root.attrib)
@ -60,7 +63,8 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
# 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)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -12,6 +12,7 @@ from openpyxl.styles import Font, Border, Side, Alignment, PatternFill
from app import log
from app.but import jury_but
from app.but.cursus_but import but_ects_valides
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.scodoc.gen_tables import GenTable
@ -54,11 +55,21 @@ 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,
@ -68,7 +79,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>
@ -93,7 +104,7 @@ def pvjury_page_but(formsemestre_id: int, fmt="html"):
},
xls_style_base=xls_style_base,
)
return tab.make_page(format=fmt, javascripts=["js/etud_info.js"], init_qtip=True)
return tab.make_page(fmt=fmt, javascripts=["js/etud_info.js"], init_qtip=True)
def pvjury_table_but(
@ -109,8 +120,13 @@ def pvjury_table_but(
"""
# remplace pour le BUT la fonction sco_pv_forms.pvjury_table
annee_but = (formsemestre.semestre_id + 1) // 2
referentiel_competence_id = formsemestre.formation.referentiel_competence_id
if referentiel_competence_id is None:
raise ScoValueError(
"pas de référentiel de compétences associé à la formation de ce semestre !"
)
titles = {
"nom": "Code" if anonymous else "Nom",
"nom_pv": "Code" if anonymous else "Nom",
"cursus": "Cursus",
"ects": "ECTS",
"ues": "UE validées",
@ -138,33 +154,47 @@ def pvjury_table_but(
except ScoValueError:
deca = None
ects_but_valides = but_ects_valides(etud, referentiel_competence_id)
row = {
"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": (
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_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",
"_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",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
),
"cursus": _descr_cursus_but(etud),
"ects": f"{deca.formsemestre_ects():g}",
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {ects_but_valides:g}""",
"_ects_xls": deca.ects_annee(),
"ects_but": ects_but_valides,
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": 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 "",
"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,
}
if deca.valide_diplome() or not only_diplome:
rows.append(row)
rows.sort(key=lambda x: x["_nom_order"])
rows.sort(key=lambda x: x["_nom_pv_order"])
return rows, titles

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -48,9 +48,9 @@ def _get_jury_but_etud_result(
# --- Les RCUEs
rcue_list = []
if deca:
for rcue in deca.rcues_annee:
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
for dec_rcue in deca.get_decisions_rcues_annee():
rcue = dec_rcue.rcue
if rcue.complete: # n'exporte que les RCUEs complets
dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
rcue_dict = {

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 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
) -> int:
formsemestre: FormSemestre, only_adm: bool = True, dry_run=False
) -> tuple[int, list[jury_but.DecisionsProposeesAnnee]]:
"""Calcul automatique des décisions de jury sur une "année" BUT.
- N'enregistre jamais de décisions de l'année scolaire précédente, même
@ -27,16 +27,22 @@ 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: nombre d'étudiants pour lesquels on a enregistré au moins un code.
Returns:
- En mode normal, (nombre d'étudiants pour lesquels on a enregistré au moins un code, []])
- En mode dry_run, (0, list[DecisionsProposeesAnnee])
"""
if not formsemestre.formation.is_apc():
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)
nb_etud_modif += deca.record_all(only_validantes=only_adm)
if not dry_run:
nb_etud_modif += deca.record_all(only_validantes=only_adm)
else:
decas.append(deca)
db.session.commit()
ScolarNews.add(
@ -49,4 +55,4 @@ def formsemestre_validation_auto_but(
formsemestre_id=formsemestre.id,
),
)
return nb_etud_modif
return nb_etud_modif, decas

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -21,8 +21,6 @@ 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,
@ -33,11 +31,8 @@ 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
@ -76,6 +71,13 @@ 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"
href={
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
)
}>visualiser son cursus</a>
</div>
<div class="but_explanation">{deca.explanation}</div>
<div class="but_annee">
@ -90,45 +92,44 @@ 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">RCUE</div>
<div class="titre" title="Décisions sur RCUEs enregistrées sur l'ensemble du cursus">RCUE</div>
"""
)
for niveau in deca.niveaux_competences:
for dec_rcue in deca.get_decisions_rcues_annee():
rcue = dec_rcue.rcue
niveau = rcue.niveau
H.append(
f"""<div class="but_niveau_titre">
<div title="{niveau.competence.titre_long}">{niveau.competence.titre}</div>
</div>"""
)
dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) # peut être None
ues = [
ue
for ue in deca.ues_impair
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
]
ue_impair = ues[0] if ues else None
ues = [
ue
for ue in deca.ues_pair
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
]
ue_pair = ues[0] if ues else None
# Les UEs à afficher,
# qui seront toujours en readonly sur le formsemestre de l'année précédente du redoublant
ues_ro = [
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 = [
(
ue_impair,
(deca.a_cheval and deca.formsemestre_id != deca.formsemestre_impair.id),
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,
deca.a_cheval and deca.formsemestre_id != deca.formsemestre_pair.id,
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[0], ues_ro[1] = ues_ro[1], ues_ro[0]
ues_ro_dispense[0], ues_ro_dispense[1] = (
ues_ro_dispense[1],
ues_ro_dispense[0],
)
# Colonnes d'UE:
for ue, ue_read_only in ues_ro:
for ue, ue_read_only, ue_dispense in ues_ro_dispense:
if ue:
H.append(
_gen_but_niveau_ue(
@ -137,6 +138,7 @@ 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:
@ -155,12 +157,13 @@ def _gen_but_select(
code_valide: str,
disabled: bool = False,
klass: str = "",
data: dict = {},
data: dict = None,
code_valide_label: str = "",
) -> str:
"Le menu html select avec les codes"
# if disabled: # mauvaise idée car le disabled est traité en JS
# return f"""<div class="but_code {klass}">{code_valide}</div>"""
data = data or {}
options_htm = "\n".join(
[
f"""<option value="{code}"
@ -174,7 +177,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);"
@ -190,59 +193,106 @@ 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("%d/%m/%Y")}
<span>le {dec_ue.ue_status["event_date"].strftime(scu.DATE_FMT)}
</span>
</div>
<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>
{ etat_en_cours }
</div>
</div>
"""
elif dec_ue.formsemestre is None:
# Validation d'UE antérieure (semestre hors année scolaire courante)
if dec_ue.validation:
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.validation.moy_ue)}</span>"""
scoplement = f"""<div class="scoplement">
<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)}
</span>
</div>
<div>Non reprise dans l'année en cours</div>
</div>
"""
else:
moy_ue_str = """<span>-</span>"""
scoplement = """<div class="scoplement">
<div>
<b>Pas d'UE en cours ou validée dans cette compétence de ce côté.</b>
</div>
</div>
"""
else:
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)}"""
if dec_ue.validation and dec_ue.validation.event_date
else ""
)
scoplement = f"""<div class="scoplement">
<div>Code {dec_ue.code_valide} enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
à {dec_ue.validation.event_date.strftime("%Hh%M")}
<div>Code {dec_ue.code_valide} {date_str}
</div>
</div>
"""
else:
scoplement = ""
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 = ""
return f"""<div class="but_niveau_ue {
'recorded' if dec_ue.code_valide is not None else ''}
ue_class = "" # 'recorded' if dec_ue.code_valide is not None else ''
if dec_ue.code_valide is not None and dec_ue.codes:
if dec_ue.code_valide == dec_ue.codes[0]:
ue_class = "recorded"
else:
ue_class = "recorded_different"
return f"""<div class="but_niveau_ue {ue_class}
{'annee_prec' if annee_prec else ''}
">
<div title="{ue.titre}">{ue.acronyme}</div>
<div title="{ue.titre or ''}">{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>"""
def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
if dec_rcue is None:
if dec_rcue is None or not dec_rcue.rcue.complete:
return """
<div class="but_niveau_rcue niveau_vide with_scoplement">
<div></div>
@ -299,250 +349,6 @@ 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

262
app/but/rcue.py Normal file
View File

@ -0,0 +1,262 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury BUT: un RCUE, ou Regroupe Cohérent d'UEs
"""
from flask_sqlalchemy.query import Query
from app.comp.res_but import ResultatsSemestreBUT
from app.models import (
ApcNiveau,
ApcValidationRCUE,
Identite,
ScolarFormSemestreValidation,
UniteEns,
)
from app.scodoc import codes_cursus
from app.scodoc.codes_cursus import BUT_CODES_ORDER
class RegroupementCoherentUE:
"""Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs
de la même année (BUT1,2,3) liées au *même niveau de compétence*.
La moyenne (10/20) au RCUE déclenche la compensation des UEs.
"""
def __init__(
self,
etud: Identite,
niveau: ApcNiveau,
res_pair: ResultatsSemestreBUT,
res_impair: ResultatsSemestreBUT,
semestre_id_impair: int,
cur_ues_pair: list[UniteEns],
cur_ues_impair: list[UniteEns],
):
"""
res_pair, res_impair: résultats des formsemestre de l'année en cours, ou None
cur_ues_pair, cur_ues_impair: ues auxquelles l'étudiant est inscrit cette année
"""
self.semestre_id_impair = semestre_id_impair
self.semestre_id_pair = semestre_id_impair + 1
self.etud: Identite = etud
self.niveau: ApcNiveau = niveau
"Le niveau de compétences de ce RCUE"
# Chercher l'UE en cours pour pair, impair
# une UE à laquelle l'étudiant est inscrit (non dispensé)
# dans l'un des formsemestre en cours
ues = [ue for ue in cur_ues_pair if ue.niveau_competence_id == niveau.id]
self.ue_cur_pair = ues[0] if ues else None
"UE paire en cours"
ues = [ue for ue in cur_ues_impair if ue.niveau_competence_id == niveau.id]
self.ue_cur_impair = ues[0] if ues else None
"UE impaire en cours"
self.validation_ue_cur_pair = (
ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id,
formsemestre_id=res_pair.formsemestre.id,
ue_id=self.ue_cur_pair.id,
).first()
if self.ue_cur_pair
else None
)
self.validation_ue_cur_impair = (
ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id,
formsemestre_id=res_impair.formsemestre.id,
ue_id=self.ue_cur_impair.id,
).first()
if self.ue_cur_impair
else None
)
# Autres validations pour les UEs paire/impaire
self.validation_ue_best_pair = best_autre_ue_validation(
etud.id,
niveau.id,
semestre_id_impair + 1,
res_pair.formsemestre.id if (res_pair and self.ue_cur_pair) else None,
)
self.validation_ue_best_impair = best_autre_ue_validation(
etud.id,
niveau.id,
semestre_id_impair,
res_impair.formsemestre.id if (res_impair and self.ue_cur_impair) else None,
)
# Suis-je complet ? (= en cours ou validé sur les deux moitiés)
self.complete = (self.ue_cur_pair or self.validation_ue_best_pair) and (
self.ue_cur_impair or self.validation_ue_best_impair
)
if not self.complete:
self.moy_rcue = None
# Stocke les moyennes d'UE
self.res_impair = None
"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
self.res_pair = None
"résultats formsemestre de l'UE si elle est courante, None sinon"
self.ue_status_pair = None
if self.ue_cur_pair:
ue_status = res_pair.get_etud_ue_status(etud.id, self.ue_cur_pair.id)
self.moy_ue_2 = ue_status["moy"] if ue_status else None # avec capitalisée
self.ue_2 = self.ue_cur_pair
self.res_pair = res_pair
self.ue_status_pair = ue_status
elif self.validation_ue_best_pair:
self.moy_ue_2 = self.validation_ue_best_pair.moy_ue
self.ue_2 = self.validation_ue_best_pair.ue
else:
self.moy_ue_2, self.ue_2 = None, None
self.moy_ue_2_val = self.moy_ue_2 if self.moy_ue_2 is not None else 0.0
# Calcul de la moyenne au RCUE (utilise les moy d'UE capitalisées ou antérieures)
if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
# Moyenne RCUE (les pondérations par défaut sont 1.)
self.moy_rcue = (
self.moy_ue_1 * self.ue_1.coef_rcue
+ self.moy_ue_2 * self.ue_2.coef_rcue
) / (self.ue_1.coef_rcue + self.ue_2.coef_rcue)
else:
self.moy_rcue = None
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} {
self.ue_1.acronyme if self.ue_1 else "?"}({self.moy_ue_1}) {
self.ue_2.acronyme if self.ue_2 else "?"}({self.moy_ue_2})>"""
def __str__(self) -> str:
return f"""RCUE {
self.ue_1.acronyme if self.ue_1 else "?"}({self.moy_ue_1}) + {
self.ue_2.acronyme if self.ue_2 else "?"}({self.moy_ue_2})"""
def query_validations(
self,
) -> Query: # list[ApcValidationRCUE]
"""Les validations de jury enregistrées pour ce RCUE"""
return (
ApcValidationRCUE.query.filter_by(
etudid=self.etud.id,
)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
.join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id)
.filter(ApcNiveau.id == self.niveau.id)
)
def other_ue(self, ue: UniteEns) -> UniteEns:
"""L'autre UE du regroupement. Si ue ne fait pas partie du regroupement, ValueError"""
if ue.id == self.ue_1.id:
return self.ue_2
elif ue.id == self.ue_2.id:
return self.ue_1
raise ValueError(f"ue {ue} hors RCUE {self}")
def est_enregistre(self) -> bool:
"""Vrai si ce RCUE, donc le niveau de compétences correspondant
a une décision jury enregistrée
"""
return self.query_validations().count() > 0
def est_compensable(self):
"""Vrai si ce RCUE est validable (uniquement) par compensation
c'est à dire que sa moyenne est > 10 avec une UE < 10.
Note: si ADM, est_compensable est faux.
"""
return (
(self.moy_rcue is not None)
and (self.moy_rcue > codes_cursus.BUT_BARRE_RCUE)
and (
(self.moy_ue_1_val < codes_cursus.NOTES_BARRE_GEN)
or (self.moy_ue_2_val < codes_cursus.NOTES_BARRE_GEN)
)
)
def est_suffisant(self) -> bool:
"""Vrai si ce RCUE est > 8"""
return (self.moy_rcue is not None) and (
self.moy_rcue > codes_cursus.BUT_RCUE_SUFFISANT
)
def est_validable(self) -> bool:
"""Vrai si ce RCUE satisfait les conditions pour être validé,
c'est à dire que la moyenne des UE qui le constituent soit > 10
"""
return (self.moy_rcue is not None) and (
self.moy_rcue > codes_cursus.BUT_BARRE_RCUE
)
def code_valide(self) -> 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 (
validation.code in codes_cursus.CODES_RCUE_VALIDES
):
return validation
return None
def best_autre_ue_validation(
etudid: int, niveau_id: int, semestre_id: int, formsemestre_id: int
) -> ScolarFormSemestreValidation:
"""La "meilleure" validation validante d'UE pour ce niveau/semestre"""
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etudid)
.join(UniteEns)
.filter_by(semestre_idx=semestre_id)
.join(ApcNiveau)
.filter(ApcNiveau.id == niveau_id)
)
validations = [v for v in validations if codes_cursus.code_ue_validant(v.code)]
# Elimine l'UE en cours si elle existe
if formsemestre_id is not None:
validations = [v for v in validations if v.formsemestre_id != formsemestre_id]
validations = sorted(validations, key=lambda v: BUT_CODES_ORDER.get(v.code, 0))
return validations[-1] if validations else None
# def compute_ues_by_niveau(
# niveaux: list[ApcNiveau],
# ) -> dict[int, tuple[list[UniteEns], list[UniteEns]]]:
# """UEs à valider cette année pour cet étudiant, selon son parcours.
# Considérer les UEs associées aux niveaux et non celles des formsemestres
# en cours. Notez que même si l'étudiant n'est pas inscrit ("dispensé") à une UE
# dans le formsemestre origine, elle doit apparaitre sur la page jury.
# Return: { niveau_id : ( [ues impair], [ues pair]) }
# """
# # Les UEs associées à ce niveau, toutes formations confondues
# return {
# niveau.id: (
# [ue for ue in niveau.ues if ue.semestre_idx % 2],
# [ue for ue in niveau.ues if not (ue.semestre_idx % 2)],
# )
# for niveau in niveaux
# }

121
app/but/validations_view.py Normal file
View File

@ -0,0 +1,121 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury édition manuelle des décisions (correction d'erreurs, parcours hors normes)
Non spécifique au BUT.
"""
from flask import render_template
import sqlalchemy as sa
from app import log
from app.but import cursus_but
from app.models import (
ApcCompetence,
ApcNiveau,
ApcReferentielCompetences,
# ApcValidationAnnee, # TODO
ApcValidationRCUE,
Formation,
FormSemestre,
Identite,
UniteEns,
# ScolarAutorisationInscription,
ScolarFormSemestreValidation,
)
from app.scodoc import codes_cursus
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
from app.views import ScoData
def validation_rcues(etud: Identite, formsemestre: FormSemestre, edit: bool = False):
"""Page de saisie des décisions de RCUEs "antérieures"
On peut l'utiliser pour saisir la validation de n'importe quel RCUE
d'une année antérieure et de la formation du formsemestre indiqué.
"""
formation: Formation = formsemestre.formation
refcomp = formation.referentiel_competence
if refcomp is None:
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
)
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()))
return render_template(
"but/validation_rcues.j2",
competences_parcour=competences_parcour,
edit=edit,
ects_acquis=ects_acquis,
ects_parcours=ects_parcours,
formation=formation,
parcour=parcour,
rcue_validation_by_niveau=rcue_validation_by_niveau,
rcue_codes=sorted(codes_cursus.CODES_JURY_RCUE),
sco=ScoData(formsemestre=formsemestre, etud=etud),
title=f"{formation.acronyme} - Niveaux et UEs",
ue_validation_by_niveau=ue_validation_by_niveau,
)
def get_ue_validation_by_niveau(
refcomp: ApcReferentielCompetences, etud: Identite
) -> dict[tuple[int, str], ScolarFormSemestreValidation]:
"""Les validations d'UEs de cet étudiant liées à ce référentiel de compétences.
Pour chaque niveau / pair ou impair, choisi la "meilleure" validation
"""
validations: list[ScolarFormSemestreValidation] = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.join(UniteEns)
.join(ApcNiveau)
.join(ApcCompetence)
.filter_by(referentiel_id=refcomp.id)
.all()
)
# La meilleure validation pour chaque UE
ue_validation_by_niveau = {} # { (niveau_id, pair|impair) : validation }
for validation in validations:
if validation.ue.niveau_competence is None:
log(
f"""validation_rcues: ignore validation d'UE {
validation.ue.id} pas de niveau de competence"""
)
key = (
validation.ue.niveau_competence.id,
"impair" if validation.ue.semestre_idx % 2 else "pair",
)
existing = ue_validation_by_niveau.get(key, None)
if (not existing) or (
codes_cursus.BUT_CODES_ORDER[existing.code]
< codes_cursus.BUT_CODES_ORDER[validation.code]
):
ue_validation_by_niveau[key] = validation
return ue_validation_by_niveau
def get_rcue_validation_by_niveau(
refcomp: ApcReferentielCompetences, etud: Identite
) -> dict[int, ApcValidationRCUE]:
"""Les validations d'UEs de cet étudiant liées à ce référentiel de compétences.
Pour chaque niveau / pair ou impair, choisi la "meilleure" validation
"""
validations: list[ApcValidationRCUE] = (
ApcValidationRCUE.query.filter_by(etudid=etud.id)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
.join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id)
.join(ApcCompetence)
.filter_by(referentiel_id=refcomp.id)
.all()
)
return {
validation.ue2.niveau_competence.id: validation for validation in validations
}

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -30,7 +30,9 @@ 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -194,7 +194,7 @@ class BonusSportAdditif(BonusSport):
# les points au dessus du seuil sont comptés (defaut: seuil_moy_gen):
seuil_comptage = None
proportion_point = 0.05 # multiplie les points au dessus du seuil
bonux_max = 20.0 # le bonus ne peut dépasser 20 points
bonus_max = 20.0 # le bonus ne peut dépasser 20 points
bonus_min = 0.0 # et ne peut pas être négatif
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
@ -435,8 +435,11 @@ class BonusAmiens(BonusSportAdditif):
class BonusBesanconVesoul(BonusSportAdditif):
"""Bonus IUT Besançon - Vesoul pour les UE libres
<p>Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,2 point
sur toutes les moyennes d'UE.
<p>Le bonus est compris entre 0 et 0,2 points.
et est reporté sur les moyennes d'UE.
</p>
<p>La valeur saisie doit être entre 0 et 0,2: toute valeur
supérieure à 0,2 entraine un bonus de 0,2.
</p>
"""
@ -444,7 +447,7 @@ class BonusBesanconVesoul(BonusSportAdditif):
displayed_name = "IUT de Besançon - Vesoul"
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 0.0 # tous les points sont comptés
proportion_point = 1e10 # infini
proportion_point = 1
bonus_max = 0.2
@ -664,10 +667,12 @@ 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 DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant.
<li><b>en BUT</b> à la moyenne de chaque UE;
</li>
<li><b>en BUT et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b>
(ex : UE2.1BS, UE32BS)
<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>
</ul>
"""
@ -689,12 +694,17 @@ 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)
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
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
class BonusColmar(BonusSportAdditif):
@ -824,16 +834,32 @@ class BonusStMalo(BonusIUTRennes1):
class BonusLaRocheSurYon(BonusSportAdditif):
"""Bonus IUT de La Roche-sur-Yon
Si une note de bonus est saisie, l'étudiant est gratifié de 0,2 points
sur sa moyenne générale ou, en BUT, sur la moyenne de chaque UE.
<p>
<b>La note saisie s'applique directement</b>: si on saisit 0,2, un bonus de 0,2 points est appliqué
aux moyennes.
La valeur maximale du bonus est 1 point. Il est appliqué sur les moyennes d'UEs en BUT,
ou sur la moyenne générale dans les autres formations.
</p>
<p>Pour les <b>semestres antérieurs à janvier 2023</b>: si une note de bonus est saisie,
l'étudiant est gratifié de 0,2 points sur sa moyenne générale ou, en BUT, sur la
moyenne de chaque UE.
</p>
"""
name = "bonus_larochesuryon"
displayed_name = "IUT de La Roche-sur-Yon"
seuil_moy_gen = 0.0
seuil_comptage = 0.0
proportion_point = 1e10 # le moindre point sature le bonus
bonus_max = 0.2 # à 0.2
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus, avec réglage différent suivant la date"""
if self.formsemestre.date_debut > datetime.date(2022, 12, 31):
self.proportion_point = 1.0
self.bonus_max = 1
else: # ancienne règle
self.proportion_point = 1e10 # le moindre point sature le bonus
self.bonus_max = 0.2 # à 0.2
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
class BonusLaRochelle(BonusSportAdditif):
@ -1057,6 +1083,36 @@ class BonusLyon(BonusSportAdditif):
)
class BonusLyon3(BonusSportAdditif):
"""IUT de Lyon 3 (septembre 2022)
<p>Nous avons deux types de bonifications : sport et/ou culture
</p>
<p>
Pour chaque point au-dessus de 10 obtenu en sport ou en culture nous
ajoutons 0,03 points à toutes les moyennes dUE du semestre. Exemple : 16 en
sport ajoute 6*0,03 = 0,18 points à toutes les moyennes dUE du semestre.
</p>
<p>
Les bonifications sport et culture peuvent se cumuler dans la limite de 0,3
points ajoutés aux moyennes des UE. Exemple : 17 en sport et 16 en culture
conduisent au calcul (7 + 6)*0,03 = 0,39 qui dépasse 0,3. La bonification
dans ce cas ne sera que de 0,3 points ajoutés à toutes les moyennes dUE du
semestre.
</p>
<p>
Dans Scodoc on déclarera une UE Sport&Culture dans laquelle on aura un
module pour le Sport et un autre pour la Culture avec pour chaque module la
note sur 20 obtenue en sport ou en culture par létudiant.
</p>
"""
name = "bonus_lyon3"
displayed_name = "IUT de Lyon 3"
proportion_point = 0.03
bonus_max = 0.3
class BonusMantes(BonusSportAdditif):
"""Calcul bonus modules optionnels (investissement, ...), IUT de Mantes en Yvelines.

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# 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
@ -27,6 +27,7 @@
"""caches pour tables APC
"""
from flask import g
from app.scodoc import sco_cache
@ -47,3 +48,27 @@ 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -23,6 +23,7 @@ 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):
@ -38,7 +39,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:
@ -84,7 +85,7 @@ class ValidationsSemestre(ResultatsCache):
"code": decision.code,
"assidu": decision.assidu,
"compense_formsemestre_id": decision.compense_formsemestre_id,
"event_date": decision.event_date.strftime("%d/%m/%Y"),
"event_date": decision.event_date.strftime(scu.DATE_FMT),
}
self.decisions_jury = decisions_jury
@ -107,7 +108,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("%d/%m/%Y"),
"event_date": decision.event_date.strftime(scu.DATE_FMT),
}
self.decisions_jury_ues = decisions_jury_ues
@ -145,11 +146,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
@ -231,12 +232,11 @@ def erase_decisions_annee_formation(
.all()
)
# Année BUT
validations += (
ApcValidationAnnee.query.filter_by(etudid=etud.id, ordre=annee)
.join(Formation)
.filter_by(formation_code=formation.formation_code)
.all()
)
validations += ApcValidationAnnee.query.filter_by(
etudid=etud.id,
ordre=annee,
referentiel_competence_id=formation.referentiel_competence_id,
).all()
# Autorisations vers les semestres suivants ceux de l'année:
validations += (
ScolarAutorisationInscription.query.filter_by(

View File

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

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# 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
@ -35,7 +35,6 @@ 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
@ -46,7 +45,6 @@ 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
@ -56,6 +54,7 @@ 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):
@ -72,7 +71,15 @@ class ModuleImplResults:
les caches sont gérés par ResultatsSemestre.
"""
def __init__(self, moduleimpl: ModuleImpl):
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.
"""
self.moduleimpl_id = moduleimpl.id
self.module_id = moduleimpl.module.id
self.etudids = None
@ -105,14 +112,23 @@ class ModuleImplResults:
"""
self.evals_etudids_sans_note = {}
"""dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
self.load_notes()
self.evals_type = {}
"""Type de chaque eval { evaluation.id : evaluation.evaluation_type }"""
self.load_notes(etudids, etudids_actifs)
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module vient de la session2"""
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): # ré-écriture de df_load_modimpl_notes
def load_notes(
self, etudids: list[int], etudids_actifs: set[int]
): # ré-écriture de df_load_modimpl_notes
"""Charge toutes les notes de toutes les évaluations du module.
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)
@ -134,13 +150,13 @@ class ModuleImplResults:
manque des notes) ssi il y a des étudiants inscrits au semestre et au module
qui ont des notes ATT.
"""
moduleimpl = ModuleImpl.query.get(self.moduleimpl_id)
self.etudids = self._etudids()
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
self.etudids = 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(
moduleimpl.formsemestre.etudids_actifs
etudids_actifs
)
self.nb_inscrits_module = len(inscrits_module)
@ -148,19 +164,24 @@ 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 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
# 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()
etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem.
is_complete = (
(evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE)
or (evaluation.evaluation_type == scu.EVALUATION_SESSION2)
(evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE)
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
@ -168,25 +189,39 @@ 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 les notes non présentes
# et met à NULL (NaN) 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)]
eval_etudids_attente = set(
eval_notes_inscr.iloc[
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
].index
)
# 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:
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 ATT dans ce modimpl:
# au moins une note en attente (ATT ou manquante en mode "immédiat") dans ce modimpl:
self.en_attente = bool(self.etudids_attente)
# Force columns names to integers (evaluation ids)
@ -219,38 +254,46 @@ class ModuleImplResults:
eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)])
return eval_df
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 ModuleImpl.query.get(
self.moduleimpl_id
).formsemestre.inscriptions
]
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
def get_evaluations_coefs(self, modimpl: ModuleImpl) -> np.array:
"""Coefficients des évaluations.
Les coefs des évals incomplètes et non "normales" (session 2, rattrapage)
sont zéro.
Les coefs des évals incomplètes, rattrapage, session 2, bonus sont forcés à zéro.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[
e.coefficient
if e.evaluation_type == scu.EVALUATION_NORMALE
else 0.0
for e in moduleimpl.evaluations
(
e.coefficient
if e.evaluation_type == Evaluation.EVALUATION_NORMALE
else 0.0
)
for e in modimpl.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:
def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"Liste des évaluations complètes"
return [
e for e in moduleimpl.evaluations if self.evaluations_completes_dict[e.id]
@ -266,7 +309,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 évaulation, brutes, sous forme d'un dict
"""Notes d'une évaluation, brutes, sous forme d'un dict
{ etudid : valeur }
avec les valeurs float, ou "ABS" ou EXC
"""
@ -275,32 +318,42 @@ class ModuleImplResults:
for (etudid, x) in self.evals_notes[evaluation_id].items()
}
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl):
"""L'évaluation de rattrapage de ce module, ou None s'il n'en a pas.
def get_evaluations_rattrapage(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations de rattrapage de ce module.
Rattrapage: la moyenne du module est la meilleure note entre moyenne
des autres évals et la note eval rattrapage.
des autres évals et la moyenne des notes de rattrapage.
"""
eval_list = [
return [
e
for e in moduleimpl.evaluations
if e.evaluation_type == scu.EVALUATION_RATTRAPAGE
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE
]
if eval_list:
return eval_list[0]
return None
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.
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.
"""
eval_list = [
return [
e
for e in moduleimpl.evaluations
if e.evaluation_type == scu.EVALUATION_SESSION2
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 eval_list:
return eval_list[0]
return None
class ModuleImplResultsAPC(ModuleImplResults):
@ -319,7 +372,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
ne donnent pas de coef vers cette UE.
"""
modimpl = ModuleImpl.query.get(self.moduleimpl_id)
modimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
nb_etuds, nb_evals = self.evals_notes.shape
nb_ues = evals_poids_df.shape[1]
if evals_poids_df.shape[0] != nb_evals:
@ -333,6 +386,7 @@ 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)
@ -346,7 +400,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)
poids_stacked = np.stack([evals_poids] * nb_etuds) # nb_etuds, nb_evals, nb_ues
evals_poids_etuds = np.where(
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
poids_stacked,
@ -354,51 +408,58 @@ 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
# 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
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 ont bien une note de session 2 calculée:
etuds_use_session2 = np.all(np.isfinite(etuds_moy_module_s2), axis=1)
etuds_moy_module = np.where(
etuds_use_session2[:, np.newaxis],
np.tile(
(notes_session2 / (eval_session2.note_max / 20.0))[:, np.newaxis],
nb_ues,
),
etuds_moy_module_s2,
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
else:
# Rattrapage: remplace la note de module ssi elle est supérieure
eval_rat = self.get_evaluation_rattrapage(modimpl)
if eval_rat:
notes_rat = self.evals_notes[eval_rat.id].values
# remplace les notes invalides (ATT, EXC...) par des NaN
notes_rat = np.where(
notes_rat > scu.NOTES_ABSENCE,
notes_rat / (eval_rat.note_max / 20.0),
np.nan,
)
# "Étend" le rattrapage sur les UE: la note de rattrapage est la même
# pour toutes les UE mais ne remplace que là où elle est supérieure
notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1)
# prend le max
etuds_use_rattrapage = notes_rat_ues > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
)
# Serie indiquant que l'étudiant utilise une note de rattrapage sur l'une des UE:
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
)
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,
)
self.etuds_moy_module = pd.DataFrame(
etuds_moy_module,
index=self.evals_notes.index,
@ -406,6 +467,58 @@ 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
@ -419,11 +532,10 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
Résultat: (evals_poids, liste de UEs du semestre sauf le sport)
"""
modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all()
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
ues = modimpl.formsemestre.get_ues(with_sport=False)
ue_ids = [ue.id for ue in ues]
evaluation_ids = [evaluation.id for evaluation in evaluations]
evaluation_ids = [evaluation.id for evaluation in modimpl.evaluations]
evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float)
if (
modimpl.module.module_type == ModuleType.RESSOURCE
@ -434,7 +546,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 as exc:
except KeyError:
pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre...
# Initialise poids non enregistrés:
@ -455,6 +567,7 @@ 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:
@ -476,12 +589,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()
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent")
return app.critical_error("moduleimpl_is_conforme: err 1")
if moduleimpl.id not in modimpl_coefs_df:
# soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
return app.critical_error("moduleimpl_is_conforme: err 2")
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
@ -498,7 +611,7 @@ class ModuleImplResultsClassic(ModuleImplResults):
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
ne donnent pas de coef.
"""
modimpl = ModuleImpl.query.get(self.moduleimpl_id)
modimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
nb_etuds, nb_evals = self.evals_notes.shape
if nb_etuds == 0:
return pd.Series()
@ -523,42 +636,87 @@ class ModuleImplResultsClassic(ModuleImplResults):
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, 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
evals_session2 = self.get_evaluations_session2(modimpl)
evals_rat = self.get_evaluations_rattrapage(modimpl)
if evals_session2:
# Session2 : quand elle existe, remplace la note de module
# Calcule la moyenne des évaluations de session2
etuds_moy_module_s2 = self._compute_moy_special(
modimpl, evals_notes_20, Evaluation.EVALUATION_SESSION2
)
etuds_use_session2 = np.isfinite(etuds_moy_module_s2)
etuds_moy_module = np.where(
etuds_use_session2,
notes_session2 / (eval_session2.note_max / 20.0),
etuds_moy_module_s2,
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
else:
elif evals_rat:
# Rattrapage: remplace la note de module ssi elle est supérieure
eval_rat = self.get_evaluation_rattrapage(modimpl)
if eval_rat:
notes_rat = self.evals_notes[eval_rat.id].values
# remplace les notes invalides (ATT, EXC...) par des NaN
notes_rat = np.where(
notes_rat > scu.NOTES_ABSENCE,
notes_rat / (eval_rat.note_max / 20.0),
np.nan,
)
# prend le max
etuds_use_rattrapage = notes_rat > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage, index=self.evals_notes.index
)
# 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,
)
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 - 2023 Emmanuel Viennet. All rights reserved.
# 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
@ -30,7 +30,10 @@
import numpy as np
import pandas as pd
from flask import flash, g, Markup, url_for
from flask import flash, g, url_for
from markupsafe import Markup
from app import db
from app.models.formations import Formation
@ -75,14 +78,18 @@ def compute_sem_moys_apc_using_ects(
else:
ects = ects_df.to_numpy()
# ects est maintenant un array nb_etuds x nb_ues
moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1)
except ZeroDivisionError:
# peut arriver si aucun module... on ignore
moy_gen = pd.Series(np.NaN, index=etud_moy_ue_df.index)
except TypeError:
if None in ects:
formation = Formation.query.get(formation_id)
formation = db.session.get(Formation, formation_id)
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>)"""
)
)
@ -92,8 +99,8 @@ def compute_sem_moys_apc_using_ects(
return moy_gen
def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series):
"""Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur
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
numérique) en tenant compte des ex-aequos.
Result: couple (tuple)

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# 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
@ -99,9 +99,11 @@ 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
}
@ -148,10 +150,12 @@ 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
}
@ -200,9 +204,10 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
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)
evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
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)
modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids
@ -344,8 +349,12 @@ 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),
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, dtype=float
),
pd.DataFrame(
columns=[ue.id for ue in ues], index=modimpl_inscr_df.index, dtype=float
),
)
# Restreint aux modules sélectionnés:
sem_matrix = sem_matrix[:, modimpl_mask]
@ -394,9 +403,13 @@ def compute_ue_moys_classic(
if sco_preferences.get_preference("use_ue_coefs", formsemestre.id):
# Cas avec coefficients d'UE forcés: (on met à zéro l'UE bonus)
etud_coef_ue_df = pd.DataFrame(
{ue.id: ue.coefficient if ue.type != UE_SPORT else 0.0 for ue in ues},
{
ue.id: (ue.coefficient or 0.0) if ue.type != UE_SPORT else 0.0
for ue in ues
},
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)
@ -412,6 +425,7 @@ 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -14,7 +14,7 @@ from app import db, log
from app.comp import moy_ue, moy_sem, inscr_mod
from app.comp.res_compat import NotesTableCompat
from app.comp.bonus_spo import BonusSport
from app.models import Formation, FormSemestreInscription, ScoDocSiteConfig
from app.models import FormSemestreInscription, ScoDocSiteConfig
from app.models.moduleimpls import ModuleImpl
from app.models.but_refcomp import ApcParcours, ApcNiveau
from app.models.ues import DispenseUE, UniteEns
@ -53,8 +53,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
self.store()
t2 = time.time()
log(
f"""ResultatsSemestreBUT: cached formsemestre_id={formsemestre.id
} ({(t1-t0):g}s +{(t2-t1):g}s)"""
f"""+++ ResultatsSemestreBUT: cached [{formsemestre.id
}] ({(t1-t0):g}s +{(t2-t1):g}s) +++"""
)
def compute(self):
@ -273,7 +273,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
return s.index[s.notna()]
def etud_parcours_ues_ids(self, etudid: int) -> set[int]:
"""Ensemble les id des UEs que l'étudiant doit valider dans ce semestre compte tenu
"""Ensemble des 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.
@ -291,7 +291,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
if parcour_id is None:
ues_ids = {ue.id for ue in self.ues if ue.type != UE_SPORT}
else:
parcour: ApcParcours = ApcParcours.query.get(parcour_id)
parcour: ApcParcours = db.session.get(ApcParcours, parcour_id)
annee = (self.formsemestre.semestre_id + 1) // 2
niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp)
# Les UEs du formsemestre associées à ces niveaux:
@ -307,7 +307,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
return ues_ids
def etud_has_decision(self, etudid) -> bool:
def etud_has_decision(self, etudid, include_rcues=True) -> 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.
@ -318,9 +318,12 @@ class ResultatsSemestreBUT(NotesTableCompat):
or ApcValidationAnnee.query.filter_by(
formsemestre_id=self.formsemestre.id, etudid=etudid
).count()
or ApcValidationRCUE.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()
)
)
def get_validations_annee(self) -> dict[int, ApcValidationAnnee]:
@ -337,17 +340,15 @@ class ResultatsSemestreBUT(NotesTableCompat):
if self.validations_annee:
return self.validations_annee
annee_but = (self.formsemestre.semestre_id + 1) // 2
validations = (
ApcValidationAnnee.query.filter_by(ordre=annee_but)
.join(Formation)
.filter_by(formation_code=self.formsemestre.formation.formation_code)
.join(
FormSemestreInscription,
db.and_(
FormSemestreInscription.etudid == ApcValidationAnnee.etudid,
FormSemestreInscription.formsemestre_id == self.formsemestre.id,
),
)
validations = ApcValidationAnnee.query.filter_by(
ordre=annee_but,
referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id,
).join(
FormSemestreInscription,
db.and_(
FormSemestreInscription.etudid == ApcValidationAnnee.etudid,
FormSemestreInscription.formsemestre_id == self.formsemestre.id,
),
)
validation_by_etud = {}
for validation in validations:

View File

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

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -50,8 +50,8 @@ class ResultatsSemestreClassic(NotesTableCompat):
self.store()
t2 = time.time()
log(
f"""ResultatsSemestreClassic: cached formsemestre_id={
formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s)"""
f"""+++ ResultatsSemestreClassic: cached formsemestre_id={
formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s) +++"""
)
# recalculé (aussi rapide que de les cacher)
self.moy_min = self.etud_moy_gen.min()
@ -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.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
url_for("scolar.fiche_etud", 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,7 +242,8 @@ class ResultatsSemestreClassic(NotesTableCompat):
)
}">saisir le coefficient de cette UE avant de continuer</a></p>
</div>
"""
""",
safe=True,
)
@ -256,8 +257,9 @@ 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)
mod_results = moy_mod.ModuleImplResultsClassic(modimpl, etudids, etudids_actifs)
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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -9,28 +9,36 @@
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
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 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.models import (
Evaluation,
FormSemestre,
FormSemestreUECoef,
Identite,
ModuleImpl,
ModuleImplInscription,
ScolarAutorisationInscription,
UniteEns,
)
from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_exceptions import ScoValueError, ScoTemporaryError
from app.scodoc import sco_utils as scu
# Il faut bien distinguer
# - ce qui est caché de façon persistente (via redis):
# ce sont les attributs listés dans `_cached_attrs`
@ -78,8 +86,8 @@ class ResultatsSemestre(ResultatsCache):
self.moy_gen_rangs_by_group = None # virtual
self.modimpl_inscr_df: pd.DataFrame = None
"Inscriptions: row etudid, col modimlpl_id"
self.modimpls_results: ModuleImplResults = None
"Résultats de chaque modimpl: dict { modimpl.id : ModuleImplResults(Classique ou BUT) }"
self.modimpls_results: dict[int, ModuleImplResults] = None
"Résultats de chaque modimpl (classique ou BUT)"
self.etud_coef_ue_df = None
"""coefs d'UE effectifs pour chaque étudiant (pour form. classiques)"""
self.modimpl_coefs_df: pd.DataFrame = None
@ -137,7 +145,7 @@ class ResultatsSemestre(ResultatsCache):
def etud_ues(self, etudid: int) -> Generator[UniteEns]:
"""Liste des UE auxquelles l'étudiant est inscrit
(sans bonus, en BUT prend en compte le parcours de l'étudiant)."""
return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid))
return (db.session.get(UniteEns, ue_id) for ue_id in self.etud_ues_ids(etudid))
def etud_ects_tot_sem(self, etudid: int) -> float:
"""Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)"""
@ -190,6 +198,87 @@ 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"]
#
# --- JURY...
def get_formsemestre_validations(self) -> ValidationsSemestre:
"""Load validations if not already stored, set attribute and return value"""
@ -347,11 +436,28 @@ 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:
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict | None:
"""L'état de l'UE pour cet étudiant.
Result: dict, ou None si l'UE n'est pas dans ce semestre.
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
}
"""
ue: UniteEns = UniteEns.query.get(ue_id)
ue: UniteEns = db.session.get(UniteEns, ue_id)
if not ue:
return None
ue_dict = ue.to_dict()
if ue.type == UE_SPORT:
@ -370,7 +476,7 @@ class ResultatsSemestre(ResultatsCache):
"ects": 0.0,
"ects_ue": ue.ects,
}
if not ue_id in self.etud_moy_ue:
if not ue_id in self.etud_moy_ue or not etudid in self.etud_moy_ue[ue_id]:
return None
if not self.validations:
self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
@ -381,7 +487,11 @@ class ResultatsSemestre(ResultatsCache):
was_capitalized = False
if etudid in self.validations.ue_capitalisees.index:
ue_cap = self._get_etud_ue_cap(etudid, ue)
if ue_cap and not np.isnan(ue_cap["moy_ue"]):
if (
ue_cap
and (ue_cap["moy_ue"] is not None)
and not np.isnan(ue_cap["moy_ue"])
):
was_capitalized = True
if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue):
moy_ue = ue_cap["moy_ue"]
@ -397,7 +507,7 @@ class ResultatsSemestre(ResultatsCache):
if (not coef_ue) and is_capitalized: # étudiant non inscrit dans l'UE courante
if self.is_apc:
# Coefs de l'UE capitalisée en formation APC: donné par ses ECTS
ue_capitalized = UniteEns.query.get(ue_cap["ue_id"])
ue_capitalized = db.session.get(UniteEns, ue_cap["ue_id"])
coef_ue = ue_capitalized.ects
if coef_ue is None:
orig_sem = FormSemestre.get_formsemestre(ue_cap["formsemestre_id"])
@ -408,7 +518,8 @@ 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:
@ -423,11 +534,13 @@ 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -9,13 +9,20 @@
from functools import cached_property
import pandas as pd
from flask import flash, g, Markup, url_for
from flask import flash, g, url_for
from markupsafe import Markup
from app import log
from app import db, log
from app.comp import moy_sem
from app.comp.aux_stats import StatsMoyenne
from app.comp.res_common import ResultatsSemestre
from app.models import Identite, FormSemestre, ModuleImpl, ScolarAutorisationInscription
from app.models import (
Evaluation,
Identite,
FormSemestre,
ModuleImpl,
ScolarAutorisationInscription,
)
from app.scodoc.codes_cursus import UE_SPORT, DEF
from app.scodoc import sco_utils as scu
@ -51,7 +58,6 @@ 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
@ -210,9 +216,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.
@ -283,9 +289,10 @@ class NotesTableCompat(ResultatsSemestre):
]
return etudids
def etud_has_decision(self, etudid) -> bool:
def etud_has_decision(self, etudid, include_rcues=True) -> 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(
@ -297,9 +304,16 @@ class NotesTableCompat(ResultatsSemestre):
)
def get_etud_decisions_ue(self, etudid: int) -> dict:
"""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 }
"""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.
}
...
}
Ne renvoie aucune decision d'UE pour les défaillants
"""
if self.get_etud_etat(etudid) == DEF:
@ -388,60 +402,57 @@ class NotesTableCompat(ResultatsSemestre):
"ects_total": ects_total,
}
def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]:
def get_modimpl_evaluations_completes(self, moduleimpl_id: int) -> list[Evaluation]:
"""Liste d'informations (compat NotesTable) sur évaluations completes
de ce module.
Évaluation "complete" ssi toutes notes saisies ou en attente.
"""
modimpl = ModuleImpl.query.get(moduleimpl_id)
modimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id)
modimpl_results = self.modimpls_results.get(moduleimpl_id)
if not modimpl_results:
return [] # safeguard
evals_results = []
evaluations = []
for e in modimpl.evaluations:
if modimpl_results.evaluations_completes_dict.get(e.id, False):
d = e.to_dict()
d["heure_debut"] = e.heure_debut # datetime.time
d["heure_fin"] = e.heure_fin
d["jour"] = e.jour # datetime
d["notes"] = {
etud.id: {
"etudid": etud.id,
"value": modimpl_results.evals_notes[e.id][etud.id],
}
for etud in self.etuds
}
d["etat"] = {
"evalattente": modimpl_results.evaluations_etat[e.id].nb_attente,
}
evals_results.append(d)
evaluations.append(e)
elif e.id not in modimpl_results.evaluations_completes_dict:
# ne devrait pas arriver ? XXX
log(
f"Warning: 220213 get_evals_in_mod {e.id} not in mod {moduleimpl_id} ?"
f"Warning: 220213 get_modimpl_evaluations_completes {e.id} not in mod {moduleimpl_id} ?"
)
return evals_results
return evaluations
def get_evaluations_etats(self):
"""[ {...evaluation et son etat...} ]"""
# TODO: à moderniser
from app.scodoc import sco_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
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
]
# 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
# ]
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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 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 = {} # pylint: disable=C0237
g.formsemestre_validations_cache = {}
else:
if formsemestre.id in g.formsemestre_validations_cache:
return g.formsemestre_validations_cache[formsemestre.id]

View File

@ -44,6 +44,7 @@ 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)
@ -186,7 +187,10 @@ 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:
v = req_args[arg_name]
try:
v = req_args[arg_name]
except KeyError as exc:
raise ScoValueError(f"argument {arg_name} manquant") from exc
# try to convert all arguments to INTEGERS
# necessary for db ids and boolean values
try:

View File

@ -1,16 +1,17 @@
# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
import datetime
from threading import Thread
from flask import current_app, g
from flask_mail import Message
from flask_mail import BadHeaderError, Message
from app import mail
from app import log, mail
from app.models.departements import Departement
from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_preferences
@ -19,7 +20,15 @@ from app.scodoc import sco_preferences
def send_async_email(app, msg):
"Send an email, async"
with app.app_context():
mail.send(msg)
try:
mail.send(msg)
except BadHeaderError:
log(
f"""send_async_email: BadHeaderError
msg={msg}
"""
)
raise
def send_email(
@ -79,13 +88,18 @@ Adresses d'origine:
to : {orig_to}
cc : {orig_cc}
bcc: {orig_bcc}
---
---
\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"""email sent to{' (mode test)' if email_test_mode_address else ''}: {msg.recipients}
f"""[{formatted_time}] email sent to{
' (mode test)' if email_test_mode_address else ''
}: {msg.recipients}
from sender {msg.sender}
"""
)
@ -98,7 +112,8 @@ def get_from_addr(dept_acronym: str = None):
"""L'adresse "from" à utiliser pour envoyer un mail
Si le departement est spécifié, ou si l'attribut `g.scodoc_dept`existe,
prend le `email_from_addr` des préférences de ce département si ce champ est non vide.
prend le `email_from_addr` des préférences de ce département si ce champ
est non vide.
Sinon, utilise le paramètre global `email_from_addr`.
Sinon, la variable de config `SCODOC_MAIL_FROM`.
"""

View File

@ -6,6 +6,7 @@ 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__)
@ -15,12 +16,12 @@ SIRET_PROVISOIRE_START = "xx"
@bp.app_template_filter()
def format_prenom(s):
return sco_etud.format_prenom(s)
return scu.format_prenom(s)
@bp.app_template_filter()
def format_nom(s):
return sco_etud.format_nom(s)
return scu.format_nom(s)
@bp.app_template_filter()
@ -58,3 +59,4 @@ def check_taxe_now(taxes):
from app.entreprises import routes
from app.entreprises.activate import activate_module

View File

@ -0,0 +1,31 @@
##############################################################################
# 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 - 2023 Emmanuel Viennet. All rights reserved.
# 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
@ -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.RelationsEntreprisesChange, None):
if current_user.has_permission(Permission.RelationsEntrepEdit, 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 - 2023 Emmanuel Viennet. All rights reserved.
# 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
@ -36,7 +36,6 @@ from sqlalchemy import text
from wtforms import (
BooleanField,
DateField,
DecimalField,
FieldList,
FormField,
HiddenField,
@ -56,6 +55,9 @@ from wtforms.validators import (
)
from wtforms.widgets import ListWidget, CheckboxInput
from app import db
from app.auth.models import User
from app.entreprises import SIRET_PROVISOIRE_START
from app.entreprises.models import (
Entreprise,
EntrepriseCorrespondant,
@ -63,9 +65,6 @@ from app.entreprises.models import (
EntrepriseSite,
EntrepriseTaxeApprentissage,
)
from app import db
from app.auth.models import User
from app.entreprises import SIRET_PROVISOIRE_START
from app.models import Identite, Departement
from app.scodoc import sco_utils as scu
@ -651,7 +650,7 @@ class StageApprentissageCreationForm(FlaskForm):
def validate_etudid(self, field):
"L'etudid doit avoit été placé par le JS"
etudid = int(field.data) if field.data else None
etudiant = Identite.query.get(etudid) if etudid is not None else None
etudiant = db.session.get(Identite, etudid) if etudid is not None else None
if etudiant is None:
raise ValidationError("Étudiant introuvable (sélectionnez dans la liste)")

View File

@ -62,7 +62,7 @@ from config import Config
@bp.route("/", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntreprisesView)
@permission_required(Permission.RelationsEntrepView)
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.RelationsEntreprisesView)
@permission_required(Permission.RelationsEntrepView)
def logs():
"""
Permet d'afficher les logs (toutes les entreprises)
@ -115,7 +115,7 @@ def logs():
@bp.route("/correspondants", methods=["GET"])
@permission_required(Permission.RelationsEntreprisesCorrespondants)
@permission_required(Permission.RelationsEntrepViewCorrs)
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.RelationsEntreprisesValidate)
@permission_required(Permission.RelationsEntrepValidate)
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.RelationsEntreprisesValidate)
@permission_required(Permission.RelationsEntrepValidate)
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.RelationsEntreprisesValidate)
@permission_required(Permission.RelationsEntrepValidate)
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.RelationsEntreprisesValidate)
@permission_required(Permission.RelationsEntrepValidate)
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(
"entreprises/form_confirmation.j2",
"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.RelationsEntreprisesView)
@permission_required(Permission.RelationsEntrepView)
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.RelationsEntreprisesView)
@permission_required(Permission.RelationsEntrepView)
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.RelationsEntreprisesValidate)
@permission_required(Permission.RelationsEntrepValidate)
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.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def add_entreprise():
"""
Permet d'ajouter une entreprise dans la base avec un formulaire
@ -338,9 +338,11 @@ 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(),
@ -352,7 +354,7 @@ def add_entreprise():
db.session.add(entreprise)
db.session.commit()
db.session.refresh(entreprise)
except:
except Exception:
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
@ -385,7 +387,7 @@ def add_entreprise():
notes=form.notes.data.strip(),
)
db.session.add(correspondant)
if current_user.has_permission(Permission.RelationsEntreprisesValidate, None):
if current_user.has_permission(Permission.RelationsEntrepValidate, None):
entreprise.visible = True
lien_entreprise = f"<a href='{url_for('entreprises.fiche_entreprise', entreprise_id=entreprise.id)}'>{entreprise.nom}</a>"
log = EntrepriseHistorique(
@ -414,7 +416,7 @@ def add_entreprise():
@bp.route("/fiche_entreprise/<int:entreprise_id>", methods=["GET"])
@permission_required(Permission.RelationsEntreprisesView)
@permission_required(Permission.RelationsEntrepView)
def fiche_entreprise(entreprise_id):
"""
Permet d'afficher la fiche entreprise d'une entreprise avec une liste des dernières opérations et
@ -456,7 +458,7 @@ def fiche_entreprise(entreprise_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/logs", methods=["GET"])
@permission_required(Permission.RelationsEntreprisesView)
@permission_required(Permission.RelationsEntrepView)
def logs_entreprise(entreprise_id):
"""
Permet d'afficher les logs d'une entreprise
@ -479,7 +481,7 @@ def logs_entreprise(entreprise_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/offres_expirees")
@permission_required(Permission.RelationsEntreprisesView)
@permission_required(Permission.RelationsEntrepView)
def offres_expirees(entreprise_id):
"""
Permet d'afficher la liste des offres expirés d'une entreprise
@ -499,7 +501,7 @@ def offres_expirees(entreprise_id):
@bp.route(
"/fiche_entreprise/<int:entreprise_id>/edit_entreprise", methods=["GET", "POST"]
)
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def edit_entreprise(entreprise_id):
"""
Permet de modifier une entreprise de la base avec un formulaire
@ -580,7 +582,7 @@ def edit_entreprise(entreprise_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/desactiver", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def fiche_entreprise_desactiver(entreprise_id):
"""
Permet de désactiver une entreprise
@ -609,7 +611,7 @@ def fiche_entreprise_desactiver(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form_confirmation.j2",
"form_confirmation.j2",
title="Désactiver entreprise",
form=form,
info_message="Cliquez sur le bouton Modifier pour confirmer la désactivation",
@ -617,7 +619,7 @@ def fiche_entreprise_desactiver(entreprise_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/activer", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def fiche_entreprise_activer(entreprise_id):
"""
Permet d'activer une entreprise
@ -645,7 +647,7 @@ def fiche_entreprise_activer(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form_confirmation.j2",
"form_confirmation.j2",
title="Activer entreprise",
form=form,
info_message="Cliquez sur le bouton Modifier pour confirmer l'activaction",
@ -774,7 +776,7 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id):
url_for("entreprises.fiche_entreprise", entreprise_id=taxe.entreprise_id)
)
return render_template(
"entreprises/form_confirmation.j2",
"form_confirmation.j2",
title="Supprimer taxe apprentissage",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -782,7 +784,7 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/add_offre", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def add_offre(entreprise_id):
"""
Permet d'ajouter une offre a une entreprise
@ -804,9 +806,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()
@ -854,7 +856,7 @@ def add_offre(entreprise_id):
"/fiche_entreprise/<int:entreprise_id>/edit_offre/<int:offre_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def edit_offre(entreprise_id, offre_id):
"""
Permet de modifier une offre
@ -930,7 +932,7 @@ def edit_offre(entreprise_id, offre_id):
"/fiche_entreprise/<int:entreprise_id>/delete_offre/<int:offre_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def delete_offre(entreprise_id, offre_id):
"""
Permet de supprimer une offre
@ -970,7 +972,7 @@ def delete_offre(entreprise_id, offre_id):
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
)
return render_template(
"entreprises/form_confirmation.j2",
"form_confirmation.j2",
title="Supression offre",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -981,7 +983,7 @@ def delete_offre(entreprise_id, offre_id):
"/fiche_entreprise/<int:entreprise_id>/expired/<int:offre_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def expired(entreprise_id, offre_id):
"""
Permet de rendre expirée et non expirée une offre
@ -1006,7 +1008,7 @@ def expired(entreprise_id, offre_id):
"/fiche_entreprise/<int:entreprise_id>/add_site",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def add_site(entreprise_id):
"""
Permet d'ajouter un site a une entreprise
@ -1107,7 +1109,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.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def add_correspondant(entreprise_id, site_id):
"""
Permet d'ajouter un correspondant a une entreprise
@ -1163,7 +1165,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.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def edit_correspondant(entreprise_id, site_id, correspondant_id):
"""
Permet de modifier un correspondant
@ -1243,7 +1245,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.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def delete_correspondant(entreprise_id, site_id, correspondant_id):
"""
Permet de supprimer un correspondant
@ -1289,7 +1291,7 @@ def delete_correspondant(entreprise_id, site_id, correspondant_id):
)
)
return render_template(
"entreprises/form_confirmation.j2",
"form_confirmation.j2",
title="Supression correspondant",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1297,7 +1299,7 @@ def delete_correspondant(entreprise_id, site_id, correspondant_id):
@bp.route("/fiche_entreprise/<int:entreprise_id>/contacts")
@permission_required(Permission.RelationsEntreprisesView)
@permission_required(Permission.RelationsEntrepView)
def contacts(entreprise_id):
"""
Permet d'afficher une page avec la liste des contacts d'une entreprise
@ -1318,7 +1320,7 @@ def contacts(entreprise_id):
"/fiche_entreprise/<int:entreprise_id>/contacts/add_contact",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def add_contact(entreprise_id):
"""
Permet d'ajouter un contact avec une entreprise
@ -1328,9 +1330,11 @@ 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))
@ -1374,7 +1378,7 @@ def add_contact(entreprise_id):
"/fiche_entreprise/<int:entreprise_id>/contacts/edit_contact/<int:contact_id>",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def edit_contact(entreprise_id, contact_id):
"""
Permet d'editer un contact avec une entreprise
@ -1430,7 +1434,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.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def delete_contact(entreprise_id, contact_id):
"""
Permet de supprimer un contact
@ -1458,7 +1462,7 @@ def delete_contact(entreprise_id, contact_id):
url_for("entreprises.contacts", entreprise_id=contact.entreprise)
)
return render_template(
"entreprises/form_confirmation.j2",
"form_confirmation.j2",
title="Supression contact",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1469,7 +1473,7 @@ def delete_contact(entreprise_id, contact_id):
"/fiche_entreprise/<int:entreprise_id>/add_stage_apprentissage",
methods=["GET", "POST"],
)
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def add_stage_apprentissage(entreprise_id):
"""
Permet d'ajouter un étudiant ayant réalisé un stage ou alternance
@ -1496,9 +1500,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)
@ -1528,7 +1532,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.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
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
@ -1580,8 +1584,8 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
)
)
elif request.method == "GET":
form.etudiant.data = f"""{sco_etud.format_nom(etudiant.nom)} {
sco_etud.format_prenom(etudiant.prenom)}"""
form.etudiant.data = f"""{scu.format_nom(etudiant.nom)} {
scu.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
@ -1598,7 +1602,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.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
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
@ -1629,7 +1633,7 @@ def delete_stage_apprentissage(entreprise_id, stage_apprentissage_id):
)
)
return render_template(
"entreprises/form_confirmation.j2",
"form_confirmation.j2",
title="Supression stage/apprentissage",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1640,7 +1644,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.RelationsEntreprisesSend)
@permission_required(Permission.RelationsEntrepSend)
def envoyer_offre(entreprise_id, offre_id):
"""
Permet d'envoyer une offre à un utilisateur ScoDoc
@ -1686,7 +1690,7 @@ def envoyer_offre(entreprise_id, offre_id):
@bp.route("/etudiants")
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
@as_json
def json_etudiants():
"""
@ -1699,7 +1703,7 @@ def json_etudiants():
list = []
for etudiant in etudiants:
content = {}
value = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}"
value = f"{scu.format_nom(etudiant.nom)} {scu.format_prenom(etudiant.prenom)}"
if etudiant.inscription_courante() is not None:
content = {
"id": f"{etudiant.id}",
@ -1717,7 +1721,7 @@ def json_etudiants():
@bp.route("/responsables")
@permission_required(Permission.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def json_responsables():
"""
Permet de récuperer un JSON avec tous les utilisateurs ScoDoc
@ -1743,7 +1747,7 @@ def json_responsables():
@bp.route("/export_donnees")
@permission_required(Permission.RelationsEntreprisesExport)
@permission_required(Permission.RelationsEntrepExport)
def export_donnees():
"""
Permet d'exporter la liste des entreprises sous format excel (.xlsx)
@ -1759,7 +1763,7 @@ def export_donnees():
@bp.route("/import_donnees/get_file_sample")
@permission_required(Permission.RelationsEntreprisesExport)
@permission_required(Permission.RelationsEntrepExport)
def import_donnees_get_file_sample():
"""
Permet de récupérer un fichier exemple vide pour pouvoir importer des entreprises
@ -1771,7 +1775,7 @@ def import_donnees_get_file_sample():
@bp.route("/import_donnees", methods=["GET", "POST"])
@permission_required(Permission.RelationsEntreprisesExport)
@permission_required(Permission.RelationsEntrepExport)
def import_donnees():
"""
Permet d'importer des entreprises à partir d'un fichier excel (.xlsx)
@ -1802,7 +1806,7 @@ def import_donnees():
db.session.add(entreprise)
db.session.commit()
db.session.refresh(entreprise)
except:
except Exception:
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
@ -1850,7 +1854,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.RelationsEntreprisesView)
@permission_required(Permission.RelationsEntrepView)
def get_offre_file(entreprise_id, offre_id, filedir, filename):
"""
Permet de télécharger un fichier d'une offre
@ -1884,7 +1888,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.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def add_offre_file(entreprise_id, offre_id):
"""
Permet d'ajouter un fichier à une offre
@ -1927,7 +1931,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.RelationsEntreprisesChange)
@permission_required(Permission.RelationsEntrepEdit)
def delete_offre_file(entreprise_id, offre_id, filedir):
"""
Permet de supprimer un fichier d'une offre
@ -1959,7 +1963,7 @@ def delete_offre_file(entreprise_id, offre_id, filedir):
)
)
return render_template(
"entreprises/form_confirmation.j2",
"form_confirmation.j2",
title="Suppression fichier d'une offre",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",

View File

@ -0,0 +1,210 @@
# -*- 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 champs de saisie par parcours
# Initialise un champ 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 - 2023 Emmanuel Viennet. All rights reserved.
# 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
@ -43,6 +43,7 @@ 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

@ -0,0 +1,52 @@
"""
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()

17
app/forms/generic.py Normal file
View File

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

@ -0,0 +1,17 @@
"""
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 - 2023 Emmanuel Viennet. All rights reserved.
# 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

View File

@ -0,0 +1,190 @@
# -*- 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 Module Assiduités
"""
import datetime
import re
from flask_wtf import FlaskForm
from wtforms import DecimalField, SubmitField, ValidationError
from wtforms.fields.simple import StringField
from wtforms.validators import Optional, Length
from wtforms.widgets import TimeInput
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)")
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 check_ics_field(form, field):
"""Vérifie que c'est un nom de champ crédible: un mot alphanumérique"""
if not re.match(r"^[a-zA-Z\-_0-9]+$", field.data):
raise ValidationError("nom de champ ics invalide")
def check_ics_regexp(form, field):
"""Vérifie que field est une expresssion régulière"""
value = field.data.strip()
# check that it compiles
try:
_ = re.compile(value)
except re.error as exc:
raise ValidationError("expression invalide") from exc
return True
class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduité"
assi_morning_time = StringField(
"Début de la journée",
default="",
validators=[Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_morning_time",
},
)
assi_lunch_time = StringField(
"Heure de midi (date pivot entre matin et après-midi)",
default="",
validators=[Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_lunch_time",
},
)
assi_afternoon_time = StringField(
"Fin de la journée",
validators=[Length(max=5)],
default="",
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_afternoon_time",
},
)
assi_tick_time = DecimalField(
"Granularité de la timeline (temps en minutes)",
places=0,
validators=[check_tick_time],
)
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],
)
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 - 2023 Emmanuel Viennet. All rights reserved.
# 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
@ -30,8 +30,23 @@ Formulaire configuration CAS
"""
from flask_wtf import FlaskForm
from wtforms import BooleanField, SubmitField
from wtforms import BooleanField, SubmitField, ValidationError
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):
@ -40,33 +55,60 @@ 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="Route du login CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas</tt> (si commence par <tt>/</tt>, part de la racine)""",
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)""",
default="/cas",
)
cas_logout_route = StringField(
label="Route du logout CAS",
label="Optionnel: route du logout CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas/logout</tt>""",
default="/cas/logout",
)
cas_validate_route = StringField(
label="Route de validation CAS",
label="Optionnel: 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 champs CAS qui sera considéré comme l'id unique des
description="""Le champ 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 - 2023 Emmanuel Viennet. All rights reserved.
# 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

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# 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
@ -28,6 +28,7 @@
"""
Formulaires configuration Exports Apogée (codes)
"""
import time
from flask import flash, url_for, redirect, request, render_template
from flask_wtf import FlaskForm
@ -47,13 +48,15 @@ class BonusConfigurationForm(FlaskForm):
for (name, displayed_name) in ScoDocSiteConfig.get_bonus_sport_class_list()
],
)
submit_bonus = SubmitField("Valider")
submit_bonus = SubmitField("Enregistrer ce bonus")
cancel_bonus = SubmitField("Annuler", render_kw={"formnovalidate": True})
class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration avancée"
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
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."""
)
month_debut_annee_scolaire = SelectField(
label="Mois de début des années scolaires",
description="""Date pivot. En France métropolitaine, août.
@ -76,7 +79,13 @@ class ScoDocConfigurationForm(FlaskForm):
Attention: si ce champ peut aussi être défini dans chaque département.""",
validators=[Optional(), Email()],
)
submit_scodoc = SubmitField("Valider")
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")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
@ -91,9 +100,12 @@ 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 (
@ -114,12 +126,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.enable_entreprises(
enabled=form_scodoc.data["enable_entreprises"]
if ScoDocSiteConfig.disable_passerelle(
disabled=form_scodoc.data["disable_passerelle"]
):
flash(
"Module entreprise "
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
"Fonction passerelle "
+ ("cachées" if form_scodoc.data["disable_passerelle"] else "montrées")
)
if ScoDocSiteConfig.set_month_debut_annee_scolaire(
int(form_scodoc.data["month_debut_annee_scolaire"])
@ -139,12 +151,33 @@ 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

@ -0,0 +1,71 @@
"""
Formulaire configuration liens personalisés (menu "Liens")
"""
from flask_wtf import FlaskForm
from wtforms import validators
from wtforms.fields.simple import BooleanField, StringField, SubmitField
from app.models import ScoDocSiteConfig
class _PersonalizedLinksForm(FlaskForm):
"form. définition des liens personnalisés"
# construit dynamiquement ci-dessous
def PersonalizedLinksForm() -> _PersonalizedLinksForm:
"Création d'un formulaire pour éditer les liens"
# Formulaire dynamique, on créé une classe ad-hoc
class F(_PersonalizedLinksForm):
pass
F.links_by_id = dict(enumerate(ScoDocSiteConfig.get_perso_links()))
def _gen_link_form(idx):
setattr(
F,
f"link_{idx}",
StringField(
"Titre",
validators=[
validators.Optional(),
validators.Length(min=1, max=80),
],
default="",
render_kw={"size": 6},
),
)
setattr(
F,
f"link_url_{idx}",
StringField(
"URL",
description="adresse, incluant le http.",
validators=[
validators.Optional(),
validators.URL(),
validators.Length(min=1, max=256),
],
default="",
),
)
setattr(
F,
f"link_with_args_{idx}",
BooleanField(
"ajouter arguments",
description="query string avec ids",
),
)
# Initialise un champ de saisie par lien
for idx in F.links_by_id:
_gen_link_form(idx)
_gen_link_form("new")
F.submit = SubmitField("Valider")
F.cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
return F()

View File

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

@ -0,0 +1,66 @@
# -*- 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 - 2023 Emmanuel Viennet. All rights reserved.
# 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

View File

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

@ -0,0 +1,65 @@
##############################################################################
#
# 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,6 +4,7 @@
"""
import sqlalchemy
from app import db
CODE_STR_LEN = 16 # chaine pour les codes
SHORT_STR_LEN = 32 # courtes chaine, eg acronymes
@ -21,6 +22,101 @@ 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 (
@ -64,7 +160,6 @@ from app.models.notes import (
NotesNotesLog,
)
from app.models.validations import (
ScolarEvent,
ScolarFormSemestreValidation,
ScolarAutorisationInscription,
)
@ -81,3 +176,6 @@ from app.models.but_refcomp import (
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

795
app/models/assiduites.py Normal file
View File

@ -0,0 +1,795 @@
# -*- coding: UTF-8 -*
"""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.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,
)
class Assiduite(ScoDocModel):
"""
Représente une assiduité:
- une plage horaire lié à un état et un étudiant
- un module si spécifiée
- une description si spécifiée
"""
__tablename__ = "assiduites"
id = db.Column(db.Integer, primary_key=True, nullable=False)
assiduite_id = db.synonym("id")
date_debut = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
date_fin = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
moduleimpl_id = db.Column(
db.Integer,
db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"),
)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etat = db.Column(db.Integer, nullable=False)
description = db.Column(db.Text)
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
)
est_just = db.Column(db.Boolean, server_default="false", nullable=False)
external_data = db.Column(db.JSON, nullable=True)
# 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.
"""
etat = self.etat
user: User | None = None
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)
data = {
"assiduite_id": self.id,
"etudid": self.etudid,
"code_nip": self.etudiant.code_nip,
"moduleimpl_id": self.moduleimpl_id,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"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"
"est_just": self.est_just,
"external_data": self.external_data,
}
return data
def __str__(self) -> str:
"chaine pour journaux et debug (lisible par humain français)"
try:
etat_str = EtatAssiduite(self.etat).name.lower().capitalize()
except ValueError:
etat_str = "Invalide"
return f"""{etat_str} {
"just." if self.est_just else "non just."
} de {
self.date_debut.strftime("%d/%m/%Y %Hh%M")
} à {
self.date_fin.strftime("%d/%m/%Y %Hh%M")
}"""
@classmethod
def create_assiduite(
cls,
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatAssiduite,
moduleimpl: ModuleImpl = None,
description: str = None,
entry_date: datetime = None,
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é")
# 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"
)
if not est_just:
est_just = (
len(
get_justifs_from_date(etud.etudid, date_debut, date_fin, valid=True)
)
> 0
)
moduleimpl_id = None
if moduleimpl is not None:
# Vérification de l'inscription de l'étudiant
if moduleimpl.est_inscrit(etud):
moduleimpl_id = moduleimpl.id
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}
)
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}")
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):
"""
Représente un justificatif:
- une plage horaire lié à un état et un étudiant
- une raison si spécifiée
- un fichier si spécifié
"""
__tablename__ = "justificatifs"
id = db.Column(db.Integer, primary_key=True)
justif_id = db.synonym("id")
date_debut = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
date_fin = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etat = db.Column(
db.Integer,
nullable=False,
)
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,
db.ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
raison = db.Column(db.Text())
# Archive_id -> sco_archives_justificatifs.py
fichier = db.Column(db.Text())
# Déclare la relation "joined" car on va très souvent vouloir récupérer
# l'étudiant en même tant que le justificatif (perf.: évite nouvelle requete SQL)
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
"""
etat = self.etat
user: User = self.user if self.user_id is not None else None
if format_api:
etat = EtatJustificatif.inverse().get(self.etat).name
data = {
"justif_id": self.justif_id,
"etudid": self.etudid,
"code_nip": self.etudiant.code_nip,
"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,
"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,
}
return data
def __repr__(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 {
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
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
raison: str = None,
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}")
Scolog.logdb(
method="create_justificatif",
etudid=etudiant.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,
) -> 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)
count: int = collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut
).count()
return count > 0
def get_assiduites_justif(assiduite_id: int, long: bool) -> list[int | dict]:
"""
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é
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.
Returns:
list[int | dict]: La liste des justificatifs (par défaut uniquement
les identifiants, sinon les dict si long est vrai)
"""
# On récupère les justificatifs d'un étudiant couvrant la période donnée
justifs: Query = Justificatif.query.filter(
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()
)

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 models : Référentiel Compétence BUT 2021
@ -8,16 +8,19 @@
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
from app import db, log
from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
REFCOMP_EQUIVALENCE_FILENAME = "ressources/referentiels/equivalences.yaml"
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
@ -94,10 +97,21 @@ class ApcReferentielCompetences(db.Model, XMLModel):
backref="referentiel_competence",
order_by="Formation.acronyme, Formation.version",
)
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:
@ -118,9 +132,11 @@ 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)
@ -228,6 +244,92 @@ 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"
@ -359,15 +461,20 @@ class ApcNiveau(db.Model, XMLModel):
return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={
self.annee!r} {self.competence!r}>"""
def __str__(self):
return f"""{self.competence.titre} niveau {self.ordre}"""
def to_dict(self, with_app_critiques=True):
"as a dict, recursif (ou non) sur les AC"
return {
"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):
@ -389,7 +496,9 @@ class ApcNiveau(db.Model, XMLModel):
return (
ApcParcours.query.join(ApcAnneeParcours)
.filter_by(ordre=annee)
.join(ApcParcoursNiveauCompetence, ApcCompetence, ApcNiveau)
.join(ApcParcoursNiveauCompetence)
.join(ApcCompetence)
.join(ApcNiveau)
.filter_by(id=self.id)
.order_by(ApcParcours.numero, ApcParcours.code)
.all()
@ -453,17 +562,24 @@ 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
]
else:
niveaux: list[ApcNiveau] = competence.niveaux.filter_by(
annee=f"BUT{int(annee)}"
).all()
niveaux: list[ApcNiveau] = (
ApcNiveau.query.filter_by(annee=f"BUT{int(annee)}")
.join(ApcCompetence)
.filter_by(id=competence.id)
.join(ApcParcoursNiveauCompetence)
.filter(ApcParcoursNiveauCompetence.niveau == ApcNiveau.ordre)
.join(ApcAnneeParcours)
.filter_by(parcours_id=parcour.id)
.all()
)
_cache[key] = niveaux
return niveaux
@ -606,7 +722,8 @@ class ApcParcours(db.Model, XMLModel):
def query_competences(self) -> Query:
"Les compétences associées à ce parcours"
return (
ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
ApcCompetence.query.join(ApcParcoursNiveauCompetence)
.join(ApcAnneeParcours)
.filter_by(parcours_id=self.id)
.order_by(ApcCompetence.numero)
)
@ -615,7 +732,8 @@ class ApcParcours(db.Model, XMLModel):
"La compétence de titre donné dans ce parcours, ou None"
return (
ApcCompetence.query.filter_by(titre=titre)
.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
.join(ApcParcoursNiveauCompetence)
.join(ApcAnneeParcours)
.filter_by(parcours_id=self.id)
.order_by(ApcCompetence.numero)
.first()

View File

@ -2,18 +2,14 @@
"""Décisions de jury (validations) des RCUE et années du BUT
"""
from typing import Union
from flask_sqlalchemy.query import Query
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 codes_cursus as sco_codes
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
@ -22,7 +18,7 @@ class ApcValidationRCUE(db.Model):
aka "regroupements cohérents d'UE" dans le jargon BUT.
Le formsemestre est celui du semestre PAIR du niveau de compétence
Le formsemestre est l'origine, utilisé pour effacer
"""
__tablename__ = "apc_validation_rcue"
@ -41,10 +37,14 @@ class ApcValidationRCUE(db.Model):
formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
)
"formsemestre pair du RCUE"
"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"), nullable=False)
ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
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
)
# 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,29 +64,34 @@ 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("%d/%m/%Y")}"""
self.code} enregistrée le {self.date.strftime(scu.DATE_FMT)}"""
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("%d/%m/%Y")}
à {self.date.strftime("%Hh%M")}</em>"""
<em>enregistrée le {self.date.strftime(scu.DATEATIME_FMT)}</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:
def niveau(self) -> ApcNiveau | None:
"""Le niveau de compétence associé à cet RCUE."""
# Par convention, il est donné par la seconde UE
return self.ue2.niveau_competence
# à 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
def to_dict(self):
"as a dict"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
d["etud"] = self.etud.to_dict_short()
d["ue1"] = self.ue1.to_dict()
d["ue2"] = self.ue2.to_dict()
return d
def to_dict_bul(self) -> dict:
@ -109,204 +114,14 @@ class ApcValidationRCUE(db.Model):
}
# Attention: ce n'est pas un modèle mais une classe ordinaire:
class RegroupementCoherentUE:
"""Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs
de la même année (BUT1,2,3) liées au *même niveau de compétence*.
La moyenne (10/20) au RCUE déclenche la compensation des UE.
"""
def __init__(
self,
etud: Identite,
formsemestre_1: FormSemestre,
dec_ue_1: "DecisionsProposeesUE",
formsemestre_2: FormSemestre,
dec_ue_2: "DecisionsProposeesUE",
inscription_etat: str,
):
ue_1 = dec_ue_1.ue
ue_2 = dec_ue_2.ue
# Ordonne les UE dans le sens croissant (S1,S2) ou (S3,S4)...
if formsemestre_1.semestre_id > formsemestre_2.semestre_id:
(ue_1, formsemestre_1), (ue_2, formsemestre_2) = (
(ue_2, formsemestre_2),
(ue_1, formsemestre_1),
)
assert formsemestre_1.semestre_id % 2 == 1
assert formsemestre_2.semestre_id % 2 == 0
assert abs(formsemestre_1.semestre_id - formsemestre_2.semestre_id) == 1
assert ue_1.niveau_competence_id == ue_2.niveau_competence_id
self.etud = etud
self.formsemestre_1 = formsemestre_1
"semestre impair"
self.ue_1 = ue_1
self.formsemestre_2 = formsemestre_2
"semestre pair"
self.ue_2 = ue_2
# Stocke les moyennes d'UE
if inscription_etat != scu.INSCRIT:
self.moy_rcue = None
self.moy_ue_1 = self.moy_ue_2 = "-"
self.moy_ue_1_val = self.moy_ue_2_val = 0.0
return
self.moy_ue_1 = dec_ue_1.moy_ue_with_cap
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
self.moy_ue_2 = dec_ue_2.moy_ue_with_cap
self.moy_ue_2_val = self.moy_ue_2 if self.moy_ue_2 is not None else 0.0
# Calcul de la moyenne au RCUE (utilise les moy d'UE capitalisées)
if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
# Moyenne RCUE (les pondérations par défaut sont 1.)
self.moy_rcue = (
self.moy_ue_1 * ue_1.coef_rcue + self.moy_ue_2 * ue_2.coef_rcue
) / (ue_1.coef_rcue + ue_2.coef_rcue)
else:
self.moy_rcue = None
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} {
self.ue_1.acronyme}({self.moy_ue_1}) {
self.ue_2.acronyme}({self.moy_ue_2})>"""
def __str__(self) -> str:
return f"""RCUE {
self.ue_1.acronyme}({self.moy_ue_1}) + {
self.ue_2.acronyme}({self.moy_ue_2})"""
def query_validations(
self,
) -> Query: # list[ApcValidationRCUE]
"""Les validations de jury enregistrées pour ce RCUE"""
niveau = self.ue_2.niveau_competence
return (
ApcValidationRCUE.query.filter_by(
etudid=self.etud.id,
)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
.join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id)
.filter(ApcNiveau.id == niveau.id)
)
def other_ue(self, ue: UniteEns) -> UniteEns:
"""L'autre UE du regroupement. Si ue ne fait pas partie du regroupement, ValueError"""
if ue.id == self.ue_1.id:
return self.ue_2
elif ue.id == self.ue_2.id:
return self.ue_1
raise ValueError(f"ue {ue} hors RCUE {self}")
def est_enregistre(self) -> bool:
"""Vrai si ce RCUE, donc le niveau de compétences correspondant
a une décision jury enregistrée
"""
return self.query_validations().count() > 0
def est_compensable(self):
"""Vrai si ce RCUE est validable (uniquement) par compensation
c'est à dire que sa moyenne est > 10 avec une UE < 10.
Note: si ADM, est_compensable est faux.
"""
return (
(self.moy_rcue is not None)
and (self.moy_rcue > sco_codes.BUT_BARRE_RCUE)
and (
(self.moy_ue_1_val < sco_codes.NOTES_BARRE_GEN)
or (self.moy_ue_2_val < sco_codes.NOTES_BARRE_GEN)
)
)
def est_suffisant(self) -> bool:
"""Vrai si ce RCUE est > 8"""
return (self.moy_rcue is not None) and (
self.moy_rcue > sco_codes.BUT_RCUE_SUFFISANT
)
def est_validable(self) -> bool:
"""Vrai si ce RCUE satisfait les conditions pour être validé,
c'est à dire que la moyenne des UE qui le constituent soit > 10
"""
return (self.moy_rcue is not None) and (
self.moy_rcue > sco_codes.BUT_BARRE_RCUE
)
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 (
validation.code in sco_codes.CODES_RCUE_VALIDES
):
return validation
return None
# unused
# def find_rcues(
# formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str
# ) -> list[RegroupementCoherentUE]:
# """Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
# ce semestre pour cette UE.
# Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit.
# En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
# Résultat: la liste peut être vide.
# """
# if (ue.niveau_competence is None) or (ue.semestre_idx is None):
# return []
# if ue.semestre_idx % 2: # S1, S3, S5
# other_semestre_idx = ue.semestre_idx + 1
# else:
# other_semestre_idx = ue.semestre_idx - 1
# cursor = db.session.execute(
# text(
# """SELECT
# ue.id, formsemestre.id
# FROM
# notes_ue ue,
# notes_formsemestre_inscription inscr,
# notes_formsemestre formsemestre
# WHERE
# inscr.etudid = :etudid
# AND inscr.formsemestre_id = formsemestre.id
# AND formsemestre.semestre_id = :other_semestre_idx
# AND ue.formation_id = formsemestre.formation_id
# AND ue.niveau_competence_id = :ue_niveau_competence_id
# AND ue.semestre_idx = :other_semestre_idx
# """
# ),
# {
# "etudid": etud.id,
# "other_semestre_idx": other_semestre_idx,
# "ue_niveau_competence_id": ue.niveau_competence_id,
# },
# )
# rcues = []
# for ue_id, formsemestre_id in cursor:
# other_ue = UniteEns.query.get(ue_id)
# other_formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# rcues.append(
# RegroupementCoherentUE(
# etud, formsemestre, ue, other_formsemestre, other_ue, inscription_etat
# )
# )
# # safety check: 1 seul niveau de comp. concerné:
# assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
# return rcues
class ApcValidationAnnee(db.Model):
"""Validation des années du BUT"""
__tablename__ = "apc_validation_annee"
# Assure unicité de la décision:
__table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire", "ordre"),)
__table_args__ = (
db.UniqueConstraint("etudid", "ordre", "referentiel_competence_id"),
)
id = db.Column(db.Integer, primary_key=True)
etudid = db.Column(
db.Integer,
@ -319,11 +134,9 @@ class ApcValidationAnnee(db.Model):
formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True
)
"le semestre IMPAIR (le 1er) de l'année"
formation_id = db.Column(
db.Integer,
db.ForeignKey("notes_formations.id"),
nullable=False,
"le semestre origine, normalement l'IMPAIR (le 1er) de l'année"
referentiel_competence_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
)
annee_scolaire = db.Column(db.Integer, nullable=False) # eg 2021
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
@ -343,17 +156,30 @@ class ApcValidationAnnee(db.Model):
"dict pour bulletins"
return {
"annee_scolaire": self.annee_scolaire,
"date": self.date.isoformat(),
"date": self.date.isoformat() if self.date else "",
"code": self.code,
"ordre": self.ordre,
}
def html(self) -> str:
"Affichage html"
date_str = (
f"""le {self.date.strftime(scu.DATEATIME_FMT)}"""
if self.date
else "(sans date)"
)
link = (
self.formsemestre.html_link_status(
label=f"{self.formsemestre.titre_formation(with_sem_idx=1)}",
title=self.formsemestre.titre_annee(),
)
if self.formsemestre
else "externe/antérieure"
)
return f"""Validation <b>année BUT{self.ordre}</b> émise par
{self.formsemestre.html_link_status() if self.formsemestre else "-"}
{link}
: <b>{self.code}</b>
le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}
{date_str}
"""
@ -394,18 +220,19 @@ 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 de ce semestre
validation = (
ApcValidationAnnee.query.filter_by(
# --- 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(),
)
.join(Formation)
.filter(Formation.formation_code == formsemestre.formation.formation_code)
.first()
)
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
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
else:
decisions["decision_annee"] = None
return decisions

View File

@ -3,9 +3,14 @@
"""Model : site config WORK IN PROGRESS #WIP
"""
import json
import re
import urllib.parse
from flask import flash
from app import current_app, db, log
from app.comp import bonus_spo
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import (
@ -87,8 +92,11 @@ 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,
@ -96,6 +104,12 @@ 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é
"morning_time": str,
"lunch_time": str,
"afternoon_time": str,
}
def __init__(self, name, value):
@ -161,7 +175,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
@ -219,6 +233,12 @@ 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"""
@ -226,20 +246,39 @@ class ScoDocSiteConfig(db.Model):
return cfg is not None and cfg.value
@classmethod
def enable_entreprises(cls, enabled=True) -> bool:
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:
"""Active (ou déactive) le module entreprises. True si changement."""
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
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 "")
@classmethod
def get(cls, name: str, default: str = "") -> str:
@ -247,20 +286,21 @@ class ScoDocSiteConfig(db.Model):
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if cfg is None:
return default
return cfg.value or ""
return cls.NAMES.get(name, lambda x: x)(cfg.value or "")
@classmethod
def set(cls, name: str, value: str) -> bool:
"Set parameter, returns True if change. Commit session."
value_str = str(value or "")
value_str = str(value or "").strip()
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 = str(value or "")
cfg.value = value_str
current_app.logger.info(
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}...'"""
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}{
'...' if len(cfg.value)>32 else ''}'"""
)
db.session.add(cfg)
db.session.commit()
@ -269,7 +309,7 @@ class ScoDocSiteConfig(db.Model):
@classmethod
def _get_int_field(cls, name: str, default=None) -> int:
"""Valeur d'un champs integer"""
"""Valeur d'un champ integer"""
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if (cfg is None) or cfg.value is None:
return default
@ -283,7 +323,7 @@ class ScoDocSiteConfig(db.Model):
default=None,
range_values: tuple = (),
) -> bool:
"""Set champs integer. True si changement."""
"""Set champ 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])
@ -336,3 +376,110 @@ class ScoDocSiteConfig(db.Model):
log(f"set_month_debut_periode2({month})")
return True
return False
@classmethod
def get_perso_links(cls) -> list["PersonalizedLink"]:
"Return links"
data_links = cls.get("personalized_links")
if not data_links:
return []
try:
links_dict = json.loads(data_links)
except json.decoder.JSONDecodeError as exc:
# Corrupted data ? erase content
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
def set_perso_links(cls, links: list["PersonalizedLink"] = None):
"Store all links"
if not links:
links = []
links_dict = [link.to_dict() for link in links]
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):
self.title = str(title or "")
self.url = str(url or "")
self.with_args = bool(with_args)
def get_url(self, params: dict = {}) -> str:
if not self.with_args:
return self.url
query_string = urllib.parse.urlencode(params)
if "?" in self.url:
return self.url + "&" + query_string
return self.url + "?" + query_string
def to_dict(self) -> dict:
"as dict"
return {"title": self.title, "url": self.url, "with_args": self.with_args}

View File

@ -26,7 +26,17 @@ class Departement(db.Model):
) # sur page d'accueil
# entreprises = db.relationship("Entreprise", lazy="dynamic", backref="departement")
etudiants = db.relationship("Identite", 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.
formations = db.relationship("Formation", lazy="dynamic", backref="departement")
formsemestres = db.relationship(
"FormSemestre", lazy="dynamic", backref="departement"
@ -80,8 +90,6 @@ 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,45 +15,57 @@ 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 ScoInvalidParamError, ScoValueError
from app.scodoc.sco_exceptions import ScoGenError, ScoInvalidParamError, ScoValueError
import app.scodoc.sco_utils as scu
class Identite(db.Model):
class Identite(models.ScoDocModel):
"""é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 remplace les données d'usage dans les documents
# données d'état-civil. Si présent (non null) 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=False, server_default="X")
prenom_etat_civil = db.Column(db.Text(), nullable=False, server_default="")
civilite_etat_civil = db.Column(db.String(1), nullable=True)
prenom_etat_civil = db.Column(db.Text(), nullable=True)
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()) # True si boursier ('O' en ScoDoc7)
boursier = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
"True si boursier"
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)
@ -61,11 +73,42 @@ class Identite(db.Model):
# 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)
#
adresses = db.relationship("Adresse", lazy="dynamic", backref="etud")
# ----- 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",
)
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
#
admission = db.relationship("Admission", backref="identite", lazy="dynamic")
dispense_ues = db.relationship(
"DispenseUE",
back_populates="etud",
@ -73,16 +116,76 @@ class Identite(db.Model):
passive_deletes=True,
)
# Relations avec les assiduites et les justificatifs
assiduites = db.relationship(
"Assiduite", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
)
justificatifs = db.relationship(
"Justificatif", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
)
# Champs "protégés" par ViewEtudData (RGPD)
protected_attrs = {"boursier", "nationalite"}
def __repr__(self):
return (
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
)
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="stdlink" href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id)
}">{self.nomprenom}</a>"""
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
)
@classmethod
def from_request(cls, etudid=None, code_nip=None) -> "Identite":
@ -103,41 +206,90 @@ class Identite(db.Model):
return cls.query.filter_by(id=etudid).first_or_404()
@classmethod
def create_etud(cls, **args):
"Crée un étudiant, avec admission et adresse vides."
etud: Identite = cls(**args)
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()
etud.adresses.append(Adresse(typeadresse="domicile"))
etud.admission.append(Admission())
db.session.flush()
event = ScolarEvent(etud=etud, event_type="CREATION")
db.session.add(event)
log(f"Identite.create {etud}")
return etud
def from_dict(self, args, **kwargs) -> bool:
"""Check arguments, then modify.
Add to session but don't commit.
True if modification.
"""
check_etud_duplicate_code(args, "code_nip")
check_etud_duplicate_code(args, "code_ine")
return super().from_dict(args, **kwargs)
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded."""
return super().filter_model_attributes(
data,
excluded=(excluded or set()) | {"adresses", "admission", "departement"},
)
@property
def civilite_str(self):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
def civilite_str(self) -> str:
"""returns civilité usuelle: '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):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
personnes ne souhaitant pas d'affichage).
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.
"""
return {"M": "M.", "F": "Mme", "X": ""}[self.civilite_etat_civil]
return (
{"M": "M.", "F": "Mme"}.get(self.civilite_etat_civil, "")
if self.civilite_etat_civil
else self.civilite_str
)
def sex_nom(self, no_accents=False) -> str:
"'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'"
"'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'. Civilité usuelle."
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):
"terminaison en français: 'ne', '', 'ou '(e)'"
def e(self) -> str:
"terminaison en français: 'ne', '', 'ou '(e)', selon la civilité usuelle"
return {"M": "", "F": "e"}.get(self.civilite, "(e)")
def nom_disp(self) -> str:
"Nom à afficher"
"""Nom à afficher.
Note: le nom est stocké en base en majuscules."""
if self.nom_usuel:
return (
(self.nom_usuel + " (" + self.nom + ")") if self.nom else self.nom_usuel
@ -145,18 +297,17 @@ class Identite(db.Model):
else:
return self.nom
@cached_property
@property
def nomprenom(self, reverse=False) -> str:
"""Civilité/nom/prenom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
Prend l'identité courant et non celle de l'état civile si elles diffèrent.
"""
nom = self.nom_usuel or self.nom
prenom = self.prenom_str
if reverse:
fields = (nom, prenom)
else:
fields = (self.civilite_str, prenom, nom)
return " ".join([x for x in fields if x])
return f"{nom} {prenom}".strip()
return f"{self.civilite_str} {prenom} {nom}".strip()
@property
def prenom_str(self):
@ -171,12 +322,11 @@ class Identite(db.Model):
return " ".join(r)
@property
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
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()
@property
def nom_short(self):
@ -184,14 +334,14 @@ class Identite(db.Model):
return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}."
@cached_property
def sort_key(self) -> tuple:
def sort_key(self) -> str:
"clé pour tris par ordre alphabétique"
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(),
)
# 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()
def get_first_email(self, field="email") -> str:
"Le mail associé à la première adresse de l'étudiant, ou None"
@ -207,10 +357,49 @@ class Identite(db.Model):
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"
fs_uppercase = {"nom", "prenom", "prenom_etat_civil"}
"""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"}
fs_empty_stored_as_nulls = {
"nom",
"prenom",
@ -232,27 +421,19 @@ class Identite(db.Model):
value = None
if key in fs_uppercase and value:
value = value.upper()
if key == "civilite" or key == "civilite_etat_civil":
if key == "civilite": # requis
value = input_civilite(value)
elif key == "civilite_etat_civil":
value = input_civilite_etat_civil(value)
elif key == "boursier":
value = bool(value)
value = scu.to_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"""
"""Les champs essentiels (aucune donnée perso protégée)"""
return {
"id": self.id,
"civilite": self.civilite,
@ -267,21 +448,25 @@ class Identite(db.Model):
"prenom_etat_civil": self.prenom_etat_civil,
}
def to_dict_scodoc7(self) -> dict:
def to_dict_scodoc7(self, restrict=False, with_inscriptions=False) -> dict:
"""Représentation dictionnaire,
compatible ScoDoc7 mais sans infos admission
compatible ScoDoc7 mais sans infos admission.
Si restrict, cache les infos "personnelles" si pas permission ViewEtudData
Si with_inscriptions, inclut les champs "inscription"
"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
e_dict = self.__dict__.copy() # dict(self.__dict__)
e_dict.pop("_sa_instance_state", None)
# ScoDoc7 output_formators: (backward compat)
e["etudid"] = self.id
e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"])
e["ne"] = self.e
e["nomprenom"] = self.nomprenom
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
adresse = self.adresses.first()
if adresse:
e.update(adresse.to_dict())
return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty
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
def to_dict_bul(self, include_urls=True):
"""Infos exportées dans les bulletins
@ -290,31 +475,34 @@ class Identite(db.Model):
from app.scodoc import sco_photos
d = {
"boursier": self.boursier or "",
"civilite_etat_civil": self.civilite_etat_civil,
"civilite": self.civilite,
"code_ine": self.code_ine or "",
"code_nip": self.code_nip or "",
"date_naissance": self.date_naissance.strftime("%d/%m/%Y")
if self.date_naissance
else "",
"dept_id": self.dept_id,
"date_naissance": (
self.date_naissance.strftime(scu.DATE_FMT)
if self.date_naissance
else ""
),
"dept_acronym": self.departement.acronym,
"dept_id": self.dept_id,
"dept_naissance": self.dept_naissance or "",
"email": self.get_first_email() or "",
"emailperso": self.get_first_email("emailperso"),
"etat_civil": self.etat_civil,
"etudid": self.id,
"nom": self.nom_disp(),
"prenom": self.prenom or "",
"nomprenom": self.nomprenom or "",
"lieu_naissance": self.lieu_naissance or "",
"dept_naissance": self.dept_naissance or "",
"nationalite": self.nationalite or "",
"boursier": self.boursier or "",
"civilite_etat_civil": self.civilite_etat_civil,
"nom": self.nom_disp(),
"nomprenom": self.nomprenom or "",
"prenom_etat_civil": self.prenom_etat_civil,
"prenom": self.prenom or "",
}
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.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=self.id
)
d["photo_url"] = sco_photos.get_etud_photo_url(self.id)
adresse = self.adresses.first()
@ -323,22 +511,37 @@ class Identite(db.Model):
d["id"] = self.id # a été écrasé par l'id de adresse
return d
def to_dict_api(self) -> dict:
"""Représentation dictionnaire pour export API, avec adresses et admission."""
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)
"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
admission = self.admission.first()
admission = self.admission
e["admission"] = admission.to_dict() if admission is not None else None
e["adresses"] = [adr.to_dict() for adr in self.adresses]
e["adresses"] = [adr.to_dict(restrict=restrict) 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(
@ -348,7 +551,7 @@ class Identite(db.Model):
.all()
)
def inscription_courante(self):
def inscription_courante(self) -> "FormSemestreInscription | None":
"""La première inscription à un formsemestre _actuellement_ en cours.
None s'il n'y en a pas (ou plus, ou pas encore).
"""
@ -364,8 +567,6 @@ class Identite(db.Model):
(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(
@ -388,7 +589,9 @@ class Identite(db.Model):
return r[0] if r else None
def inscription_descr(self) -> dict:
"""Description de l'état d'inscription"""
"""Description de l'état d'inscription
avec champs compatibles templates ScoDoc7
"""
inscription_courante = self.inscription_courante()
if inscription_courante:
titre_sem = inscription_courante.formsemestre.titre_mois()
@ -399,7 +602,7 @@ class Identite(db.Model):
else:
inscr_txt = "Inscrit en"
return {
result = {
"etat_in_cursem": inscription_courante.etat,
"inscription_courante": inscription_courante,
"inscription": titre_sem,
@ -422,15 +625,20 @@ class Identite(db.Model):
inscription = "ancien"
situation = "ancien élève"
else:
inscription = ("non inscrit",)
inscription = "non inscrit"
situation = inscription
return {
result = {
"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:
@ -527,7 +735,7 @@ class Identite(db.Model):
"""
if with_paragraph:
return f"""{self.etat_civil}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
self.date_naissance.strftime(scu.DATE_FMT) if self.date_naissance else ""}{
line_sep}à {self.lieu_naissance or ""}"""
return self.etat_civil
@ -551,6 +759,58 @@ class Identite(db.Model):
)
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:
@ -593,11 +853,13 @@ def make_etud_args(
return args
def input_civilite(s):
def input_civilite(s: str) -> str:
"""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"
@ -608,6 +870,11 @@ def input_civilite(s):
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
@ -624,7 +891,7 @@ def pivot_year(y) -> int:
return y
class Adresse(db.Model):
class Adresse(models.ScoDocModel):
"""Adresse d'un étudiant
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule)
"""
@ -633,10 +900,10 @@ class Adresse(db.Model):
id = db.Column(db.Integer, primary_key=True)
adresse_id = db.synonym("id")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
)
etudid = db.Column(db.Integer, db.ForeignKey("identite.id"), nullable=False)
# Relationship to Identite
etud = db.relationship("Identite", back_populates="adresses")
email = db.Column(db.Text()) # mail institutionnel
emailperso = db.Column(db.Text) # email personnel (exterieur)
domicile = db.Column(db.Text)
@ -651,26 +918,42 @@ class Adresse(db.Model):
)
description = db.Column(db.Text)
def to_dict(self, convert_nulls_to_str=False):
"""Représentation dictionnaire,"""
# 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)."""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
if convert_nulls_to_str:
return {k: e[k] or "" for k in e}
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 e
class Admission(db.Model):
class Admission(models.ScoDocModel):
"""Informations liées à l'admission d'un étudiant"""
__tablename__ = "admissions"
id = db.Column(db.Integer, primary_key=True)
adm_id = db.synonym("id")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
)
# 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)
# 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
@ -680,9 +963,9 @@ class Admission(db.Model):
specialite = db.Column(db.Text)
annee_bac = db.Column(db.Integer)
math = db.Column(db.Text)
physique = db.Column(db.Float)
anglais = db.Column(db.Float)
francais = db.Column(db.Float)
physique = db.Column(db.Text)
anglais = db.Column(db.Text)
francais = db.Column(db.Text)
# 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)
@ -708,12 +991,16 @@ class Admission(db.Model):
# 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):
"""Représentation dictionnaire,"""
def to_dict(self, no_nulls=False, restrict=False):
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
if no_nulls:
@ -728,11 +1015,16 @@ class Admission(db.Model):
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"
"""Convert fields in the given dict. No other side effect.
args: dict with args in application.
returns: dict to store in model's db.
"""
fs_uppercase = {"bac", "specialite"}
args_dict = {}
for key, value in args.items():
@ -743,8 +1035,6 @@ class Admission(db.Model):
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":
@ -752,16 +1042,6 @@ class Admission(db.Model):
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):
@ -804,6 +1084,16 @@ 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) # sans contrainte (compat ScoDoc 7))
etudid = db.Column(db.Integer, db.ForeignKey(Identite.id))
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,19 +5,27 @@
import datetime
from operator import attrgetter
from app import db
from flask import abort, 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.moduleimpls import ModuleImpl
from app.models.events import ScolarNews
from app.models.notes import NotesNotes
from app.models.ues import UniteEns
from app.scodoc.sco_exceptions import ScoValueError
import app.scodoc.notesdb as ndb
from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
import app.scodoc.sco_utils as scu
from app.scodoc.sco_xml import quote_xml_attr
MAX_EVALUATION_DURATION = datetime.timedelta(days=365)
NOON = datetime.time(12, 00)
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
class Evaluation(db.Model):
class Evaluation(models.ScoDocModel):
"""Evaluation (contrôle, examen, ...)"""
__tablename__ = "notes_evaluation"
@ -27,70 +35,203 @@ class Evaluation(db.Model):
moduleimpl_id = db.Column(
db.Integer, db.ForeignKey("notes_moduleimpl.id"), index=True
)
jour = db.Column(db.Date)
heure_debut = db.Column(db.Time)
heure_fin = db.Column(db.Time)
description = db.Column(db.Text)
note_max = db.Column(db.Float)
coefficient = db.Column(db.Float)
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)
visibulletin = db.Column(
db.Boolean, nullable=False, default=True, server_default="true"
)
"visible sur les bulletins version intermédiaire"
publish_incomplete = db.Column(
db.Boolean, nullable=False, default=False, server_default="false"
)
# type d'evaluation: 0 normale, 1 rattrapage, 2 "2eme session"
"prise en compte immédiate"
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.jour.isoformat() if self.jour else ''} "{
self.date_debut.isoformat() if self.date_debut else ''} "{
self.description[:16] if self.description else ''}">"""
@classmethod
def create(
cls,
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(
f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
)
args = locals()
del args["cls"]
del args["kw"]
check_and_convert_evaluation_args(args, moduleimpl)
# 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",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=moduleimpl.id,
)
log(f"created evaluation in {moduleimpl.module.titre_str()}")
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=moduleimpl.id,
text=f"""Création d'une évaluation dans <a href="{url}">{
moduleimpl.module.titre_str()}</a>""",
url=url,
)
return evaluation
@classmethod
def get_new_numero(
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.
"""
n = None
# Détermine le numero grâce à la date
# Liste des eval existantes triées par date, la plus ancienne en tete
evaluations = moduleimpl.evaluations.order_by(Evaluation.date_debut).all()
if date_debut is not None:
next_eval = None
t = date_debut
for e in evaluations:
if e.date_debut and e.date_debut > t:
next_eval = e
break
if next_eval:
n = _moduleimpl_evaluation_insert_before(evaluations, next_eval)
else:
n = None # à placer en fin
if n is None: # pas de date ou en fin:
if evaluations:
n = evaluations[-1].numero + 1
else:
n = 0 # the only one
return n
def delete(self):
"delete evaluation (commit) (check permission)"
from app.scodoc import sco_evaluation_db
modimpl: "ModuleImpl" = self.moduleimpl
if not modimpl.can_edit_evaluation(current_user):
raise AccessDenied(
f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
)
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
self.id
) # { etudid : value }
notes = [x["value"] for x in notes_db.values()]
if notes:
raise ScoValueError(
"Impossible de supprimer cette évaluation: il reste des notes"
)
log(f"deleting evaluation {self}")
db.session.delete(self)
db.session.commit()
# inval cache pour ce semestre
sco_cache.invalidate_formsemestre(formsemestre_id=modimpl.formsemestre_id)
# news
url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=modimpl.id,
text=f"""Suppression d'une évaluation dans <a href="{
url
}">{modimpl.module.titre}</a>""",
url=url,
)
def to_dict(self) -> dict:
"Représentation dict (riche, compat ScoDoc 7)"
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
e_dict = dict(self.__dict__)
e_dict.pop("_sa_instance_state", None)
# ScoDoc7 output_formators
e["evaluation_id"] = self.id
e["jour"] = e["jour"].strftime("%d/%m/%Y") if e["jour"] else ""
if self.jour is None:
e["date_debut"] = None
e["date_fin"] = None
else:
e["date_debut"] = datetime.datetime.combine(
self.jour, self.heure_debut or datetime.time(0, 0)
).isoformat()
e["date_fin"] = datetime.datetime.combine(
self.jour, self.heure_fin or datetime.time(0, 0)
).isoformat()
e["numero"] = ndb.int_null_is_zero(e["numero"])
e["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
return evaluation_enrich_dict(e)
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["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 ""
)
return evaluation_enrich_dict(self, e_dict)
def to_dict_api(self) -> dict:
"Représentation dict pour API JSON"
if self.jour is None:
date_debut = None
date_fin = None
else:
date_debut = datetime.datetime.combine(
self.jour, self.heure_debut or datetime.time(0, 0)
).isoformat()
date_fin = datetime.datetime.combine(
self.jour, self.heure_fin or datetime.time(0, 0)
).isoformat()
return {
"blocked": self.is_blocked(),
"blocked_until": (
self.blocked_until.isoformat() if self.blocked_until else ""
),
"coefficient": self.coefficient,
"date_debut": date_debut,
"date_fin": date_fin,
"date_debut": self.date_debut.isoformat() if self.date_debut else "",
"date_fin": self.date_fin.isoformat() if self.date_fin else "",
"description": self.description,
"evaluation_type": self.evaluation_type,
"id": self.id,
@ -99,66 +240,170 @@ class Evaluation(db.Model):
"numero": self.numero,
"poids": self.get_ue_poids_dict(),
"publish_incomplete": self.publish_incomplete,
"visi_bulletin": self.visibulletin,
"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_fin": self.date_fin.time().isoformat() if self.date_fin else "",
}
def from_dict(self, data):
"""Set evaluation attributes from given dict values."""
check_evaluation_args(data)
for k in self.__dict__.keys():
if k != "_sa_instance_state" and k != "id" and k in data:
setattr(self, k, data[k])
def to_dict_bul(self) -> dict:
"dict pour les bulletins json"
# c'est la version API avec quelques champs legacy en plus
e_dict = self.to_dict_api()
# Pour les bulletins (json ou xml), quote toujours la description
e_dict["description"] = quote_xml_attr(self.description or "")
# deprecated fields:
e_dict["evaluation_id"] = self.id
e_dict["jour"] = e_dict["date_debut"] # chaine iso
e_dict["heure_debut"] = (
self.date_debut.time().isoformat() if self.date_debut else ""
)
e_dict["heure_fin"] = self.date_fin.time().isoformat() if self.date_fin else ""
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()
@classmethod
def get_max_numero(cls, moduleimpl_id: int) -> int:
"""Return max numero among evaluations in this
moduleimpl (0 if None)
"""
max_num = (
db.session.query(sa.sql.functions.max(Evaluation.numero))
.filter_by(moduleimpl_id=moduleimpl_id)
.first()[0]
)
return max_num or 0
@classmethod
def moduleimpl_evaluation_renumber(
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
Note: existing numeros are ignored
"""
# Liste des eval existantes triées par date, la plus ancienne en tete
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)
if all_numbered and only_if_unumbered:
return # all ok
# Reset all numeros:
i = 1
for e in evaluations:
e.numero = i
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"
if self.heure_debut and (
not self.heure_fin or self.heure_fin == self.heure_debut
):
return f"""à {self.heure_debut.strftime("%Hh%M")}"""
elif self.heure_debut and self.heure_fin:
return f"""de {self.heure_debut.strftime("%Hh%M")} à {self.heure_fin.strftime("%Hh%M")}"""
"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)}"""
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)}"""
else:
return ""
def descr_duree(self) -> str:
"Description de la durée pour affichages"
if self.heure_debut is None and self.heure_fin is None:
"Description de la durée pour affichages ('3h' ou '2h30')"
if self.date_debut is None or self.date_fin is None:
return ""
debut = self.heure_debut or DEFAULT_EVALUATION_TIME
fin = self.heure_fin or DEFAULT_EVALUATION_TIME
d = (fin.hour * 60 + fin.minute) - (debut.hour * 60 + debut.minute)
duree = f"{d//60}h"
if d % 60:
duree += f"{d%60:02d}"
minutes = (self.date_fin - self.date_debut).seconds // 60
duree = f"{minutes // 60}h"
minutes = minutes % 60
if minutes != 0:
duree += f"{minutes:02d}"
return duree
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain commit
def descr_date(self) -> str:
"""Description de la date pour affichages
'sans date'
'le 21/9/2021 à 13h'
'le 21/9/2021 de 13h à 14h30'
'du 21/9/2021 à 13h30 au 23/9/2021 à 15h'
"""
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
if self.date_debut is None:
return "sans date"
def _h(dt: datetime.datetime) -> str:
if dt.minute:
return dt.strftime(scu.TIME_FMT)
return f"{dt.hour}h"
if self.date_fin is None:
return f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}"
if self.date_debut.date() == self.date_fin.date(): # même jour
if self.date_debut.time() == self.date_fin.time():
return (
f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}"
)
return f"""le {self.date_debut.strftime('%d/%m/%Y')} de {
_h(self.date_debut)} à {_h(self.date_fin)}"""
# évaluation sur plus d'une journée
return f"""du {self.date_debut.strftime('%d/%m/%Y')} à {
_h(self.date_debut)} au {self.date_fin.strftime('%d/%m/%Y')} à {_h(self.date_fin)}"""
def heure_debut(self) -> str:
"""L'heure de début (sans la date), en ISO.
Chaine vide si non renseignée."""
return self.date_debut.time().isoformat("minutes") if self.date_debut else ""
def heure_fin(self) -> str:
"""L'heure de fin (sans la date), en ISO.
Chaine vide si non renseignée."""
return self.date_fin.time().isoformat("minutes") if self.date_fin else ""
def is_matin(self) -> bool:
"Evaluation ayant lieu le matin (faux si pas de date)"
heure_debut_dt = self.heure_debut or datetime.time(8, 00)
# 8:00 au cas ou pas d'heure (note externe?)
return bool(self.jour) and heure_debut_dt < datetime.time(12, 00)
"Evaluation commençant le matin (faux si pas de date)"
if not self.date_debut:
return False
return self.date_debut.time() < NOON
def is_apresmidi(self) -> bool:
"Evaluation ayant lieu l'après midi (faux si pas de date)"
heure_debut_dt = self.heure_debut or datetime.time(8, 00)
# 8:00 au cas ou pas d'heure (note externe?)
return bool(self.jour) and heure_debut_dt >= datetime.time(12, 00)
"Evaluation commençant l'après midi (faux si pas de date)"
if not self.date_debut:
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 bvers les UE à leurs valeurs par défaut
"""Initialize les poids vers les UE à leurs valeurs par défaut
C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
Les poids existants ne sont pas modifiés.
Return True if (uncommited) modification, False otherwise.
@ -181,18 +426,28 @@ class Evaluation(db.Model):
return modified
def set_ue_poids(self, ue, poids: float) -> None:
"""Set poids évaluation vers cette UE"""
"""Set poids évaluation vers cette UE. Commit."""
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 = UniteEns.query.get(ue_id)
L.append(EvaluationUEPoids(evaluation=self, ue=ue, poids=poids))
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)
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:
@ -217,8 +472,8 @@ class Evaluation(db.Model):
def get_ue_poids_str(self) -> str:
"""string describing poids, for excel cells and pdfs
Note: si les poids ne sont pas initialisés (poids par défaut),
ils ne sont pas affichés.
Note: les poids nuls ou non initialisés (poids par défaut),
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
@ -229,7 +484,7 @@ class Evaluation(db.Model):
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
if evaluation_semestre_idx == p.ue.semestre_idx and (p.poids or 0) > 0
]
)
@ -239,6 +494,29 @@ class Evaluation(db.Model):
"""
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)
@ -264,7 +542,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"),
)
@ -272,88 +550,142 @@ class EvaluationUEPoids(db.Model):
return f"<EvaluationUEPoids {self.evaluation} {self.ue} poids={self.poids}>"
# Fonction héritée de ScoDoc7 à refactorer
def evaluation_enrich_dict(e: dict):
# Fonction héritée de ScoDoc7
def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
"""add or convert some fields in an evaluation dict"""
# For ScoDoc7 compat
heure_debut_dt = e["heure_debut"] or datetime.time(
8, 00
) # au cas ou pas d'heure (note externe?)
heure_fin_dt = e["heure_fin"] or datetime.time(8, 00)
e["heure_debut"] = ndb.TimefromISO8601(e["heure_debut"])
e["heure_fin"] = ndb.TimefromISO8601(e["heure_fin"])
e["jour_iso"] = ndb.DateDMYtoISO(e["jour"])
heure_debut, heure_fin = e["heure_debut"], e["heure_fin"]
d = ndb.TimeDuration(heure_debut, heure_fin)
if d is not None:
m = d % 60
e["duree"] = "%dh" % (d / 60)
if m != 0:
e["duree"] += "%02d" % m
else:
e["duree"] = ""
if heure_debut and (not heure_fin or heure_fin == heure_debut):
e["descrheure"] = " à " + heure_debut
elif heure_debut and heure_fin:
e["descrheure"] = " de %s à %s" % (heure_debut, heure_fin)
else:
e["descrheure"] = ""
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["jour_iso"] = e.date_debut.isoformat() if e.date_debut else ""
# Calcule durée en minutes
e_dict["descrheure"] = e.descr_heure()
e_dict["descrduree"] = e.descr_duree()
# matin, apresmidi: utile pour se referer aux absences:
if e["jour"] and heure_debut_dt < datetime.time(12, 00):
e["matin"] = 1
# note août 2023: si l'évaluation s'étend sur plusieurs jours,
# cet indicateur n'a pas grand sens
if e.date_debut and e.date_debut.time() < datetime.time(12, 00):
e_dict["matin"] = 1
else:
e["matin"] = 0
if e["jour"] and heure_fin_dt > datetime.time(12, 00):
e["apresmidi"] = 1
e_dict["matin"] = 0
if e.date_fin and e.date_fin.time() > datetime.time(12, 00):
e_dict["apresmidi"] = 1
else:
e["apresmidi"] = 0
return e
e_dict["apresmidi"] = 0
return e_dict
def check_evaluation_args(args):
"Check coefficient, dates and duration, raises exception if invalid"
moduleimpl_id = args["moduleimpl_id"]
# check bareme
note_max = args.get("note_max", None)
if note_max is None:
raise ScoValueError("missing note_max")
def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
"""Check coefficient, dates and duration, raises exception if invalid.
Convert date and time strings to date and time objects.
Set required default value for unspecified fields.
May raise ScoValueError.
"""
# --- description
data["description"] = data.get("description", "") or ""
if len(data["description"]) > scu.MAX_TEXT_LEN:
raise ScoValueError("description too large")
# --- evaluation_type
try:
data["evaluation_type"] = int(data.get("evaluation_type", 0) or 0)
if not data["evaluation_type"] in Evaluation.VALID_EVALUATION_TYPES:
raise ScoValueError("invalid evaluation_type value")
except ValueError as exc:
raise ScoValueError("invalid evaluation_type value") from exc
# --- note_max (bareme)
note_max = data.get("note_max", 20.0) or 20.0
try:
note_max = float(note_max)
except ValueError:
raise ScoValueError("Invalid note_max value")
except ValueError as exc:
raise ScoValueError("invalid note_max value") from exc
if note_max < 0:
raise ScoValueError("Invalid note_max value (must be positive or null)")
# check coefficient
coef = args.get("coefficient", None)
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:
raise ScoValueError("missing coefficient")
coef = 1.0
try:
coef = float(coef)
except ValueError:
raise ScoValueError("Invalid coefficient value")
except ValueError as exc:
raise ScoValueError("invalid coefficient value") from exc
if coef < 0:
raise ScoValueError("Invalid coefficient value (must be positive or null)")
# check date
jour = args.get("jour", None)
args["jour"] = jour
if jour:
modimpl = ModuleImpl.query.get(moduleimpl_id)
formsemestre = modimpl.formsemestre
y, m, d = [int(x) for x in ndb.DateDMYtoISO(jour).split("-")]
jour = datetime.date(y, m, d)
if (jour > formsemestre.date_fin) or (jour < formsemestre.date_debut):
raise ScoValueError("invalid coefficient value (must be positive or null)")
data["coefficient"] = coef
# --- date de l'évaluation dans le semestre ?
formsemestre = moduleimpl.formsemestre
date_debut = data.get("date_debut", None)
if date_debut:
if isinstance(date_debut, str):
data["date_debut"] = datetime.datetime.fromisoformat(date_debut)
if data["date_debut"].tzinfo is None:
data["date_debut"] = scu.TIME_ZONE.localize(data["date_debut"])
if not (
formsemestre.date_debut
<= data["date_debut"].date()
<= formsemestre.date_fin
):
raise ScoValueError(
"La date de l'évaluation (%s/%s/%s) n'est pas dans le semestre !"
% (d, m, y),
f"""La date de début de l'évaluation ({
data["date_debut"].strftime(scu.DATE_FMT)
}) n'est pas dans le semestre !""",
dest_url="javascript:history.back();",
)
heure_debut = args.get("heure_debut", None)
args["heure_debut"] = heure_debut
heure_fin = args.get("heure_fin", None)
args["heure_fin"] = heure_fin
if jour and ((not heure_debut) or (not heure_fin)):
raise ScoValueError("Les heures doivent être précisées")
d = ndb.TimeDuration(heure_debut, heure_fin)
if d and ((d < 0) or (d > 60 * 12)):
raise ScoValueError("Heures de l'évaluation incohérentes !")
date_fin = data.get("date_fin", None)
if date_fin:
if isinstance(date_fin, str):
data["date_fin"] = datetime.datetime.fromisoformat(date_fin)
if data["date_fin"].tzinfo is None:
data["date_fin"] = scu.TIME_ZONE.localize(data["date_fin"])
if not (
formsemestre.date_debut <= data["date_fin"].date() <= formsemestre.date_fin
):
raise ScoValueError(
f"""La date de fin de l'évaluation ({
data["date_fin"].strftime(scu.DATE_FMT)
}) 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
def heure_to_time(heure: str) -> datetime.time:
"Convert external heure ('10h22' or '10:22') to a time"
t = heure.strip().upper().replace("H", ":")
h, m = t.split(":")[:2]
return datetime.time(int(h), int(m))
def _moduleimpl_evaluation_insert_before(
evaluations: list[Evaluation], next_eval: Evaluation
) -> int:
"""Renumber evaluations such that an evaluation with can be inserted before next_eval
Returns numero suitable for the inserted evaluation
"""
if next_eval:
n = next_eval.numero
if n is None:
Evaluation.moduleimpl_evaluation_renumber(next_eval.moduleimpl)
n = next_eval.numero
else:
n = 1
# all numeros >= n are incremented
for e in evaluations:
if e.numero >= n:
e.numero += 1
db.session.add(e)
db.session.commit()
return n
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription

View File

@ -13,7 +13,6 @@ 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
@ -133,7 +132,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):
def add(cls, typ, obj=None, text="", url=None, max_frequency=600, dept_id=None):
"""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).
@ -141,10 +140,11 @@ 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=g.scodoc_dept_id,
dept_id=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=g.scodoc_dept_id,
dept_id=dept_id,
authenticated_user=current_user.user_name,
type=typ,
object=obj,
@ -180,6 +180,7 @@ 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:
@ -187,14 +188,14 @@ class ScolarNews(db.Model):
elif self.type == self.NEWS_NOTE:
moduleimpl_id = self.object
if moduleimpl_id:
modimpl = ModuleImpl.query.get(moduleimpl_id)
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
if modimpl is None:
return None # module does not exists anymore
formsemestre_id = modimpl.formsemestre_id
if not formsemestre_id:
return None
formsemestre = FormSemestre.query.get(formsemestre_id)
formsemestre = db.session.get(FormSemestre, formsemestre_id)
return formsemestre
def notify_by_mail(self):
@ -231,7 +232,9 @@ class ScolarNews(db.Model):
)
# Transforme les URL en URL absolues
base = scu.ScoURL()
base = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)[
: -len("/index_html")
]
txt = re.sub('href=/.*?"', 'href="' + base + "/", txt)
# Transforme les liens HTML en texte brut: '<a href="url">texte</a>' devient 'texte: url'
@ -248,11 +251,12 @@ 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="news"><span class="newstitle"><a href="{
url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
f"""<div class="scobox news"><div class="scobox-title"><a href="{
dept_news_url
}">Dernières opérations</a>
</span><ul class="newslist">"""
</div><ul class="newslist">"""
]
for news in news_list:
@ -260,19 +264,22 @@ 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>")
H.append("</ul></div>")
# Informations générales
H.append(
f"""<div>
Pour être informé des évolutions de ScoDoc,
vous pouvez vous
<a class="stdlink" href="{scu.SCO_ANNONCES_WEBSITE}">
abonner à la liste de diffusion</a>.
Pour en savoir plus sur ScoDoc voir
<a class="stdlink" href="{scu.SCO_ANNONCES_WEBSITE}">scodoc.org</a>
</div>
"""
)
H.append("</div>")
return "\n".join(H)

View File

@ -1,5 +1,7 @@
"""ScoDoc 9 models : Formations
"""
from flask import abort, g
from flask_sqlalchemy.query import Query
import app
@ -64,6 +66,21 @@ 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 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -10,18 +10,22 @@
"""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 flash, g, url_for
from flask import abort, 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,
@ -29,23 +33,29 @@ 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
from app.models.moduleimpls import (
ModuleImpl,
ModuleImplInscription,
notes_modules_enseignants,
)
from app.models.modules import Module
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus, sco_preferences
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric
from app.scodoc.sco_vdi import ApoEtapeVDI
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
class FormSemestre(db.Model):
class FormSemestre(models.ScoDocModel):
"""Mise en oeuvre d'un semestre de formation"""
__tablename__ = "notes_formsemestre"
@ -59,7 +69,9 @@ class FormSemestre(db.Model):
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)
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)"
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
"False si verrouillé"
modalite = db.Column(
@ -73,7 +85,7 @@ class FormSemestre(db.Model):
bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
"ne publie pas le bulletin XML ou JSON"
"ne publie pas le bulletin sur l'API"
block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
@ -82,6 +94,10 @@ class FormSemestre(db.Model):
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"
)
@ -135,6 +151,7 @@ class FormSemestre(db.Model):
secondary="notes_formsemestre_responsables",
lazy=True,
backref=db.backref("formsemestres", lazy=True),
order_by=func.upper(User.nom),
)
partitions = db.relationship(
"Partition",
@ -163,25 +180,35 @@ class FormSemestre(db.Model):
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>"
def html_link_status(self) -> str:
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=g.scodoc_dept,
url_for("notes.formsemestre_status", scodoc_dept=self.departement.acronym,
formsemestre_id=self.id,)
}">{self.titre_mois()}</a>
}" title="{title or ''}">{label or self.titre_mois()}</a>
"""
@classmethod
def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre":
""" "FormSemestre ou 404, cherche uniquement dans le département courant"""
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")
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=g.scodoc_dept_id
id=formsemestre_id, dept_id=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 alphabétique
"""clé pour tris par ordre de date_debut, le plus ancien en tête
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
return (self.date_debut, self.semestre_id)
@ -192,16 +219,17 @@ class FormSemestre(db.Model):
"""
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("%d/%m/%Y")
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
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("%d/%m/%Y")
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
d["date_fin_iso"] = self.date_fin.isoformat()
else:
d["date_fin"] = d["date_fin_iso"] = ""
@ -219,18 +247,20 @@ class FormSemestre(db.Model):
def to_dict_api(self):
"""
Un dict avec les informations sur le semestre destiné à l'api
Un dict avec les informations sur le semestre destinées à 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("%d/%m/%Y")
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
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("%d/%m/%Y")
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
d["date_fin_iso"] = self.date_fin.isoformat()
else:
d["date_fin"] = d["date_fin_iso"] = ""
@ -246,6 +276,27 @@ class FormSemestre(db.Model):
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
@ -305,6 +356,17 @@ class FormSemestre(db.Model):
- et sont associées à l'un des parcours de ce formsemestre
(ou à aucun, donc tronc commun).
"""
# per-request caching
key = (self.id, with_sport)
_cache = getattr(g, "_formsemestre_get_ues_cache", None)
if _cache:
result = _cache.get(key, False)
if result is not False:
return result
else:
g._formsemestre_get_ues_cache = {}
_cache = g._formsemestre_get_ues_cache
formation: Formation = self.formation
if formation.is_apc():
# UEs de tronc commun (sans parcours indiqué)
@ -324,8 +386,7 @@ class FormSemestre(db.Model):
).filter(UniteEns.semestre_idx == self.semestre_id)
}
)
ues = sem_ues.values()
return sorted(ues, key=attrgetter("numero", "acronyme"))
ues = sorted(sem_ues.values(), key=attrgetter("numero", "acronyme"))
else:
sem_ues = db.session.query(UniteEns).filter(
ModuleImpl.formsemestre_id == self.id,
@ -334,13 +395,105 @@ class FormSemestre(db.Model):
)
if not with_sport:
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
return sem_ues.order_by(UniteEns.numero).all()
ues = sem_ues.order_by(UniteEns.numero).all()
_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 (
Evaluation.query.join(ModuleImpl)
.filter_by(formsemestre_id=self.id)
.join(Module)
.order_by(
Module.numero,
Module.code,
Evaluation.numero,
Evaluation.date_debut,
)
.all()
)
@cached_property
def modimpls_sorted(self) -> list[ModuleImpl]:
"""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():
@ -352,6 +505,14 @@ class FormSemestre(db.Model):
)
)
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,
@ -382,11 +543,11 @@ class FormSemestre(db.Model):
),
{"formsemestre_id": self.id, "parcours_id": parcours.id},
)
return [ModuleImpl.query.get(modimpl_id) for modimpl_id in cursor]
return [db.session.get(ModuleImpl, modimpl_id) for modimpl_id in cursor]
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.ScoImplement): # pas chef
if not user.has_permission(Permission.EditFormSemestre): # pas chef
if not self.resp_can_edit or user.id not in [
resp.id for resp in self.responsables
]:
@ -463,7 +624,7 @@ class FormSemestre(db.Model):
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
@ -513,6 +674,26 @@ class FormSemestre(db.Model):
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
@ -526,6 +707,11 @@ class FormSemestre(db.Model):
return ""
return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
def add_etape(self, etape_apo: str):
"Ajoute une étape"
etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=etape_apo)
db.session.add(etape)
def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
"""Calcule la liste des regroupements cohérents d'UE impliquant ce
formsemestre.
@ -564,10 +750,21 @@ class FormSemestre(db.Model):
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.ScoImplement) or self.est_responsable(
return user.has_permission(Permission.EditFormSemestre) 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).
"""
if not self.etat:
return False # semestre verrouillé
user = user or current_user
if user.has_permission(Permission.EtudChangeGroups):
return True # typiquement admin, chef dept
return self.est_responsable(user)
def can_edit_jury(self, user: User = None):
"""Vrai si utilisateur (par def. current) peut saisir decision de jury
dans ce semestre: vérifie permission et verrouillage.
@ -578,9 +775,9 @@ class FormSemestre(db.Model):
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 ScoEtudChangeAdr
# Autorise les secrétariats, repérés via la permission EtudChangeAdr
return self.est_chef_or_diretud(user) or user.has_permission(
Permission.ScoEtudChangeAdr
Permission.EtudChangeAdr
)
def annee_scolaire(self) -> int:
@ -679,15 +876,19 @@ class FormSemestre(db.Model):
descr_sem += " " + self.modalite
return descr_sem
def get_abs_count(self, etudid):
def get_abs_count(self, etudid) -> tuple[int, int, int]:
"""Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs, nb abs justifiées)
tuple (nb abs non just, nb abs justifiées, nb abs total)
Utilise un cache.
"""
from app.scodoc import sco_abs
from app.scodoc import sco_assiduites
return sco_abs.get_abs_count_in_interval(
etudid, self.date_debut.isoformat(), self.date_fin.isoformat()
metrique = sco_preferences.get_preference("assi_metrique", self.id)
return sco_assiduites.get_assiduites_count_in_interval(
etudid,
self.date_debut.isoformat(),
self.date_fin.isoformat(),
translate_assiduites_metric(metrique),
)
def get_codes_apogee(self, category=None) -> set[str]:
@ -717,12 +918,34 @@ class FormSemestre(db.Model):
etuds.sort(key=lambda e: e.sort_key)
return etuds
@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 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) -> 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
def etuds_inscriptions(self) -> dict:
"""Map { etudid : inscription } (incluant DEM et DEF)"""
return {ins.etud.id: ins for ins in self.inscriptions}
@ -784,11 +1007,15 @@ class FormSemestre(db.Model):
db.session.commit()
def update_inscriptions_parcours_from_groups(self) -> None:
def update_inscriptions_parcours_from_groups(self, etudid: int = None) -> None:
"""Met à jour les inscriptions dans les parcours du semestres en
fonction des groupes de parcours.
Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS
et leur nom est le code du parcours (eg "Cyber").
Si etudid est spécifié, n'affecte que cet étudiant,
sinon traite tous les inscrits du semestre.
"""
if self.formation.referentiel_competence_id is None:
return # safety net
@ -799,17 +1026,32 @@ class FormSemestre(db.Model):
return
# Efface les inscriptions aux parcours:
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription
SET parcour_id=NULL
WHERE formsemestre_id=:formsemestre_id
"""
),
{
"formsemestre_id": self.id,
},
)
if etudid:
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription
SET parcour_id=NULL
WHERE formsemestre_id=:formsemestre_id
AND etudid=:etudid
"""
),
{
"formsemestre_id": self.id,
"etudid": etudid,
},
)
else:
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription
SET parcour_id=NULL
WHERE formsemestre_id=:formsemestre_id
"""
),
{
"formsemestre_id": self.id,
},
)
# Inscrit les étudiants des groupes de parcours:
for group in partition.groups:
query = (
@ -827,22 +1069,42 @@ class FormSemestre(db.Model):
)
continue
parcour = query.first()
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription ins
SET parcour_id=:parcour_id
FROM group_membership gm
WHERE formsemestre_id=:formsemestre_id
AND gm.etudid = ins.etudid
AND gm.group_id = :group_id
"""
),
{
"formsemestre_id": self.id,
"parcour_id": parcour.id,
"group_id": group.id,
},
)
if etudid:
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription ins
SET parcour_id=:parcour_id
FROM group_membership gm
WHERE formsemestre_id=:formsemestre_id
AND ins.etudid = :etudid
AND gm.etudid = :etudid
AND gm.group_id = :group_id
"""
),
{
"etudid": etudid,
"formsemestre_id": self.id,
"parcour_id": parcour.id,
"group_id": group.id,
},
)
else:
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription ins
SET parcour_id=:parcour_id
FROM group_membership gm
WHERE formsemestre_id=:formsemestre_id
AND gm.etudid = ins.etudid
AND gm.group_id = :group_id
"""
),
{
"formsemestre_id": self.id,
"parcour_id": parcour.id,
"group_id": group.id,
},
)
db.session.commit()
def etud_validations_description_html(self, etudid: int) -> str:
@ -867,15 +1129,12 @@ class FormSemestre(db.Model):
.order_by(UniteEns.numero)
.all()
)
vals_annee = ( # issues de ce formsemestre seulement
vals_annee = ( # issues de cette année scolaire seulement
ApcValidationAnnee.query.filter_by(
etudid=etudid,
annee_scolaire=self.annee_scolaire(),
)
.join(ApcValidationAnnee.formsemestre)
.join(FormSemestre.formation)
.filter(Formation.formation_code == self.formation.formation_code)
.all()
referentiel_competence_id=self.formation.referentiel_competence_id,
).all()
)
H = []
for vals in (vals_sem, vals_ues, vals_rcues, vals_annee):
@ -910,6 +1169,33 @@ class FormSemestre(db.Model):
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,21 +1,25 @@
# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc models: Groups & partitions
"""
from operator import attrgetter
from sqlalchemy.exc import IntegrityError
from app import db
from app.models import SHORT_STR_LEN
from app.models import GROUPNAME_STR_LEN
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.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
class Partition(db.Model):
class Partition(ScoDocModel):
"""Partition: découpage d'une promotion en groupes"""
__table_args__ = (db.UniqueConstraint("formsemestre_id", "partition_name"),)
@ -48,7 +52,7 @@ class Partition(db.Model):
backref=db.backref("partition", lazy=True),
lazy="dynamic",
cascade="all, delete-orphan",
order_by="GroupDescr.numero",
order_by="GroupDescr.numero, GroupDescr.group_name",
)
def __init__(self, **kwargs):
@ -81,6 +85,14 @@ class Partition(db.Model):
return False
return True
@classmethod
def formsemestre_remove_etud(cls, formsemestre_id: int, etud: "Identite"):
"retire l'étudiant de toutes les partitions de ce semestre"
for group in GroupDescr.query.join(Partition).filter_by(
formsemestre_id=formsemestre_id
):
group.remove_etud(etud)
def is_parcours(self) -> bool:
"Vrai s'il s'agit de la partition de parcours"
return self.partition_name == scu.PARTITION_PARCOURS
@ -117,8 +129,83 @@ class Partition(db.Model):
.first()
)
def set_etud_group(self, etud: "Identite", group: "GroupDescr") -> bool:
"""Affect etudid to group_id in given partition.
Raises IntegrityError si conflit,
or ValueError si ce group_id n'est pas dans cette partition
ou que l'étudiant n'est pas inscrit au semestre.
Return True si changement, False s'il était déjà dans ce groupe.
"""
if not group.id in (g.id for g in self.groups):
raise ScoValueError(
f"""Le groupe {group.id} n'est pas dans la partition {
self.partition_name or "tous"}"""
)
if etud.id not in (e.id for e in self.formsemestre.etuds):
raise ScoValueError(
f"""étudiant {etud.nomprenom} non inscrit au formsemestre du groupe {
group.group_name}"""
)
try:
existing_row = (
db.session.query(group_membership)
.filter_by(etudid=etud.id)
.join(GroupDescr)
.filter_by(partition_id=self.id)
.first()
)
if existing_row:
existing_group_id = existing_row[1]
if group.id == existing_group_id:
return False
# Fait le changement avec l'ORM sinon risque élevé de blocage
existing_group = db.session.get(GroupDescr, existing_group_id)
db.session.commit()
group.etuds.append(etud)
existing_group.etuds.remove(etud)
db.session.add(etud)
db.session.add(existing_group)
db.session.add(group)
else:
new_row = group_membership.insert().values(
etudid=etud.id, group_id=group.id
)
db.session.execute(new_row)
db.session.commit()
except IntegrityError:
db.session.rollback()
raise
return True
class GroupDescr(db.Model):
def create_group(self, group_name="", default=False) -> "GroupDescr":
"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,
ou bien le semestre est verrouillé !"""
)
if group_name:
group_name = group_name.strip()
if not group_name and not default:
raise ValueError("invalid group name: ()")
if not GroupDescr.check_name(self, group_name, default=default):
raise ScoValueError(
f"Le groupe {group_name} existe déjà dans cette partition"
)
numeros = [g.numero if g.numero is not None else 0 for g in self.groups]
if len(numeros) > 0:
new_numero = max(numeros) + 1
else:
new_numero = 0
group = GroupDescr(partition=self, group_name=group_name, numero=new_numero)
db.session.add(group)
db.session.commit()
log(f"create_group: created group_id={group.id}")
#
return group
class GroupDescr(ScoDocModel):
"""Description d'un groupe d'une partition"""
__tablename__ = "group_descr"
@ -127,10 +214,12 @@ class GroupDescr(db.Model):
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))
# Numero = ordre de presentation
"""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 = db.Column(db.Integer, nullable=False, default=0)
"Numero = ordre de presentation"
etuds = db.relationship(
"Identite",
@ -143,18 +232,46 @@ class GroupDescr(db.Model):
f"""<{self.__class__.__name__} {self.id} "{self.group_name or '(tous)'}">"""
)
def get_nom_with_part(self) -> str:
"Nom avec partition: 'TD A'"
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
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"] = self.partition.to_dict(with_groups=False)
d["partition"] = partition_dict
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
@ -171,6 +288,64 @@ class GroupDescr(db.Model):
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:
self.etuds.remove(etud)
db.session.commit()
Scolog.logdb(
method="group_remove_etud",
etudid=etud.id,
msg=f"Retrait du groupe {self.group_name} de {self.partition.partition_name}",
commit=True,
)
# Update parcours
if self.partition.partition_name == scu.PARTITION_PARCOURS:
self.partition.formsemestre.update_inscriptions_parcours_from_groups(
etudid=etud.id
)
sco_cache.invalidate_formsemestre(self.partition.formsemestre_id)
group_membership = db.Table(
"group_membership",

View File

@ -2,25 +2,34 @@
"""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(db.Model):
class ModuleImpl(ScoDocModel):
"""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(
@ -29,27 +38,50 @@ class ModuleImpl(db.Model):
index=True,
nullable=False,
)
responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id"))
responsable_id = db.Column(
"responsable_id", db.Integer, db.ForeignKey("user.id", ondelete="SET NULL")
)
responsable = db.relationship("User", back_populates="modimpls")
# formule de calcul moyenne:
computation_expr = db.Column(db.Text())
evaluations = db.relationship("Evaluation", lazy="dynamic", backref="moduleimpl")
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."
enseignants = db.relationship(
"User",
secondary="notes_modules_enseignants",
lazy="dynamic",
backref="moduleimpl",
viewonly=True,
)
def __init__(self, **kwargs):
super(ModuleImpl, self).__init__(**kwargs)
"enseignants du module (sans le responsable)"
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 UE (accès via cache)"""
"""Les poids des évaluations vers les UEs (accès via cache redis).
Toutes les évaluations sont considérées (normales, bonus, rattr., etc.)
"""
evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id)
if evaluations_poids is None:
from app.comp import moy_mod
@ -58,24 +90,58 @@ class ModuleImpl(db.Model):
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") -> bool:
"""true si les poids des évaluations du module permettent de satisfaire
les coefficients du PN.
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.
"""
# appelé par formsemestre_status, liste notes, et moduleimpl_status
if not self.module.formation.get_cursus().APC_SAE or (
self.module.module_type != scu.ModuleType.RESSOURCE
and self.module.module_type != scu.ModuleType.SAE
self.module.module_type
not in {scu.ModuleType.RESSOURCE, 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,
self.get_evaluations_poids(),
selected_evaluations_poids,
res.modimpl_coefs_df,
)
@ -99,9 +165,53 @@ class ModuleImpl(db.Model):
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_change_ens_by(self, user: User, raise_exc=False) -> bool:
def can_edit_evaluation(self, user) -> bool:
"""True if this user can create, delete or edit and evaluation in this modimpl
(nb: n'implique pas le droit de saisir ou modifier des notes)
"""
# acces pour resp. moduleimpl et resp. form semestre (dir etud)
if (
user.has_permission(Permission.EditAllEvals)
or user.id == self.responsable_id
or user.id in (r.id for r in self.formsemestre.responsables)
):
return True
elif self.formsemestre.ens_can_edit_eval:
if user.id in (e.id for e in self.enseignants):
return True
return False
def can_edit_notes(self, user: "User", allow_ens=True) -> bool:
"""True if authuser can enter or edit notes in this module.
If allow_ens, grant access to all ens in this module
Si des décisions de jury ont déjà été saisies dans ce semestre,
seul le directeur des études peut saisir des notes (et il ne devrait pas).
"""
# was sco_permissions_check.can_edit_notes
from app.scodoc import sco_cursus_dut
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)
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
if (
not can_edit_all_notes
and user.id != self.responsable_id
and not is_dir_etud
):
# enseignant (chargé de TD) ?
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:
"""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)
@ -112,7 +222,7 @@ class ModuleImpl(db.Model):
return False
# -- check access
# admin ou resp. semestre avec flag resp_can_change_resp
if user.has_permission(Permission.ScoImplement):
if user.has_permission(Permission.EditFormSemestre):
return True
if (
user.id in [resp.id for resp in self.formsemestre.responsables]
@ -122,6 +232,43 @@ class ModuleImpl(db.Model):
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.
"""
is_module: int = (
ModuleImplInscription.query.filter_by(
etudid=etud.id, moduleimpl_id=self.id
).count()
> 0
)
return is_module
# Enseignants (chargés de TD ou TP) d'un moduleimpl
notes_modules_enseignants = db.Table(
@ -160,6 +307,14 @@ 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,18 +1,24 @@
"""ScoDoc 9 models : Modules
"""
from operator import attrgetter
from flask import current_app
from flask import current_app, g
from app import db
from app import models
from app.models import APO_CODE_STR_LEN
from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
from app.models.but_refcomp import (
ApcParcours,
ApcReferentielCompetences,
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(db.Model):
class Module(models.ScoDocModel):
"""Module"""
__tablename__ = "notes_modules"
@ -23,6 +29,7 @@ class Module(db.Model):
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)
@ -35,8 +42,10 @@ class Module(db.Model):
# 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:
@ -74,6 +83,55 @@ class Module(db.Model):
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(
@ -154,6 +212,14 @@ class Module(db.Model):
"""
return scu.ModuleType.get_abbrev(self.module_type)
def titre_str(self) -> str:
"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
@ -179,8 +245,8 @@ class Module(db.Model):
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.logguer.info(
f"set_ue_coef_dict: locked formation, ignoring request"
current_app.logger.info(
"set_ue_coef_dict: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
changed = False
@ -199,7 +265,7 @@ class Module(db.Model):
else:
# crée nouveau coef:
if coef != 0.0:
ue = UniteEns.query.get(ue_id)
ue = db.session.get(UniteEns, ue_id)
ue_coef = ModuleUECoef(module=self, ue=ue, coef=coef)
db.session.add(ue_coef)
self.ue_coefs.append(ue_coef)
@ -210,8 +276,8 @@ class Module(db.Model):
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.logguer.info(
f"update_ue_coef_dict: locked formation, ignoring request"
current_app.logger.info(
"update_ue_coef_dict: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
current = self.get_ue_coef_dict()
@ -229,11 +295,11 @@ class Module(db.Model):
def delete_ue_coef(self, ue):
"""delete coef"""
if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logguer.info(
current_app.logger.info(
"delete_ue_coef: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
ue_coef = ModuleUECoef.query.get((self.id, ue.id))
ue_coef = db.session.get(ModuleUECoef, (self.id, ue.id))
if ue_coef:
db.session.delete(ue_coef)
self.formation.invalidate_module_coefs()
@ -280,6 +346,13 @@ class Module(db.Model):
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 (!).
@ -293,6 +366,14 @@ class Module(db.Model):
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)
@ -355,6 +436,19 @@ class NotesTag(db.Model):
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
title = db.Column(db.Text(), nullable=False)
@classmethod
def get_or_create(cls, title: str, dept_id: int | None = None) -> "NotesTag":
"""Get tag, or create it if it doesn't yet exists.
If dept_id unspecified, use current dept.
"""
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
tag = NotesTag.query.filter_by(dept_id=dept_id, title=title).first()
if tag is None:
tag = NotesTag(dept_id=dept_id, title=title)
db.session.add(tag)
db.session.flush()
return tag
# Association tag <-> module
notes_modules_tags = db.Table(

View File

@ -5,6 +5,7 @@
import sqlalchemy as sa
from app import db
from app.scodoc import safehtml
import app.scodoc.sco_utils as scu
@ -26,6 +27,31 @@ class BulAppreciations(db.Model):
author = db.Column(db.Text) # le pseudo (user_name), sans contrainte
comment = db.Column(db.Text) # texte libre
@classmethod
def get_appreciations_list(
cls, formsemestre_id: int, etudid: int
) -> list["BulAppreciations"]:
"Liste des appréciations pour cet étudiant dans ce semestre"
return (
BulAppreciations.query.filter_by(
etudid=etudid, formsemestre_id=formsemestre_id
)
.order_by(BulAppreciations.date)
.all()
)
@classmethod
def summarize(cls, appreciations: list["BulAppreciations"]) -> list[str]:
"Liste de chaines résumant une liste d'appréciations, pour bulletins"
return [
f"{x.date.strftime('%d/%m/%Y') if x.date else ''}: {x.comment or ''}"
for x in appreciations
]
def comment_safe(self) -> str:
"Le comment, safe pour inclusion dans HTML (None devient '')"
return safehtml.html_to_safe_html(self.comment or "")
class NotesNotes(db.Model):
"""Une note"""
@ -57,7 +83,7 @@ class NotesNotes(db.Model):
from app.models.evaluations import Evaluation
return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} v={self.value} {self.date.isoformat()
} {Evaluation.query.get(self.evaluation_id) if self.evaluation_id else "X" }>"""
} {db.session.get(Evaluation, self.evaluation_id) if self.evaluation_id else "X" }>"""
class NotesNotesLog(db.Model):

View File

@ -0,0 +1,48 @@
"""é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})"

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