WIP: PR assiduites -> bac à sable prod #649

Closed
iziram wants to merge 84 commits from iziram/ScoDoc:assiduites into bac_a_sable_prod
196 changed files with 22218 additions and 2069 deletions

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

@ -3,6 +3,7 @@
import base64
import datetime
import json
import os
import socket
import sys
@ -12,16 +13,19 @@ import traceback
import logging
from logging.handlers import SMTPHandler, WatchedFileHandler
from threading import Thread
import warnings
from flask import current_app, g, request
from flask import Flask
from flask import abort, flash, has_request_context, jsonify
from flask import abort, flash, has_request_context
from flask import render_template
from flask.json import JSONEncoder
# 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
@ -29,9 +33,10 @@ from flask_moment import Moment
from flask_sqlalchemy import SQLAlchemy
from jinja2 import select_autoescape
import sqlalchemy
import sqlalchemy as sa
from flask_cas import CAS
import werkzeug.debug
from app.scodoc.sco_exceptions import (
AccessDenied,
@ -42,6 +47,8 @@ from app.scodoc.sco_exceptions import (
ScoValueError,
APIInvalidParams,
)
from app.scodoc.sco_vdi import ApoEtapeVDI
from config import DevConfig
import sco_version
@ -134,18 +141,22 @@ def _async_dump(app, request_url: str):
def handle_invalid_usage(error):
response = jsonify(error.to_dict())
response = json_response(data_=error.to_dict())
response.status_code = error.status_code
return response
# JSON ENCODING
class ScoDocJSONEncoder(JSONEncoder):
def default(self, o):
if isinstance(o, (datetime.datetime, datetime.date)):
# used by some internal finctions
# the API is now using flask_son, NOT THIS ENCODER
class ScoDocJSONEncoder(json.JSONEncoder):
def default(self, o): # pylint: disable=E0202
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat()
return super().default(o)
elif isinstance(o, ApoEtapeVDI):
return str(o)
else:
return json.JSONEncoder.default(self, o)
def render_raw_html(template_filename: str, **args) -> str:
@ -244,17 +255,33 @@ class ReverseProxied(object):
def create_app(config_class=DevConfig):
app = Flask(__name__, static_url_path="/ScoDoc/static", static_folder="static")
app.config.from_object(config_class)
from app.auth import cas
CAS(app, url_prefix="/cas", configuration_function=cas.set_cas_configuration)
app.wsgi_app = ReverseProxied(app.wsgi_app)
app.json_encoder = ScoDocJSONEncoder
FlaskJSON(app)
# Pour conserver l'ordre des objets dans les JSON:
# e.g. l'ordre des UE dans les bulletins
app.json.sort_keys = False
app.config.from_object(config_class)
# Evite de logguer toutes les requetes dans notre log
logging.getLogger("werkzeug").disabled = True
app.logger.setLevel(app.config["LOG_LEVEL"])
if app.config["TESTING"] or app.config["DEBUG"]:
# S'arrête sur tous les warnings, sauf
# flask_sqlalchemy/query (pb deprecation du model.get())
warnings.filterwarnings("error", module="flask_sqlalchemy/query")
# warnings.filterwarnings("ignore", module="json/provider.py") xxx sans effet en test
if app.config["DEBUG"]:
# comme on a désactivé ci-dessus les logs de werkzeug,
# on affiche nous même le PIN en mode debug:
print(
f""" * Debugger is active!
* Debugger PIN: {werkzeug.debug.get_pin_and_cookie_name(app)[0]}
"""
)
# Vérifie/crée lien sym pour les URL statiques
link_filename = f"{app.root_path}/static/links/{sco_version.SCOVERSION}"
if not os.path.exists(link_filename):
@ -295,6 +322,7 @@ 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
@ -313,6 +341,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")
@ -405,7 +436,7 @@ def create_app(config_class=DevConfig):
with app.app_context():
try:
set_cas_configuration(app)
except sqlalchemy.exc.ProgrammingError:
except sa.exc.ProgrammingError:
# Si la base n'a pas été upgradée (arrive durrant l'install)
# il se peut que la table scodoc_site_config n'existe pas encore.
pass
@ -417,7 +448,7 @@ def set_sco_dept(scodoc_dept: str, open_cnx=True):
# Check that dept exists
try:
dept = Departement.query.filter_by(acronym=scodoc_dept).first()
except sqlalchemy.exc.OperationalError:
except sa.exc.OperationalError:
abort(503)
if not dept:
raise ScoValueError(f"Invalid dept: {scodoc_dept}")
@ -495,14 +526,15 @@ def truncate_database():
"""
# use a stored SQL function, see createtables.sql
try:
db.session.execute("SELECT truncate_tables('scodoc');")
db.session.execute(sa.text("SELECT truncate_tables('scodoc');"))
db.session.commit()
except:
db.session.rollback()
raise
# Remet les compteurs (séquences sql) à zéro
db.session.execute(
"""
sa.text(
"""
CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$
DECLARE
statements CURSOR FOR
@ -518,6 +550,7 @@ def truncate_database():
SELECT reset_sequences('scodoc');
"""
)
)
db.session.commit()

View File

@ -1,8 +1,9 @@
"""api.__init__
"""
from flask_json import as_json
from flask import Blueprint
from flask import request
from flask import request, g
from app import db
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException
@ -34,9 +35,27 @@ 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):
"""
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, Formsemstre) -> join_cls
exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py
"""
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_or_404()
return unique.to_dict(format_api=True)
from app.api import tokens
from app.api import (
absences,
assiduites,
billets_absences,
departements,
etudiants,
@ -44,6 +63,7 @@ from app.api import (
formations,
formsemestres,
jury,
justificatifs,
logos,
partitions,
semset,

View File

@ -6,7 +6,7 @@
"""ScoDoc 9 API : Absences
"""
from flask import jsonify
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
@ -19,10 +19,12 @@ 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
@ -57,12 +59,13 @@ def absences(etudid: int = None):
abs_list = sco_abs.list_abs_date(etud.id)
for absence in abs_list:
absence["jour"] = absence["jour"].isoformat()
return jsonify(abs_list)
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é
@ -103,7 +106,7 @@ def absences_just(etudid: int = None):
]
for absence in abs_just:
absence["jour"] = absence["jour"].isoformat()
return jsonify(abs_just)
return abs_just
@bp.route(
@ -116,6 +119,7 @@ def absences_just(etudid: int = None):
)
@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)
@ -167,7 +171,7 @@ def abs_groupe_etat(group_id: int, date_debut=None, date_fin=None):
}
data.append(absence)
return jsonify(data)
return data
# XXX TODO EV: A REVOIR (data json dans le POST + modifier les routes)

877
app/api/assiduites.py Normal file
View File

@ -0,0 +1,877 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask_json import as_json
from flask import g, request
from flask_login import login_required, current_user
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object
from app.decorators import permission_required, scodoc
from app.models import Assiduite, FormSemestre, Identite, ModuleImpl
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@bp.route("/assiduite/<int:assiduite_id>")
@api_web_bp.route("/assiduite/<int:assiduite_id>")
@scodoc
@permission_required(Permission.ScoView)
def assiduite(assiduite_id: int = None):
"""Retourne un objet assiduité à partir de son id
Exemple de résultat:
{
"assiduite_id": 1,
"etudid": 2,
"moduleimpl_id": 3,
"date_debut": "2022-10-31T08:00+01:00",
"date_fin": "2022-10-31T10:00+01:00",
"etat": "retard",
"desc": "une description",
"user_id: 1 or null,
"est_just": False or True,
}
"""
return get_model_api_object(Assiduite, assiduite_id, Identite)
@bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def count_assiduites(etudid: int = None, with_query: bool = False):
"""
Retourne le nombre d'assiduités d'un étudiant
chemin : /assiduites/<int:etudid>/count
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/count/query?
Les différents filtres :
Type (type de comptage -> journee, demi, heure, nombre d'assiduite):
query?type=(journee, demi, heure) -> une seule valeur parmis les trois
ex: .../query?type=heure
Comportement par défaut : compte le nombre d'assiduité enregistrée
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemestre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
filtered: dict[str, object] = {}
metric: str = "all"
if with_query:
metric, filtered = _count_manager(request)
return scass.get_assiduites_stats(
assiduites=etud.assiduites, metric=metric, filtered=filtered
)
@bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def assiduites(etudid: int = None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /assiduites/<int:etudid>
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/query?
Les différents filtres :
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemstre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
assiduites_query = etud.assiduites
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
data_set: list[dict] = []
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data_set.append(data)
return data_set
@bp.route("/assiduites/group/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/group/query", defaults={"with_query": True})
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def assiduites_group(with_query: bool = False):
"""
Retourne toutes les assiduités d'un groupe d'étudiants
chemin : /assiduites/group/query?etudids=1,2,3
Un filtrage peut être donné avec une query
chemin : /assiduites/group/query?etudids=1,2,3
Les différents filtres :
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemstre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
"""
etuds = request.args.get("etudids", "")
etuds = etuds.split(",")
try:
etuds = [int(etu) for etu in etuds]
except ValueError:
return json_error(404, "Le champs etudids n'est pas correctement formé")
query = Identite.query.filter(Identite.id.in_(etuds))
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
if len(etuds) != query.count() or len(etuds) == 0:
return json_error(
404,
"Tous les étudiants ne sont pas dans le même département et/ou n'existe pas.",
)
assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds))
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
data_set: dict[list[dict]] = {key: [] for key in etuds}
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data_set.get(data["etudid"]).append(data)
return data_set
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/query",
defaults={"with_query": True},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/query",
defaults={"with_query": True},
)
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne toutes les assiduités du formsemestre"""
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
assiduites_query = scass.filter_by_formsemestre(Assiduite.query, formsemestre)
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
data_set: list[dict] = []
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data_set.append(data)
return data_set
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count",
defaults={"with_query": False},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count",
defaults={"with_query": False},
)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count/query",
defaults={"with_query": True},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count/query",
defaults={"with_query": True},
)
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def count_assiduites_formsemestre(
formsemestre_id: int = None, with_query: bool = False
):
"""Comptage des assiduités du formsemestre"""
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
etuds = formsemestre.etuds.all()
etuds_id = [etud.id for etud in etuds]
assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds_id))
assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre)
metric: str = "all"
filtered: dict = {}
if with_query:
metric, filtered = _count_manager(request)
return scass.get_assiduites_stats(assiduites_query, metric, filtered)
@bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@scodoc
@as_json
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_create(etudid: int = None):
"""
Création d'une assiduité 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,
"moduleimpl_id": int,
"desc":str,
}
...
]
"""
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
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: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(create_list):
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
else:
success[i] = obj
db.session.commit()
return {"errors": errors, "success": success}
@bp.route("/assiduites/create", methods=["POST"])
@api_web_bp.route("/assiduites/create", methods=["POST"])
@scodoc
@as_json
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduites_create():
"""
Création d'une assiduité ou plusieurs assiduites
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
"date_fin": str,
"etat": str,
"etudid":int,
},
{
"date_debut": str,
"date_fin": str,
"etat": str,
"etudid":int,
"moduleimpl_id": int,
"desc":str,
}
...
]
"""
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: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(create_list):
etud: Identite = Identite.query.filter_by(id=data["etudid"]).first()
if etud is None:
errors[i] = "Cet étudiant n'existe pas."
continue
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
else:
success[i] = obj
return {"errors": errors, "success": success}
def _create_singular(
data: dict,
etud: Identite,
) -> tuple[int, object]:
errors: list[str] = []
# -- vérifications de l'objet json --
# cas 1 : ETAT
etat = data.get("etat", None)
if etat is None:
errors.append("param 'etat': manquant")
elif not scu.EtatAssiduite.contains(etat):
errors.append("param 'etat': invalide")
etat = scu.EtatAssiduite.get(etat)
# cas 2 : date_debut
date_debut = data.get("date_debut", None)
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut, convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 3 : date_fin
date_fin = data.get("date_fin", None)
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin, convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
# cas 4 : moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
# cas 5 : desc
desc: str = data.get("desc", None)
if errors:
err: str = ", ".join(errors)
return (404, err)
# TOUT EST OK
try:
nouv_assiduite: Assiduite = Assiduite.create_assiduite(
date_debut=deb,
date_fin=fin,
etat=etat,
etud=etud,
moduleimpl=moduleimpl,
description=desc,
user_id=current_user.id,
)
db.session.add(nouv_assiduite)
db.session.commit()
return (200, {"assiduite_id": nouv_assiduite.id})
except ScoValueError as excp:
return (
404,
excp.args[0],
)
@bp.route("/assiduite/delete", methods=["POST"])
@api_web_bp.route("/assiduite/delete", methods=["POST"])
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_delete():
"""
Suppression d'une assiduité à partir de son id
Forme des données envoyées :
[
<assiduite_id:int>,
...
]
"""
assiduites_list: list[int] = request.get_json(force=True)
if not isinstance(assiduites_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
output = {"errors": {}, "success": {}}
for i, ass in enumerate(assiduites_list):
code, msg = _delete_singular(ass, db)
if code == 404:
output["errors"][f"{i}"] = msg
else:
output["success"][f"{i}"] = {"OK": True}
db.session.commit()
return output
def _delete_singular(assiduite_id: int, database):
assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first()
if assiduite_unique is None:
return (404, "Assiduite non existante")
database.session.delete(assiduite_unique)
return (200, "OK")
@bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
@api_web_bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_edit(assiduite_id: int):
"""
Edition d'une assiduité à partir de son id
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"moduleimpl_id"?: int
"desc"?: str
"est_just"?: bool
}
"""
assiduite_unique: Assiduite = Assiduite.query.filter_by(
id=assiduite_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatAssiduite.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
assiduite_unique.etat = etat
# Cas 2 : Moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
if moduleimpl_id is not None:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
else:
if not moduleimpl.est_inscrit(
Identite.query.filter_by(id=assiduite_unique.etudid).first()
):
errors.append("param 'moduleimpl_id': etud non inscrit")
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
# Cas 3 : desc
desc = data.get("desc", False)
if desc is not False:
assiduite_unique.desc = desc
# Cas 4 : est_just
est_just = data.get("est_just")
if est_just is not None:
if not isinstance(est_just, bool):
errors.append("param 'est_just' : booléen non reconnu")
else:
assiduite_unique.est_just = est_just
if errors:
err: str = ", ".join(errors)
return json_error(404, err)
db.session.add(assiduite_unique)
db.session.commit()
return {"OK": True}
@bp.route("/assiduites/edit", methods=["POST"])
@api_web_bp.route("/assiduites/edit", methods=["POST"])
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduites_edit():
"""
Edition d'une assiduité à partir de son id
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"moduleimpl_id"?: int
"desc"?: str
"est_just"?: bool
}
"""
edit_list: list[object] = request.get_json(force=True)
if not isinstance(edit_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(edit_list):
assi: Identite = Assiduite.query.filter_by(id=data["assiduite_id"]).first()
if assi is None:
errors[i] = "Cet assiduité n'existe pas."
continue
code, obj = _edit_singular(assi, data)
if code == 404:
errors[i] = obj
else:
success[i] = obj
db.session.commit()
return {"errors": errors, "success": success}
def _edit_singular(assiduite_unique, data):
errors: list[str] = []
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatAssiduite.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
assiduite_unique.etat = etat
# Cas 2 : Moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
if moduleimpl_id is not None:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
else:
if not moduleimpl.est_inscrit(
Identite.query.filter_by(id=assiduite_unique.etudid).first()
):
errors.append("param 'moduleimpl_id': etud non inscrit")
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
# Cas 3 : desc
desc = data.get("desc", False)
if desc is not False:
assiduite_unique.desc = desc
# Cas 4 : est_just
est_just = data.get("est_just")
if est_just is not None:
if not isinstance(est_just, bool):
errors.append("param 'est_just' : booléen non reconnu")
else:
assiduite_unique.est_just = est_just
if errors:
err: str = ", ".join(errors)
return (404, err)
db.session.add(assiduite_unique)
return (200, "OK")
# -- Utils --
def _count_manager(requested) -> tuple[str, dict]:
"""
Retourne la/les métriques à utiliser ainsi que le filtre donnés en query de la requête
"""
filtered: dict = {}
# cas 1 : etat assiduite
etat = requested.args.get("etat")
if etat is not None:
filtered["etat"] = etat
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
if deb is not None:
filtered["date_debut"] = deb
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if fin is not None:
filtered["date_fin"] = fin
# cas 4 : moduleimpl_id
module = requested.args.get("moduleimpl_id", False)
try:
if module is False:
raise ValueError
if module != "":
module = int(module)
else:
module = None
except ValueError:
module = False
if module is not False:
filtered["moduleimpl_id"] = module
# cas 5 : formsemestre_id
formsemestre_id = requested.args.get("formsemestre_id")
if formsemestre_id is not None:
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
filtered["formsemestre"] = formsemestre
# cas 6 : type
metric = requested.args.get("metric", "all")
# cas 7 : est_just
est_just: str = requested.args.get("est_just")
if est_just is not None:
trues: tuple[str] = ("v", "t", "vrai", "true")
falses: tuple[str] = ("f", "faux", "false")
if est_just.lower() in trues:
filtered["est_just"] = True
elif est_just.lower() in falses:
filtered["est_just"] = False
# cas 8 : user_id
user_id = requested.args.get("user_id", False)
if user_id is not False:
filtered["user_id"] = user_id
return (metric, filtered)
def _filter_manager(requested, assiduites_query: Assiduite):
"""
Retourne les assiduites entrées filtrées en fonction de la request
"""
# cas 1 : etat assiduite
etat = requested.args.get("etat")
if etat is not None:
assiduites_query = scass.filter_assiduites_by_etat(assiduites_query, etat)
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if (deb, fin) != (None, None):
assiduites_query: Assiduite = scass.filter_by_date(
assiduites_query, Assiduite, deb, fin
)
# cas 4 : moduleimpl_id
module = requested.args.get("moduleimpl_id", False)
try:
if module is False:
raise ValueError
if module != "":
module = int(module)
else:
module = None
except ValueError:
module = False
if module is not False:
assiduites_query = scass.filter_by_module_impl(assiduites_query, module)
# cas 5 : formsemestre_id
formsemestre_id = requested.args.get("formsemestre_id")
if formsemestre_id is not None:
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre)
# cas 6 : est_just
est_just: str = requested.args.get("est_just")
if est_just is not None:
trues: tuple[str] = ("v", "t", "vrai", "true")
falses: tuple[str] = ("f", "faux", "false")
if est_just.lower() in trues:
assiduites_query: Assiduite = scass.filter_assiduites_by_est_just(
assiduites_query, True
)
elif est_just.lower() in falses:
assiduites_query: Assiduite = scass.filter_assiduites_by_est_just(
assiduites_query, False
)
# cas 8 : user_id
user_id = requested.args.get("user_id", False)
if user_id is not False:
assiduites_query: Assiduite = scass.filter_by_user_id(assiduites_query, user_id)
return assiduites_query

View File

@ -8,7 +8,8 @@
API : billets d'absences
"""
from flask import g, jsonify, request
from flask import g, request
from flask_json import as_json
from flask_login import login_required
from app import db
@ -26,10 +27,11 @@ from app.scodoc.sco_permissions import Permission
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def billets_absence_etudiant(etudid: int):
"""Liste des billets d'absence pour cet étudiant"""
billets = sco_abs_billets.query_billets_etud(etudid)
return jsonify([billet.to_dict() for billet in billets])
return [billet.to_dict() for billet in billets]
@bp.route("/billets_absence/create", methods=["POST"])
@ -37,6 +39,7 @@ def billets_absence_etudiant(etudid: int):
@login_required
@scodoc
@permission_required(Permission.ScoAbsAddBillet)
@as_json
def billets_absence_create():
"""Ajout d'un billet d'absence"""
data = request.get_json(force=True) # may raise 400 Bad Request
@ -60,7 +63,7 @@ def billets_absence_create():
)
db.session.add(billet)
db.session.commit()
return jsonify(billet.to_dict())
return billet.to_dict()
@bp.route("/billets_absence/<int:billet_id>/delete", methods=["POST"])
@ -68,6 +71,7 @@ def billets_absence_create():
@login_required
@scodoc
@permission_required(Permission.ScoAbsAddBillet)
@as_json
def billets_absence_delete(billet_id: int):
"""Suppression d'un billet d'absence"""
query = BilletAbsence.query.filter_by(id=billet_id)
@ -77,4 +81,4 @@ def billets_absence_delete(billet_id: int):
billet = query.first_or_404()
db.session.delete(billet)
db.session.commit()
return jsonify({"OK": True})
return {"OK": True}

View File

@ -12,7 +12,8 @@
"""
from datetime import datetime
from flask import jsonify, request
from flask import request
from flask_json import as_json
from flask_login import login_required
import app
@ -41,24 +42,27 @@ def get_departement(dept_ident: str) -> Departement:
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def departements_list():
"""Liste les départements"""
return jsonify([dept.to_dict(with_dept_name=True) for dept in Departement.query])
return [dept.to_dict(with_dept_name=True) for dept in Departement.query]
@bp.route("/departements_ids")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def departements_ids():
"""Liste des ids de départements"""
return jsonify([dept.id for dept in Departement.query])
return [dept.id for dept in Departement.query]
@bp.route("/departement/<string:acronym>")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def departement(acronym: str):
"""
Info sur un département. Accès par acronyme.
@ -74,25 +78,27 @@ def departement(acronym: str):
}
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return jsonify(dept.to_dict(with_dept_name=True))
return dept.to_dict(with_dept_name=True)
@bp.route("/departement/id/<int:dept_id>")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def departement_by_id(dept_id: int):
"""
Info sur un département. Accès par id.
"""
dept = Departement.query.get_or_404(dept_id)
return jsonify(dept.to_dict())
return dept.to_dict()
@bp.route("/departement/create", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def departement_create():
"""
Création d'un département.
@ -111,13 +117,14 @@ def departement_create():
dept = departements.create_dept(acronym, visible=visible)
except ScoValueError as exc:
return json_error(500, exc.args[0] if exc.args else "")
return jsonify(dept.to_dict())
return dept.to_dict()
@bp.route("/departement/<string:acronym>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def departement_edit(acronym):
"""
Edition d'un département: seul visible peut être modifié
@ -135,7 +142,7 @@ def departement_edit(acronym):
dept.visible = visible
db.session.add(dept)
db.session.commit()
return jsonify(dept.to_dict())
return dept.to_dict()
@bp.route("/departement/<string:acronym>/delete", methods=["POST"])
@ -149,13 +156,14 @@ def departement_delete(acronym):
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
db.session.delete(dept)
db.session.commit()
return jsonify({"OK": True})
return {"OK": True}
@bp.route("/departement/<string:acronym>/etudiants", methods=["GET"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_etudiants(acronym: str):
"""
Retourne la liste des étudiants d'un département
@ -179,45 +187,49 @@ def dept_etudiants(acronym: str):
]
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return jsonify([etud.to_dict_short() for etud in dept.etudiants])
return [etud.to_dict_short() for etud in dept.etudiants]
@bp.route("/departement/id/<int:dept_id>/etudiants")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_etudiants_by_id(dept_id: int):
"""
Retourne la liste des étudiants d'un département d'id donné.
"""
dept = Departement.query.get_or_404(dept_id)
return jsonify([etud.to_dict_short() for etud in dept.etudiants])
return [etud.to_dict_short() for etud in dept.etudiants]
@bp.route("/departement/<string:acronym>/formsemestres_ids")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_formsemestres_ids(acronym: str):
"""liste des ids formsemestre du département"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return jsonify([formsemestre.id for formsemestre in dept.formsemestres])
return [formsemestre.id for formsemestre in dept.formsemestres]
@bp.route("/departement/id/<int:dept_id>/formsemestres_ids")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_formsemestres_ids_by_id(dept_id: int):
"""liste des ids formsemestre du département"""
dept = Departement.query.get_or_404(dept_id)
return jsonify([formsemestre.id for formsemestre in dept.formsemestres])
return [formsemestre.id for formsemestre in dept.formsemestres]
@bp.route("/departement/<string:acronym>/formsemestres_courants")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_formsemestres_courants(acronym: str):
"""
Liste des semestres actifs d'un département d'acronyme donné
@ -269,13 +281,14 @@ def dept_formsemestres_courants(acronym: str):
FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= test_date,
)
return jsonify([d.to_dict_api() for d in formsemestres])
return [d.to_dict_api() for d in formsemestres]
@bp.route("/departement/id/<int:dept_id>/formsemestres_courants")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_formsemestres_courants_by_id(dept_id: int):
"""
Liste des semestres actifs d'un département d'id donné
@ -294,4 +307,4 @@ def dept_formsemestres_courants_by_id(dept_id: int):
FormSemestre.date_fin >= test_date,
)
return jsonify([d.to_dict_api() for d in formsemestres])
return [d.to_dict_api() for d in formsemestres]

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

@ -9,7 +9,8 @@
"""
from datetime import datetime
from flask import abort, g, jsonify, request
from flask import g, request
from flask_json import as_json
from flask_login import current_user
from flask_login import login_required
from sqlalchemy import desc, or_
@ -30,6 +31,7 @@ from app.scodoc import sco_bulletins
from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_photos as sco_photos
# Un exemple:
# @bp.route("/api_function/<int:arg>")
@ -37,11 +39,11 @@ from app.scodoc.sco_permissions import Permission
# @login_required
# @scodoc
# @permission_required(Permission.ScoView)
# @as_json
# def api_function(arg: int):
# """Une fonction quelconque de l'API"""
# return jsonify(
# {"current_user": current_user.to_dict(), "arg": arg, "dept": g.scodoc_dept}
# )
# return {"current_user": current_user.to_dict(), "arg": arg, "dept": g.scodoc_dept}
#
@bp.route("/etudiants/courants", defaults={"long": False})
@ -51,6 +53,7 @@ from app.scodoc.sco_permissions import Permission
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants_courants(long=False):
"""
La liste des étudiants des semestres "courants" (tous départements)
@ -96,7 +99,7 @@ def etudiants_courants(long=False):
data = [etud.to_dict_api() for etud in etuds]
else:
data = [etud.to_dict_short() for etud in etuds]
return jsonify(data)
return data
@bp.route("/etudiant/etudid/<int:etudid>")
@ -108,6 +111,7 @@ def etudiants_courants(long=False):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiant(etudid: int = None, nip: str = None, ine: str = None):
"""
Retourne les informations de l'étudiant correspondant, ou 404 si non trouvé.
@ -127,7 +131,43 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
message="étudiant inconnu",
)
return jsonify(etud.to_dict_api())
return etud.to_dict_api()
@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
Attention : Ne peut être qu'utilisée en tant que route de département
"""
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("/etudiants/etudid/<int:etudid>", methods=["GET"])
@ -138,6 +178,7 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
@api_web_bp.route("/etudiants/ine/<string:ine>", methods=["GET"])
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants(etudid: int = None, nip: str = None, ine: str = None):
"""
Info sur le ou les étudiants correspondant. Comme /etudiant mais renvoie
@ -163,7 +204,7 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
etuds = etuds.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
return jsonify([etud.to_dict_api() for etud in query])
return [etud.to_dict_api() for etud in query]
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@ -174,6 +215,7 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
@api_web_bp.route("/etudiant/ine/<string:ine>/formsemestres")
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None):
"""
Liste des semestres qu'un étudiant a suivi, triés par ordre chronologique.
@ -206,7 +248,7 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None)
formsemestres = query.order_by(FormSemestre.date_debut)
return jsonify([formsemestre.to_dict_api() for formsemestre in formsemestres])
return [formsemestre.to_dict_api() for formsemestre in formsemestres]
@bp.route(
@ -265,7 +307,7 @@ def bulletin(
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
if g.scodoc_dept and dept.acronym != g.scodoc_dept:
return json_error(404, "formsemestre inexistant")
return json_error(404, "formsemestre inexistant", as_response=True)
app.set_sco_dept(dept.acronym)
if code_type == "nip":
@ -303,6 +345,7 @@ def bulletin(
)
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiant_groups(formsemestre_id: int, etudid: int = None):
"""
Retourne la liste des groupes auxquels appartient l'étudiant dans le formsemestre indiqué
@ -352,4 +395,4 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
app.set_sco_dept(dept.acronym)
data = sco_groups.get_etud_groups(etud.id, formsemestre.id)
return jsonify(data)
return data

View File

@ -8,7 +8,8 @@
ScoDoc 9 API : accès aux évaluations
"""
from flask import g, jsonify
from flask import g
from flask_json import as_json
from flask_login import login_required
import app
@ -26,7 +27,8 @@ import app.scodoc.sco_utils as scu
@login_required
@scodoc
@permission_required(Permission.ScoView)
def evaluation(evaluation_id: int):
@as_json
def the_eval(evaluation_id: int):
"""Description d'une évaluation.
{
@ -56,7 +58,7 @@ def evaluation(evaluation_id: int):
.filter_by(dept_id=g.scodoc_dept_id)
)
e = query.first_or_404()
return jsonify(e.to_dict_api())
return e.to_dict_api()
@bp.route("/moduleimpl/<int:moduleimpl_id>/evaluations")
@ -64,6 +66,7 @@ def evaluation(evaluation_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def evaluations(moduleimpl_id: int):
"""
Retourne la liste des évaluations d'un moduleimpl
@ -79,7 +82,7 @@ def evaluations(moduleimpl_id: int):
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
)
return jsonify([e.to_dict_api() for e in query])
return [e.to_dict_api() for e in query]
@bp.route("/evaluation/<int:evaluation_id>/notes")
@ -87,6 +90,7 @@ def evaluations(moduleimpl_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def evaluation_notes(evaluation_id: int):
"""
Retourne la liste des notes à partir de l'id d'une évaluation donnée
@ -124,8 +128,8 @@ def evaluation_notes(evaluation_id: int):
.filter_by(dept_id=g.scodoc_dept_id)
)
evaluation = query.first_or_404()
dept = evaluation.moduleimpl.formsemestre.departement
the_eval = query.first_or_404()
dept = the_eval.moduleimpl.formsemestre.departement
app.set_sco_dept(dept.acronym)
notes = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
@ -133,7 +137,7 @@ def evaluation_notes(evaluation_id: int):
# "ABS", "EXC", etc mais laisse les notes sur le barème de l'éval.
note = notes[etudid]
note["value"] = scu.fmt_note(note["value"], keep_numeric=True)
note["note_max"] = evaluation.note_max
note["note_max"] = the_eval.note_max
del note["id"]
return jsonify(notes)
return notes

View File

@ -8,16 +8,23 @@
ScoDoc 9 API : accès aux formations
"""
from flask import g, jsonify
from flask import flash, g, request
from flask_json import as_json
from flask_login import login_required
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
from app.models.moduleimpls import ModuleImpl
from app.models import (
ApcNiveau,
ApcParcours,
Formation,
FormSemestre,
ModuleImpl,
UniteEns,
)
from app.scodoc import sco_formations
from app.scodoc.sco_permissions import Permission
@ -27,6 +34,7 @@ from app.scodoc.sco_permissions import Permission
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formations():
"""
Retourne la liste de toutes les formations (tous départements)
@ -35,7 +43,7 @@ def formations():
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
return jsonify([d.to_dict() for d in query])
return [d.to_dict() for d in query]
@bp.route("/formations_ids")
@ -43,6 +51,7 @@ def formations():
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formations_ids():
"""
Retourne la liste de toutes les id de formations (tous départements)
@ -52,7 +61,7 @@ def formations_ids():
query = Formation.query
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
return jsonify([d.id for d in query])
return [d.id for d in query]
@bp.route("/formation/<int:formation_id>")
@ -60,6 +69,7 @@ def formations_ids():
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formation_by_id(formation_id: int):
"""
La formation d'id donné
@ -84,7 +94,7 @@ def formation_by_id(formation_id: int):
query = Formation.query.filter_by(id=formation_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
return jsonify(query.first_or_404().to_dict())
return query.first_or_404().to_dict()
@bp.route(
@ -106,6 +116,7 @@ def formation_by_id(formation_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formation_export_by_formation_id(formation_id: int, export_ids=False):
"""
Retourne la formation, avec UE, matières, modules
@ -174,7 +185,7 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
]
},
{
"titre": "Se sensibiliser \u00e0 l&apos;hygi\u00e8ne informatique et \u00e0 la cybers\u00e9curit\u00e9",
"titre": "Se sensibiliser \u00e0 l&apos;hygi\u00e8ne informatique...",
"abbrev": "Hygi\u00e8ne informatique",
"code": "SAE11",
"heures_cours": 0.0,
@ -212,7 +223,7 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
except ValueError:
return json_error(500, message="Erreur inconnue")
return jsonify(data)
return data
@bp.route("/formation/<int:formation_id>/referentiel_competences")
@ -220,6 +231,7 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def referentiel_competences(formation_id: int):
"""
Retourne le référentiel de compétences
@ -233,8 +245,8 @@ def referentiel_competences(formation_id: int):
query = query.filter_by(dept_id=g.scodoc_dept_id)
formation = query.first_or_404(formation_id)
if formation.referentiel_competence is None:
return jsonify(None)
return jsonify(formation.referentiel_competence.to_dict())
return None
return formation.referentiel_competence.to_dict()
@bp.route("/moduleimpl/<int:moduleimpl_id>")
@ -242,6 +254,7 @@ def referentiel_competences(formation_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def moduleimpl(moduleimpl_id: int):
"""
Retourne un moduleimpl en fonction de son id
@ -281,4 +294,92 @@ def moduleimpl(moduleimpl_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
modimpl: ModuleImpl = query.first_or_404()
return jsonify(modimpl.to_dict(convert_objects=True))
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)
@as_json
def set_ue_parcours(ue_id: int):
"""Associe UE et parcours BUT.
La liste des ids de parcours est passée en argument JSON.
JSON arg: [parcour_id1, parcour_id2, ...]
"""
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
parcours_ids = request.get_json(force=True) or [] # may raise 400 Bad Request
if parcours_ids == [""]:
parcours = []
else:
parcours = [
ApcParcours.query.get_or_404(int(parcour_id)) for parcour_id in parcours_ids
]
log(f"set_ue_parcours: ue_id={ue.id} parcours_ids={parcours_ids}")
ok, error_message = ue.set_parcours(parcours)
if not ok:
return json_error(404, error_message)
return {"status": ok, "message": error_message}
@bp.route(
"/assoc_ue_niveau/<int:ue_id>/<int:niveau_id>",
methods=["POST"],
)
@api_web_bp.route(
"/assoc_ue_niveau/<int:ue_id>/<int:niveau_id>",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoChangeFormation)
@as_json
def assoc_ue_niveau(ue_id: int, niveau_id: int):
"""Associe l'UE au niveau de compétence"""
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
niveau: ApcNiveau = ApcNiveau.query.get_or_404(niveau_id)
ok, error_message = ue.set_niveau_competence(niveau)
if not ok:
if g.scodoc_dept: # "usage web"
flash(error_message, "error")
return json_error(404, error_message)
if g.scodoc_dept: # "usage web"
flash(f"""{ue.acronyme} associée au niveau "{niveau.libelle}" """)
return {"status": 0}
@bp.route(
"/desassoc_ue_niveau/<int:ue_id>",
methods=["POST"],
)
@api_web_bp.route(
"/desassoc_ue_niveau/<int:ue_id>",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoChangeFormation)
@as_json
def desassoc_ue_niveau(ue_id: int):
"""Désassocie cette UE de son niveau de compétence
(si elle n'est pas associée, ne fait rien)
"""
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
ue.niveau_competence = None
db.session.add(ue)
db.session.commit()
log(f"desassoc_ue_niveau: {ue}")
if g.scodoc_dept:
# "usage web"
flash(f"UE {ue.acronyme} dé-associée")
return {"status": 0}

View File

@ -7,10 +7,14 @@
"""
ScoDoc 9 API : accès aux formsemestres
"""
from flask import g, jsonify, request
from operator import attrgetter, itemgetter
from flask import g, make_response, request
from flask_json import as_json
from flask_login import login_required
import app
from app import db
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.decorators import scodoc, permission_required
from app.scodoc.sco_utils import json_error
@ -27,6 +31,7 @@ from app.models import (
ModuleImpl,
NotesNotes,
)
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_groups
from app.scodoc.sco_permissions import Permission
@ -40,6 +45,7 @@ from app.tables.recap import TableRecap
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_infos(formsemestre_id: int):
"""
Information sur le formsemestre indiqué.
@ -81,7 +87,7 @@ def formsemestre_infos(formsemestre_id: int):
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
return jsonify(formsemestre.to_dict_api())
return formsemestre.to_dict_api()
@bp.route("/formsemestres/query")
@ -89,6 +95,7 @@ def formsemestre_infos(formsemestre_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestres_query():
"""
Retourne les formsemestres filtrés par
@ -144,7 +151,7 @@ def formsemestres_query():
formsemestres = formsemestres.join(FormSemestreInscription).join(Identite)
formsemestres = formsemestres.filter_by(code_ine=ine)
return jsonify([formsemestre.to_dict_api() for formsemestre in formsemestres])
return [formsemestre.to_dict_api() for formsemestre in formsemestres]
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
@ -154,6 +161,7 @@ def formsemestres_query():
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def bulletins(formsemestre_id: int, version: str = "long"):
"""
Retourne les bulletins d'un formsemestre donné
@ -177,7 +185,7 @@ def bulletins(formsemestre_id: int, version: str = "long"):
)
data.append(bul_etu.json)
return jsonify(data)
return data
@bp.route("/formsemestre/<int:formsemestre_id>/programme")
@ -185,6 +193,7 @@ def bulletins(formsemestre_id: int, version: str = "long"):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_programme(formsemestre_id: int):
"""
Retourne la liste des Ues, ressources et SAE d'un semestre
@ -254,7 +263,7 @@ def formsemestre_programme(formsemestre_id: int):
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
ues = formsemestre.query_ues()
ues = formsemestre.get_ues()
m_list = {
ModuleType.RESSOURCE: [],
ModuleType.SAE: [],
@ -264,15 +273,13 @@ def formsemestre_programme(formsemestre_id: int):
for modimpl in formsemestre.modimpls_sorted:
d = modimpl.to_dict(convert_objects=True)
m_list[modimpl.module.module_type].append(d)
return jsonify(
{
"ues": [ue.to_dict(convert_objects=True) for ue in ues],
"ressources": m_list[ModuleType.RESSOURCE],
"saes": m_list[ModuleType.SAE],
"modules": m_list[ModuleType.STANDARD],
"malus": m_list[ModuleType.MALUS],
}
)
return {
"ues": [ue.to_dict(convert_objects=True) for ue in ues],
"ressources": m_list[ModuleType.RESSOURCE],
"saes": m_list[ModuleType.SAE],
"modules": m_list[ModuleType.STANDARD],
"malus": m_list[ModuleType.MALUS],
}
@bp.route(
@ -310,6 +317,7 @@ def formsemestre_programme(formsemestre_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_etudiants(
formsemestre_id: int, with_query: bool = False, long: bool = False
):
@ -345,7 +353,7 @@ def formsemestre_etudiants(
etud["id"], formsemestre_id, exclude_default=True
)
return jsonify(sorted(etuds, key=lambda e: e["sort_key"]))
return sorted(etuds, key=itemgetter("sort_key"))
@bp.route("/formsemestre/<int:formsemestre_id>/etat_evals")
@ -353,6 +361,7 @@ def formsemestre_etudiants(
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etat_evals(formsemestre_id: int):
"""
Informations sur l'état des évaluations d'un formsemestre.
@ -432,7 +441,7 @@ def etat_evals(formsemestre_id: int):
# Si il y a plus d'une note saisie pour l'évaluation
if len(notes) >= 1:
# Tri des notes en fonction de leurs dates
notes_sorted = sorted(notes, key=lambda note: note.date)
notes_sorted = sorted(notes, key=attrgetter("date"))
date_debut = notes_sorted[0].date
date_fin = notes_sorted[-1].date
@ -454,7 +463,7 @@ def etat_evals(formsemestre_id: int):
modimpl_dict["evaluations"] = list_eval
result.append(modimpl_dict)
return jsonify(result)
return result
@bp.route("/formsemestre/<int:formsemestre_id>/resultats")
@ -462,6 +471,7 @@ def etat_evals(formsemestre_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_resultat(formsemestre_id: int):
"""Tableau récapitulatif des résultats
Pour chaque étudiant, son état, ses groupes, ses moyennes d'UE et de modules.
@ -487,4 +497,45 @@ def formsemestre_resultat(formsemestre_id: int):
for row in rows:
row["partitions"] = etud_groups.get(row["etudid"], {})
return jsonify(rows)
return rows
@bp.route("/formsemestre/<int:formsemestre_id>/get_groups_auto_assignment")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/get_groups_auto_assignment")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def get_groups_auto_assignment(formsemestre_id: int):
"""rend les données"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
response = make_response(formsemestre.groups_auto_assignment_data or b"")
response.headers["Content-Type"] = scu.JSON_MIMETYPE
return response
@bp.route(
"/formsemestre/<int:formsemestre_id>/save_groups_auto_assignment", methods=["POST"]
)
@api_web_bp.route(
"/formsemestre/<int:formsemestre_id>/save_groups_auto_assignment", methods=["POST"]
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def save_groups_auto_assignment(formsemestre_id: int):
"""enregistre les données"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
if len(request.data) > GROUPS_AUTO_ASSIGNMENT_DATA_MAX:
return json_error(413, "data too large")
formsemestre.groups_auto_assignment_data = request.data
db.session.add(formsemestre)
db.session.commit()

View File

@ -8,7 +8,7 @@
ScoDoc 9 API : jury WIP
"""
from flask import jsonify
from flask_json import as_json
from flask_login import login_required
import app
@ -25,6 +25,7 @@ from app.scodoc.sco_permissions import Permission
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def decisions_jury(formsemestre_id: int):
"""Décisions du jury des étudiants du formsemestre."""
# APC, pair:
@ -32,6 +33,6 @@ def decisions_jury(formsemestre_id: int):
if formsemestre.formation.is_apc():
app.set_sco_dept(formsemestre.departement.acronym)
rows = jury_but_results.get_jury_but_results(formsemestre)
return jsonify(rows)
return rows
else:
raise ScoException("non implemente")

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

@ -0,0 +1,603 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask_json import as_json
from flask import g, jsonify, request
from flask_login import login_required, current_user
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object
from app.decorators import permission_required, scodoc
from app.models import Identite, Justificatif
from app.models.assiduites import compute_assiduites_justified
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
# 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",
"entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null,
}
"""
return get_model_api_object(Justificatif, justif_id, Identite)
@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>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def justificatifs(etudid: int = 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
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
justificatifs_query = etud.justificatifs
if with_query:
justificatifs_query = _filter_manager(request, justificatifs_query)
data_set: list[dict] = []
for just in justificatifs_query.all():
data = just.to_dict(format_api=True)
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"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_create(etudid: int = 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,
}
...
]
"""
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
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: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(create_list):
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
else:
success[i] = obj
compute_assiduites_justified(Justificatif.query.filter_by(etudid=etudid), True)
return {"errors": errors, "success": success}
def _create_singular(
data: dict,
etud: Identite,
) -> tuple[int, object]:
errors: list[str] = []
# -- vérifications de l'objet json --
# cas 1 : ETAT
etat = data.get("etat", None)
if etat is None:
errors.append("param 'etat': manquant")
elif not scu.EtatJustificatif.contains(etat):
errors.append("param 'etat': invalide")
etat = scu.EtatJustificatif.get(etat)
# cas 2 : date_debut
date_debut = data.get("date_debut", None)
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut, convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 3 : date_fin
date_fin = data.get("date_fin", None)
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = 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)
if errors:
err: str = ", ".join(errors)
return (404, err)
# TOUT EST OK
try:
nouv_justificatif: Justificatif = Justificatif.create_justificatif(
date_debut=deb,
date_fin=fin,
etat=etat,
etud=etud,
raison=raison,
user_id=current_user.id,
)
db.session.add(nouv_justificatif)
db.session.commit()
return (
200,
{
"justif_id": nouv_justificatif.id,
"couverture": scass.justifies(nouv_justificatif),
},
)
except ScoValueError as excp:
return (
404,
excp.args[0],
)
@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.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
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
}
"""
justificatif_unique: Justificatif = Justificatif.query.filter_by(
id=justif_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
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.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
justificatif_unique.etat = etat
# Cas 2 : raison
raison = data.get("raison", False)
if raison is not False:
justificatif_unique.raison = raison
deb, fin = None, None
# cas 3 : date_debut
date_debut = data.get("date_debut", False)
if date_debut is not False:
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut.replace(" ", "+"), convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
if justificatif_unique.date_fin >= deb:
errors.append("param 'date_debut': date de début située après date de fin ")
# cas 4 : date_fin
date_fin = data.get("date_fin", False)
if date_fin is not False:
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin.replace(" ", "+"), convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
if justificatif_unique.date_debut <= fin:
errors.append("param 'date_fin': date de fin située avant date de début ")
# Mise à jour des dates
deb = deb if deb is not None else justificatif_unique.date_debut
fin = fin if fin is not None else justificatif_unique.date_fin
justificatif_unique.date_debut = deb
justificatif_unique.date_fin = fin
if errors:
err: str = ", ".join(errors)
return json_error(404, err)
db.session.add(justificatif_unique)
db.session.commit()
return {
"couverture": {
"avant": avant_ids,
"après": compute_assiduites_justified(
Justificatif.query.filter_by(etudid=justificatif_unique.etudid),
True,
),
}
}
@bp.route("/justificatif/delete", methods=["POST"])
@api_web_bp.route("/justificatif/delete", methods=["POST"])
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_delete():
"""
Suppression d'un justificatif à partir de son id
Forme des données envoyées :
[
<justif_id:int>,
...
]
"""
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_singular(ass, db)
if code == 404:
output["errors"][f"{i}"] = msg
else:
output["success"][f"{i}"] = {"OK": True}
db.session.commit()
return output
def _delete_singular(justif_id: int, database):
justificatif_unique: Justificatif = Justificatif.query.filter_by(
id=justif_id
).first()
if justificatif_unique is None:
return (404, "Justificatif non existant")
archive_name: str = justificatif_unique.fichier
if archive_name is not None:
archiver: JustificatifArchiver = JustificatifArchiver()
archiver.delete_justificatif(justificatif_unique.etudid, archive_name)
database.session.delete(justificatif_unique)
compute_assiduites_justified(
Justificatif.query.filter_by(etudid=justificatif_unique.etudid), True
)
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.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_import(justif_id: int = None):
"""
Importation d'un fichier (création d'archive)
"""
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")
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
archiver: JustificatifArchiver = JustificatifArchiver()
try:
fname: str
archive_name, fname = archiver.save_justificatif(
etudid=justificatif_unique.etudid,
filename=file.filename,
data=file.stream.read(),
archive_name=archive_name,
)
justificatif_unique.fichier = archive_name
db.session.add(justificatif_unique)
db.session.commit()
return {"filename": fname}
except ScoValueError as err:
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_export(justif_id: int = None, filename: str = None):
"""
Retourne un fichier d'une archive d'un justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
if archive_name is None:
return json_error(404, "le justificatif ne possède pas de fichier")
archiver: JustificatifArchiver = JustificatifArchiver()
try:
return archiver.get_justificatif_file(
archive_name, justificatif_unique.etudid, filename
)
except ScoValueError as err:
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.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_remove(justif_id: int = None):
"""
Supression d'un fichier ou d'une archive
# TOTALK: Doc, expliquer les noms coté server
{
"remove": <"all"/"list">
"filenames"?: [
<filename:str>,
...
]
}
"""
data: dict = request.get_json(force=True)
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
if archive_name is None:
return json_error(404, "le justificatif ne possède pas de fichier")
remove: str = data.get("remove")
if remove is None or remove not in ("all", "list"):
return json_error(404, "param 'remove': Valeur invalide")
archiver: JustificatifArchiver = JustificatifArchiver()
etudid: int = justificatif_unique.etudid
try:
if remove == "all":
archiver.delete_justificatif(etudid=etudid, archive_name=archive_name)
justificatif_unique.fichier = None
db.session.add(justificatif_unique)
db.session.commit()
else:
for fname in data.get("filenames", []):
archiver.delete_justificatif(
etudid=etudid,
archive_name=archive_name,
filename=fname,
)
if len(archiver.list_justificatifs(archive_name, etudid)) == 0:
archiver.delete_justificatif(etudid, archive_name)
justificatif_unique.fichier = None
db.session.add(justificatif_unique)
db.session.commit()
except ScoValueError as err:
return json_error(404, err.args[0])
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)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_list(justif_id: int = None):
"""
Liste les fichiers du justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
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.etudid
)
return filenames
# 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.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_justifies(justif_id: int = None):
"""
Liste assiduite_id justifiées par le justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
assiduites_list: list[int] = scass.justifies(justificatif_unique)
return assiduites_list
# -- Utils --
def _filter_manager(requested, justificatifs_query):
"""
Retourne les justificatifs entrés filtrés en fonction de la request
"""
# cas 1 : etat justificatif
etat = requested.args.get("etat")
if etat is not None:
justificatifs_query = scass.filter_justificatifs_by_etat(
justificatifs_query, etat
)
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if (deb, fin) != (None, None):
justificatifs_query: Justificatif = scass.filter_by_date(
justificatifs_query, Justificatif, deb, fin
)
user_id = requested.args.get("user_id", False)
if user_id is not False:
justificatif_query: Justificatif = scass.filter_by_user_id(
justificatif_query, user_id
)
return justificatifs_query

View File

@ -30,11 +30,10 @@ Contrib @jmp
"""
from datetime import datetime
from flask import jsonify, g, send_file
from flask_login import login_required
from flask import Response, send_file
from flask_json import as_json
from app.api import api_bp as bp, api_web_bp
from app.api import requested_format
from app.api import api_bp as bp
from app.scodoc.sco_utils import json_error
from app.models import Departement
from app.scodoc.sco_logos import list_logos, find_logo
@ -47,10 +46,11 @@ from app.scodoc.sco_permissions import Permission
@bp.route("/logos")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def api_get_glob_logos():
"""Liste tous les logos"""
logos = list_logos()[None]
return jsonify(list(logos.keys()))
return list(logos.keys())
@bp.route("/logo/<string:logoname>")
@ -68,27 +68,29 @@ def api_get_glob_logo(logoname):
)
def core_get_logos(dept_id):
def _core_get_logos(dept_id) -> list:
logos = list_logos().get(dept_id, dict())
return jsonify(list(logos.keys()))
return list(logos.keys())
@bp.route("/departement/<string:departement>/logos")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def api_get_local_logos_by_acronym(departement):
dept_id = Departement.from_acronym(departement).id
return core_get_logos(dept_id)
return _core_get_logos(dept_id)
@bp.route("/departement/id/<int:dept_id>/logos")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def api_get_local_logos_by_id(dept_id):
return core_get_logos(dept_id)
return _core_get_logos(dept_id)
def core_get_logo(dept_id, logoname):
def _core_get_logo(dept_id, logoname) -> Response:
logo = find_logo(logoname=logoname, dept_id=dept_id)
if logo is None:
return json_error(404, message="logo not found")
@ -105,11 +107,11 @@ def core_get_logo(dept_id, logoname):
@permission_required(Permission.ScoSuperAdmin)
def api_get_local_logo_dept_by_acronym(departement, logoname):
dept_id = Departement.from_acronym(departement).id
return core_get_logo(dept_id, logoname)
return _core_get_logo(dept_id, logoname)
@bp.route("/departement/id/<int:dept_id>/logo/<string:logoname>")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
def api_get_local_logo_dept_by_id(dept_id, logoname):
return core_get_logo(dept_id, logoname)
return _core_get_logo(dept_id, logoname)

View File

@ -7,7 +7,10 @@
"""
ScoDoc 9 API : partitions
"""
from flask import g, jsonify, request
from operator import attrgetter
from flask import g, request
from flask_json import as_json
from flask_login import login_required
import app
@ -29,6 +32,7 @@ from app.scodoc import sco_utils as scu
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def partition_info(partition_id: int):
"""Info sur une partition.
@ -53,7 +57,7 @@ def partition_info(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition = query.first_or_404()
return jsonify(partition.to_dict(with_groups=True))
return partition.to_dict(with_groups=True)
@bp.route("/formsemestre/<int:formsemestre_id>/partitions")
@ -61,6 +65,7 @@ def partition_info(partition_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_partitions(formsemestre_id: int):
"""Liste de toutes les partitions d'un formsemestre
@ -85,14 +90,12 @@ def formsemestre_partitions(formsemestre_id: int):
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
partitions = sorted(formsemestre.partitions, key=lambda p: p.numero or 0)
return jsonify(
{
partition.id: partition.to_dict(with_groups=True)
for partition in partitions
if partition.partition_name is not None
}
)
partitions = sorted(formsemestre.partitions, key=attrgetter("numero"))
return {
str(partition.id): partition.to_dict(with_groups=True, str_keys=True)
for partition in partitions
if partition.partition_name is not None
}
@bp.route("/group/<int:group_id>/etudiants")
@ -100,6 +103,7 @@ def formsemestre_partitions(formsemestre_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etud_in_group(group_id: int):
"""
Retourne la liste des étudiants dans un groupe
@ -126,7 +130,7 @@ 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 jsonify([etud.to_dict_short() for etud in group.etuds])
return [etud.to_dict_short() for etud in group.etuds]
@bp.route("/group/<int:group_id>/etudiants/query")
@ -134,6 +138,7 @@ def etud_in_group(group_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etud_in_group_query(group_id: int):
"""Étudiants du groupe, filtrés par état"""
etat = request.args.get("etat")
@ -154,7 +159,7 @@ def etud_in_group_query(group_id: int):
query = query.join(group_membership).filter_by(group_id=group_id)
return jsonify([etud.to_dict_short() for etud in query])
return [etud.to_dict_short() for etud in query]
@bp.route("/group/<int:group_id>/set_etudiant/<int:etudid>", methods=["POST"])
@ -162,6 +167,7 @@ def etud_in_group_query(group_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def set_etud_group(etudid: int, group_id: int):
"""Affecte l'étudiant au groupe indiqué"""
etud = Identite.query.get_or_404(etudid)
@ -180,7 +186,7 @@ def set_etud_group(etudid: int, group_id: int):
etudid, group_id, group.partition.to_dict()
)
return jsonify({"group_id": group_id, "etudid": etudid})
return {"group_id": group_id, "etudid": etudid}
@bp.route("/group/<int:group_id>/remove_etudiant/<int:etudid>", methods=["POST"])
@ -190,6 +196,7 @@ def set_etud_group(etudid: int, group_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def group_remove_etud(group_id: int, etudid: int):
"""Retire l'étudiant de ce groupe. S'il n'y est pas, ne fait rien."""
etud = Identite.query.get_or_404(etudid)
@ -213,7 +220,7 @@ def group_remove_etud(group_id: int, etudid: int):
# Update parcours
group.partition.formsemestre.update_inscriptions_parcours_from_groups()
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return jsonify({"group_id": group_id, "etudid": etudid})
return {"group_id": group_id, "etudid": etudid}
@bp.route(
@ -225,6 +232,7 @@ def group_remove_etud(group_id: int, etudid: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def partition_remove_etud(partition_id: int, etudid: int):
"""Enlève l'étudiant de tous les groupes de cette partition
(NB: en principe, un étudiant ne doit être que dans 0 ou 1 groupe d'une partition)
@ -254,7 +262,7 @@ def partition_remove_etud(partition_id: int, etudid: int):
partition.formsemestre.update_inscriptions_parcours_from_groups()
app.set_sco_dept(partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(partition.formsemestre_id)
return jsonify({"partition_id": partition_id, "etudid": etudid})
return {"partition_id": partition_id, "etudid": etudid}
@bp.route("/partition/<int:partition_id>/group/create", methods=["POST"])
@ -262,6 +270,7 @@ def partition_remove_etud(partition_id: int, etudid: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def group_create(partition_id: int):
"""Création d'un groupe dans une partition
@ -292,7 +301,7 @@ def group_create(partition_id: int):
log(f"created group {group}")
app.set_sco_dept(partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(partition.formsemestre_id)
return jsonify(group.to_dict(with_partition=True))
return group.to_dict(with_partition=True)
@bp.route("/group/<int:group_id>/delete", methods=["POST"])
@ -300,6 +309,7 @@ def group_create(partition_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def group_delete(group_id: int):
"""Suppression d'un groupe"""
query = GroupDescr.query.filter_by(id=group_id)
@ -318,7 +328,7 @@ def group_delete(group_id: int):
db.session.commit()
app.set_sco_dept(group.partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(formsemestre_id)
return jsonify({"OK": True})
return {"OK": True}
@bp.route("/group/<int:group_id>/edit", methods=["POST"])
@ -326,6 +336,7 @@ def group_delete(group_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def group_edit(group_id: int):
"""Edit a group"""
query = GroupDescr.query.filter_by(id=group_id)
@ -350,7 +361,7 @@ def group_edit(group_id: int):
log(f"modified {group}")
app.set_sco_dept(group.partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return jsonify(group.to_dict(with_partition=True))
return group.to_dict(with_partition=True)
@bp.route("/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"])
@ -360,6 +371,7 @@ def group_edit(group_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def partition_create(formsemestre_id: int):
"""Création d'une partition dans un semestre
@ -412,7 +424,7 @@ def partition_create(formsemestre_id: int):
log(f"created partition {partition}")
app.set_sco_dept(formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(formsemestre_id)
return jsonify(partition.to_dict(with_groups=True))
return partition.to_dict(with_groups=True)
@bp.route("/formsemestre/<int:formsemestre_id>/partitions/order", methods=["POST"])
@ -422,6 +434,7 @@ def partition_create(formsemestre_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def formsemestre_order_partitions(formsemestre_id: int):
"""Modifie l'ordre des partitions du formsemestre
JSON args: [partition_id1, partition_id2, ...]
@ -441,19 +454,17 @@ def formsemestre_order_partitions(formsemestre_id: int):
message="paramètre liste des partitions invalide",
)
for p_id, numero in zip(partition_ids, range(len(partition_ids))):
p = Partition.query.get_or_404(p_id)
p.numero = numero
db.session.add(p)
partition = Partition.query.get_or_404(p_id)
partition.numero = numero
db.session.add(partition)
db.session.commit()
app.set_sco_dept(formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(formsemestre_id)
return jsonify(
[
partition.to_dict()
for partition in formsemestre.partitions.order_by(Partition.numero)
if partition.partition_name is not None
]
)
return [
partition.to_dict()
for partition in formsemestre.partitions.order_by(Partition.numero)
if partition.partition_name is not None
]
@bp.route("/partition/<int:partition_id>/groups/order", methods=["POST"])
@ -461,6 +472,7 @@ def formsemestre_order_partitions(formsemestre_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def partition_order_groups(partition_id: int):
"""Modifie l'ordre des groupes de la partition
JSON args: [group_id1, group_id2, ...]
@ -487,7 +499,7 @@ def partition_order_groups(partition_id: int):
app.set_sco_dept(partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(partition.formsemestre_id)
log(f"partition_order_groups: {partition} : {group_ids}")
return jsonify(partition.to_dict(with_groups=True))
return partition.to_dict(with_groups=True)
@bp.route("/partition/<int:partition_id>/edit", methods=["POST"])
@ -495,6 +507,7 @@ def partition_order_groups(partition_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def partition_edit(partition_id: int):
"""Modification d'une partition dans un semestre
@ -556,7 +569,7 @@ def partition_edit(partition_id: int):
app.set_sco_dept(partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(partition.formsemestre_id)
return jsonify(partition.to_dict(with_groups=True))
return partition.to_dict(with_groups=True)
@bp.route("/partition/<int:partition_id>/delete", methods=["POST"])
@ -564,6 +577,7 @@ def partition_edit(partition_id: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
def partition_delete(partition_id: int):
"""Suppression d'une partition (et de tous ses groupes).
@ -591,4 +605,4 @@ def partition_delete(partition_id: int):
sco_cache.invalidate_formsemestre(formsemestre.id)
if is_parcours:
formsemestre.update_inscriptions_parcours_from_groups()
return jsonify({"OK": True})
return {"OK": True}

View File

@ -7,33 +7,34 @@
"""
ScoDoc 9 API : accès aux formsemestres
"""
from flask import g, jsonify, request
from flask_login import login_required
# from flask import g, jsonify, request
# from flask_login import login_required
import app
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.decorators import scodoc, permission_required
from app.scodoc.sco_utils import json_error
from app.models.formsemestre import NotesSemSet
from app.scodoc.sco_permissions import Permission
# import app
# from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
# from app.decorators import scodoc, permission_required
# from app.scodoc.sco_utils import json_error
# from app.models.formsemestre import NotesSemSet
# from app.scodoc.sco_permissions import Permission
@bp.route("/semset/set_periode/<int:semset_id>", methods=["POST"])
@api_web_bp.route("/semset/set_periode/<int:semset_id>", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEditApo)
# TODO à modifier pour utiliser @as_json
def semset_set_periode(semset_id: int):
"Change la période d'un semset"
query = NotesSemSet.query.filter_by(semset_id=semset_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
semset: NotesSemSet = query.first_or_404()
data = request.get_json(force=True) # may raise 400 Bad Request
try:
periode = int(data)
semset.set_periode(periode)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid periode value")
return jsonify({"OK": True})
# Impossible de changer la période à cause des archives
# @bp.route("/semset/set_periode/<int:semset_id>", methods=["POST"])
# @api_web_bp.route("/semset/set_periode/<int:semset_id>", methods=["POST"])
# @login_required
# @scodoc
# @permission_required(Permission.ScoEditApo)
# # TODO à modifier pour utiliser @as_json
# def semset_set_periode(semset_id: int):
# "Change la période d'un semset"
# query = NotesSemSet.query.filter_by(semset_id=semset_id)
# if g.scodoc_dept:
# query = query.filter_by(dept_id=g.scodoc_dept_id)
# semset: NotesSemSet = query.first_or_404()
# data = request.get_json(force=True) # may raise 400 Bad Request
# try:
# periode = int(data)
# semset.set_periode(periode)
# except ValueError:
# return json_error(API_CLIENT_ERROR, "invalid periode value")
# return jsonify({"OK": True})

View File

@ -1,4 +1,4 @@
from flask import jsonify
from flask_json import as_json
from app import db, log
from app.api import api_bp as bp
from app.auth.logic import basic_auth, token_auth
@ -6,12 +6,13 @@ from app.auth.logic import basic_auth, token_auth
@bp.route("/tokens", methods=["POST"])
@basic_auth.login_required
@as_json
def get_token():
"renvoie un jeton jwt pour l'utilisateur courant"
token = basic_auth.current_user().get_token()
log(f"API: giving token to {basic_auth.current_user()}")
db.session.commit()
return jsonify({"token": token})
return {"token": token}
@bp.route("/tokens", methods=["DELETE"])

View File

@ -9,7 +9,8 @@
"""
from flask import g, jsonify, request
from flask import g, request
from flask_json import as_json
from flask_login import current_user, login_required
from app import db
@ -29,6 +30,7 @@ from app.scodoc import sco_utils as scu
@login_required
@scodoc
@permission_required(Permission.ScoUsersView)
@as_json
def user_info(uid: int):
"""
Info sur un compte utilisateur scodoc
@ -41,7 +43,7 @@ def user_info(uid: int):
if (None not in allowed_depts) and (user.dept not in allowed_depts):
return json_error(404, "user not found")
return jsonify(user.to_dict())
return user.to_dict()
@bp.route("/users/query")
@ -49,6 +51,7 @@ def user_info(uid: int):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def users_info_query():
"""Utilisateurs, filtrés par dept, active ou début nom
/users/query?departement=dept_acronym&active=1&starts_with=<string:nom>
@ -79,7 +82,7 @@ def users_info_query():
)
query = query.order_by(User.user_name)
return jsonify([user.to_dict() for user in query])
return [user.to_dict() for user in query]
@bp.route("/user/create", methods=["POST"])
@ -87,6 +90,7 @@ def users_info_query():
@login_required
@scodoc
@permission_required(Permission.ScoUsersAdmin)
@as_json
def user_create():
"""Création d'un utilisateur
The request content type should be "application/json":
@ -121,7 +125,7 @@ def user_create():
user = User(user_name=user_name, active=active, dept=dept, nom=nom, prenom=prenom)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict())
return user.to_dict()
@bp.route("/user/<int:uid>/edit", methods=["POST"])
@ -129,6 +133,7 @@ def user_create():
@login_required
@scodoc
@permission_required(Permission.ScoUsersAdmin)
@as_json
def user_edit(uid: int):
"""Modification d'un utilisateur
Champs modifiables:
@ -165,7 +170,7 @@ def user_edit(uid: int):
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict())
return user.to_dict()
@bp.route("/user/<int:uid>/password", methods=["POST"])
@ -173,6 +178,7 @@ def user_edit(uid: int):
@login_required
@scodoc
@permission_required(Permission.ScoUsersAdmin)
@as_json
def user_password(uid: int):
"""Modification du mot de passe d'un utilisateur
Champs modifiables:
@ -194,7 +200,7 @@ def user_password(uid: int):
user.set_password(password)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict())
return user.to_dict()
@bp.route("/user/<int:uid>/role/<string:role_name>/add", methods=["POST"])
@ -210,6 +216,7 @@ def user_password(uid: int):
@login_required
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def user_role_add(uid: int, role_name: str, dept: str = None):
"""Add a role to the user"""
user: User = User.query.get_or_404(uid)
@ -222,7 +229,7 @@ def user_role_add(uid: int, role_name: str, dept: str = None):
user.add_role(role, dept)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict())
return user.to_dict()
@bp.route("/user/<int:uid>/role/<string:role_name>/remove", methods=["POST"])
@ -238,6 +245,7 @@ def user_role_add(uid: int, role_name: str, dept: str = None):
@login_required
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def user_role_remove(uid: int, role_name: str, dept: str = None):
"""Remove the role from the user"""
user: User = User.query.get_or_404(uid)
@ -256,7 +264,7 @@ def user_role_remove(uid: int, role_name: str, dept: str = None):
db.session.delete(user_role)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict())
return user.to_dict()
@bp.route("/permissions")
@ -264,9 +272,10 @@ def user_role_remove(uid: int, role_name: str, dept: str = None):
@login_required
@scodoc
@permission_required(Permission.ScoUsersView)
@as_json
def list_permissions():
"""Liste des noms de permissions définies"""
return jsonify(list(Permission.permission_by_name.keys()))
return list(Permission.permission_by_name.keys())
@bp.route("/role/<string:role_name>")
@ -274,9 +283,10 @@ def list_permissions():
@login_required
@scodoc
@permission_required(Permission.ScoUsersView)
@as_json
def list_role(role_name: str):
"""Un rôle"""
return jsonify(Role.query.filter_by(name=role_name).first_or_404().to_dict())
return Role.query.filter_by(name=role_name).first_or_404().to_dict()
@bp.route("/roles")
@ -284,9 +294,10 @@ def list_role(role_name: str):
@login_required
@scodoc
@permission_required(Permission.ScoUsersView)
@as_json
def list_roles():
"""Tous les rôles définis"""
return jsonify([role.to_dict() for role in Role.query])
return [role.to_dict() for role in Role.query]
@bp.route(
@ -300,6 +311,7 @@ def list_roles():
@login_required
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_permission_add(role_name: str, perm_name: str):
"""Add permission to role"""
role: Role = Role.query.filter_by(name=role_name).first_or_404()
@ -309,7 +321,7 @@ def role_permission_add(role_name: str, perm_name: str):
role.add_permission(permission)
db.session.add(role)
db.session.commit()
return jsonify(role.to_dict())
return role.to_dict()
@bp.route(
@ -323,6 +335,7 @@ def role_permission_add(role_name: str, perm_name: str):
@login_required
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_permission_remove(role_name: str, perm_name: str):
"""Remove permission from role"""
role: Role = Role.query.filter_by(name=role_name).first_or_404()
@ -332,7 +345,7 @@ def role_permission_remove(role_name: str, perm_name: str):
role.remove_permission(permission)
db.session.add(role)
db.session.commit()
return jsonify(role.to_dict())
return role.to_dict()
@bp.route("/role/create/<string:role_name>", methods=["POST"])
@ -340,6 +353,7 @@ def role_permission_remove(role_name: str, perm_name: str):
@login_required
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_create(role_name: str):
"""Create a new role with permissions.
{
@ -359,7 +373,7 @@ def role_create(role_name: str):
return json_error(404, "role_create: invalid permissions")
db.session.add(role)
db.session.commit()
return jsonify(role.to_dict())
return role.to_dict()
@bp.route("/role/<string:role_name>/edit", methods=["POST"])
@ -367,6 +381,7 @@ def role_create(role_name: str):
@login_required
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_edit(role_name: str):
"""Edit a role. On peut spécifier un nom et/ou des permissions.
{
@ -390,7 +405,7 @@ def role_edit(role_name: str):
role.name = role_name
db.session.add(role)
db.session.commit()
return jsonify(role.to_dict())
return role.to_dict()
@bp.route("/role/<string:role_name>/delete", methods=["POST"])
@ -398,9 +413,10 @@ def role_edit(role_name: str):
@login_required
@scodoc
@permission_required(Permission.ScoSuperAdmin)
@as_json
def role_delete(role_name: str):
"""Delete a role"""
role: Role = Role.query.filter_by(name=role_name).first_or_404()
db.session.delete(role)
db.session.commit()
return jsonify({"OK": True})
return {"OK": True}

View File

@ -30,7 +30,7 @@ def after_cas_login():
flask.session.get("CAS_USERNAME"),
)
if cas_id is not None:
user: User = User.query.filter_by(cas_id=cas_id).first()
user: User = User.query.filter_by(cas_id=str(cas_id)).first()
if user and user.active:
if user.cas_allow_login:
current_app.logger.info(f"CAS: login {user.user_name}")

View File

@ -8,68 +8,69 @@
Edition associations UE <-> Ref. Compétence
"""
from flask import g, url_for
from app.models import ApcReferentielCompetences, Formation, UniteEns
from app.models import ApcReferentielCompetences, UniteEns
from app.scodoc import codes_cursus
def form_ue_choix_niveau(ue: UniteEns) -> str:
"""Form. HTML pour associer une UE à un niveau de compétence.
Le menu select lui meême est vide et rempli en JS par appel à get_ue_niveaux_options_html
def form_ue_choix_parcours(ue: UniteEns) -> str:
"""Form. HTML pour associer une UE à ses parcours.
Le menu select lui même est vide et rempli en JS par appel à get_ue_niveaux_options_html
"""
if ue.type != codes_cursus.UE_STANDARD:
return ""
ref_comp = ue.formation.referentiel_competence
if ref_comp is None:
return f"""<div class="ue_choix_niveau">
return f"""<div class="ue_advanced">
<div class="warning">Pas de référentiel de compétence associé à cette formation !</div>
<div><a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation',
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)
}">associer un référentiel de compétence</a>
</div>
</div>"""
# Les parcours:
parcours_options = []
for parcour in ref_comp.parcours:
parcours_options.append(
f"""<option value="{parcour.id}" {
'selected' if ue.parcour == parcour else ''}
>{parcour.libelle} ({parcour.code})
</option>"""
)
newline = "\n"
return f"""
<div class="ue_choix_niveau">
<form class="form_ue_choix_niveau">
<div class="cont_ue_choix_niveau">
<div>
<b>Parcours&nbsp;:</b>
<select class="select_parcour"
onchange="set_ue_parcour(this);"
data-ue_id="{ue.id}"
data-setter="{
url_for( "notes.set_ue_parcours", scodoc_dept=g.scodoc_dept)
}">
<option value="" {
'selected' if ue.parcour is None else ''
}>Tous</option>
{newline.join(parcours_options)}
</select>
</div>
<div>
<b>Niveau de compétence&nbsp;:</b>
<select class="select_niveau_ue"
onchange="set_ue_niveau_competence(this);"
data-ue_id="{ue.id}"
data-setter="{
url_for( "notes.set_ue_niveau_competence", scodoc_dept=g.scodoc_dept)
}">
</select>
</div>
</div>
</form>
H = [
"""
<div class="ue_advanced">
<h3>Parcours du BUT</h3>
"""
]
# Choix des parcours
ue_pids = [p.id for p in ue.parcours]
H.append("""<form id="choix_parcours">""")
ects_differents = {
ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours
} != {None}
for parcour in ref_comp.parcours:
ects_parcour = ue.get_ects(parcour)
ects_parcour_txt = (
f" ({ue.get_ects(parcour):.3g} ects)" if ects_differents else ""
)
H.append(
f"""<label><input type="checkbox" name="{parcour.id}" value="{parcour.id}"
{'checked' if parcour.id in ue_pids else ""}
onclick="set_ue_parcour(this);"
data-setter="{url_for("apiweb.set_ue_parcours",
scodoc_dept=g.scodoc_dept, ue_id=ue.id)}"
>{parcour.code}{ects_parcour_txt}</label>"""
)
H.append("""</form>""")
#
H.append(
f"""
<ul>
<li>
<a class="stdlink" href="{
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>
</ul>
</div>
"""
)
return "\n".join(H)
def get_ue_niveaux_options_html(ue: UniteEns) -> str:
@ -85,9 +86,7 @@ def get_ue_niveaux_options_html(ue: UniteEns) -> str:
return ""
# Les niveaux:
annee = ue.annee() # 1, 2, 3
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(
annee, parcour=ue.parcour
)
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee, ue.parcours)
# Les niveaux déjà associés à d'autres UE du même semestre
autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx)
@ -101,7 +100,7 @@ def get_ue_niveaux_options_html(ue: UniteEns) -> str:
options.append(
f"""<option value="{n.id}" {
'selected' if ue.niveau_competence == n else ''}
>{n.annee} {n.competence.titre_long}
>{n.annee} {n.competence.titre} / {n.competence.titre_long}
niveau {n.ordre}</option>"""
)
options.append("""</optgroup>""")
@ -116,7 +115,7 @@ def get_ue_niveaux_options_html(ue: UniteEns) -> str:
options.append(
f"""<option value="{n.id}" {'selected'
if ue.niveau_competence == n else ''}
{disabled}>{n.annee} {n.competence.titre_long}
{disabled}>{n.annee} {n.competence.titre} / {n.competence.titre_long}
niveau {n.ordre}</option>"""
)
options.append("""</optgroup>""")

View File

@ -285,9 +285,9 @@ class BulletinBUT:
eval_notes[etud.id],
note_max=e.note_max,
),
"min": fmt_note(notes_ok.min()),
"max": fmt_note(notes_ok.max()),
"moy": fmt_note(notes_ok.mean()),
"min": fmt_note(notes_ok.min(), note_max=e.note_max),
"max": fmt_note(notes_ok.max(), note_max=e.note_max),
"moy": fmt_note(notes_ok.mean(), note_max=e.note_max),
},
"poids": poids,
"url": url_for(
@ -484,6 +484,7 @@ class BulletinBUT:
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(

View File

@ -24,7 +24,6 @@ from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat
from app.comp import res_sem
from app.models import formsemestre
from app.models.but_refcomp import (
ApcAnneeParcours,
@ -32,6 +31,7 @@ from app.models.but_refcomp import (
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences,
)
from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
@ -109,7 +109,7 @@ class EtudCursusBUT:
"cache les niveaux"
for annee in (1, 2, 3):
niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
annee, self.parcour
annee, [self.parcour] if self.parcour else None
)[1]
# groupe les niveaux de tronc commun et ceux spécifiques au parcour
self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
@ -170,6 +170,7 @@ class EtudCursusBUT:
}
}
"""
# XXX lent, provisoirement utilisé par TableJury.add_but_competences()
return {
competence.id: {
annee: self.validation_par_competence_et_annee.get(
@ -185,7 +186,7 @@ class EtudCursusBUT:
"""
{
competence_id : {
annee : { validation}
annee : { validation }
}
}
validation est un petit dict avec niveau_id, etc.
@ -204,3 +205,211 @@ class EtudCursusBUT:
validation_rcue.to_dict_codes() if validation_rcue else None
)
return d
class FormSemestreCursusBUT:
"""L'état des étudiants d'un formsemestre dans leur cursus BUT
Permet d'obtenir pour chacun liste des niveaux validés/à valider
"""
def __init__(self, res: ResultatsSemestreBUT):
"""res indique le formsemestre de référence,
qui donne la liste des étudiants et le référentiel de compétence.
"""
self.res = res
self.formsemestre = res.formsemestre
if not res.formsemestre.formation.referentiel_competence:
raise ScoNoReferentielCompetences(formation=res.formsemestre.formation)
# Données cachées pour accélerer les accès:
self.referentiel_competences_id: int = (
self.res.formsemestre.formation.referentiel_competence_id
)
self.ue_ids: set[int] = set()
"set of ue_ids known to belong to our cursus"
self.parcours_by_id: dict[int, ApcParcours] = {}
"cache des parcours"
self.niveaux_by_parcour_by_annee: dict[int, dict[int, list[ApcNiveau]]] = {}
"cache { parcour_id : { annee : [ parcour] } }"
self.niveaux_by_id: dict[int, ApcNiveau] = {}
"cache niveaux"
def get_niveaux_parcours_etud(self, etud: Identite) -> dict[int, list[ApcNiveau]]:
"""Les niveaux compétences que doit valider cet étudiant.
Le parcour considéré est celui de l'inscription dans le semestre courant.
Si on est en début de cursus, on peut être en tronc commun sans avoir choisi
de parcours. Dans ce cas, on n'aura que les compétences de tronc commun.
Il faudra donc, avant de diplômer, s'assurer que les compétences du parcours
du dernier semestre (S6) sont validées (avec parcour non NULL).
"""
parcour_id = self.res.etuds_parcour_id.get(etud.id)
if parcour_id is None:
parcour = None
else:
if parcour_id not in self.parcours_by_id:
self.parcours_by_id[parcour_id] = ApcParcours.query.get(parcour_id)
parcour = self.parcours_by_id[parcour_id]
return self.get_niveaux_parcours_by_annee(parcour)
def get_niveaux_parcours_by_annee(
self, parcour: ApcParcours
) -> dict[int, list[ApcNiveau]]:
"""La liste des niveaux de compétences du parcours, par année BUT.
{ 1 : [ niveau, ... ] }
Si parcour est None, donne uniquement les niveaux tronc commun
(cas utile par exemple en 1ere année, mais surtout pas pour donner un diplôme!)
"""
parcour_id = None if parcour is None else parcour.id
if parcour_id in self.niveaux_by_parcour_by_annee:
return self.niveaux_by_parcour_by_annee[parcour_id]
ref_comp: ApcReferentielCompetences = (
self.res.formsemestre.formation.referentiel_competence
)
niveaux_by_annee = {}
for annee in (1, 2, 3):
niveaux_d = ref_comp.get_niveaux_by_parcours(
annee, [parcour] if parcour else None
)[1]
# groupe les niveaux de tronc commun et ceux spécifiques au parcour
niveaux_by_annee[annee] = niveaux_d["TC"] + (
niveaux_d[parcour.id] if parcour else []
)
self.niveaux_by_parcour_by_annee[parcour_id] = niveaux_by_annee
self.niveaux_by_id.update(
{niveau.id: niveau for niveau in niveaux_by_annee[annee]}
)
return niveaux_by_annee
def get_etud_validation_par_competence_et_annee(self, etud: Identite):
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
validation_par_competence_et_annee = {}
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# On s'assurer qu'elle concerne notre cursus !
ue = validation_rcue.ue2
if ue.id not in self.ue_ids:
if (
ue.formation.referentiel_competences_id
== self.referentiel_competences_id
):
self.ue_ids = ue.id
else:
continue # skip this validation
niveau = validation_rcue.niveau()
if not niveau.competence.id in validation_par_competence_et_annee:
validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = validation_par_competence_et_annee.get(
niveau.competence.id
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
] = validation_rcue
return validation_par_competence_et_annee
def list_etud_inscriptions(self, etud: Identite):
"Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
self.niveaux_by_annee = {}
"{ annee : liste des niveaux à valider }"
self.niveaux: dict[int, ApcNiveau] = {}
"cache les niveaux"
for annee in (1, 2, 3):
niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
annee, [self.parcour] if self.parcour else None # XXX WIP
)[1]
# groupe les niveaux de tronc commun et ceux spécifiques au parcour
self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
niveaux_d[self.parcour.id] if self.parcour else []
)
self.niveaux.update(
{niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
)
self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau()
if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get(
niveau.competence.id
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
] = validation_rcue
self.competences = {
competence.id: competence
for competence in (
self.parcour.query_competences()
if self.parcour
else self.formation.referentiel_competence.get_competences_tronc_commun()
)
}
"cache { competence_id : competence }"
def formsemestre_warning_apc_setup(
formsemestre: FormSemestre, res: ResultatsSemestreBUT
) -> str:
"""Vérifie que la formation est OK pour un BUT:
- ref. compétence associé
- tous les niveaux des parcours du semestre associés à des UEs du formsemestre
- pas d'UE non associée à un niveau
Renvoie fragment de HTML.
"""
if not formsemestre.formation.is_apc():
return ""
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>
</div>
"""
# Vérifie les niveaux de chaque parcours
H = []
for parcour in formsemestre.parcours or [None]:
annee = (formsemestre.semestre_id + 1) // 2
niveaux_ids = {
niveau.id
for niveau in ApcNiveau.niveaux_annee_de_parcours(
parcour, annee, formsemestre.formation.referentiel_competence
)
}
ues_parcour = formsemestre.formation.query_ues_parcour(parcour).filter(
UniteEns.semestre_idx == formsemestre.semestre_id
)
ues_niveaux_ids = {
ue.niveau_competence.id for ue in ues_parcour if ue.niveau_competence
}
if niveaux_ids != ues_niveaux_ids:
H.append(
f"""Parcours {parcour.code if parcour else "Tronc commun"} :
{len(ues_niveaux_ids)} UE avec niveaux
mais {len(niveaux_ids)} niveaux à valider !
"""
)
if not H:
return ""
return f"""<div class="formsemestre_status_warning">
Problème dans la configuration de la formation:
<ul>
<li>{ '</li><li>'.join(H) }</li>
</ul>
<p class="help">Vérifiez les parcours cochés pour ce semestre,
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>
"""

View File

@ -38,8 +38,8 @@ class RefCompLoadForm(FlaskForm):
submit = SubmitField("Valider")
cancel = SubmitField("Annuler")
def validate(self):
if not super().validate():
def validate(self, extra_validators=None):
if not super().validate(extra_validators):
return False
if (self.referentiel_standard.data == "0") == (not self.upload.data):
self.referentiel_standard.errors.append(

View File

@ -324,7 +324,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
parcours,
niveaux_by_parcours,
) = formation.referentiel_competence.get_niveaux_by_parcours(
self.annee_but, self.parcour
self.annee_but, [self.parcour] if self.parcour else None
)
self.niveaux_competences = niveaux_by_parcours["TC"] + (
niveaux_by_parcours[self.parcour.id] if self.parcour else []
@ -421,7 +421,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
+ '</div><div class="warning">'.join(messages)
+ "</div>"
)
#
# WIP TODO XXX def get_moyenne_annuelle(self)
def infos(self) -> str:
"""informations, for debugging purpose."""
@ -521,7 +522,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
ramène [ listes des UE du semestre impair, liste des UE du semestre pair ].
"""
ues_sems = []
for (formsemestre, res) in (
for formsemestre, res in (
(self.formsemestre_impair, self.res_impair),
(self.formsemestre_pair, self.res_pair),
):
@ -1003,7 +1004,7 @@ def list_ue_parcour_etud(
parcour = ApcParcours.query.get(res.etuds_parcour_id[etud.id])
ues = (
formsemestre.formation.query_ues_parcour(parcour)
.filter_by(semestre_idx=formsemestre.semestre_id)
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
.order_by(UniteEns.numero)
.all()
)

View File

@ -106,6 +106,8 @@ class BonusSport:
if formsemestre.formation.is_apc():
# BUT
nb_ues_no_bonus = sem_modimpl_moys.shape[2]
if nb_ues_no_bonus == 0: # aucune UE...
return # no bonus at all
# Duplique les inscriptions sur les UEs non bonus:
modimpl_inscr_spo_stacked = np.stack(
[modimpl_inscr_spo] * nb_ues_no_bonus, axis=2
@ -228,14 +230,14 @@ class BonusSportAdditif(BonusSport):
# en APC, bonus_moy_arr est (nb_etuds, nb_ues_non_bonus)
if self.formsemestre.formation.is_apc():
# Bonus sur les UE et None sur moyenne générale
ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)]
ues_idx = [ue.id for ue in self.formsemestre.get_ues(with_sport=False)]
self.bonus_ues = pd.DataFrame(
bonus_moy_arr, index=self.etuds_idx, columns=ues_idx, dtype=float
)
elif self.classic_use_bonus_ues:
# Formations classiques apppliquant le bonus sur les UEs
# ici bonus_moy_arr = ndarray 1d nb_etuds
ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)]
ues_idx = [ue.id for ue in self.formsemestre.get_ues(with_sport=False)]
self.bonus_ues = pd.DataFrame(
np.stack([bonus_moy_arr] * len(ues_idx)).T,
index=self.etuds_idx,
@ -420,7 +422,7 @@ class BonusAmiens(BonusSportAdditif):
# # Bonus moyenne générale et sur les UE
# self.bonus_moy_gen = pd.Series(bonus, index=self.etuds_idx, dtype=float)
# ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)]
# ues_idx = [ue.id for ue in self.formsemestre.get_ues(with_sport=False)]
# nb_ues_no_bonus = len(ues_idx)
# self.bonus_ues = pd.DataFrame(
# np.stack([bonus] * nb_ues_no_bonus, axis=1),
@ -597,7 +599,7 @@ class BonusCachan1(BonusSportAdditif):
sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0]
# ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic
note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds
ues = self.formsemestre.query_ues(with_sport=False).all()
ues = self.formsemestre.get_ues(with_sport=False)
ues_idx = [ue.id for ue in ues]
if self.formsemestre.formation.is_apc(): # --- BUT
@ -687,7 +689,7 @@ 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.query_ues(with_sport=False).all()
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
@ -788,7 +790,7 @@ class BonusIUTRennes1(BonusSportAdditif):
sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0]
# ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic
note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds
nb_ues = self.formsemestre.query_ues(with_sport=False).count()
nb_ues = len(self.formsemestre.get_ues(with_sport=False))
bonus_moy_arr = np.where(
note_bonus_max > self.seuil_moy_gen,

View File

@ -4,6 +4,7 @@
"""Matrices d'inscription aux modules d'un semestre
"""
import pandas as pd
import sqlalchemy as sa
from app import db
@ -12,6 +13,13 @@ from app import db
# sur test debug 116 etuds, 18 modules, on est autour de 250ms.
# On a testé trois approches, ci-dessous (et retenu la 1ere)
#
_load_modimpl_inscr_q = sa.text(
"""SELECT etudid, 1 AS ":moduleimpl_id"
FROM notes_moduleimpl_inscription
WHERE moduleimpl_id=:moduleimpl_id"""
)
def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame:
"""Charge la matrice des inscriptions aux modules du semestre
rows: etudid (inscrits au semestre, avec DEM et DEF)
@ -22,17 +30,16 @@ def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame:
moduleimpl_ids = [m.id for m in formsemestre.modimpls_sorted]
etudids = [inscr.etudid for inscr in formsemestre.inscriptions]
df = pd.DataFrame(index=etudids, dtype=int)
for moduleimpl_id in moduleimpl_ids:
ins_df = pd.read_sql_query(
"""SELECT etudid, 1 AS "%(moduleimpl_id)s"
FROM notes_moduleimpl_inscription
WHERE moduleimpl_id=%(moduleimpl_id)s""",
db.engine,
params={"moduleimpl_id": moduleimpl_id},
index_col="etudid",
dtype=int,
)
df = df.merge(ins_df, how="left", left_index=True, right_index=True)
with db.engine.begin() as connection:
for moduleimpl_id in moduleimpl_ids:
ins_df = pd.read_sql_query(
_load_modimpl_inscr_q,
connection,
params={"moduleimpl_id": moduleimpl_id},
index_col="etudid",
dtype=int,
)
df = df.merge(ins_df, how="left", left_index=True, right_index=True)
# Force columns names to integers (moduleimpl ids)
df.columns = pd.Index([int(x) for x in df.columns], dtype=int)
# les colonnes de df sont en float (Nan) quand il n'y a

View File

@ -7,6 +7,7 @@
"""Stockage des décisions de jury
"""
import pandas as pd
import sqlalchemy as sa
from app import db
from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns
@ -132,7 +133,8 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
# Note: pour récupérer aussi les UE validées en CMp ou ADJ, changer une ligne
# and ( SFV.code = 'ADM' or SFV.code = 'ADJ' or SFV.code = 'CMP' )
query = """
query = sa.text(
"""
SELECT DISTINCT SFV.*, ue.ue_code
FROM
notes_ue ue,
@ -144,21 +146,22 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
WHERE ue.formation_id = nf.id
and nf.formation_code = nf2.formation_code
and nf2.id=%(formation_id)s
and nf2.id=:formation_id
and ins.etudid = SFV.etudid
and ins.formsemestre_id = %(formsemestre_id)s
and ins.formsemestre_id = :formsemestre_id
and SFV.ue_id = ue.id
and SFV.code = 'ADM'
and ( (sem.id = SFV.formsemestre_id
and sem.date_debut < %(date_debut)s
and sem.semestre_id = %(semestre_id)s )
and sem.date_debut < :date_debut
and sem.semestre_id = :semestre_id )
or (
((SFV.formsemestre_id is NULL) OR (SFV.is_external)) -- les UE externes ou "anterieures"
AND (SFV.semestre_id is NULL OR SFV.semestre_id=%(semestre_id)s)
AND (SFV.semestre_id is NULL OR SFV.semestre_id=:semestre_id)
) )
"""
)
params = {
"formation_id": formsemestre.formation.id,
"formsemestre_id": formsemestre.id,
@ -166,5 +169,6 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
"date_debut": formsemestre.date_debut,
}
df = pd.read_sql_query(query, db.engine, params=params, index_col="etudid")
with db.engine.begin() as connection:
df = pd.read_sql_query(query, connection, params=params, index_col="etudid")
return df

View File

@ -38,6 +38,7 @@ from dataclasses import dataclass
import numpy as np
import pandas as pd
import sqlalchemy as sa
import app
from app import db
@ -192,24 +193,29 @@ class ModuleImplResults:
evals_notes.columns = pd.Index([int(x) for x in evals_notes.columns], dtype=int)
self.evals_notes = evals_notes
_load_evaluation_notes_q = sa.text(
"""SELECT n.etudid, n.value AS ":evaluation_id"
FROM notes_notes n, notes_moduleimpl_inscription i
WHERE evaluation_id=:evaluation_id
AND n.etudid = i.etudid
AND i.moduleimpl_id = :moduleimpl_id
"""
)
def _load_evaluation_notes(self, evaluation: Evaluation) -> pd.DataFrame:
"""Charge les notes de l'évaluation
Resultat: dataframe, index: etudid ayant une note, valeur: note brute.
"""
eval_df = pd.read_sql_query(
"""SELECT n.etudid, n.value AS "%(evaluation_id)s"
FROM notes_notes n, notes_moduleimpl_inscription i
WHERE evaluation_id=%(evaluation_id)s
AND n.etudid = i.etudid
AND i.moduleimpl_id = %(moduleimpl_id)s
""",
db.engine,
params={
"evaluation_id": evaluation.id,
"moduleimpl_id": evaluation.moduleimpl.id,
},
index_col="etudid",
)
with db.engine.begin() as connection:
eval_df = pd.read_sql_query(
self._load_evaluation_notes_q,
connection,
params={
"evaluation_id": evaluation.id,
"moduleimpl_id": evaluation.moduleimpl.id,
},
index_col="etudid",
)
eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)])
return eval_df
@ -409,7 +415,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
"""
modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all()
ues = modimpl.formsemestre.query_ues(with_sport=False).all()
ues = modimpl.formsemestre.get_ues(with_sport=False)
ue_ids = [ue.id for ue in ues]
evaluation_ids = [evaluation.id for evaluation in evaluations]
evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float)

View File

@ -121,7 +121,7 @@ def df_load_modimpl_coefs(
DataFrame rows = UEs (sans bonus), columns = modimpl, value = coef.
"""
if ues is None:
ues = formsemestre.query_ues().all()
ues = formsemestre.get_ues()
ue_ids = [x.id for x in ues]
if modimpls is None:
modimpls = formsemestre.modimpls_sorted

View File

@ -16,6 +16,7 @@ from app.comp.res_compat import NotesTableCompat
from app.comp.bonus_spo import BonusSport
from app.models import ScoDocSiteConfig
from app.models.moduleimpls import ModuleImpl
from app.models.but_refcomp import ApcParcours, ApcNiveau
from app.models.ues import DispenseUE, UniteEns
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.scodoc import sco_preferences
@ -41,6 +42,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
"""ndarray (etuds x modimpl x ue)"""
self.etuds_parcour_id = None
"""Parcours de chaque étudiant { etudid : parcour_id }"""
self.ues_ids_by_parcour: dict[set[int]] = {}
"""{ parcour_id : set }, ue_id de chaque parcours"""
if not self.load_cached():
t0 = time.time()
@ -227,7 +230,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
}
self.etuds_parcour_id = etuds_parcour_id
ue_ids = [ue.id for ue in self.ues if ue.type != UE_SPORT]
ue_ids_set = set(ue_ids)
if self.formsemestre.formation.referentiel_competence is None:
return pd.DataFrame(
1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
@ -237,16 +240,20 @@ class ResultatsSemestreBUT(NotesTableCompat):
np.nan, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
)
# Construit pour chaque parcours du référentiel l'ensemble de ses UE
# (considère aussi le cas des semestres sans parcours: None)
# - considère aussi le cas des semestres sans parcours (clé parcour None)
# - retire les UEs qui ont un parcours mais qui ne sont pas dans l'un des
# parcours du semestre
ue_by_parcours = {} # parcours_id : {ue_id:0|1}
for (
parcour
) in self.formsemestre.formation.referentiel_competence.parcours.all() + [None]:
ue_by_parcours[None if parcour is None else parcour.id] = {
ue.id: 1.0
for ue in self.formsemestre.formation.query_ues_parcour(
parcour
).filter_by(semestre_idx=self.formsemestre.semestre_id)
for ue in self.formsemestre.formation.query_ues_parcour(parcour).filter(
UniteEns.semestre_idx == self.formsemestre.semestre_id
)
if ue.id in ue_ids_set
}
#
for etudid in etuds_parcour_id:
@ -259,10 +266,46 @@ class ResultatsSemestreBUT(NotesTableCompat):
def etud_ues_ids(self, etudid: int) -> list[int]:
"""Liste des id d'UE auxquelles l'étudiant est inscrit (sans bonus).
(surchargée ici pour prendre en compte les parcours)
Ne prend pas en compte les éventuelles DispenseUE (pour le moment ?)
"""
s = self.ues_inscr_parcours_df.loc[etudid]
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
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.
Ensemble vide si pas de référentiel.
Si l'étudiant n'est pas inscrit dans un parcours, toutes les UEs du semestre.
La requête est longue, les ue_ids par parcour sont donc cachés.
"""
parcour_id = self.etuds_parcour_id[etudid]
if parcour_id in self.ues_ids_by_parcour: # cache
return self.ues_ids_by_parcour[parcour_id]
# Hors cache:
ref_comp = self.formsemestre.formation.referentiel_competence
if ref_comp is None:
return set()
if parcour_id is None:
ues_ids = {ue.id for ue in self.ues}
else:
parcour: ApcParcours = ApcParcours.query.get(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:
ues_parcour = self.formsemestre.formation.query_ues_parcour(parcour)
ues_ids = set()
for niveau in niveaux:
ue = ues_parcour.filter(UniteEns.niveau_competence == niveau).first()
if ue:
ues_ids.add(ue.id)
# memoize
self.ues_ids_by_parcour[parcour_id] = ues_ids
return ues_ids
def etud_has_decision(self, etudid):
"""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.

View File

@ -10,6 +10,8 @@
from collections import Counter, defaultdict
from collections.abc import Generator
from functools import cached_property
from operator import attrgetter
import numpy as np
import pandas as pd
@ -87,6 +89,7 @@ class ResultatsSemestre(ResultatsCache):
self.autorisations_inscription = None
self.moyennes_matieres = {}
"""Moyennes de matières, si calculées. { matiere_id : Series, index etudid }"""
# self._ues_by_id_cache: dict[int, UniteEns] = {} # per-instance cache
def __repr__(self):
return f"<{self.__class__.__name__}(formsemestre='{self.formsemestre}')>"
@ -124,6 +127,13 @@ class ResultatsSemestre(ResultatsCache):
# car tous les étudiants sont inscrits à toutes les UE
return [ue.id for ue in self.ues if ue.type != UE_SPORT]
def etud_parcours_ues_ids(self, etudid: int) -> set[int]:
"""Ensemble des UEs que l'étudiant "doit" valider.
En formations classiques, c'est la même chose (en set) que etud_ues_ids.
Surchargée en BUT pour donner les UEs du parcours de l'étudiant.
"""
return {ue.id for ue in self.ues if ue.type != UE_SPORT}
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)."""
@ -154,7 +164,7 @@ class ResultatsSemestre(ResultatsCache):
(indices des DataFrames).
Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs.
"""
return self.formsemestre.query_ues(with_sport=True).all()
return self.formsemestre.get_ues(with_sport=True)
@cached_property
def ressources(self):
@ -225,7 +235,7 @@ class ResultatsSemestre(ResultatsCache):
for modimpl in self.formsemestre.modimpls_sorted
if self.modimpl_inscr_df[modimpl.id][etudid]
}
ues = sorted(list(ues), key=lambda x: x.numero or 0)
ues = sorted(list(ues), key=attrgetter("numero"))
return ues
def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]:
@ -275,7 +285,7 @@ class ResultatsSemestre(ResultatsCache):
# Quand il y a une capitalisation, vérifie toutes les UEs
sum_notes_ue = 0.0
sum_coefs_ue = 0.0
for ue in self.formsemestre.query_ues():
for ue in self.formsemestre.get_ues():
ue_cap = self.get_etud_ue_status(etudid, ue.id)
if ue_cap is None:
continue
@ -341,7 +351,9 @@ class ResultatsSemestre(ResultatsCache):
"""L'état de l'UE pour cet étudiant.
Result: dict, ou None si l'UE n'est pas dans ce semestre.
"""
ue = UniteEns.query.get(ue_id)
ue: UniteEns = UniteEns.query.get(ue_id)
ue_dict = ue.to_dict()
if ue.type == UE_SPORT:
return {
"is_capitalized": False,
@ -351,7 +363,7 @@ class ResultatsSemestre(ResultatsCache):
"cur_moy_ue": 0.0,
"moy": 0.0,
"event_date": None,
"ue": ue.to_dict(),
"ue": ue_dict,
"formsemestre_id": None,
"capitalized_ue_id": None,
"ects_pot": 0.0,
@ -420,7 +432,7 @@ class ResultatsSemestre(ResultatsCache):
"cur_moy_ue": cur_moy_ue,
"moy": moy_ue,
"event_date": ue_cap["event_date"] if is_capitalized else None,
"ue": ue.to_dict(),
"ue": ue_dict,
"formsemestre_id": ue_cap["formsemestre_id"] if is_capitalized else None,
"capitalized_ue_id": ue_cap["ue_id"] if is_capitalized else None,
}

View File

@ -19,6 +19,7 @@ from app.models import Identite, FormSemestre, ModuleImpl, ScolarAutorisationIns
from app.scodoc.codes_cursus import UE_SPORT, DEF
from app.scodoc import sco_utils as scu
# Pour raccorder le code des anciens codes qui attendent une NoteTable
class NotesTableCompat(ResultatsSemestre):
"""Implementation partielle de NotesTable
@ -108,7 +109,7 @@ class NotesTableCompat(ResultatsSemestre):
Si filter_sport, retire les UE de type SPORT.
Résultat: liste de dicts { champs UE U stats moyenne UE }
"""
ues = self.formsemestre.query_ues(with_sport=not filter_sport)
ues = self.formsemestre.get_ues(with_sport=not filter_sport)
ues_dict = []
for ue in ues:
d = ue.to_dict()
@ -178,7 +179,7 @@ class NotesTableCompat(ResultatsSemestre):
self.etud_moy_gen_ranks,
self.etud_moy_gen_ranks_int,
) = moy_sem.comp_ranks_series(etud_moy_gen_dem_zero)
ues = self.formsemestre.query_ues()
ues = self.formsemestre.get_ues()
for ue in ues:
moy_ue = self.etud_moy_ue[ue.id]
self.ue_rangs[ue.id] = (
@ -260,22 +261,27 @@ class NotesTableCompat(ResultatsSemestre):
Return: True|False, message explicatif
"""
ue_status_list = []
for ue in self.formsemestre.query_ues():
for ue in self.formsemestre.get_ues():
ue_status = self.get_etud_ue_status(etudid, ue.id)
if ue_status:
ue_status_list.append(ue_status)
return self.parcours.check_barre_ues(ue_status_list)
def all_etuds_have_sem_decisions(self):
"""True si tous les étudiants du semestre ont une décision de jury.
Ne regarde pas les décisions d'UE.
def etudids_without_decisions(self) -> list[int]:
"""Liste des id d'étudiants du semestre non démissionnaires
n'ayant pas de décision de jury.
- En classic: ne regarde pas que la décision de semestre (pas les décisions d'UE).
- en BUT: utilise etud_has_decision
"""
for ins in self.formsemestre.inscriptions:
if ins.etat != scu.INSCRIT:
continue # skip démissionnaires
if self.get_etud_decision_sem(ins.etudid) is None:
return False
return True
check_func = (
self.etud_has_decision if self.is_apc else self.get_etud_decision_sem
)
etudids = [
ins.etudid
for ins in self.formsemestre.inscriptions
if (ins.etat == scu.INSCRIT) and (not check_func(ins.etudid))
]
return etudids
def etud_has_decision(self, etudid):
"""True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre.
@ -316,7 +322,8 @@ class NotesTableCompat(ResultatsSemestre):
def get_etud_decision_sem(self, etudid: int) -> dict:
"""Decision du jury semestre prise pour cet etudiant, ou None s'il n'y en pas eu.
{ 'code' : None|ATT|..., 'assidu' : 0|1, 'event_date' : , compense_formsemestre_id }
Si état défaillant, force le code a DEF
Si état défaillant, force le code a DEF.
Toujours None en BUT.
"""
if self.get_etud_etat(etudid) == DEF:
return {
@ -477,7 +484,7 @@ class NotesTableCompat(ResultatsSemestre):
"""
table_moyennes = []
etuds_inscriptions = self.formsemestre.etuds_inscriptions
ues = self.formsemestre.query_ues(with_sport=True) # avec bonus
ues = self.formsemestre.get_ues(with_sport=True) # avec bonus
for etudid in etuds_inscriptions:
moy_gen = self.etud_moy_gen.get(etudid, False)
if moy_gen is False:

View File

@ -318,7 +318,7 @@ class OffreCreationForm(FlaskForm):
duree = _build_string_field("Durée (*)")
depts = MultiCheckboxField("Départements (*)", validators=[Optional()], coerce=int)
expiration_date = DateField("Date expiration", validators=[Optional()])
correspondant = SelectField("Correspondant à contacté", validators=[Optional()])
correspondant = SelectField("Correspondant à contacter", validators=[Optional()])
fichier = FileField(
"Fichier",
validators=[
@ -373,7 +373,7 @@ class OffreModificationForm(FlaskForm):
duree = _build_string_field("Durée (*)")
depts = MultiCheckboxField("Départements (*)", validators=[Optional()], coerce=int)
expiration_date = DateField("Date expiration", validators=[Optional()])
correspondant = SelectField("Correspondant à contacté", validators=[Optional()])
correspondant = SelectField("Correspondant à contacter", validators=[Optional()])
submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)

View File

@ -1,12 +1,13 @@
import os
from config import Config
from datetime import datetime, date
from datetime import datetime
import glob
import shutil
from flask import render_template, redirect, url_for, request, flash, send_file, abort
from flask.json import jsonify
from flask_json import as_json
from flask_login import current_user
from sqlalchemy import text, sql
from werkzeug.utils import secure_filename
from app.decorators import permission_required
@ -58,8 +59,7 @@ from app.scodoc import sco_etud, sco_excel
import app.scodoc.sco_utils as scu
from app import db
from sqlalchemy import text, sql
from werkzeug.utils import secure_filename
from config import Config
@bp.route("/", methods=["GET", "POST"])
@ -1698,6 +1698,7 @@ def envoyer_offre(entreprise_id, offre_id):
@bp.route("/etudiants")
@permission_required(Permission.RelationsEntreprisesChange)
@as_json
def json_etudiants():
"""
Permet de récuperer un JSON avec tous les étudiants
@ -1723,7 +1724,7 @@ def json_etudiants():
"info": f"Département {are.get_dept_acronym_by_id(etudiant.dept_id)}",
}
list.append(content)
return jsonify(results=list)
return list
@bp.route("/responsables")
@ -1749,7 +1750,7 @@ def json_responsables():
value = f"{responsable.get_nomplogin()}"
content = {"id": f"{responsable.id}", "value": value}
list.append(content)
return jsonify(results=list)
return list
@bp.route("/export_donnees")
@ -1843,7 +1844,7 @@ def import_donnees():
db.session.add(correspondant)
correspondants.append(correspondant)
db.session.commit()
flash(f"Importation réussie")
flash("Importation réussie")
return render_template(
"entreprises/import_donnees.j2",
title="Importation données",

View File

@ -0,0 +1 @@
# empty but required for pylint

View File

@ -0,0 +1,35 @@
from flask import g, url_for
from flask_wtf import FlaskForm
from wtforms import FieldList, Form, DecimalField, validators
from app.models import ApcParcours, ApcReferentielCompetences, UniteEns
class _UEParcoursECTSForm(FlaskForm):
"Formulaire association ECTS par parcours à une UE"
# construit dynamiquement ci-dessous
def UEParcoursECTSForm(ue: UniteEns) -> FlaskForm:
"Génère formulaire association ECTS par parcours à une UE"
class F(_UEParcoursECTSForm):
pass
parcours: list[ApcParcours] = ue.formation.referentiel_competence.parcours
# Initialise un champs de saisie par parcours
for parcour in parcours:
ects = ue.get_ects(parcour, only_parcours=True)
setattr(
F,
f"ects_parcour_{parcour.id}",
DecimalField(
f"Parcours {parcour.code}",
validators=[
validators.Optional(),
validators.NumberRange(min=0, max=30),
],
default=ects,
),
)
return F()

View File

@ -29,14 +29,14 @@ Formulaire changement formation
"""
from flask_wtf import FlaskForm
from wtforms import RadioField, SubmitField, validators
from wtforms import RadioField, SubmitField
from app.models import Formation
class FormSemestreChangeFormationForm(FlaskForm):
"Formulaire changement formation d'un formsemestre"
# consrtuit dynamiquement ci-dessous
# construit dynamiquement ci-dessous
def gen_formsemestre_change_formation_form(

View File

@ -0,0 +1,86 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# 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
"""
from flask_wtf import FlaskForm
from wtforms import SubmitField
from wtforms.fields.simple import StringField
from wtforms.widgets import TimeInput
import datetime
class TimeField(StringField):
"""HTML5 time input."""
widget = TimeInput()
def __init__(self, label=None, validators=None, fmt="%H:%M:%S", **kwargs):
super(TimeField, self).__init__(label, validators, **kwargs)
self.fmt = fmt
self.data = None
def _value(self):
if self.raw_data:
return " ".join(self.raw_data)
if self.data and isinstance(self.data, str):
self.data = datetime.time(*map(int, self.data.split(":")))
return self.data and self.data.strftime(self.fmt) or ""
def process_formdata(self, valuelist):
if valuelist:
time_str = " ".join(valuelist)
try:
components = time_str.split(":")
hour = 0
minutes = 0
seconds = 0
if len(components) in range(2, 4):
hour = int(components[0])
minutes = int(components[1])
if len(components) == 3:
seconds = int(components[2])
else:
raise ValueError
self.data = datetime.time(hour, minutes, seconds)
except ValueError:
self.data = None
raise ValueError(self.gettext("Not a valid time string"))
class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduités"
morning_time = TimeField("Début de la journée")
lunch_time = TimeField("Heure de midi (date pivot entre Matin et Après Midi)")
afternoon_time = TimeField("Fin de la journée")
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -21,8 +21,6 @@ convention = {
metadata_obj = sqlalchemy.MetaData(naming_convention=convention)
from app.models.raw_sql_init import create_database_functions
from app.models.absences import Absence, AbsenceNotification, BilletAbsence
from app.models.departements import Departement
from app.models.etudiants import (
@ -83,3 +81,5 @@ 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

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

@ -0,0 +1,341 @@
# -*- coding: UTF-8 -*
"""Gestion de l'assiduité (assiduités + justificatifs)
"""
from datetime import datetime
from app import db
from app.models import ModuleImpl
from app.models.etudiants import Identite
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
localize_datetime,
is_period_overlapping,
)
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
localize_datetime,
)
class Assiduite(db.Model):
"""
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)
desc = 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)
def to_dict(self, format_api=True) -> dict:
"""Retourne la représentation json de l'assiduité"""
etat = self.etat
if format_api:
etat = EtatAssiduite.inverse().get(self.etat).name
data = {
"assiduite_id": self.id,
"etudid": self.etudid,
"moduleimpl_id": self.moduleimpl_id,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"desc": self.desc,
"entry_date": self.entry_date,
"user_id": self.user_id,
"est_just": self.est_just,
}
return data
@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,
) -> object or int:
"""Créer une nouvelle assiduité pour l'étudiant"""
# Vérification de non duplication des périodes
assiduites: list[Assiduite] = etud.assiduites
if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite):
raise ScoValueError(
"Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)"
)
if moduleimpl is not None:
# Vérification de l'existence du module pour l'étudiant
if moduleimpl.est_inscrit(etud):
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
moduleimpl_id=moduleimpl.id,
desc=description,
entry_date=entry_date,
user_id=user_id,
est_just=est_just,
)
else:
raise ScoValueError("L'étudiant n'est pas inscrit au moduleimpl")
else:
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
desc=description,
entry_date=entry_date,
user_id=user_id,
est_just=est_just,
)
return nouv_assiduite
@classmethod
def fast_create_assiduite(
cls,
etudid: int,
date_debut: datetime,
date_fin: datetime,
etat: EtatAssiduite,
moduleimpl_id: int = None,
description: str = None,
entry_date: datetime = None,
est_just: bool = False,
) -> object or int:
"""Créer une nouvelle assiduité pour l'étudiant"""
# Vérification de non duplication des périodes
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudid=etudid,
moduleimpl_id=moduleimpl_id,
desc=description,
entry_date=entry_date,
est_just=est_just,
)
return nouv_assiduite
class Justificatif(db.Model):
"""
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())
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())
def to_dict(self, format_api: bool = False) -> dict:
"""transformation de l'objet en dictionnaire sérialisable"""
etat = self.etat
if format_api:
etat = EtatJustificatif.inverse().get(self.etat).name
data = {
"justif_id": self.justif_id,
"etudid": self.etudid,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"raison": self.raison,
"fichier": self.fichier,
"entry_date": self.entry_date,
"user_id": self.user_id,
}
return data
@classmethod
def create_justificatif(
cls,
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
raison: str = None,
entry_date: datetime = None,
user_id: int = None,
) -> object or int:
"""Créer un nouveau justificatif pour l'étudiant"""
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
raison=raison,
entry_date=entry_date,
user_id=user_id,
)
return nouv_justificatif
@classmethod
def fast_create_justificatif(
cls,
etudid: int,
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
raison: str = None,
entry_date: datetime = None,
) -> object or int:
"""Créer un nouveau justificatif pour l'étudiant"""
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudid=etudid,
raison=raison,
entry_date=entry_date,
)
return nouv_justificatif
def is_period_conflicting(
date_debut: datetime,
date_fin: datetime,
collection: list[Assiduite or Justificatif],
collection_cls: Assiduite or Justificatif,
) -> bool:
"""
Vérifie si une date n'entre pas en collision
avec les justificatifs ou assiduites déjà présentes
"""
date_debut = localize_datetime(date_debut)
date_fin = localize_datetime(date_fin)
if (
collection.filter_by(date_debut=date_debut, date_fin=date_fin).first()
is not None
):
return True
count: int = collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut
).count()
return count > 0
def compute_assiduites_justified(
justificatifs: Justificatif = Justificatif, reset: bool = False
) -> list[int]:
"""Calcule et modifie les champs "est_just" de chaque assiduité lié à l'étud
retourne la liste des assiduite_id justifiées
Si reset alors : met à false toutes les assiduités non justifiées par les justificatifs donnés
"""
list_assiduites_id: set[int] = set()
for justi in justificatifs:
assiduites: Assiduite = (
Assiduite.query.join(Justificatif, Justificatif.etudid == Assiduite.etudid)
.filter(justi.etat == EtatJustificatif.VALIDE)
.filter(
Assiduite.date_debut < justi.date_fin,
Assiduite.date_fin > justi.date_debut,
)
)
for assi in assiduites:
assi.est_just = True
list_assiduites_id.add(assi.id)
db.session.add(assi)
if reset:
un_justified: Assiduite = Assiduite.query.filter(
Assiduite.id.not_in(list_assiduites_id)
).join(Justificatif, Justificatif.etudid == Assiduite.etudid)
for assi in un_justified:
assi.est_just = False
db.session.add(assi)
db.session.commit()
return list(list_assiduites_id)

View File

@ -6,8 +6,10 @@
"""ScoDoc 9 models : Référentiel Compétence BUT 2021
"""
from datetime import datetime
import functools
from operator import attrgetter
import flask_sqlalchemy
from flask_sqlalchemy.query import Query
from sqlalchemy.orm import class_mapper
import sqlalchemy
@ -84,6 +86,7 @@ class ApcReferentielCompetences(db.Model, XMLModel):
backref="referentiel",
lazy="dynamic",
cascade="all, delete-orphan",
order_by="ApcParcours.numero, ApcParcours.code",
)
formations = db.relationship(
"Formation",
@ -129,11 +132,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
}
def get_niveaux_by_parcours(
self, annee: int, parcour: "ApcParcours" = None
self, annee: int, parcours: list["ApcParcours"] = None
) -> tuple[list["ApcParcours"], dict]:
"""
Construit la liste des niveaux de compétences pour chaque parcours
de ce référentiel, ou seulement pour le parcours donné.
de ce référentiel, ou seulement pour les parcours donnés.
Les niveaux sont groupés par parcours, en isolant les niveaux de tronc commun.
@ -150,10 +153,8 @@ class ApcReferentielCompetences(db.Model, XMLModel):
)
"""
parcours_ref = self.parcours.order_by(ApcParcours.numero).all()
if parcour is None:
if parcours is None:
parcours = parcours_ref
else:
parcours = [parcour]
niveaux_by_parcours = {
parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self)
for parcour in parcours_ref
@ -205,9 +206,27 @@ class ApcReferentielCompetences(db.Model, XMLModel):
for competence in parcours[0].query_competences()
if competence.id in ids
],
key=lambda c: c.numero or 0,
key=attrgetter("numero"),
)
def table_niveaux_parcours(self) -> dict:
"""Une table avec les parcours:années BUT et les niveaux
{ parcour_id : { 1 : { competence_id : ordre }}}
"""
parcours_info = {}
for parcour in self.parcours:
descr_parcour = {}
parcours_info[parcour.id] = descr_parcour
for annee in (1, 2, 3):
descr_parcour[annee] = {
niveau.competence.id: niveau.ordre
for niveau in ApcNiveau.niveaux_annee_de_parcours(
parcour, annee, self
)
}
return parcours_info
class ApcCompetence(db.Model, XMLModel):
"Compétence"
@ -223,7 +242,7 @@ class ApcCompetence(db.Model, XMLModel):
titre = db.Column(db.Text(), nullable=False, index=True)
titre_long = db.Column(db.Text())
couleur = db.Column(db.Text())
numero = db.Column(db.Integer) # ordre de présentation
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
_xml_attribs = { # xml_attrib : attribute
"id": "id_orebut",
"nom_court": "titre", # was name
@ -289,6 +308,7 @@ class ApcSituationPro(db.Model, XMLModel):
nullable=False,
)
libelle = db.Column(db.Text(), nullable=False)
# aucun attribut (le text devient le libellé)
def to_dict(self):
return {"libelle": self.libelle}
@ -358,15 +378,39 @@ class ApcNiveau(db.Model, XMLModel):
"competence": self.competence.to_dict_bul(),
}
@functools.cached_property
def parcours(self) -> list["ApcParcours"]:
"""Les parcours passant par ce niveau.
Les associations Parcours/Niveaux/compétences ne sont jamais
changées par ScoDoc, la valeur est donc cachée.
"""
annee = int(self.annee[-1])
return (
ApcParcours.query.join(ApcAnneeParcours)
.filter_by(ordre=annee)
.join(ApcParcoursNiveauCompetence, ApcCompetence, ApcNiveau)
.filter_by(id=self.id)
.order_by(ApcParcours.numero, ApcParcours.code)
.all()
)
@functools.cached_property
def is_tronc_commun(self) -> bool:
"""Vrai si ce niveau fait partie du Tronc Commun"""
return len(self.parcours) == self.competence.referentiel.parcours.count()
@classmethod
def niveaux_annee_de_parcours(
cls,
parcour: "ApcParcours",
annee: int,
referentiel_competence: ApcReferentielCompetences = None,
competence: ApcCompetence = None,
) -> list["ApcNiveau"]:
"""Les niveaux de l'année du parcours
Si le parcour est None, tous les niveaux de l'année
(dans ce cas, spécifier referentiel_competence)
Si competence est indiquée, filtre les niveaux de cette compétence.
"""
if annee not in {1, 2, 3}:
raise ValueError("annee invalide pour un parcours BUT")
@ -377,22 +421,31 @@ class ApcNiveau(db.Model, XMLModel):
raise ScoNoReferentielCompetences()
if not parcour:
annee_formation = f"BUT{annee}"
return ApcNiveau.query.filter(
query = ApcNiveau.query.filter(
ApcNiveau.annee == annee_formation,
ApcCompetence.id == ApcNiveau.competence_id,
ApcCompetence.referentiel_id == referentiel_competence.id,
)
annee_parcour = parcour.annees.filter_by(ordre=annee).first()
if competence is not None:
query = query.filter(ApcCompetence.id == competence.id)
return query.all()
annee_parcour: ApcAnneeParcours = parcour.annees.filter_by(ordre=annee).first()
if not annee_parcour:
return []
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
]
if competence is None:
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()
return niveaux
@ -433,7 +486,7 @@ class ApcAppCritique(db.Model, XMLModel):
ref_comp: ApcReferentielCompetences,
annee: str,
competence: ApcCompetence = None,
) -> flask_sqlalchemy.BaseQuery:
) -> Query:
"Liste les AC de tous les parcours de ref_comp pour l'année indiquée"
assert annee in {"BUT1", "BUT2", "BUT3"}
query = cls.query.filter(
@ -505,7 +558,7 @@ class ApcParcours(db.Model, XMLModel):
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
nullable=False,
)
numero = db.Column(db.Integer) # ordre de présentation
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
code = db.Column(db.Text(), nullable=False)
libelle = db.Column(db.Text(), nullable=False)
annees = db.relationship(
@ -514,7 +567,6 @@ class ApcParcours(db.Model, XMLModel):
lazy="dynamic",
cascade="all, delete-orphan",
)
ues = db.relationship("UniteEns", back_populates="parcour")
def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>"
@ -532,7 +584,7 @@ class ApcParcours(db.Model, XMLModel):
d["annees"] = {x.ordre: x.to_dict() for x in self.annees}
return d
def query_competences(self) -> flask_sqlalchemy.BaseQuery:
def query_competences(self) -> Query:
"Les compétences associées à ce parcours"
return (
ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
@ -540,6 +592,16 @@ class ApcParcours(db.Model, XMLModel):
.order_by(ApcCompetence.numero)
)
def get_competence_by_titre(self, titre: str) -> ApcCompetence:
"La compétence de titre donné dans ce parcours, ou None"
return (
ApcCompetence.query.filter_by(titre=titre)
.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
.filter_by(parcours_id=self.id)
.order_by(ApcCompetence.numero)
.first()
)
class ApcAnneeParcours(db.Model, XMLModel):
id = db.Column(db.Integer, primary_key=True)
@ -550,7 +612,8 @@ class ApcAnneeParcours(db.Model, XMLModel):
"numéro de l'année: 1, 2, 3"
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} ordre={self.ordre!r} parcours={self.parcours.code!r}>"
return f"""<{self.__class__.__name__} {
self.id} ordre={self.ordre!r} parcours={self.parcours.code!r}>"""
def to_dict(self):
return {

View File

@ -4,7 +4,7 @@
"""
from typing import Union
import flask_sqlalchemy
from flask_sqlalchemy.query import Query
from app import db
from app.models import CODE_STR_LEN
@ -177,7 +177,7 @@ class RegroupementCoherentUE:
def query_validations(
self,
) -> flask_sqlalchemy.BaseQuery: # list[ApcValidationRCUE]
) -> Query: # list[ApcValidationRCUE]
"""Les validations de jury enregistrées pour ce RCUE"""
niveau = self.ue_2.niveau_competence

View File

@ -8,6 +8,8 @@ from app import current_app, db, log
from app.comp import bonus_spo
from app.scodoc import sco_utils as scu
from datetime import time
from app.scodoc.codes_cursus import (
ABAN,
ABL,
@ -94,6 +96,10 @@ class ScoDocSiteConfig(db.Model):
"cas_logout_route": str,
"cas_validate_route": str,
"cas_attribute_id": str,
# Assiduités
"morning_time": str,
"lunch_time": str,
"afternoon_time": str,
}
def __init__(self, name, value):

View File

@ -18,7 +18,7 @@ from app import models
from app.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat
from app.scodoc.sco_exceptions import ScoInvalidParamError
from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
import app.scodoc.sco_utils as scu
@ -30,6 +30,7 @@ class Identite(db.Model):
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)
@ -41,6 +42,12 @@ class Identite(db.Model):
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 officiels (bulletins, PV)
# cf 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="")
date_naissance = db.Column(db.Date)
lieu_naissance = db.Column(db.Text())
dept_naissance = db.Column(db.Text())
@ -66,6 +73,10 @@ class Identite(db.Model):
passive_deletes=True,
)
# Relations avec les assiduites et les justificatifs
assiduites = db.relationship("Assiduite", backref="etudiant", lazy="dynamic")
justificatifs = db.relationship("Justificatif", backref="etudiant", lazy="dynamic")
def __repr__(self):
return (
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
@ -93,7 +104,7 @@ class Identite(db.Model):
def create_etud(cls, **args):
"Crée un étudiant, avec admission et adresse vides."
etud: Identite = cls(**args)
etud.adresses.append(Adresse())
etud.adresses.append(Adresse(typeadresse="domicile"))
etud.admission.append(Admission())
return etud
@ -104,6 +115,13 @@ class Identite(db.Model):
"""
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).
"""
return {"M": "M.", "F": "Mme", "X": ""}[self.civilite_etat_civil]
def sex_nom(self, no_accents=False) -> str:
"'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'"
s = f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()}"
@ -150,6 +168,14 @@ class Identite(db.Model):
r.append("-".join([x.lower().capitalize() for x in fields]))
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
@property
def nom_short(self):
"Nom et début du prénom pour table recap: 'DUPONT Pi.'"
@ -179,6 +205,50 @@ class Identite(db.Model):
reverse=True,
)
@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"}
fs_empty_stored_as_nulls = {
"nom",
"prenom",
"nom_usuel",
"date_naissance",
"lieu_naissance",
"dept_naissance",
"nationalite",
"statut",
"photo_filename",
"code_nip",
"code_ine",
}
args_dict = {}
for key, value in args.items():
if hasattr(cls, key):
# compat scodoc7 (mauvaise idée de l'époque)
if key in fs_empty_stored_as_nulls and value == "":
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 == "boursier":
value = bool(value)
elif key == "date_naissance":
value = ndb.DateDMYtoISO(value)
args_dict[key] = value
return args_dict
def from_dict(self, args: dict):
"update fields given in dict. Add to session but don't commit."
args_dict = Identite.convert_dict_fields(args)
args_dict.pop("id", None)
args_dict.pop("etudid", None)
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.add(self)
def to_dict_short(self) -> dict:
"""Les champs essentiels"""
return {
@ -191,6 +261,8 @@ class Identite(db.Model):
"nom_usuel": self.nom_usuel,
"prenom": self.prenom,
"sort_key": self.sort_key,
"civilite_etat_civil": self.civilite_etat_civil,
"prenom_etat_civil": self.prenom_etat_civil,
}
def to_dict_scodoc7(self) -> dict:
@ -234,6 +306,8 @@ class Identite(db.Model):
"dept_naissance": self.dept_naissance or "",
"nationalite": self.nationalite or "",
"boursier": self.boursier or "",
"civilite_etat_civil": self.civilite_etat_civil,
"prenom_etat_civil": self.prenom_etat_civil,
}
if include_urls and has_request_context():
# test request context so we can use this func in tests under the flask shell
@ -450,10 +524,10 @@ class Identite(db.Model):
M. Pierre Dupont
"""
if with_paragraph:
return f"""{self.nomprenom}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
return f"""{self.etat_civil}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
line_sep}à {self.lieu_naissance or ""}"""
return self.nomprenom
return self.etat_civil
def photo_html(self, title=None, size="small") -> str:
"""HTML img tag for the photo, either in small size (h90)
@ -517,6 +591,37 @@ def make_etud_args(
return args
def input_civilite(s):
"""Converts external representation of civilite to internal:
'M', 'F', or 'X' (and nothing else).
Raises ScoValueError if conversion fails.
"""
s = s.upper().strip()
if s in ("M", "M.", "MR", "H"):
return "M"
elif s in ("F", "MLLE", "MLLE.", "MELLE", "MME"):
return "F"
elif s == "X" or not s:
return "X"
raise ScoValueError(f"valeur invalide pour la civilité: {s}")
PIVOT_YEAR = 70
def pivot_year(y) -> int:
"converti et calcule l'année si saisie à deux chiffres"
if y == "" or y is None:
return None
y = int(round(float(y)))
if y >= 0 and y < 100:
if y < PIVOT_YEAR:
y = y + 2000
else:
y = y + 1900
return y
class Adresse(db.Model):
"""Adresse d'un étudiant
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule)
@ -610,19 +715,51 @@ class Admission(db.Model):
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
if no_nulls:
for k in d.keys():
if d[k] is None:
for key, value in d.items():
if value is None:
col_type = getattr(
sqlalchemy.inspect(models.Admission).columns, "apb_groupe"
sqlalchemy.inspect(models.Admission).columns, key
).expression.type
if isinstance(col_type, sqlalchemy.Text):
d[k] = ""
d[key] = ""
elif isinstance(col_type, sqlalchemy.Integer):
d[k] = 0
d[key] = 0
elif isinstance(col_type, sqlalchemy.Boolean):
d[k] = False
d[key] = False
return d
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"Convert fields in the given dict. No other side effect"
fs_uppercase = {"bac", "specialite"}
args_dict = {}
for key, value in args.items():
if hasattr(cls, key):
if (
value == ""
): # les chaines vides donne des NULLS (scodoc7 convention)
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":
value = ndb.int_null_is_null(value)
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):

View File

@ -3,6 +3,7 @@
"""ScoDoc models: evaluations
"""
import datetime
from operator import attrgetter
from app import db
from app.models.etudiants import Identite
@ -44,7 +45,7 @@ class Evaluation(db.Model):
)
# ordre de presentation (par défaut, le plus petit numero
# est la plus ancienne eval):
numero = db.Column(db.Integer)
numero = db.Column(db.Integer, nullable=False, default=0)
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
def __repr__(self):
@ -151,7 +152,7 @@ class Evaluation(db.Model):
Return True if (uncommited) modification, False otherwise.
"""
ue_coef_dict = self.moduleimpl.module.get_ue_coef_dict()
sem_ues = self.moduleimpl.formsemestre.query_ues(with_sport=False).all()
sem_ues = self.moduleimpl.formsemestre.get_ues(with_sport=False)
modified = False
for ue in sem_ues:
existing_poids = EvaluationUEPoids.query.filter_by(
@ -196,7 +197,7 @@ class Evaluation(db.Model):
return {
p.ue.id: p.poids
for p in sorted(
self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme)
self.ue_poids, key=attrgetter("ue.numero", "ue.acronyme")
)
}

View File

@ -1,6 +1,6 @@
"""ScoDoc 9 models : Formations
"""
import flask_sqlalchemy
from flask_sqlalchemy.query import Query
import app
from app import db
@ -9,13 +9,12 @@ from app.models import SHORT_STR_LEN
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
)
from app.models.modules import Module
from app.models.moduleimpls import ModuleImpl
from app.models.ues import UniteEns
from app.models.ues import UniteEns, UEParcours
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu
@ -52,7 +51,9 @@ class Formation(db.Model):
)
ues = db.relationship("UniteEns", backref="formation", lazy="dynamic")
formsemestres = db.relationship("FormSemestre", lazy="dynamic", backref="formation")
ues = db.relationship("UniteEns", lazy="dynamic", backref="formation")
ues = db.relationship(
"UniteEns", lazy="dynamic", backref="formation", order_by="UniteEns.numero"
)
modules = db.relationship("Module", lazy="dynamic", backref="formation")
def __repr__(self):
@ -213,27 +214,38 @@ class Formation(db.Model):
if change:
app.clear_scodoc_cache()
def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery:
"""Les UEs d'un parcours de la formation.
def query_ues_parcour(
self, parcour: ApcParcours, with_sport: bool = False
) -> Query:
"""Les UEs (sans bonus, sauf si with_sport) d'un parcours de la formation
(déclarée comme faisant partie du parcours ou du tronc commun, sans aucun parcours)
Si parcour est None, les UE sans parcours.
Exemple: pour avoir les UE du semestre 3, faire
`formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)`
`formation.query_ues_parcour(parcour).filter(UniteEns.semestre_idx == 3)`
"""
if parcour is None:
return UniteEns.query.filter_by(
formation=self, type=UE_STANDARD, parcour_id=None
)
return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter(
UniteEns.niveau_competence_id == ApcNiveau.id,
(UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None),
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
ApcAnneeParcours.parcours_id == parcour.id,
if with_sport:
query_f = UniteEns.query.filter_by(formation=self)
else:
query_f = UniteEns.query.filter_by(formation=self, type=UE_STANDARD)
# Les UE sans parcours:
query_no_parcours = query_f.outerjoin(UEParcours).filter(
UEParcours.parcours_id == None
)
if parcour is None:
return query_no_parcours.order_by(UniteEns.numero)
# Ajoute les UE du parcours sélectionné:
return query_no_parcours.union(
query_f.join(UEParcours).filter_by(parcours_id=parcour.id)
).order_by(UniteEns.numero)
# return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter(
# UniteEns.niveau_competence_id == ApcNiveau.id,
# (UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None),
# ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
# ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
# ApcAnneeParcours.parcours_id == parcour.id,
# )
def query_competences_parcour(
self, parcour: ApcParcours
) -> flask_sqlalchemy.BaseQuery:
def query_competences_parcour(self, parcour: ApcParcours) -> Query:
"""Les ApcCompetences d'un parcours de la formation.
None si pas de référentiel de compétences.
"""
@ -281,7 +293,7 @@ class Matiere(db.Model):
matiere_id = db.synonym("id")
ue_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"))
titre = db.Column(db.Text())
numero = db.Column(db.Integer) # ordre de présentation
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
modules = db.relationship("Module", lazy="dynamic", backref="matiere")

View File

@ -12,11 +12,11 @@
"""
import datetime
from functools import cached_property
from operator import attrgetter
from flask_login import current_user
import flask_sqlalchemy
from flask import flash, g
from sqlalchemy import and_, or_
from sqlalchemy.sql import text
import app.scodoc.sco_utils as scu
@ -24,10 +24,7 @@ from app import db, log
from app.auth.models import User
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences,
parcours_formsemestre,
)
@ -45,6 +42,8 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV
from app.scodoc.sco_vdi import ApoEtapeVDI
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
class FormSemestre(db.Model):
"""Mise en oeuvre d'un semestre de formation"""
@ -111,6 +110,10 @@ class FormSemestre(db.Model):
elt_annee_apo = db.Column(db.Text())
"code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
# Data pour groups_auto_assignment
# (ce champ est utilisé uniquement via l'API par le front js)
groups_auto_assignment_data = db.Column(db.LargeBinary(), nullable=True)
# Relations:
etapes = db.relationship(
"FormSemestreEtape", cascade="all,delete", backref="formsemestre"
@ -149,6 +152,7 @@ class FormSemestre(db.Model):
secondary=parcours_formsemestre,
lazy="subquery",
backref=db.backref("formsemestres", lazy=True),
order_by=(ApcParcours.numero, ApcParcours.code),
)
def __init__(self, **kwargs):
@ -195,11 +199,14 @@ class FormSemestre(db.Model):
d["date_fin"] = d["date_fin_iso"] = ""
d["responsables"] = [u.id for u in self.responsables]
d["titre_formation"] = self.titre_formation()
if convert_objects:
if convert_objects: # pour API
d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
d["departement"] = self.departement.to_dict()
d["formation"] = self.formation.to_dict()
d["etape_apo"] = self.etapes_apo_str()
else:
# Converti les étapes Apogee sous forme d'ApoEtapeVDI (compat scodoc7)
d["etapes"] = [e.as_apovdi() for e in self.etapes]
return d
def to_dict_api(self):
@ -281,60 +288,45 @@ class FormSemestre(db.Model):
)
return r or []
def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery:
def get_ues(self, with_sport=False) -> list[UniteEns]:
"""UE des modules de ce semestre, triées par numéro.
- Formations classiques: les UEs auxquelles appartiennent
les modules mis en place dans ce semestre.
- Formations APC / BUT: les UEs de la formation qui
- ont le même numéro de semestre que ce formsemestre
- sont associées à l'un des parcours de ce formsemestre (ou à aucun)
- ont le même numéro de semestre que ce formsemestre;
- et sont associées à l'un des parcours de ce formsemestre
(ou à aucun, donc tronc commun).
"""
if self.formation.get_cursus().APC_SAE:
sem_ues = UniteEns.query.filter_by(
formation=self.formation, semestre_idx=self.semestre_id
)
if self.parcours:
# Prend toutes les UE de l'un des parcours du sem., ou déclarées sans parcours
sem_ues = sem_ues.filter(
(UniteEns.parcour == None)
| (UniteEns.parcour_id.in_([p.id for p in self.parcours]))
formation: Formation = self.formation
if formation.is_apc():
# UEs de tronc commun (sans parcours indiqué)
sem_ues = {
ue.id: ue
for ue in formation.query_ues_parcour(
None, with_sport=with_sport
).filter(UniteEns.semestre_idx == self.semestre_id)
}
# Ajoute les UE de parcours
for parcour in self.parcours:
sem_ues.update(
{
ue.id: ue
for ue in formation.query_ues_parcour(
parcour, with_sport=with_sport
).filter(UniteEns.semestre_idx == self.semestre_id)
}
)
# si le sem. ne coche aucun parcours, prend toutes les UE
ues = sem_ues.values()
return sorted(ues, key=attrgetter("numero"))
else:
sem_ues = db.session.query(UniteEns).filter(
ModuleImpl.formsemestre_id == self.id,
Module.id == ModuleImpl.module_id,
UniteEns.id == Module.ue_id,
)
if not with_sport:
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
return sem_ues.order_by(UniteEns.numero)
def query_ues_parcours_etud(self, etudid: int) -> flask_sqlalchemy.BaseQuery:
"""XXX inutilisé à part pour un test unitaire => supprimer ?
UEs que suit l'étudiant dans ce semestre BUT
en fonction du parcours dans lequel il est inscrit.
Si l'étudiant n'est inscrit à aucun parcours,
renvoie uniquement les UEs de tronc commun (sans parcours).
Si voulez les UE d'un parcours, il est plus efficace de passer par
`formation.query_ues_parcour(parcour)`.
"""
return self.query_ues().filter(
FormSemestreInscription.etudid == etudid,
FormSemestreInscription.formsemestre == self,
UniteEns.niveau_competence_id == ApcNiveau.id,
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
or_(
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
and_(
FormSemestreInscription.parcour_id.is_(None),
UniteEns.parcour_id.is_(None),
),
),
)
if not with_sport:
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
return sem_ues.order_by(UniteEns.numero).all()
@cached_property
def modimpls_sorted(self) -> list[ModuleImpl]:
@ -937,7 +929,7 @@ class FormSemestreEtape(db.Model):
def __repr__(self):
return f"<Etape {self.id} apo={self.etape_apo!r}>"
def as_apovdi(self):
def as_apovdi(self) -> ApoEtapeVDI:
return ApoEtapeVDI(self.etape_apo)
@ -960,7 +952,7 @@ class FormationModalite(db.Model):
) # code
titre = db.Column(db.Text()) # texte explicatif
# numero = ordre de presentation)
numero = db.Column(db.Integer)
numero = db.Column(db.Integer, nullable=False, default=0)
@staticmethod
def insert_modalites():

View File

@ -7,6 +7,7 @@
"""ScoDoc models: Groups & partitions
"""
from operator import attrgetter
from app import db
from app.models import SHORT_STR_LEN
@ -29,7 +30,7 @@ class Partition(db.Model):
# "TD", "TP", ... (NULL for 'all')
partition_name = db.Column(db.String(SHORT_STR_LEN))
# Numero = ordre de presentation)
numero = db.Column(db.Integer)
numero = db.Column(db.Integer, nullable=False, default=0)
# Calculer le rang ?
bul_show_rank = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
@ -84,21 +85,38 @@ class Partition(db.Model):
"Vrai s'il s'agit de la partition de parcours"
return self.partition_name == scu.PARTITION_PARCOURS
def to_dict(self, with_groups=False) -> dict:
"""as a dict, with or without groups"""
def to_dict(self, with_groups=False, str_keys: bool = False) -> dict:
"""as a dict, with or without groups.
If str_keys, convert integer dict keys to strings (useful for JSON)
"""
d = dict(self.__dict__)
d["partition_id"] = self.id
d.pop("_sa_instance_state", None)
d.pop("formsemestre", None)
if with_groups:
groups = sorted(self.groups, key=lambda g: (g.numero or 0, g.group_name))
groups = sorted(self.groups, key=attrgetter("numero", "group_name"))
# un dict et non plus une liste, pour JSON
d["groups"] = {
group.id: group.to_dict(with_partition=False) for group in groups
}
if str_keys:
d["groups"] = {
str(group.id): group.to_dict(with_partition=False)
for group in groups
}
else:
d["groups"] = {
group.id: group.to_dict(with_partition=False) for group in groups
}
return d
def get_etud_group(self, etudid: int) -> "GroupDescr":
"Le groupe de l'étudiant dans cette partition, ou None si pas présent"
return (
GroupDescr.query.filter_by(partition_id=self.id)
.join(group_membership)
.filter_by(etudid=etudid)
.first()
)
class GroupDescr(db.Model):
"""Description d'un groupe d'une partition"""
@ -112,7 +130,7 @@ class GroupDescr(db.Model):
# "A", "C2", ... (NULL for 'all'):
group_name = db.Column(db.String(GROUPNAME_STR_LEN))
# Numero = ordre de presentation
numero = db.Column(db.Integer)
numero = db.Column(db.Integer, nullable=False, default=0)
etuds = db.relationship(
"Identite",

View File

@ -2,7 +2,7 @@
"""ScoDoc models: moduleimpls
"""
import pandas as pd
import flask_sqlalchemy
from flask_sqlalchemy.query import Query
from app import db
from app.auth.models import User
@ -122,6 +122,22 @@ class ModuleImpl(db.Model):
raise AccessDenied(f"Modification impossible pour {user}")
return False
def est_inscrit(self, etud: Identite) -> bool:
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl
Retourne Vrai si c'est le cas, 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(
@ -163,7 +179,7 @@ class ModuleImplInscription(db.Model):
@classmethod
def etud_modimpls_in_ue(
cls, formsemestre_id: int, etudid: int, ue_id: int
) -> flask_sqlalchemy.BaseQuery:
) -> Query:
"""moduleimpls de l'UE auxquels l'étudiant est inscrit.
(Attention: inutile en APC, il faut considérer les coefficients)
"""

View File

@ -33,7 +33,7 @@ class Module(db.Model):
# pas un id mais le numéro du semestre: 1, 2, ...
# 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) # ordre de présentation
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))
# Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)

View File

@ -3,6 +3,7 @@
"""Notes, décisions de jury, évènements scolaires
"""
import sqlalchemy as sa
from app import db
import app.scodoc.sco_utils as scu
@ -86,7 +87,8 @@ def etud_has_notes_attente(etudid, formsemestre_id):
(ne compte que les notes en attente dans des évaluations avec coef. non nul).
"""
cursor = db.session.execute(
"""SELECT COUNT(*)
sa.text(
"""SELECT COUNT(*)
FROM notes_notes n, notes_evaluation e, notes_moduleimpl m,
notes_moduleimpl_inscription i
WHERE n.etudid = :etudid
@ -97,7 +99,8 @@ def etud_has_notes_attente(etudid, formsemestre_id):
and e.coefficient != 0
and m.id = i.moduleimpl_id
and i.etudid = :etudid
""",
"""
),
{
"formsemestre_id": formsemestre_id,
"etudid": etudid,

View File

@ -1,57 +0,0 @@
# -*- coding: UTF-8 -*
"""
Create some Postgresql sequences and functions used by ScoDoc
using raw SQL
"""
from app import db
def create_database_functions(): # XXX obsolete
"""Create specific SQL functions and sequences
XXX Obsolete: cette fonction est dans la première migration 9.0.3
Flask-Migrate fait maintenant (dans les versions >= 9.0.4) ce travail.
"""
# Important: toujours utiliser IF NOT EXISTS
# car cette fonction peut être appelée plusieurs fois sur la même db
db.session.execute(
"""
CREATE SEQUENCE IF NOT EXISTS notes_idgen_fcod;
CREATE OR REPLACE FUNCTION notes_newid_fcod() RETURNS TEXT
AS $$ SELECT 'FCOD' || to_char(nextval('notes_idgen_fcod'), 'FM999999999'); $$
LANGUAGE SQL;
CREATE OR REPLACE FUNCTION notes_newid_ucod() RETURNS TEXT
AS $$ SELECT 'UCOD' || to_char(nextval('notes_idgen_fcod'), 'FM999999999'); $$
LANGUAGE SQL;
CREATE OR REPLACE FUNCTION truncate_tables(username IN VARCHAR) RETURNS void AS $$
DECLARE
statements CURSOR FOR
SELECT tablename FROM pg_tables
WHERE tableowner = username AND schemaname = 'public'
AND tablename <> 'notes_semestres'
AND tablename <> 'notes_form_modalites'
AND tablename <> 'alembic_version';
BEGIN
FOR stmt IN statements LOOP
EXECUTE 'TRUNCATE TABLE ' || quote_ident(stmt.tablename) || ' CASCADE;';
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- Fonction pour anonymisation:
-- inspirée par https://www.simononsoftware.com/random-string-in-postgresql/
CREATE OR REPLACE FUNCTION random_text_md5( integer ) returns text
LANGUAGE SQL
AS $$
select upper( substring( (SELECT string_agg(md5(random()::TEXT), '')
FROM generate_series(
1,
CEIL($1 / 32.)::integer)
), 1, $1) );
$$;
"""
)
db.session.commit()

View File

@ -21,7 +21,7 @@ class UniteEns(db.Model):
ue_id = db.synonym("id")
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
acronyme = db.Column(db.Text(), nullable=False)
numero = db.Column(db.Integer) # ordre de présentation
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
titre = db.Column(db.Text())
# Le semestre_idx n'est pas un id mais le numéro du semestre: 1, 2, ...
# En ScoDoc7 et pour les formations classiques, il est NULL
@ -38,7 +38,7 @@ class UniteEns(db.Model):
server_default=db.text("notes_newid_ucod()"),
nullable=False,
)
ects = db.Column(db.Float) # nombre de credits ECTS
ects = db.Column(db.Float) # nombre de credits ECTS (sauf si parcours spécifié)
is_external = db.Column(db.Boolean(), default=False, server_default="false")
# id de l'element pedagogique Apogee correspondant:
code_apogee = db.Column(db.String(APO_CODE_STR_LEN))
@ -56,11 +56,10 @@ class UniteEns(db.Model):
)
niveau_competence = db.relationship("ApcNiveau", back_populates="ues")
# Une ue appartient soit à tous les parcours (tronc commun), soit à un seul:
parcour_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="SET NULL"), index=True
# Une UE appartient soit à tous les parcours (tronc commun), soit à un sous-ensemble
parcours = db.relationship(
ApcParcours, secondary="ue_parcours", backref=db.backref("ues", lazy=True)
)
parcour = db.relationship("ApcParcours", back_populates="ues")
# relations
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
@ -101,10 +100,9 @@ class UniteEns(db.Model):
return ue
def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
"""as a dict, with the same conversions as in ScoDoc7
(except ECTS: keep None)
"""as a dict, with the same conversions as in ScoDoc7.
If convert_objects, convert all attributes to native types
(suitable jor json encoding).
(suitable for json encoding).
"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
@ -112,10 +110,19 @@ class UniteEns(db.Model):
# ScoDoc7 output_formators
e["ue_id"] = self.id
e["numero"] = e["numero"] if e["numero"] else 0
e["ects"] = e["ects"]
e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0
e["code_apogee"] = e["code_apogee"] or "" # pas de None
e["parcour"] = self.parcour.to_dict() if self.parcour else None
e["ects_by_parcours"] = {
parcour.code: self.get_ects(parcour) for parcour in self.parcours
}
e["parcours"] = []
for parcour in self.parcours:
p_dict = parcour.to_dict(with_annees=False)
ects = self.get_ects(parcour, only_parcours=True)
if ects is not None:
p_dict["ects"] = ects
e["parcours"].append(p_dict)
if with_module_ue_coefs:
if convert_objects:
e["module_ue_coefs"] = [
@ -163,6 +170,44 @@ class UniteEns(db.Model):
db.session.add(self)
db.session.commit()
def get_ects(self, parcour: ApcParcours = None, only_parcours=False) -> float:
"""Crédits ECTS associés à cette UE.
En BUT, cela peut quelquefois dépendre du parcours.
Si only_parcours, renvoie None si pas de valeur spéciquement définie dans
le parcours indiqué.
"""
if parcour is not None:
ue_parcour = UEParcours.query.filter_by(
ue_id=self.id, parcours_id=parcour.id
).first()
if ue_parcour is not None and ue_parcour.ects is not None:
return ue_parcour.ects
if only_parcours:
return None
return self.ects
def set_ects(self, ects: float, parcour: ApcParcours = None):
"""Fixe les crédits. Do not commit.
Si le parcours n'est pas spécifié, affecte les ECTS par défaut de l'UE.
Si ects est None et parcours indiqué, efface l'association.
"""
if parcour is not None:
ue_parcour = UEParcours.query.filter_by(
ue_id=self.id, parcours_id=parcour.id
).first()
if ects is None:
if ue_parcour:
db.session.delete(ue_parcour)
else:
if ue_parcour is None:
ue_parcour = UEParcours(parcours_id=parcour.id, ue_id=self.id)
ue_parcour.ects = float(ects)
db.session.add(ue_parcour)
else:
self.ects = ects
log(f"ue.set_ects( ue_id={self.id}, acronyme={self.acronyme}, ects={ects} )")
db.session.add(self)
def get_ressources(self):
"Liste des modules ressources rattachés à cette UE"
return self.modules.filter_by(module_type=scu.ModuleType.RESSOURCE).all()
@ -184,84 +229,203 @@ class UniteEns(db.Model):
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
def _check_apc_conflict(self, new_niveau_id: int, new_parcour_id: int):
"raises ScoFormationConflict si (niveau, parcours) pas unique dans ce semestre"
# Les UE du même semestre que nous:
ues_sem = self.formation.ues.filter_by(semestre_idx=self.semestre_idx)
if (new_niveau_id, new_parcour_id) in (
(oue.niveau_competence_id, oue.parcour_id)
for oue in ues_sem
if oue.id != self.id
):
log(
f"set_ue_niveau_competence: {self}: ({new_niveau_id}, {new_parcour_id}) déjà associé"
)
raise ScoFormationConflict()
def _parcours_niveaux_ids(self, parcours=list[ApcParcours]) -> set[int]:
"""set des ids de niveaux communs à tous les parcours listés"""
return set.intersection(
*[
{
n.id
for n in self.niveau_competence.niveaux_annee_de_parcours(
parcour, self.annee(), self.formation.referentiel_competence
)
}
for parcour in parcours
]
)
def set_niveau_competence(self, niveau: ApcNiveau):
def check_niveau_unique_dans_parcours(
self, niveau: ApcNiveau, parcours=list[ApcParcours]
) -> tuple[bool, str]:
"""Vérifie que
- le niveau est dans au moins l'un des parcours listés;
- et que l'un des parcours associé à cette UE ne contient pas
déjà une UE associée au niveau donné dans une autre année.
Renvoie: (True, "") si ok, sinon (False, message).
"""
# Le niveau est-il dans l'un des parcours listés ?
if parcours:
if niveau.id not in self._parcours_niveaux_ids(parcours):
log(
f"Le niveau {niveau} ne fait pas partie des parcours de l'UE {self}."
)
return (
False,
f"""Le niveau {
niveau.libelle} ne fait pas partie des parcours de l'UE {self.acronyme}.""",
)
for parcour in parcours or [None]:
if parcour is None:
code_parcour = "TC"
ues_meme_niveau = [
ue
for ue in self.formation.query_ues_parcour(None).filter(
UniteEns.niveau_competence == niveau
)
]
else:
code_parcour = parcour.code
ues_meme_niveau = [
ue
for ue in parcour.ues
if ue.id != self.id
and ue.formation_id == self.formation_id
and ue.niveau_competence_id == niveau.id
]
if ues_meme_niveau:
msg_parc = f"parcours {code_parcour}" if parcour else "tronc commun"
if len(ues_meme_niveau) > 1: # deja 2 UE sur ce niveau
msg = f"""Niveau "{
niveau.libelle}" déjà associé à deux UE du {msg_parc}"""
log(
f"check_niveau_unique_dans_parcours(niveau_id={niveau.id}): "
+ msg
)
return False, msg
# s'il y a déjà une UE associée à ce niveau, elle doit être dans l'autre semestre
# de la même année scolaire
other_semestre_idx = self.semestre_idx + (
2 * (self.semestre_idx % 2) - 1
)
if ues_meme_niveau[0].semestre_idx != other_semestre_idx:
msg = f"""Erreur: niveau "{
niveau.libelle}" déjà associé à une autre UE du semestre S{
ues_meme_niveau[0].semestre_idx} du {msg_parc}"""
log(
f"check_niveau_unique_dans_parcours(niveau_id={niveau.id}): "
+ msg
)
return False, msg
return True, ""
def set_niveau_competence(self, niveau: ApcNiveau) -> tuple[bool, str]:
"""Associe cette UE au niveau de compétence indiqué.
Le niveau doit être dans le parcours de l'UE, s'il y en a un.
Le niveau doit être dans l'un des parcours de l'UE (si elle n'est pas
de tronc commun).
Assure que ce soit la seule dans son parcours.
Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie.
Returns True if (de)association done, False on error.
"""
# Sanity checks
if not self.formation.referentiel_competence:
return (
False,
"La formation n'est pas associée à un référentiel de compétences",
)
if niveau is not None:
self._check_apc_conflict(niveau.id, self.parcour_id)
# Le niveau est-il dans le parcours ? Sinon, erreur
if self.parcour and niveau.id not in (
n.id
for n in niveau.niveaux_annee_de_parcours(
self.parcour, self.annee(), self.formation.referentiel_competence
if self.niveau_competence_id is not None:
return (
False,
f"""{self.acronyme} déjà associée à un niveau de compétences ({
self.id}, {self.niveau_competence_id})""",
)
if (
niveau.competence.referentiel.id
!= self.formation.referentiel_competence.id
):
log(
f"set_niveau_competence: niveau {niveau} hors parcours {self.parcour}"
return (
False,
"Le niveau n'appartient pas au référentiel de la formation",
)
return
if niveau.id == self.niveau_competence_id:
return True, "" # nothing to do
if self.niveau_competence_id is not None:
ok, error_message = self.check_niveau_unique_dans_parcours(
niveau, self.parcours
)
if not ok:
return ok, error_message
elif self.niveau_competence_id is None:
return True, "" # nothing to do
self.niveau_competence = niveau
db.session.add(self)
db.session.commit()
# Invalidation du cache
self.formation.invalidate_cached_sems()
log(f"ue.set_niveau_competence( {self}, {niveau} )")
return True, ""
def set_parcour(self, parcour: ApcParcours):
"""Associe cette UE au parcours indiqué.
Assure que ce soit la seule dans son parcours.
Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie.
def set_parcours(self, parcours: list[ApcParcours]) -> tuple[bool, str]:
"""Associe cette UE aux parcours indiqués.
Si un niveau est déjà associé, vérifie sa cohérence.
Renvoie (True, "") si ok, sinon (False, error_message)
"""
if (parcour is not None) and self.niveau_competence is not None:
self._check_apc_conflict(self.niveau_competence.id, parcour.id)
self.parcour = parcour
# Le niveau est-il dans ce parcours ? Sinon, l'enlève
msg = ""
# Le niveau est-il dans tous ces parcours ? Sinon, l'enlève
prev_niveau = self.niveau_competence
if (
parcour
parcours
and self.niveau_competence
and self.niveau_competence.id
not in (
n.id
for n in self.niveau_competence.niveaux_annee_de_parcours(
parcour, self.annee(), self.formation.referentiel_competence
)
)
and self.niveau_competence.id not in self._parcours_niveaux_ids(parcours)
):
self.niveau_competence = None
msg = " (niveau compétence désassocié !)"
if parcours and self.niveau_competence:
ok, error_message = self.check_niveau_unique_dans_parcours(
self.niveau_competence, parcours
)
if not ok:
self.niveau_competence = prev_niveau # restore
return False, error_message
self.parcours = parcours
db.session.add(self)
db.session.commit()
# Invalidation du cache
self.formation.invalidate_cached_sems()
log(f"ue.set_parcour( {self}, {parcour} )")
log(f"ue.set_parcours( {self}, {parcours} )")
return True, "parcours enregistrés" + msg
def add_parcour(self, parcour: ApcParcours) -> tuple[bool, str]:
"""Ajoute ce parcours à ceux de l'UE"""
if parcour.id in {p.id for p in self.parcours}:
return True, "" # déjà présent
if parcour.referentiel.id != self.formation.referentiel_competence.id:
return False, "Le parcours n'appartient pas au référentiel de la formation"
return self.set_parcours(self.parcours + [parcour])
class UEParcours(db.Model):
"""Association ue <-> parcours, indiquant les ECTS"""
__tablename__ = "ue_parcours"
ue_id = db.Column(
db.Integer,
db.ForeignKey("notes_ue.id", ondelete="CASCADE"),
primary_key=True,
)
parcours_id = db.Column(
db.Integer,
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
primary_key=True,
)
ects = db.Column(db.Float, nullable=True) # si NULL, on prendra les ECTS de l'UE
def __repr__(self):
return f"<UEParcours( ue_id={self.ue_id}, parcours_id={self.parcours_id}, ects={self.ects})>"
class DispenseUE(db.Model):
"""Dispense d'UE
Utilisé en APC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée
qu'ils ne refont pas.
Utilisé en APC (BUT) pour indiquer
- les étudiants redoublants avec une UE capitalisée qu'ils ne refont pas.
- les étudiants "non inscrit" à une UE car elle ne fait pas partie de leur Parcours.
La dispense d'UE n'est PAS une validation:
- elle n'est pas affectée par les décisions de jury (pas effacée)
- elle est associée à un formsemestre

43
app/profiler.py Normal file
View File

@ -0,0 +1,43 @@
from time import time
from datetime import datetime
class Profiler:
OUTPUT: str = "/tmp/scodoc.profiler.csv"
def __init__(self, tag: str) -> None:
self.tag: str = tag
self.start_time: time = None
self.stop_time: time = None
def start(self):
self.start_time = time()
return self
def stop(self):
self.stop_time = time()
return self
def elapsed(self) -> float:
return self.stop_time - self.start_time
def dates(self) -> tuple[datetime, datetime]:
return datetime.fromtimestamp(self.start_time), datetime.fromtimestamp(
self.stop_time
)
def write(self):
with open(Profiler.OUTPUT, "a") as file:
dates: tuple = self.dates()
date_str = (dates[0].isoformat(), dates[1].isoformat())
file.write(f"\n{self.tag},{self.elapsed() : .2}")
@classmethod
def write_in(cls, msg: str):
with open(cls.OUTPUT, "a") as file:
file.write(f"\n# {msg}")
@classmethod
def clear(cls):
with open(cls.OUTPUT, "w") as file:
file.write("")

View File

@ -275,6 +275,7 @@ DEVENIRS_NEXT2 = {NEXT_OR_NEXT2: 1, NEXT2: 1}
NO_SEMESTRE_ID = -1 # code semestre si pas de semestres
# Règles gestion cursus
class DUTRule(object):
def __init__(self, rule_id, premise, conclusion):
@ -298,7 +299,7 @@ class DUTRule(object):
# Types de cursus
DEFAULT_TYPE_CURSUS = 100 # pour le menu de creation nouvelle formation
DEFAULT_TYPE_CURSUS = 700 # (BUT) pour le menu de creation nouvelle formation
class TypeCursus:

View File

@ -40,7 +40,6 @@ Par exemple, la clé '_css_row_class' spécifie le style CSS de la ligne.
"""
from __future__ import print_function
import random
from collections import OrderedDict
from xml.etree import ElementTree
@ -60,7 +59,7 @@ from app.scodoc import sco_pdf
from app.scodoc import sco_xml
from app.scodoc.sco_exceptions import ScoPDFFormatError
from app.scodoc.sco_pdf import SU
from app import log
from app import log, ScoDocJSONEncoder
def mark_paras(L, tags) -> list[str]:
@ -647,7 +646,7 @@ class GenTable(object):
# v = str(v)
r[cid] = v
d.append(r)
return json.dumps(d, cls=scu.ScoDocJSONEncoder)
return json.dumps(d, cls=ScoDocJSONEncoder)
def make_page(
self,

4
app/scodoc/html_sidebar.py Normal file → Executable file
View File

@ -126,7 +126,7 @@ def sidebar(etudid: int = None):
if current_user.has_permission(Permission.ScoAbsChange):
H.append(
f"""
<li><a href="{ url_for('absences.SignaleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li>
<li><a href="{ url_for('assiduites.signal_assiduites_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li>
<li><a href="{ url_for('absences.JustifAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li>
<li><a href="{ url_for('absences.AnnuleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Supprimer</a></li>
"""
@ -138,7 +138,7 @@ def sidebar(etudid: int = None):
H.append(
f"""
<li><a href="{ url_for('absences.CalAbs', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Calendrier</a></li>
<li><a href="{ url_for('absences.ListeAbsEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Liste</a></li>
<li><a href="{ url_for('assiduites.liste_assiduites_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Liste</a></li>
</ul>
"""
)

View File

@ -186,7 +186,7 @@ def DBSelectArgs(
cond = ""
i = 1
cl = []
for (_, aux_id) in aux_tables:
for _, aux_id in aux_tables:
cl.append("T0.%s = T%d.%s" % (id_name, i, aux_id))
i = i + 1
cond += " and ".join(cl)
@ -403,7 +403,7 @@ class EditableTable(object):
def format_output(self, r, disable_formatting=False):
"Format dict using provided output_formators"
for (k, v) in r.items():
for k, v in r.items():
if v is None and self.convert_null_outputs_to_empty:
v = ""
# format value

41
app/scodoc/sco_abs.py Normal file → Executable file
View File

@ -42,6 +42,8 @@ from app.scodoc import sco_cache
from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_preferences
from app.models import Assiduite, Justificatif
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
# --- Misc tools.... ------------------
@ -1052,6 +1054,45 @@ def get_abs_count_in_interval(etudid, date_debut_iso, date_fin_iso):
return r
def get_assiduites_count_in_interval(etudid, date_debut_iso, date_fin_iso):
"""Les comptes d'absences de cet étudiant entre ces deux dates, incluses:
tuple (nb abs, nb abs justifiées)
Utilise un cache.
"""
key = str(etudid) + "_" + date_debut_iso + "_" + date_fin_iso + "_assiduites"
r = sco_cache.AbsSemEtudCache.get(key)
if not r:
date_debut: datetime.datetime = scu.is_iso_formated(date_debut_iso, True)
date_fin: datetime.datetime = scu.is_iso_formated(date_fin_iso, True)
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
justificatifs: Justificatif = Justificatif.query.filter_by(etudid=etudid)
assiduites = scass.filter_by_date(assiduites, Assiduite, date_debut, date_fin)
justificatifs = scass.filter_by_date(
justificatifs, Justificatif, date_debut, date_fin
)
calculator: scass.CountCalculator = scass.CountCalculator()
calculator.compute_assiduites(assiduites)
nb_abs: dict = calculator.to_dict()["demi"]
abs_just: list[Assiduite] = scass.get_all_justified(
etudid, date_debut, date_fin
)
calculator.reset()
calculator.compute_assiduites(abs_just)
nb_abs_just: dict = calculator.to_dict()["demi"]
r = (nb_abs, nb_abs_just)
ans = sco_cache.AbsSemEtudCache.set(key, r)
if not ans:
log("warning: get_assiduites_count failed to cache")
return r
def invalidate_abs_count(etudid, sem):
"""Invalidate (clear) cached counts"""
date_debut = sem["date_debut_iso"]

View File

@ -29,16 +29,14 @@
"""
from flask import g, url_for
import flask_sqlalchemy
from flask_sqlalchemy.query import Query
from app.models.absences import BilletAbsence
from app.models.etudiants import Identite
from app.scodoc.gen_tables import GenTable
from app.scodoc import sco_preferences
def query_billets_etud(
etudid: int = None, etat: bool = None
) -> flask_sqlalchemy.BaseQuery:
def query_billets_etud(etudid: int = None, etat: bool = None) -> Query:
"""Billets d'absences pour un étudiant, ou tous si etudid is None.
Si etat, filtre par état.
Si dans un département et que la gestion des billets n'a pas été activée

View File

@ -46,13 +46,14 @@ Pour chaque étudiant commun:
from flask import g, url_for
from app import log
from app.scodoc import sco_apogee_csv
from app.scodoc import sco_apogee_csv, sco_apogee_reader
from app.scodoc.sco_apogee_csv import ApoData
from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import html_sco_header
from app.scodoc import sco_preferences
_help_txt = """
_HELP_TXT = """
<div class="help">
<p>Outil de comparaison de fichiers (maquettes CSV) Apogée.
</p>
@ -69,7 +70,7 @@ def apo_compare_csv_form():
"""<h2>Comparaison de fichiers Apogée</h2>
<form id="apo_csv_add" action="apo_compare_csv" method="post" enctype="multipart/form-data">
""",
_help_txt,
_HELP_TXT,
"""
<div class="apo_compare_csv_form_but">
Fichier Apogée A:
@ -109,14 +110,14 @@ def apo_compare_csv(file_a, file_b, autodetect=True):
raise ScoValueError(
f"""
Erreur: l'encodage de l'un des fichiers est incorrect.
Vérifiez qu'il est bien en {sco_apogee_csv.APO_INPUT_ENCODING}
Vérifiez qu'il est bien en {sco_apogee_reader.APO_INPUT_ENCODING}
""",
dest_url=dest_url,
) from exc
H = [
html_sco_header.sco_header(page_title="Comparaison de fichiers Apogée"),
"<h2>Comparaison de fichiers Apogée</h2>",
_help_txt,
_HELP_TXT,
'<div class="apo_compare_csv">',
_apo_compare_csv(apo_data_a, apo_data_b),
"</div>",
@ -130,17 +131,17 @@ def _load_apo_data(csvfile, autodetect=True):
"Read data from request variable and build ApoData"
data_b = csvfile.read()
if autodetect:
data_b, message = sco_apogee_csv.fix_data_encoding(data_b)
data_b, message = sco_apogee_reader.fix_data_encoding(data_b)
if message:
log(f"apo_compare_csv: {message}")
if not data_b:
raise ScoValueError("fichier vide ? (apo_compare_csv: no data)")
data = data_b.decode(sco_apogee_csv.APO_INPUT_ENCODING)
data = data_b.decode(sco_apogee_reader.APO_INPUT_ENCODING)
apo_data = sco_apogee_csv.ApoData(data, orig_filename=csvfile.filename)
return apo_data
def _apo_compare_csv(A, B):
def _apo_compare_csv(apo_a: ApoData, apo_b: ApoData):
"""Generate html report comparing A and B, two instances of ApoData
representing Apogee CSV maquettes.
"""
@ -148,74 +149,75 @@ def _apo_compare_csv(A, B):
# 1-- Check etape and codes
L.append('<div class="section"><div class="tit">En-tête</div>')
L.append('<div><span class="key">Nom fichier A:</span><span class="val_ok">')
L.append(A.orig_filename)
L.append(apo_a.orig_filename)
L.append("</span></div>")
L.append('<div><span class="key">Nom fichier B:</span><span class="val_ok">')
L.append(B.orig_filename)
L.append(apo_b.orig_filename)
L.append("</span></div>")
L.append('<div><span class="key">Étape Apogée:</span>')
if A.etape_apogee != B.etape_apogee:
if apo_a.etape_apogee != apo_b.etape_apogee:
L.append(
'<span class="val_dif">%s != %s</span>' % (A.etape_apogee, B.etape_apogee)
f"""<span class="val_dif">{apo_a.etape_apogee} != {apo_b.etape_apogee}</span>"""
)
else:
L.append('<span class="val_ok">%s</span>' % (A.etape_apogee,))
L.append(f"""<span class="val_ok">{apo_a.etape_apogee}</span>""")
L.append("</div>")
L.append('<div><span class="key">VDI Apogée:</span>')
if A.vdi_apogee != B.vdi_apogee:
L.append('<span class="val_dif">%s != %s</span>' % (A.vdi_apogee, B.vdi_apogee))
if apo_a.vdi_apogee != apo_b.vdi_apogee:
L.append(
f"""<span class="val_dif">{apo_a.vdi_apogee} != {apo_b.vdi_apogee}</span>"""
)
else:
L.append('<span class="val_ok">%s</span>' % (A.vdi_apogee,))
L.append(f"""<span class="val_ok">{apo_a.vdi_apogee}</span>""")
L.append("</div>")
L.append('<div><span class="key">Code diplôme :</span>')
if A.cod_dip_apogee != B.cod_dip_apogee:
if apo_a.cod_dip_apogee != apo_b.cod_dip_apogee:
L.append(
'<span class="val_dif">%s != %s</span>'
% (A.cod_dip_apogee, B.cod_dip_apogee)
f"""<span class="val_dif">{apo_a.cod_dip_apogee} != {apo_b.cod_dip_apogee}</span>"""
)
else:
L.append('<span class="val_ok">%s</span>' % (A.cod_dip_apogee,))
L.append(f"""<span class="val_ok">{apo_a.cod_dip_apogee}</span>""")
L.append("</div>")
L.append('<div><span class="key">Année scolaire :</span>')
if A.annee_scolaire != B.annee_scolaire:
if apo_a.annee_scolaire != apo_b.annee_scolaire:
L.append(
'<span class="val_dif">%s != %s</span>'
% (A.annee_scolaire, B.annee_scolaire)
% (apo_a.annee_scolaire, apo_b.annee_scolaire)
)
else:
L.append('<span class="val_ok">%s</span>' % (A.annee_scolaire,))
L.append('<span class="val_ok">%s</span>' % (apo_a.annee_scolaire,))
L.append("</div>")
# Colonnes:
A_elts = set(A.apo_elts.keys())
B_elts = set(B.apo_elts.keys())
a_elts = set(apo_a.apo_csv.apo_elts.keys())
b_elts = set(apo_b.apo_csv.apo_elts.keys())
L.append('<div><span class="key">Éléments Apogée :</span>')
if A_elts == B_elts:
L.append('<span class="val_ok">%d</span>' % len(A_elts))
if a_elts == b_elts:
L.append(f"""<span class="val_ok">{len(a_elts)}</span>""")
else:
elts_communs = A_elts.intersection(B_elts)
elts_only_A = A_elts - A_elts.intersection(B_elts)
elts_only_B = B_elts - A_elts.intersection(B_elts)
elts_communs = a_elts.intersection(b_elts)
elts_only_a = a_elts - a_elts.intersection(b_elts)
elts_only_b = b_elts - a_elts.intersection(b_elts)
L.append(
'<span class="val_dif">différents (%d en commun, %d seulement dans A, %d seulement dans B)</span>'
% (
len(elts_communs),
len(elts_only_A),
len(elts_only_B),
len(elts_only_a),
len(elts_only_b),
)
)
if elts_only_A:
if elts_only_a:
L.append(
'<div span class="key">Éléments seulement dans A : </span><span class="val_dif">%s</span></div>'
% ", ".join(sorted(elts_only_A))
% ", ".join(sorted(elts_only_a))
)
if elts_only_B:
if elts_only_b:
L.append(
'<div span class="key">Éléments seulement dans B : </span><span class="val_dif">%s</span></div>'
% ", ".join(sorted(elts_only_B))
% ", ".join(sorted(elts_only_b))
)
L.append("</div>")
L.append("</div>") # /section
@ -223,22 +225,21 @@ def _apo_compare_csv(A, B):
# 2--
L.append('<div class="section"><div class="tit">Étudiants</div>')
A_nips = set(A.etud_by_nip)
B_nips = set(B.etud_by_nip)
nb_etuds_communs = len(A_nips.intersection(B_nips))
nb_etuds_dif = len(A_nips.union(B_nips) - A_nips.intersection(B_nips))
a_nips = set(apo_a.etud_by_nip)
b_nips = set(apo_b.etud_by_nip)
nb_etuds_communs = len(a_nips.intersection(b_nips))
nb_etuds_dif = len(a_nips.union(b_nips) - a_nips.intersection(b_nips))
L.append("""<div><span class="key">Liste d'étudiants :</span>""")
if A_nips == B_nips:
if a_nips == b_nips:
L.append(
"""<span class="s_ok">
%d étudiants (tous présents dans chaque fichier)</span>
f"""<span class="s_ok">
{len(a_nips)} étudiants (tous présents dans chaque fichier)</span>
"""
% len(A_nips)
)
else:
L.append(
'<span class="val_dif">différents (%d en commun, %d différents)</span>'
% (nb_etuds_communs, nb_etuds_dif)
f"""<span class="val_dif">différents ({nb_etuds_communs} en commun, {
nb_etuds_dif} différents)</span>"""
)
L.append("</div>")
L.append("</div>") # /section
@ -247,19 +248,22 @@ def _apo_compare_csv(A, B):
if nb_etuds_communs > 0:
L.append(
"""<div class="section sec_table">
<div class="tit">Différences de résultats des étudiants présents dans les deux fichiers</div>
<div class="tit">Différences de résultats des étudiants présents dans les deux fichiers
</div>
<p>
"""
)
T = apo_table_compare_etud_results(A, B)
T = apo_table_compare_etud_results(apo_a, apo_b)
if T.get_nb_rows() > 0:
L.append(T.html())
else:
L.append(
"""<p class="p_ok">aucune différence de résultats
sur les %d étudiants communs (<em>les éléments Apogée n'apparaissant pas dans les deux fichiers sont omis</em>)</p>
f"""<p class="p_ok">aucune différence de résultats
sur les {nb_etuds_communs} étudiants communs
(<em>les éléments Apogée n'apparaissant pas dans les deux
fichiers sont omis</em>)
</p>
"""
% nb_etuds_communs
)
L.append("</div>") # /section
@ -290,19 +294,17 @@ def apo_table_compare_etud_results(A, B):
def _build_etud_res(e, apo_data):
r = {}
for elt_code in apo_data.apo_elts:
elt = apo_data.apo_elts[elt_code]
for elt_code in apo_data.apo_csv.apo_elts:
elt = apo_data.apo_csv.apo_elts[elt_code]
try:
# les colonnes de cet élément
col_ids_type = [
(ec["apoL_a01_code"], ec["Type R\xc3\xa9s."]) for ec in elt.cols
]
col_ids_type = [(ec["apoL_a01_code"], ec["Type Rés."]) for ec in elt.cols]
except KeyError as exc:
raise ScoValueError(
"Erreur: un élément sans 'Type R\xc3\xa9s.'. Vérifiez l'encodage de vos fichiers."
"Erreur: un élément sans 'Type Rés.'. Vérifiez l'encodage de vos fichiers."
) from exc
r[elt_code] = {}
for (col_id, type_res) in col_ids_type:
for col_id, type_res in col_ids_type:
r[elt_code][type_res] = e.cols[col_id]
return r

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,487 @@
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# 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
#
##############################################################################
"""Lecture du fichier "maquette" Apogée
Le fichier CSV, champs séparés par des tabulations, a la structure suivante:
<pre>
XX-APO_TITRES-XX
apoC_annee 2007/2008
apoC_cod_dip VDTCJ
apoC_Cod_Exp 1
apoC_cod_vdi 111
apoC_Fichier_Exp VDTCJ_V1CJ.txt
apoC_lib_dip DUT CJ
apoC_Titre1 Export Apogée du 13/06/2008 à 14:29
apoC_Titre2
XX-APO_TYP_RES-XX
...section optionnelle au contenu quelconque...
XX-APO_COLONNES-XX
apoL_a01_code Type Objet Code Version Année Session Admission/Admissibilité Type Rés. Etudiant Numéro
apoL_a02_nom 1 Nom
apoL_a03_prenom 1 Prénom
apoL_a04_naissance Session Admissibilité Naissance
APO_COL_VAL_DEB
apoL_c0001 VET V1CJ 111 2007 0 1 N V1CJ - DUT CJ an1 0 1 Note
apoL_c0002 VET V1CJ 111 2007 0 1 B 0 1 Barème
apoL_c0003 VET V1CJ 111 2007 0 1 R 0 1 Résultat
APO_COL_VAL_FIN
apoL_c0030 APO_COL_VAL_FIN
XX-APO_VALEURS-XX
apoL_a01_code apoL_a02_nom apoL_a03_prenom apoL_a04_naissance apoL_c0001 apoL_c0002 apoL_c0003 apoL_c0004 apoL_c0005 apoL_c0006 apoL_c0007 apoL_c0008 apoL_c0009 apoL_c0010 apoL_c0011 apoL_c0012 apoL_c0013 apoL_c0014 apoL_c0015 apoL_c0016 apoL_c0017 apoL_c0018 apoL_c0019 apoL_c0020 apoL_c0021 apoL_c0022 apoL_c0023 apoL_c0024 apoL_c0025 apoL_c0026 apoL_c0027 apoL_c0028 apoL_c0029
10601232 AARIF MALIKA 22/09/1986 18 20 ADM 18 20 ADM 18 20 ADM 18 20 ADM 18 20 ADM 18 20 18 20 ADM 18 20 ADM 18 20 ADM 18 20 ADM
</pre>
On récupère nos éléments pédagogiques dans la section XX-APO-COLONNES-XX et
notre liste d'étudiants dans la section XX-APO_VALEURS-XX. Les champs de la
section XX-APO_VALEURS-XX sont décrits par les lignes successives de la
section XX-APO_COLONNES-XX.
Le fichier CSV correspond à une étape, qui est récupérée sur la ligne
<pre>
apoL_c0001 VET V1CJ ...
</pre>
"""
from collections import namedtuple
import io
import pprint
import re
# Pour la détection auto de l'encodage des fichiers Apogée:
from chardet import detect as chardet_detect
from app import log
from app.scodoc.sco_exceptions import ScoFormatError
from app.scodoc import sco_preferences
APO_PORTAL_ENCODING = (
"utf8" # encodage du fichier CSV Apogée (était 'ISO-8859-1' avant jul. 2016)
)
APO_INPUT_ENCODING = "ISO-8859-1" #
APO_OUTPUT_ENCODING = APO_INPUT_ENCODING # encodage des fichiers Apogee générés
APO_DECIMAL_SEP = "," # separateur décimal: virgule
APO_SEP = "\t"
APO_NEWLINE = "\r\n"
ApoEtudTuple = namedtuple("ApoEtudTuple", ("nip", "nom", "prenom", "naissance", "cols"))
class DictCol(dict):
"A dict, where we can add attributes"
class StringIOWithLineNumber(io.StringIO):
"simple wrapper to use a string as a file with line numbers"
def __init__(self, data: str):
super().__init__(data)
self.lineno = 0
def readline(self):
self.lineno += 1
return super().readline()
class ApoCSVReadWrite:
"Gestion lecture/écriture de fichiers csv Apogée"
def __init__(self, data: str):
if not data:
raise ScoFormatError("Fichier Apogée vide !")
self.data = data
self._file = StringIOWithLineNumber(data) # pour traiter comme un fichier
self.apo_elts: dict = None
self.cols: dict[str, dict[str, str]] = None
self.column_titles: str = None
self.col_ids: list[str] = None
self.csv_etuds: list[ApoEtudTuple] = []
# section_str: utilisé pour ré-écrire les headers sans aucune altération
self.sections_str: dict[str, str] = {}
"contenu initial de chaque section"
# self.header: str = ""
# "début du fichier Apogée jusqu'à XX-APO_TYP_RES-XX non inclu (sera ré-écrit non modifié)"
self.header_apo_typ_res: str = ""
"section XX-APO_TYP_RES-XX (qui peut en option ne pas être ré-écrite)"
self.titles: dict[str, str] = {}
"titres Apogée (section XX-APO_TITRES-XX)"
self.read_sections()
# Check that we have collected all requested infos:
if not self.header_apo_typ_res:
# on pourrait rendre XX-APO_TYP_RES-XX optionnelle mais mieux vaut vérifier:
raise ScoFormatError(
"format incorrect: pas de XX-APO_TYP_RES-XX",
filename=self.get_filename(),
)
if self.cols is None:
raise ScoFormatError(
"format incorrect: pas de XX-APO_COLONNES-XX",
filename=self.get_filename(),
)
if self.column_titles is None:
raise ScoFormatError(
"format incorrect: pas de XX-APO_VALEURS-XX",
filename=self.get_filename(),
)
def read_sections(self):
"""Lit une à une les sections du fichier Apogée"""
# sanity check: we are at the begining of Apogee CSV
start_pos = self._file.tell()
section = self._file.readline().strip()
if section != "XX-APO_TITRES-XX":
raise ScoFormatError("format incorrect: pas de XX-APO_TITRES-XX")
while True:
self.read_section(section)
line, end_pos = _apo_next_non_blank_line(self._file)
self.sections_str[section] = self.data[start_pos:end_pos]
if not line:
break
section = line
start_pos = end_pos
def read_section(self, section_name: str):
"""Read a section: _file is on the first line after section title"""
if section_name == "XX-APO_TITRES-XX":
# Titres:
# on va y chercher apoC_Fichier_Exp qui donnera le nom du fichier
# ainsi que l'année scolaire et le code diplôme.
self.titles = self._apo_read_titres(self._file)
elif section_name == "XX-APO_TYP_RES-XX":
self.header_apo_typ_res = _apo_read_typ_res(self._file)
elif section_name == "XX-APO_COLONNES-XX":
self.cols = self.apo_read_cols()
self.apo_elts = self.group_elt_cols(self.cols)
elif section_name == "XX-APO_VALEURS-XX":
# les étudiants
self.apo_read_section_valeurs()
else:
raise ScoFormatError(
f"format incorrect: section inconnue: {section_name}",
filename=self.get_filename(),
)
def apo_read_cols(self):
"""Lecture colonnes apo :
Démarre après la balise XX-APO_COLONNES-XX
et s'arrête après la ligne suivant la balise APO_COL_VAL_FIN
Colonne Apogee: les champs sont données par la ligne
apoL_a01_code de la section XX-APO_COLONNES-XX
col_id est apoL_c0001, apoL_c0002, ...
:return: { col_id : { title : value } }
Example: { 'apoL_c0001' : { 'Type Objet' : 'VET', 'Code' : 'V1IN', ... }, ... }
"""
line = self._file.readline().strip(" " + APO_NEWLINE)
fields = line.split(APO_SEP)
if fields[0] != "apoL_a01_code":
raise ScoFormatError(
f"invalid line: {line} (expecting apoL_a01_code)",
filename=self.get_filename(),
)
col_keys = fields
while True: # skip premiere partie (apoL_a02_nom, ...)
line = self._file.readline().strip(" " + APO_NEWLINE)
if line == "APO_COL_VAL_DEB":
break
# après APO_COL_VAL_DEB
cols = {}
i = 0
while True:
line = self._file.readline().strip(" " + APO_NEWLINE)
if line == "APO_COL_VAL_FIN":
break
i += 1
fields = line.split(APO_SEP)
# sanity check
col_id = fields[0] # apoL_c0001, ...
if col_id in cols:
raise ScoFormatError(
f"duplicate column definition: {col_id}",
filename=self.get_filename(),
)
m = re.match(r"^apoL_c([0-9]{4})$", col_id)
if not m:
raise ScoFormatError(
f"invalid column id: {line} (expecting apoL_c{col_id})",
filename=self.get_filename(),
)
if int(m.group(1)) != i:
raise ScoFormatError(
f"invalid column id: {col_id} for index {i}",
filename=self.get_filename(),
)
cols[col_id] = DictCol(list(zip(col_keys, fields)))
cols[col_id].lineno = self._file.lineno # for debuging purpose
self._file.readline() # skip next line
return cols
def group_elt_cols(self, cols) -> dict:
"""Return (ordered) dict of ApoElt from list of ApoCols.
Clé: id apogée, eg 'V1RT', 'V1GE2201', ...
Valeur: ApoElt, avec les attributs code, type_objet
Si les id Apogée ne sont pas uniques (ce n'est pas garanti), garde le premier
"""
elts = {}
for col_id in sorted(list(cols.keys()), reverse=True):
col = cols[col_id]
if col["Code"] in elts:
elts[col["Code"]].append(col)
else:
elts[col["Code"]] = ApoElt([col])
return elts # { code apo : ApoElt }
def apo_read_section_valeurs(self):
"traitement de la section XX-APO_VALEURS-XX"
self.column_titles = self._file.readline()
self.col_ids = self.column_titles.strip().split()
self.csv_etuds = self.apo_read_etuds()
def apo_read_etuds(self) -> list[ApoEtudTuple]:
"""Lecture des étudiants (et résultats) du fichier CSV Apogée.
Les lignes "étudiant" commencent toujours par
`12345678 NOM PRENOM 15/05/2003`
le premier code étant le NIP.
"""
etud_tuples = []
while True:
line = self._file.readline()
# cette section est impérativement la dernière du fichier
# donc on arrête ici:
if not line:
break
if not line.strip():
continue # silently ignore blank lines
line = line.strip(APO_NEWLINE)
fields = line.split(APO_SEP)
if len(fields) < 4:
raise ScoFormatError(
"""Ligne étudiant invalide
(doit commencer par 'NIP NOM PRENOM dd/mm/yyyy')""",
filename=self.get_filename(),
)
cols = {} # { col_id : value }
for i, field in enumerate(fields):
cols[self.col_ids[i]] = field
etud_tuples.append(
ApoEtudTuple(
nip=fields[0], # id etudiant
nom=fields[1],
prenom=fields[2],
naissance=fields[3],
cols=cols,
)
# XXX à remettre dans apogee_csv.py
# export_res_etape=self.export_res_etape,
# export_res_sem=self.export_res_sem,
# export_res_ues=self.export_res_ues,
# export_res_modules=self.export_res_modules,
# export_res_sdj=self.export_res_sdj,
# export_res_rat=self.export_res_rat,
# )
)
return etud_tuples
def _apo_read_titres(self, f) -> dict:
"Lecture section TITRES du fichier Apogée, renvoie dict"
d = {}
while True:
line = f.readline().strip(
" " + APO_NEWLINE
) # ne retire pas le \t (pour les clés vides)
if not line.strip(): # stoppe sur ligne pleines de \t
break
fields = line.split(APO_SEP)
if len(fields) == 2:
k, v = fields
else:
log(f"Error read CSV: \nline={line}\nfields={fields}")
log(dir(f))
raise ScoFormatError(
f"Fichier Apogee incorrect (section titres, {len(fields)} champs au lieu de 2)",
filename=self.get_filename(),
)
d[k] = v
#
if not d.get("apoC_Fichier_Exp", None):
raise ScoFormatError(
"Fichier Apogee incorrect: pas de titre apoC_Fichier_Exp",
filename=self.get_filename(),
)
# keep only basename: may be a windows or unix pathname
s = d["apoC_Fichier_Exp"].split("/")[-1]
s = s.split("\\")[-1] # for DOS paths, eg C:\TEMP\VL4RT_V3ASR.TXT
d["apoC_Fichier_Exp"] = s
return d
def get_filename(self) -> str:
"""Le nom du fichier APogée, tel qu'indiqué dans le fichier
ou vide."""
if self.titles:
return self.titles.get("apoC_Fichier_Exp", "")
return ""
def write(self, apo_etuds: list["ApoEtud"]) -> bytes:
"""Renvoie le contenu actualisé du fichier Apogée"""
f = io.StringIO()
self._write_header(f)
self._write_etuds(f, apo_etuds)
return f.getvalue().encode(APO_OUTPUT_ENCODING)
def _write_etuds(self, f, apo_etuds: list["ApoEtud"]):
"""write apo CSV etuds on f"""
for apo_etud in apo_etuds:
fields = [] # e['nip'], e['nom'], e['prenom'], e['naissance'] ]
for col_id in self.col_ids:
try:
fields.append(str(apo_etud.new_cols[col_id]))
except KeyError:
log(
f"""Error: {apo_etud["nip"]} {apo_etud["nom"]} missing column key {col_id}
Details:\napo_etud = {pprint.pformat(apo_etud)}
col_ids={pprint.pformat(self.col_ids)}
étudiant ignoré.
"""
)
f.write(APO_SEP.join(fields) + APO_NEWLINE)
def _write_header(self, f):
"""write apo CSV header on f
(beginning of CSV until columns titles just after XX-APO_VALEURS-XX line)
"""
remove_typ_res = sco_preferences.get_preference("export_res_remove_typ_res")
for section, data in self.sections_str.items():
# ne recopie pas la section résultats, et en option supprime APO_TYP_RES
if (section != "XX-APO_VALEURS-XX") and (
section != "XX-APO_TYP_RES-XX" or not remove_typ_res
):
f.write(data)
f.write("XX-APO_VALEURS-XX" + APO_NEWLINE)
f.write(self.column_titles)
class ApoElt:
"""Définition d'un Element Apogée
sur plusieurs colonnes du fichier CSV
"""
def __init__(self, cols):
assert len(cols) > 0
assert len(set([c["Code"] for c in cols])) == 1 # colonnes de meme code
assert len(set([c["Type Objet"] for c in cols])) == 1 # colonnes de meme type
self.cols = cols
self.code = cols[0]["Code"]
self.version = cols[0]["Version"]
self.type_objet = cols[0]["Type Objet"]
def append(self, col):
"""ajoute une "colonne" à l'élément"""
assert col["Code"] == self.code
if col["Type Objet"] != self.type_objet:
log(
f"""Warning: ApoElt: duplicate id {
self.code} ({self.type_objet} and {col["Type Objet"]})"""
)
self.type_objet = col["Type Objet"]
self.cols.append(col)
def __repr__(self):
return f"ApoElt(code='{self.code}', cols={pprint.pformat(self.cols)})"
def guess_data_encoding(text: bytes, threshold=0.6):
"""Guess string encoding, using chardet heuristics.
Returns encoding, or None if detection failed (confidence below threshold)
"""
r = chardet_detect(text)
if r["confidence"] < threshold:
return None
else:
return r["encoding"]
def fix_data_encoding(
text: bytes,
default_source_encoding=APO_INPUT_ENCODING,
dest_encoding=APO_INPUT_ENCODING,
) -> tuple[bytes, str]:
"""Try to ensure that text is using dest_encoding
returns converted text, and a message describing the conversion.
Raises UnicodeEncodeError en cas de problème, en général liée à
une auto-détection errornée.
"""
message = ""
detected_encoding = guess_data_encoding(text)
if not detected_encoding:
if default_source_encoding != dest_encoding:
message = f"converting from {default_source_encoding} to {dest_encoding}"
text = text.decode(default_source_encoding).encode(dest_encoding)
else:
if detected_encoding != dest_encoding:
message = (
f"converting from detected {default_source_encoding} to {dest_encoding}"
)
text = text.decode(detected_encoding).encode(dest_encoding)
return text, message
def _apo_read_typ_res(f) -> str:
"Lit la section XX-APO_TYP_RES-XX"
text = "XX-APO_TYP_RES-XX" + APO_NEWLINE
while True:
line = f.readline()
stripped_line = line.strip()
if not stripped_line:
break
text += line
return text
def _apo_next_non_blank_line(f: StringIOWithLineNumber) -> tuple[str, int]:
"Ramène prochaine ligne non blanche, stripped, et l'indice de son début"
while True:
pos = f.tell()
line = f.readline()
if not line:
return "", -1
stripped_line = line.strip()
if stripped_line:
return stripped_line, pos

View File

@ -64,11 +64,11 @@ from flask import flash, g, request, url_for
import app.scodoc.sco_utils as scu
from config import Config
from app import log
from app import log, ScoDocJSONEncoder
from app.but import jury_but_pv
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Departement, FormSemestre
from app.models import FormSemestre
from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_exceptions import ScoPermissionDenied
from app.scodoc import html_sco_header
@ -86,6 +86,11 @@ class BaseArchiver(object):
self.archive_type = archive_type
self.initialized = False
self.root = None
self.dept_id = None
def set_dept_id(self, dept_id: int):
"set dept"
self.dept_id = dept_id
def initialize(self):
if self.initialized:
@ -107,6 +112,8 @@ class BaseArchiver(object):
finally:
scu.GSL.release()
self.initialized = True
if self.dept_id is None:
self.dept_id = getattr(g, "scodoc_dept_id")
def get_obj_dir(self, oid: int):
"""
@ -114,8 +121,7 @@ class BaseArchiver(object):
If directory does not yet exist, create it.
"""
self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first()
dept_dir = os.path.join(self.root, str(dept.id))
dept_dir = os.path.join(self.root, str(self.dept_id))
try:
scu.GSL.acquire()
if not os.path.isdir(dept_dir):
@ -134,8 +140,7 @@ class BaseArchiver(object):
:return: list of archive oids
"""
self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first()
base = os.path.join(self.root, str(dept.id)) + os.path.sep
base = os.path.join(self.root, str(self.dept_id)) + os.path.sep
dirs = glob.glob(base + "*")
return [os.path.split(x)[1] for x in dirs]
@ -360,7 +365,7 @@ def do_formsemestre_archive(
# Bulletins en JSON
data = gen_formsemestre_recapcomplet_json(formsemestre_id, xml_with_decisions=True)
data_js = json.dumps(data, indent=1, cls=scu.ScoDocJSONEncoder)
data_js = json.dumps(data, indent=1, cls=ScoDocJSONEncoder)
if data:
PVArchive.store(archive_id, "Bulletins.json", data_js)
# Décisions de jury, en XLS

View File

@ -0,0 +1,215 @@
"""
Gestion de l'archivage des justificatifs
Ecrit par Matthias HARTMANN
"""
import os
from datetime import datetime
from shutil import rmtree
from app.models import Identite
from app.scodoc.sco_archives import BaseArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import is_iso_formated
class Trace:
"""gestionnaire de la trace des fichiers justificatifs"""
def __init__(self, path: str) -> None:
self.path: str = path + "/_trace.csv"
self.content: dict[str, list[datetime, datetime]] = {}
self.import_from_file()
def import_from_file(self):
"""import trace from file"""
if os.path.isfile(self.path):
with open(self.path, "r", encoding="utf-8") as file:
for line in file.readlines():
csv = line.split(",")
fname: str = csv[0]
entry_date: datetime = is_iso_formated(csv[1], True)
delete_date: datetime = is_iso_formated(csv[2], True)
self.content[fname] = [entry_date, delete_date]
def set_trace(self, *fnames: str, mode: str = "entry"):
"""Ajoute une trace du fichier donné
mode : entry / delete
"""
modes: list[str] = ["entry", "delete"]
for fname in fnames:
if fname in modes:
continue
traced: list[datetime, datetime] = self.content.get(fname, False)
if not traced:
self.content[fname] = [None, None]
traced = self.content[fname]
traced[modes.index(mode)] = datetime.now()
self.save_trace()
def save_trace(self):
"""Enregistre la trace dans le fichier _trace.csv"""
lines: list[str] = []
for fname, traced in self.content.items():
date_fin: datetime or None = traced[1].isoformat() if traced[1] else "None"
lines.append(f"{fname},{traced[0].isoformat()},{date_fin}")
with open(self.path, "w", encoding="utf-8") as file:
file.write("\n".join(lines))
def get_trace(self, fnames: list[str] = ()) -> dict[str, list[datetime, datetime]]:
"""Récupère la trace pour les noms de fichiers.
si aucun nom n'est donné, récupère tous les fichiers"""
if fnames is None or len(fnames) == 0:
return self.content
traced: dict = {}
for fname in fnames:
traced[fname] = self.content.get(fname, None)
return traced
class JustificatifArchiver(BaseArchiver):
"""
TOTALK:
- oid -> etudid
- archive_id -> date de création de l'archive (une archive par dépot de document)
justificatif
<dept_id>
<etudid/oid>
[_trace.csv]
<archive_id>
[_description.txt]
[<filename.ext>]
"""
def __init__(self):
BaseArchiver.__init__(self, archive_type="justificatifs")
def save_justificatif(
self,
etudid: int,
filename: str,
data: bytes or str,
archive_name: str = None,
description: str = "",
) -> str:
"""
Ajoute un fichier dans une archive "justificatif" pour l'etudid donné
Retourne l'archive_name utilisé
"""
self._set_dept(etudid)
if archive_name is None:
archive_id: str = self.create_obj_archive(
oid=etudid, description=description
)
else:
archive_id: str = self.get_id_from_name(etudid, archive_name)
fname: str = self.store(archive_id, filename, data)
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(fname, "entry")
return self.get_archive_name(archive_id), fname
def delete_justificatif(
self,
etudid: int,
archive_name: str,
filename: str = None,
has_trace: bool = True,
):
"""
Supprime une archive ou un fichier particulier de l'archivage de l'étudiant donné
Si trace == True : sauvegarde le nom du/des fichier(s) supprimé(s) dans la trace de l'étudiant
"""
self._set_dept(etudid)
if str(etudid) not in self.list_oids():
raise ValueError(f"Aucune archive pour etudid[{etudid}]")
archive_id = self.get_id_from_name(etudid, archive_name)
if filename is not None:
if filename not in self.list_archive(archive_id):
raise ValueError(
f"filename {filename} inconnu dans l'archive archive_id[{archive_id}] -> etudid[{etudid}]"
)
path: str = os.path.join(self.get_obj_dir(etudid), archive_id, filename)
if os.path.isfile(path):
if has_trace:
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(filename, "delete")
os.remove(path)
else:
if has_trace:
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(*self.list_archive(archive_id), mode="delete")
self.delete_archive(
os.path.join(
self.get_obj_dir(etudid),
archive_id,
)
)
def list_justificatifs(self, archive_name: str, etudid: int) -> list[str]:
"""
Retourne la liste des noms de fichiers dans l'archive donnée
"""
self._set_dept(etudid)
filenames: list[str] = []
archive_id = self.get_id_from_name(etudid, archive_name)
filenames = self.list_archive(archive_id)
return filenames
def get_justificatif_file(self, archive_name: str, etudid: int, filename: str):
"""
Retourne une réponse de téléchargement de fichier si le fichier existe
"""
self._set_dept(etudid)
archive_id: str = self.get_id_from_name(etudid, archive_name)
if filename in self.list_archive(archive_id):
return self.get_archived_file(etudid, archive_name, filename)
raise ScoValueError(
f"Fichier {filename} introuvable dans l'archive {archive_name}"
)
def _set_dept(self, etudid: int):
"""
Mets à jour le dept_id de l'archiver en fonction du département de l'étudiant
"""
etud: Identite = Identite.query.filter_by(id=etudid).first()
self.set_dept_id(etud.dept_id)
def remove_dept_archive(self, dept_id: int = None):
"""
Supprime toutes les archives d'un département (ou de tous les départements)
Supprime aussi les fichiers de trace
"""
self.set_dept_id(1)
self.initialize()
if dept_id is None:
rmtree(self.root, ignore_errors=True)
else:
rmtree(os.path.join(self.root, str(dept_id)), ignore_errors=True)
def get_trace(
self, etudid: int, *fnames: str
) -> dict[str, list[datetime, datetime]]:
"""Récupère la trace des justificatifs de l'étudiant"""
trace = Trace(self.get_obj_dir(etudid))
return trace.get_trace(fnames)

View File

@ -0,0 +1,352 @@
"""
Ecrit par Matthias Hartmann.
"""
from datetime import date, datetime, time, timedelta
import app.scodoc.sco_utils as scu
from app.models.assiduites import Assiduite, Justificatif
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre, FormSemestreInscription
class CountCalculator:
"""Classe qui gére le comptage des assiduités"""
def __init__(
self,
morning: time = time(8, 0),
noon: time = time(12, 0),
after_noon: time = time(14, 00),
evening: time = time(18, 0),
skip_saturday: bool = True,
) -> None:
self.morning: time = morning
self.noon: time = noon
self.after_noon: time = after_noon
self.evening: time = evening
self.skip_saturday: bool = skip_saturday
delta_total: timedelta = datetime.combine(date.min, evening) - datetime.combine(
date.min, morning
)
delta_lunch: timedelta = datetime.combine(
date.min, after_noon
) - datetime.combine(date.min, noon)
self.hour_per_day: float = (delta_total - delta_lunch).total_seconds() / 3600
self.days: list[date] = []
self.half_days: list[tuple[date, bool]] = [] # tuple -> (date, morning:bool)
self.hours: float = 0.0
self.count: int = 0
def reset(self):
"""Remet à zero le compteur"""
self.days = []
self.half_days = []
self.hours = 0.0
self.count = 0
def add_half_day(self, day: date, is_morning: bool = True):
"""Ajoute une demi journée dans le comptage"""
key: tuple[date, bool] = (day, is_morning)
if key not in self.half_days:
self.half_days.append(key)
def add_day(self, day: date):
"""Ajoute un jour dans le comptage"""
if day not in self.days:
self.days.append(day)
def check_in_morning(self, period: tuple[datetime, datetime]) -> bool:
"""Vérifiée si la période donnée fait partie du matin
(Test sur la date de début)
"""
interval_morning: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(period[0].date(), self.morning)),
scu.localize_datetime(datetime.combine(period[0].date(), self.noon)),
)
in_morning: bool = scu.is_period_overlapping(
period, interval_morning, bornes=False
)
return in_morning
def check_in_evening(self, period: tuple[datetime, datetime]) -> bool:
"""Vérifie si la période fait partie de l'aprèm
(test sur la date de début)
"""
interval_evening: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(period[0].date(), self.after_noon)),
scu.localize_datetime(datetime.combine(period[0].date(), self.evening)),
)
in_evening: bool = scu.is_period_overlapping(period, interval_evening)
return in_evening
def compute_long_assiduite(self, assi: Assiduite):
"""Calcule les métriques sur une assiduité longue (plus d'un jour)"""
pointer_date: date = assi.date_debut.date() + timedelta(days=1)
start_hours: timedelta = assi.date_debut - scu.localize_datetime(
datetime.combine(assi.date_debut, self.morning)
)
finish_hours: timedelta = assi.date_fin - scu.localize_datetime(
datetime.combine(assi.date_fin, self.morning)
)
self.add_day(assi.date_debut.date())
self.add_day(assi.date_fin.date())
start_period: tuple[datetime, datetime] = (
assi.date_debut,
scu.localize_datetime(
datetime.combine(assi.date_debut.date(), self.evening)
),
)
finish_period: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(assi.date_fin.date(), self.morning)),
assi.date_fin,
)
hours = 0.0
for period in (start_period, finish_period):
if self.check_in_evening(period):
self.add_half_day(period[0].date(), False)
if self.check_in_morning(period):
self.add_half_day(period[0].date())
while pointer_date < assi.date_fin.date():
if pointer_date.weekday() < (6 - self.skip_saturday):
self.add_day(pointer_date)
self.add_half_day(pointer_date)
self.add_half_day(pointer_date, False)
self.hours += self.hour_per_day
hours += self.hour_per_day
pointer_date += timedelta(days=1)
self.hours += finish_hours.total_seconds() / 3600
self.hours += self.hour_per_day - (start_hours.total_seconds() / 3600)
def compute_assiduites(self, assiduites: Assiduite):
"""Calcule les métriques pour la collection d'assiduité donnée"""
assi: Assiduite
assiduites: list[Assiduite] = (
assiduites.all() if isinstance(assiduites, Assiduite) else assiduites
)
for assi in assiduites:
self.count += 1
delta: timedelta = assi.date_fin - assi.date_debut
if delta.days > 0:
# raise Exception(self.hours)
self.compute_long_assiduite(assi)
continue
period: tuple[datetime, datetime] = (assi.date_debut, assi.date_fin)
deb_date: date = assi.date_debut.date()
if self.check_in_morning(period):
self.add_half_day(deb_date)
if self.check_in_evening(period):
self.add_half_day(deb_date, False)
self.add_day(deb_date)
self.hours += delta.total_seconds() / 3600
def to_dict(self) -> dict[str, object]:
"""Retourne les métriques sous la forme d'un dictionnaire"""
return {
"compte": self.count,
"journee": len(self.days),
"demi": len(self.half_days),
"heure": round(self.hours, 2),
}
def get_assiduites_stats(
assiduites: Assiduite, metric: str = "all", filtered: dict[str, object] = None
) -> Assiduite:
"""Compte les assiduités en fonction des filtres"""
if filtered is not None:
deb, fin = None, None
for key in filtered:
if key == "etat":
assiduites = filter_assiduites_by_etat(assiduites, filtered[key])
elif key == "date_fin":
fin = filtered[key]
elif key == "date_debut":
deb = filtered[key]
elif key == "moduleimpl_id":
assiduites = filter_by_module_impl(assiduites, filtered[key])
elif key == "formsemestre":
assiduites = filter_by_formsemestre(assiduites, filtered[key])
elif key == "est_just":
assiduites = filter_assiduites_by_est_just(assiduites, filtered[key])
elif key == "user_id":
assiduites = filter_by_user_id(assiduites, filtered[key])
if (deb, fin) != (None, None):
assiduites = filter_by_date(assiduites, Assiduite, deb, fin)
calculator: CountCalculator = CountCalculator()
calculator.compute_assiduites(assiduites)
count: dict = calculator.to_dict()
metrics: list[str] = metric.split(",")
output: dict = {}
for key, val in count.items():
if key in metrics:
output[key] = val
return output if output else count
def filter_assiduites_by_etat(assiduites: Assiduite, etat: str) -> Assiduite:
"""
Filtrage d'une collection d'assiduites en fonction de leur état
"""
etats: list[str] = list(etat.split(","))
etats = [scu.EtatAssiduite.get(e, -1) for e in etats]
return assiduites.filter(Assiduite.etat.in_(etats))
def filter_assiduites_by_est_just(
assiduites: Assiduite, est_just: bool
) -> Justificatif:
"""
Filtrage d'une collection d'assiduites en fonction de s'ils sont justifiés
"""
return assiduites.filter_by(est_just=est_just)
def filter_by_user_id(
collection: Assiduite or Justificatif,
user_id: int,
) -> Justificatif:
"""
Filtrage d'une collection en fonction de l'user_id
"""
return collection.filter_by(user_id=user_id)
def filter_by_date(
collection: Assiduite or Justificatif,
collection_cls: Assiduite or Justificatif,
date_deb: datetime = None,
date_fin: datetime = None,
strict: bool = False,
):
"""
Filtrage d'une collection d'assiduites en fonction d'une date
"""
if date_deb is None:
date_deb = datetime.min
if date_fin is None:
date_fin = datetime.max
date_deb = scu.localize_datetime(date_deb)
date_fin = scu.localize_datetime(date_fin)
if not strict:
return collection.filter(
collection_cls.date_debut <= date_fin, collection_cls.date_fin >= date_deb
)
return collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_deb
)
def filter_justificatifs_by_etat(
justificatifs: Justificatif, etat: str
) -> Justificatif:
"""
Filtrage d'une collection de justificatifs en fonction de leur état
"""
etats: list[str] = list(etat.split(","))
etats = [scu.EtatJustificatif.get(e, -1) for e in etats]
return justificatifs.filter(Justificatif.etat.in_(etats))
def filter_by_module_impl(
assiduites: Assiduite, module_impl_id: int or None
) -> Assiduite:
"""
Filtrage d'une collection d'assiduites en fonction de l'ID du module_impl
"""
return assiduites.filter(Assiduite.moduleimpl_id == module_impl_id)
def filter_by_formsemestre(assiduites_query: Assiduite, formsemestre: FormSemestre):
"""
Filtrage d'une collection d'assiduites en fonction d'un formsemestre
"""
if formsemestre is None:
return assiduites_query.filter(False)
assiduites_query = (
assiduites_query.join(Identite, Assiduite.etudid == Identite.id)
.join(
FormSemestreInscription,
Identite.id == FormSemestreInscription.etudid,
)
.filter(FormSemestreInscription.formsemestre_id == formsemestre.id)
)
assiduites_query = assiduites_query.filter(
Assiduite.date_debut >= formsemestre.date_debut
)
return assiduites_query.filter(Assiduite.date_fin <= formsemestre.date_fin)
def justifies(justi: Justificatif, obj: bool = False) -> list[int]:
"""
Retourne la liste des assiduite_id qui sont justifié par la justification
Une assiduité est justifiée si elle est COMPLETEMENT ou PARTIELLEMENT comprise dans la plage du justificatif
et que l'état du justificatif est "valide"
renvoie des id si obj == False, sinon les Assiduités
"""
if justi.etat != scu.EtatJustificatif.VALIDE:
return []
assiduites_query: Assiduite = Assiduite.query.join(
Justificatif, Assiduite.etudid == Justificatif.etudid
).filter(
Assiduite.date_debut <= justi.date_fin,
Assiduite.date_fin >= justi.date_debut,
)
if not obj:
return [assi.id for assi in assiduites_query.all()]
return assiduites_query
def get_all_justified(
etudid: int, date_deb: datetime = None, date_fin: datetime = None
) -> list[Assiduite]:
"""Retourne toutes les assiduités justifiées sur une période"""
if date_deb is None:
date_deb = datetime.min
if date_fin is None:
date_fin = datetime.max
date_deb = scu.localize_datetime(date_deb)
date_fin = scu.localize_datetime(date_fin)
justified = Assiduite.query.filter_by(est_just=True, etudid=etudid)
after = filter_by_date(
justified,
Assiduite,
date_deb,
date_fin,
)
return after

View File

@ -33,8 +33,9 @@ import email
import time
import numpy as np
from flask import g, request
from flask import flash, jsonify, render_template, url_for
from flask import g, request, Response
from flask import flash, render_template, url_for
from flask_json import json_response
from flask_login import current_user
from app import email
@ -79,14 +80,14 @@ def get_formsemestre_bulletin_etud_json(
etud: Identite,
force_publishing=False,
version="long",
) -> str:
) -> Response:
"""Le JSON du bulletin d'un étudiant, quel que soit le type de formation."""
if formsemestre.formation.is_apc():
bulletins_sem = bulletin_but.BulletinBUT(formsemestre)
if not etud.id in bulletins_sem.res.identdict:
return json_error(404, "get_formsemestre_bulletin_etud_json: invalid etud")
return jsonify(
bulletins_sem.bulletin_etud(
return json_response(
data_=bulletins_sem.bulletin_etud(
etud,
formsemestre,
force_publishing=force_publishing,
@ -143,7 +144,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
"""
from app.scodoc import sco_abs
if not version in scu.BULLETINS_VERSIONS:
if version not in scu.BULLETINS_VERSIONS:
raise ValueError("invalid version code !")
prefs = sco_preferences.SemPreferences(formsemestre_id)

View File

@ -167,8 +167,9 @@ class BulletinGenerator:
formsemestre_id = self.bul_dict["formsemestre_id"]
nomprenom = self.bul_dict["etud"]["nomprenom"]
etat_civil = self.bul_dict["etud"]["etat_civil"]
marque_debut_bulletin = sco_pdf.DebutBulletin(
nomprenom,
self.bul_dict["etat_civil"],
filigranne=self.bul_dict["filigranne"],
footer_content=f"""ScoDoc - Bulletin de {nomprenom} - {time.strftime("%d/%m/%Y %H:%M")}""",
)
@ -211,7 +212,7 @@ class BulletinGenerator:
document,
author="%s %s (E. Viennet) [%s]"
% (sco_version.SCONAME, sco_version.SCOVERSION, self.description),
title=f"""Bulletin {sem["titremois"]} de {nomprenom}""",
title=f"""Bulletin {sem["titremois"]} de {etat_civil}""",
subject="Bulletin de note",
margins=self.margins,
server_name=self.server_name,

View File

@ -33,6 +33,7 @@ import json
from flask import abort
from app import ScoDocJSONEncoder
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import but_validations
@ -74,7 +75,7 @@ def make_json_formsemestre_bulletinetud(
version=version,
)
return json.dumps(d, cls=scu.ScoDocJSONEncoder)
return json.dumps(d, cls=ScoDocJSONEncoder)
# (fonction séparée: n'utilise pas formsemestre_bulletinetud_dict()
@ -387,10 +388,10 @@ def _list_modimpls(
if prefs["bul_show_minmax_eval"] or prefs["bul_show_moypromo"]:
etat = sco_evaluations.do_evaluation_etat(e["evaluation_id"])
if prefs["bul_show_minmax_eval"]:
eval_dict["min"] = scu.fmt_note(etat["mini"])
eval_dict["max"] = scu.fmt_note(etat["maxi"])
eval_dict["min"] = etat["mini"] # chaine, sur 20
eval_dict["max"] = etat["maxi"]
if prefs["bul_show_moypromo"]:
eval_dict["moy"] = scu.fmt_note(etat["moy"])
eval_dict["moy"] = etat["moy"]
mod_dict["evaluation"].append(eval_dict)

View File

@ -69,6 +69,7 @@ from app.scodoc import sco_groups
from app.scodoc import sco_evaluations
from app.scodoc import gen_tables
# Important: Le nom de la classe ne doit pas changer (bien le choisir),
# car il sera stocké en base de données (dans les préférences)
class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
@ -685,10 +686,10 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
if prefs["bul_show_minmax_eval"] or prefs["bul_show_moypromo"]:
etat = sco_evaluations.do_evaluation_etat(e["evaluation_id"])
if prefs["bul_show_minmax_eval"]:
t["min"] = scu.fmt_note(etat["mini"])
t["max"] = scu.fmt_note(etat["maxi"])
t["min"] = etat["mini"]
t["max"] = etat["maxi"]
if prefs["bul_show_moypromo"]:
t["moy"] = scu.fmt_note(etat["moy"])
t["moy"] = etat["moy"]
P.append(t)
nbeval += 1
return nbeval

View File

@ -31,7 +31,7 @@ from app import db
from app.but import apc_edit_ue
from app.models import UniteEns, Matiere, Module, FormSemestre, ModuleImpl
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc import codes_cursus
import app.scodoc.sco_utils as scu
from app.scodoc import sco_groups
from app.scodoc.sco_utils import ModuleType
@ -74,7 +74,11 @@ def html_edit_formation_apc(
ues_by_sem[semestre_idx] = formation.ues.filter_by(
semestre_idx=semestre_idx
).order_by(UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme)
ects = [ue.ects for ue in ues_by_sem[semestre_idx] if ue.type != UE_SPORT]
ects = [
ue.ects
for ue in ues_by_sem[semestre_idx]
if ue.type != codes_cursus.UE_SPORT
]
if None in ects:
ects_by_sem[semestre_idx] = '<span class="missing_ue_ects">manquant</span>'
else:
@ -107,7 +111,8 @@ def html_edit_formation_apc(
icons=icons,
ues_by_sem=ues_by_sem,
ects_by_sem=ects_by_sem,
form_ue_choix_niveau=apc_edit_ue.form_ue_choix_niveau,
scu=scu,
codes_cursus=codes_cursus,
),
]
for semestre_idx in semestre_ids:
@ -118,7 +123,7 @@ def html_edit_formation_apc(
Matiere.ue_id == UniteEns.id,
UniteEns.formation_id == formation.id,
UniteEns.semestre_idx == semestre_idx,
UniteEns.type != UE_SPORT,
UniteEns.type != codes_cursus.UE_SPORT,
).first()
H += [
render_template(

View File

@ -30,6 +30,7 @@
"""
import re
import sqlalchemy as sa
import flask
from flask import flash, render_template, url_for
from flask import g, request
@ -127,7 +128,7 @@ def do_ue_create(args):
):
# évite les conflits de code
while True:
cursor = db.session.execute("select notes_newid_ucod();")
cursor = db.session.execute(sa.text("select notes_newid_ucod();"))
code = cursor.fetchone()[0]
if UniteEns.query.filter_by(ue_code=code).count() == 0:
break
@ -368,7 +369,12 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"min_value": 0,
"max_value": 1000,
"title": "ECTS",
"explanation": "nombre de crédits ECTS (indiquer 0 si UE bonus)",
"explanation": "nombre de crédits ECTS (indiquer 0 si UE bonus)"
+ (
". (si les ECTS dépendent du parcours, voir plus bas.)"
if is_apc
else ""
),
"allow_null": not is_apc, # ects requis en APC
},
),
@ -470,9 +476,9 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
cancelbutton="Revenir à la formation",
)
if tf[0] == 0:
niveau_competence_div = ""
ue_parcours_div = ""
if ue and is_apc:
niveau_competence_div = apc_edit_ue.form_ue_choix_niveau(ue)
ue_parcours_div = apc_edit_ue.form_ue_choix_parcours(ue)
if ue and ue.modules.count() and ue.semestre_idx is not None:
modules_div = f"""<div id="ue_list_modules">
<div><b>{ue.modules.count()} modules sont rattachés
@ -502,7 +508,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"\n".join(H)
+ tf[1]
+ clone_form
+ niveau_competence_div
+ ue_parcours_div
+ modules_div
+ bonus_div
+ ue_div
@ -737,8 +743,10 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
)
H = [
html_sco_header.sco_header(
cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css"],
javascripts=[
cssstyles=html_sco_header.BOOTSTRAP_MULTISELECT_CSS
+ ["libjs/jQuery-tagEditor/jquery.tag-editor.css"],
javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS
+ [
"libjs/jinplace-1.2.1.min.js",
"js/ue_list.js",
"js/edit_ue.js",
@ -822,7 +830,8 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
<a href="{url_for('notes.refcomp_show',
scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id)}"
class="stdlink">
{formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long}
{formation.referentiel_competence.type_titre}
{formation.referentiel_competence.specialite_long}
</a>&nbsp;"""
msg_refcomp = "changer"
H.append(f"""<ul><li>{descr_refcomp}""")
@ -838,9 +847,23 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
)
elif formation.referentiel_competence is not None:
H.append("""(non modifiable car utilisé par des semestres)""")
H.append("</li>")
if formation.referentiel_competence is not None:
H.append(
"""<li>Parcours, compétences et UEs&nbsp;:
<div class="formation_parcs">
"""
)
for parc in formation.referentiel_competence.parcours:
H.append(
f"""<div><a href="{url_for("notes.parcour_formation",
scodoc_dept=g.scodoc_dept, formation_id=formation.id, parcour_id=parc.id )
}">{parc.code}</a></div>"""
)
H.append("""</div></li>""")
H.append(
f"""</li>
f"""
<li> <a class="stdlink" href="{
url_for('notes.edit_modules_ue_coefs',
scodoc_dept=g.scodoc_dept, formation_id=formation_id, semestre_idx=semestre_idx)
@ -855,7 +878,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
<div class="formation_ue_list">
<div class="ue_list_tit">Programme pédagogique:</div>
<form>
<input type="checkbox" class="sco_tag_checkbox">montrer les tags</input>
<input type="checkbox" class="sco_tag_checkbox">montrer les tags des modules</input>
</form>
"""
)
@ -1428,7 +1451,7 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
if ues and ues[0]["ue_id"] != ue_id:
raise ScoValueError(
f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé !
(chaque UE doit avoir un acronyme unique dans la formation)"""
(chaque UE doit avoir un acronyme unique dans la formation.)"""
)
# On ne peut pas supprimer le code UE:
if "ue_code" in args and not args["ue_code"]:

View File

@ -76,7 +76,7 @@ import re
import app.scodoc.sco_utils as scu
from app.scodoc import sco_archives
from app.scodoc import sco_apogee_csv
from app.scodoc import sco_apogee_csv, sco_apogee_reader
from app.scodoc.sco_exceptions import ScoValueError
@ -108,7 +108,7 @@ def apo_csv_store(csv_data: str, annee_scolaire, sem_id):
# sanity check
filesize = len(csv_data)
if filesize < 10 or filesize > scu.CONFIG.ETUD_MAX_FILE_SIZE:
raise ScoValueError("Fichier csv de taille invalide ! (%d)" % filesize)
raise ScoValueError(f"Fichier csv de taille invalide ! ({filesize})")
if not annee_scolaire:
raise ScoValueError("Impossible de déterminer l'année scolaire !")
@ -121,13 +121,13 @@ def apo_csv_store(csv_data: str, annee_scolaire, sem_id):
if str(apo_data.etape) in apo_csv_list_stored_etapes(annee_scolaire, sem_id=sem_id):
raise ScoValueError(
"Etape %s déjà stockée pour cette année scolaire !" % apo_data.etape
f"Etape {apo_data.etape} déjà stockée pour cette année scolaire !"
)
oid = "%d-%d" % (annee_scolaire, sem_id)
description = "%s;%s;%s" % (str(apo_data.etape), annee_scolaire, sem_id)
oid = f"{annee_scolaire}-{sem_id}"
description = f"""{str(apo_data.etape)};{annee_scolaire};{sem_id}"""
archive_id = ApoCSVArchive.create_obj_archive(oid, description)
csv_data_bytes = csv_data.encode(sco_apogee_csv.APO_OUTPUT_ENCODING)
csv_data_bytes = csv_data.encode(sco_apogee_reader.APO_OUTPUT_ENCODING)
ApoCSVArchive.store(archive_id, filename, csv_data_bytes)
return apo_data.etape
@ -212,7 +212,7 @@ def apo_csv_get(etape_apo="", annee_scolaire="", sem_id="") -> str:
data = ApoCSVArchive.get(archive_id, etape_apo + ".csv")
# ce fichier a été archivé donc généré par ScoDoc
# son encodage est donc APO_OUTPUT_ENCODING
return data.decode(sco_apogee_csv.APO_OUTPUT_ENCODING)
return data.decode(sco_apogee_reader.APO_OUTPUT_ENCODING)
# ------------------------------------------------------------------------

View File

@ -32,13 +32,13 @@ import io
from zipfile import ZipFile
import flask
from flask import flash, g, request, send_file, url_for
from flask import flash, g, request, Response, send_file, url_for
import app.scodoc.sco_utils as scu
from app import log
from app.models import Formation
from app.scodoc import html_sco_header
from app.scodoc import sco_apogee_csv
from app.scodoc import sco_apogee_csv, sco_apogee_reader
from app.scodoc import sco_etape_apogee
from app.scodoc import sco_formsemestre
from app.scodoc import sco_portal_apogee
@ -46,7 +46,7 @@ from app.scodoc import sco_preferences
from app.scodoc import sco_semset
from app.scodoc import sco_etud
from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_apogee_csv import APO_INPUT_ENCODING, APO_OUTPUT_ENCODING
from app.scodoc.sco_apogee_reader import APO_INPUT_ENCODING, APO_OUTPUT_ENCODING
from app.scodoc.sco_exceptions import ScoValueError
@ -240,7 +240,11 @@ def apo_semset_maq_status(
if semset["jury_ok"]:
H.append("""<li>Décisions de jury saisies</li>""")
else:
H.append("""<li>Il manque des décisions de jury !</li>""")
H.append(
f"""<li>Il manque de {semset["jury_nb_missing"]}
décision{"s" if semset["jury_nb_missing"] > 1 else ""}
de jury !</li>"""
)
if ok_for_export:
H.append("""<li>%d étudiants, prêt pour l'export.</li>""" % len(nips_ok))
@ -275,11 +279,10 @@ def apo_semset_maq_status(
if semset and ok_for_export:
H.append(
"""<form class="form_apo_export" action="apo_csv_export_results" method="get">
f"""<form class="form_apo_export" action="apo_csv_export_results" method="get">
<input type="submit" value="Export vers Apogée">
<input type="hidden" name="semset_id" value="%s"/>
<input type="hidden" name="semset_id" value="{semset_id}"/>
"""
% (semset_id,)
)
H.append('<div id="param_export_res">')
@ -372,7 +375,7 @@ def apo_semset_maq_status(
H.append("</div>")
# Aide:
H.append(
"""
f"""
<p><a class="stdlink" href="semset_page">Retour aux ensembles de semestres</a></p>
<div class="pas_help">
@ -381,10 +384,12 @@ def apo_semset_maq_status(
l'export des résultats après les jurys, puis de remplir et exporter ces fichiers.
</p>
<p>
Les fichiers ("maquettes") Apogée sont de type CSV, du texte codé en %s.
Les fichiers ("maquettes") Apogée sont de type CSV, du texte codé en {APO_INPUT_ENCODING}.
</p>
<p>On a un fichier par étape Apogée. Pour les obtenir, soit on peut les télécharger
directement (si votre ScoDoc est interfacé avec Apogée), soit se débrouiller pour
exporter le fichier texte depuis Apogée. Son contenu ressemble à cela:
</p>
<p>On a un fichier par étape Apogée. Pour les obtenir, soit on peut les télécharger directement (si votre ScoDoc est interfacé avec Apogée), soit se débrouiller pour exporter le fichier
texte depuis Apogée. Son contenu ressemble à cela:</p>
<pre class="small_pre_acc">
XX-APO_TITRES-XX
apoC_annee 2007/2008
@ -427,7 +432,6 @@ def apo_semset_maq_status(
</p>
</div>
"""
% (APO_INPUT_ENCODING,)
)
H.append(html_sco_header.sco_footer())
return "\n".join(H)
@ -446,21 +450,25 @@ def table_apo_csv_list(semset):
# Ajoute qq infos pour affichage:
csv_data = sco_etape_apogee.apo_csv_get(t["etape_apo"], annee_scolaire, sem_id)
apo_data = sco_apogee_csv.ApoData(csv_data, periode=semset["sem_id"])
t["filename"] = apo_data.titles["apoC_Fichier_Exp"]
t["filename"] = apo_data.apo_csv.titles["apoC_Fichier_Exp"]
t["nb_etuds"] = len(apo_data.etuds)
t["date_str"] = t["date"].strftime("%d/%m/%Y à %H:%M")
view_link = "view_apo_csv?etape_apo=%s&semset_id=%s" % (
t["etape_apo"],
semset["semset_id"],
view_link = url_for(
"notes.view_apo_csv",
scodoc_dept=g.scodoc_dept,
etape_apo=t["etape_apo"],
semset_id=semset["semset_id"],
)
t["_filename_target"] = view_link
t["_etape_apo_target"] = view_link
t["suppress"] = scu.icontag(
"delete_small_img", border="0", alt="supprimer", title="Supprimer"
)
t["_suppress_target"] = "view_apo_csv_delete?etape_apo=%s&semset_id=%s" % (
t["etape_apo"],
semset["semset_id"],
t["_suppress_target"] = url_for(
"notes.view_apo_csv_delete",
scodoc_dept=g.scodoc_dept,
etape_apo=t["etape_apo"],
semset_id=semset["semset_id"],
)
columns_ids = ["filename", "etape_apo", "date_str", "nb_etuds"]
@ -504,13 +512,16 @@ def view_apo_etuds(semset_id, title="", nip_list="", format="html"):
for etud in etuds.values():
etud_sco = sco_etud.get_etud_info(code_nip=etud["nip"], filled=True)
if etud_sco:
e = etud_sco[0]
etud["inscriptions_scodoc"] = ", ".join(
[
'<a href="formsemestre_bulletinetud?formsemestre_id={s[formsemestre_id]}&etudid={e[etudid]}">{s[etapes_apo_str]} (S{s[semestre_id]})</a>'.format(
s=sem, e=e
)
for sem in e["sems"]
f"""<a href="{
url_for('notes.formsemestre_bulletinetud',
scodoc_dept=g.scodoc_dept,
formsemestre_id=sem["formsemestre_id"],
etudid=etud_sco[0]["etudid"])
}">{sem["etapes_apo_str"]} (S{sem["semestre_id"]})</a>
"""
for sem in etud_sco[0]["sems"]
]
)
@ -534,8 +545,8 @@ def view_scodoc_etuds(semset_id, title="", nip_list="", format="html"):
tgt = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"])
e["_nom_target"] = tgt
e["_prenom_target"] = tgt
e["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"],)
e["_prenom_td_attrs"] = 'id="pre-%s" class="etudinfo"' % (e["etudid"],)
e["_nom_td_attrs"] = f"""id="{e['etudid']}" class="etudinfo" """
e["_prenom_td_attrs"] = f"""id="pre-{e['etudid']}" class="etudinfo" """
return _view_etuds_page(
semset_id,
@ -546,20 +557,14 @@ def view_scodoc_etuds(semset_id, title="", nip_list="", format="html"):
)
def _view_etuds_page(semset_id, title="", etuds=[], keys=(), format="html"):
def _view_etuds_page(
semset_id: int, title="", etuds: list = None, keys=(), format="html"
) -> str:
"Affiche les étudiants indiqués"
# Tri les étudiants par nom:
if etuds:
if etuds: # XXX TODO modifier pour utiliser clé de tri
etuds.sort(key=lambda x: (x["nom"], x["prenom"]))
H = [
html_sco_header.sco_header(
page_title=title,
init_qtip=True,
javascripts=["js/etud_info.js"],
),
"<h2>%s</h2>" % title,
]
tab = GenTable(
titles={
"nip": "Code NIP",
@ -579,14 +584,23 @@ def _view_etuds_page(semset_id, title="", etuds=[], keys=(), format="html"):
if format != "html":
return tab.make_page(format=format)
H.append(tab.html())
return f"""
{html_sco_header.sco_header(
page_title=title,
init_qtip=True,
javascripts=["js/etud_info.js"],
)}
<h2>{title}</h2>
H.append(
"""<p><a href="apo_semset_maq_status?semset_id=%s">Retour à la page d'export Apogée</a>"""
% semset_id
)
{tab.html()}
return "\n".join(H) + html_sco_header.sco_footer()
<p><a href="{
url_for("notes.apo_semset_maq_status",
scodoc_dept=g.scodoc_dept, semset_id=semset_id)
}">Retour à la page d'export Apogée</a>
</p>
{html_sco_header.sco_footer()}
"""
def view_apo_csv_store(semset_id="", csvfile=None, data: bytes = "", autodetect=False):
@ -603,7 +617,7 @@ def view_apo_csv_store(semset_id="", csvfile=None, data: bytes = "", autodetect=
if autodetect:
# check encoding (although documentation states that users SHOULD upload LATIN1)
data, message = sco_apogee_csv.fix_data_encoding(data)
data, message = sco_apogee_reader.fix_data_encoding(data)
if message:
log(f"view_apo_csv_store: {message}")
else:
@ -623,7 +637,7 @@ def view_apo_csv_store(semset_id="", csvfile=None, data: bytes = "", autodetect=
f"""
Erreur: l'encodage du fichier est mal détecté.
Essayez sans auto-détection, ou vérifiez le codage et le contenu
du fichier (qui doit être en {sco_apogee_csv.APO_INPUT_ENCODING}).
du fichier (qui doit être en {sco_apogee_reader.APO_INPUT_ENCODING}).
""",
dest_url=dest_url,
) from exc
@ -631,7 +645,7 @@ def view_apo_csv_store(semset_id="", csvfile=None, data: bytes = "", autodetect=
raise ScoValueError(
f"""
Erreur: l'encodage du fichier est incorrect.
Vérifiez qu'il est bien en {sco_apogee_csv.APO_INPUT_ENCODING}
Vérifiez qu'il est bien en {sco_apogee_reader.APO_INPUT_ENCODING}
""",
dest_url=dest_url,
) from exc
@ -640,20 +654,20 @@ def view_apo_csv_store(semset_id="", csvfile=None, data: bytes = "", autodetect=
apo_data = sco_apogee_csv.ApoData(
data_str, periode=semset["sem_id"]
) # parse le fichier -> exceptions
dest_url = url_for(
"notes.apo_semset_maq_status",
scodoc_dept=g.scodoc_dept,
semset_id=semset_id,
)
if apo_data.etape not in semset["etapes"]:
raise ScoValueError(
"Le code étape de ce fichier ne correspond pas à ceux de cet ensemble"
"Le code étape de ce fichier ne correspond pas à ceux de cet ensemble",
dest_url=dest_url,
)
sco_etape_apogee.apo_csv_store(data_str, semset["annee_scolaire"], semset["sem_id"])
return flask.redirect(
url_for(
"notes.apo_semset_maq_status",
scodoc_dept=g.scodoc_dept,
semset_id=semset_id,
)
)
return flask.redirect(dest_url)
def view_apo_csv_download_and_store(etape_apo="", semset_id=""):
@ -679,9 +693,8 @@ def view_apo_csv_delete(etape_apo="", semset_id="", dialog_confirmed=False):
dest_url = f"apo_semset_maq_status?semset_id={semset_id}"
if not dialog_confirmed:
return scu.confirm_dialog(
"""<h2>Confirmer la suppression du fichier étape <tt>%s</tt>?</h2>
<p>La suppression sera définitive.</p>"""
% (etape_apo,),
f"""<h2>Confirmer la suppression du fichier étape <tt>{etape_apo}</tt>?</h2>
<p>La suppression sera définitive.</p>""",
dest_url="",
cancel_url=dest_url,
parameters={"semset_id": semset_id, "etape_apo": etape_apo},
@ -727,24 +740,24 @@ def view_apo_csv(etape_apo="", semset_id="", format="html"):
H = [
html_sco_header.sco_header(
page_title="Maquette Apogée enregistrée pour %s" % etape_apo,
page_title=f"""Maquette Apogée enregistrée pour {etape_apo}""",
init_qtip=True,
javascripts=["js/etud_info.js"],
),
"""<h2>Etudiants dans la maquette Apogée %s</h2>""" % etape_apo,
"""<p>Pour l'ensemble <a class="stdlink" href="apo_semset_maq_status?semset_id=%(semset_id)s">%(title)s</a> (indice semestre: %(sem_id)s)</p>"""
% semset,
f"""<h2>Étudiants dans la maquette Apogée {etape_apo}</h2>
<p>Pour l'ensemble <a class="stdlink" href="{
url_for("notes.apo_semset_maq_status",
scodoc_dept=g.scodoc_dept, semset_id=semset["semset_id"])
}">{semset['title']}</a> (indice semestre: {semset['sem_id']})
</p>
<div class="apo_csv_infos">
<div class="apo_csv_etape"><span>Code étape:</span><span>{
apo_data.etape_apogee} VDI {apo_data.vdi_apogee} (année {apo_data.annee_scolaire
})</span>
</div>
</div>
""",
]
# Infos générales
H.append(
"""
<div class="apo_csv_infos">
<div class="apo_csv_etape"><span>Code étape:</span><span>{0.etape_apogee} VDI {0.vdi_apogee} (année {0.annee_scolaire})</span></div>
</div>
""".format(
apo_data
)
)
# Liste des étudiants (sans les résultats pour le moment): TODO
etuds = apo_data.etuds
@ -789,12 +802,21 @@ def view_apo_csv(etape_apo="", semset_id="", format="html"):
return tab.make_page(format=format)
H += [
tab.html(),
"""<p><a class="stdlink" href="view_apo_csv?etape_apo=%s&semset_id=%s&format=raw">fichier maquette CSV brut (non rempli par ScoDoc)</a></p>"""
% (etape_apo, semset_id),
"""<div><a class="stdlink" href="apo_semset_maq_status?semset_id=%s">Retour</a>
</div>"""
% semset_id,
f"""
{tab.html()}
<p><a class="stdlink" href="{
url_for("notes.view_apo_csv",
scodoc_dept=g.scodoc_dept,
etape_apo=etape_apo, semset_id=semset_id, format="raw")
}">fichier maquette CSV brut (non rempli par ScoDoc)</a>
</p>
<div>
<a class="stdlink" href="{
url_for("notes.apo_semset_maq_status",
scodoc_dept=g.scodoc_dept, semset_id=semset_id)
}">Retour</a>
</div>
""",
html_sco_header.sco_footer(),
]
@ -809,7 +831,7 @@ def apo_csv_export_results(
block_export_res_ues=False,
block_export_res_modules=False,
block_export_res_sdj=False,
):
) -> Response:
"""Remplit les fichiers CSV archivés
et donne un ZIP avec tous les résultats.
"""
@ -833,31 +855,28 @@ def apo_csv_export_results(
periode = semset["sem_id"]
data = io.BytesIO()
dest_zip = ZipFile(data, "w")
etapes_apo = sco_etape_apogee.apo_csv_list_stored_etapes(
annee_scolaire, periode, etapes=semset.list_etapes()
)
for etape_apo in etapes_apo:
apo_csv = sco_etape_apogee.apo_csv_get(etape_apo, annee_scolaire, periode)
sco_apogee_csv.export_csv_to_apogee(
apo_csv,
periode=periode,
export_res_etape=export_res_etape,
export_res_sem=export_res_sem,
export_res_ues=export_res_ues,
export_res_modules=export_res_modules,
export_res_sdj=export_res_sdj,
export_res_rat=export_res_rat,
dest_zip=dest_zip,
with ZipFile(data, "w") as dest_zip:
etapes_apo = sco_etape_apogee.apo_csv_list_stored_etapes(
annee_scolaire, periode, etapes=semset.list_etapes()
)
for etape_apo in etapes_apo:
apo_csv = sco_etape_apogee.apo_csv_get(etape_apo, annee_scolaire, periode)
sco_apogee_csv.export_csv_to_apogee(
apo_csv,
periode=periode,
export_res_etape=export_res_etape,
export_res_sem=export_res_sem,
export_res_ues=export_res_ues,
export_res_modules=export_res_modules,
export_res_sdj=export_res_sdj,
export_res_rat=export_res_rat,
dest_zip=dest_zip,
)
dest_zip.close()
data.seek(0)
basename = (
sco_preferences.get_preference("DeptName")
+ str(annee_scolaire)
+ "-%s-" % periode
+ f"{annee_scolaire}-{periode}-"
+ "-".join(etapes_apo)
)
basename = scu.unescape_html(basename)

View File

@ -174,7 +174,7 @@ class DataEtudiant(object):
return self.data_apogee["nom"] + self.data_apogee["prenom"]
def help():
def _help() -> str:
return """
<div id="export_help" class="pas_help"> <span>Explications sur les tableaux des effectifs et liste des
étudiants</span>
@ -501,7 +501,7 @@ class EtapeBilan:
entete_liste_etudiant(),
self.table_effectifs(),
"""</details>""",
help(),
_help(),
]
return "\n".join(H)

View File

@ -35,10 +35,10 @@ from operator import itemgetter
from flask import url_for, g
from app import email
from app import db, email
from app import log
from app.models import Admission
from app.models.etudiants import make_etud_args
from app.models import Admission, Identite
from app.models.etudiants import input_civilite, make_etud_args, pivot_year
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc.sco_exceptions import ScoGenError, ScoValueError
@ -57,7 +57,12 @@ def format_etud_ident(etud):
else:
etud["nom_usuel"] = ""
etud["prenom"] = format_prenom(etud["prenom"])
if "prenom_etat_civil" in etud:
etud["prenom_etat_civil"] = format_prenom(etud["prenom_etat_civil"])
else:
etud["prenom_etat_civil"] = ""
etud["civilite_str"] = format_civilite(etud["civilite"])
etud["civilite_etat_civil_str"] = format_civilite(etud["civilite_etat_civil"])
# Nom à afficher:
if etud["nom_usuel"]:
etud["nom_disp"] = etud["nom_usuel"]
@ -67,6 +72,7 @@ def format_etud_ident(etud):
etud["nom_disp"] = etud["nom"]
etud["nomprenom"] = format_nomprenom(etud) # M. Pierre DUPONT
etud["etat_civil"] = format_etat_civil(etud)
if etud["civilite"] == "M":
etud["ne"] = ""
elif etud["civilite"] == "F":
@ -122,21 +128,6 @@ def format_nom(s, uppercase=True):
return format_prenom(s)
def input_civilite(s):
"""Converts external representation of civilite to internal:
'M', 'F', or 'X' (and nothing else).
Raises ScoValueError if conversion fails.
"""
s = s.upper().strip()
if s in ("M", "M.", "MR", "H"):
return "M"
elif s in ("F", "MLLE", "MLLE.", "MELLE", "MME"):
return "F"
elif s == "X" or not s:
return "X"
raise ScoValueError("valeur invalide pour la civilité: %s" % s)
def format_civilite(civilite):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
personne ne souhaitant pas d'affichage).
@ -152,6 +143,14 @@ def format_civilite(civilite):
raise ScoValueError("valeur invalide pour la civilité: %s" % civilite)
def format_etat_civil(etud: dict):
if etud["prenom_etat_civil"]:
civ = {"M": "M.", "F": "Mme", "X": ""}[etud["civilite_etat_civil"]]
return f'{civ} {etud["prenom_etat_civil"]} {etud["nom"]}'
else:
return etud["nomprenom"]
def format_lycee(nomlycee):
nomlycee = nomlycee.strip()
s = nomlycee.lower()
@ -190,21 +189,6 @@ def format_pays(s):
return ""
PIVOT_YEAR = 70
def pivot_year(y):
if y == "" or y is None:
return None
y = int(round(float(y)))
if y >= 0 and y < 100:
if y < PIVOT_YEAR:
y = y + 2000
else:
y = y + 1900
return y
def etud_sort_key(etud: dict) -> tuple:
"""Clé de tri pour les étudiants représentés par des dict (anciens codes).
Equivalent moderne: identite.sort_key
@ -225,7 +209,12 @@ _identiteEditor = ndb.EditableTable(
"nom",
"nom_usuel",
"prenom",
"prenom_etat_civil",
"cas_id",
"cas_allow_login",
"cas_allow_scodoc_login",
"civilite", # 'M", "F", or "X"
"civilite_etat_civil",
"date_naissance",
"lieu_naissance",
"dept_naissance",
@ -242,7 +231,9 @@ _identiteEditor = ndb.EditableTable(
input_formators={
"nom": force_uppercase,
"prenom": force_uppercase,
"prenom_etat_civil": force_uppercase,
"civilite": input_civilite,
"civilite_etat_civil": input_civilite,
"date_naissance": ndb.DateDMYtoISO,
"boursier": bool,
},
@ -263,12 +254,15 @@ def identite_list(cnx, *a, **kw):
else:
o["annee_naissance"] = o["date_naissance"]
o["civilite_str"] = format_civilite(o["civilite"])
o["civilite_etat_civil_str"] = format_civilite(o["civilite_etat_civil"])
return objs
def identite_edit_nocheck(cnx, args):
"""Modifie les champs mentionnes dans args, sans verification ni notification."""
_identiteEditor.edit(cnx, args)
etud = Identite.query.get(args["etudid"])
etud.from_dict(args)
db.session.commit()
def check_nom_prenom(cnx, nom="", prenom="", etudid=None):
@ -559,6 +553,7 @@ admission_delete = _admissionEditor.delete
admission_list = _admissionEditor.list
admission_edit = _admissionEditor.edit
# Edition simultanee de identite et admission
class EtudIdentEditor(object):
def create(self, cnx, args):
@ -602,7 +597,6 @@ class EtudIdentEditor(object):
_etudidentEditor = EtudIdentEditor()
etudident_list = _etudidentEditor.list
etudident_edit = _etudidentEditor.edit
etudident_create = _etudidentEditor.create
def log_unknown_etud():
@ -628,21 +622,8 @@ def get_etud_info(etudid=False, code_nip=False, filled=False) -> list[dict]:
return etud
# Optim par cache local, utilité non prouvée mais
# on s'oriente vers un cahce persistent dans Redis ou bien l'utilisation de NT
# def get_etud_info_filled_by_etudid(etudid, cnx=None) -> dict:
# """Infos sur un étudiant, avec cache local à la requête"""
# if etudid in g.stored_etud_info:
# return g.stored_etud_info[etudid]
# cnx = cnx or ndb.GetDBConnexion()
# etud = etudident_list(cnx, args={"etudid": etudid})
# fill_etuds_info(etud)
# g.stored_etud_info[etudid] = etud[0]
# return etud[0]
def create_etud(cnx, args={}):
"""Creation d'un étudiant. génère aussi évenement et "news".
def create_etud(cnx, args: dict = None):
"""Création d'un étudiant. Génère aussi évenement et "news".
Args:
args: dict avec les attributs de l'étudiant
@ -653,16 +634,16 @@ def create_etud(cnx, args={}):
from app.models import ScolarNews
# creation d'un etudiant
etudid = etudident_create(cnx, args)
# crée une adresse vide (chaque etudiant doit etre dans la table "adresse" !)
_ = adresse_create(
cnx,
{
"etudid": etudid,
"typeadresse": "domicile",
"description": "(creation individuelle)",
},
)
args_dict = Identite.convert_dict_fields(args)
args_dict["dept_id"] = g.scodoc_dept_id
etud = Identite.create_etud(**args_dict)
db.session.add(etud)
db.session.commit()
admission = etud.admission.first()
admission.from_dict(args)
db.session.add(admission)
db.session.commit()
etudid = etud.id
# event
scolar_events_create(

View File

@ -79,7 +79,7 @@ def evaluation_create_form(
mod = modimpl_o["module"]
formsemestre_id = modimpl_o["formsemestre_id"]
formsemestre = modimpl.formsemestre
sem_ues = formsemestre.query_ues(with_sport=False).all()
sem_ues = formsemestre.get_ues(with_sport=False)
is_malus = mod["module_type"] == ModuleType.MALUS
is_apc = mod["module_type"] in (ModuleType.RESSOURCE, ModuleType.SAE)
preferences = sco_preferences.SemPreferences(formsemestre.id)

View File

@ -97,11 +97,22 @@ def ListMedian(L):
# --------------------------------------------------------------------
def do_evaluation_etat(evaluation_id, partition_id=None, select_first_partition=False):
"""donne infos sur l'état de l'évaluation
{ nb_inscrits, nb_notes, nb_abs, nb_neutre, nb_att,
moyenne, mediane, mini, maxi,
date_last_modif, gr_complets, gr_incomplets, evalcomplete }
def do_evaluation_etat(
evaluation_id: int, partition_id: int = None, select_first_partition=False
) -> dict:
"""Donne infos sur l'état de l'évaluation.
Ancienne fonction, lente: préférer ModuleImplResults pour tout calcul.
{
nb_inscrits : inscrits au module
nb_notes
nb_abs,
nb_neutre,
nb_att,
moy, median, mini, maxi : # notes, en chaine, sur 20
last_modif: datetime,
gr_complets, gr_incomplets,
evalcomplete
}
evalcomplete est vrai si l'eval est complete (tous les inscrits
à ce module ont des notes)
evalattente est vrai s'il ne manque que des notes en attente
@ -137,7 +148,7 @@ def do_evaluation_etat(evaluation_id, partition_id=None, select_first_partition=
insmod = sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=E["moduleimpl_id"]
)
insmodset = set([x["etudid"] for x in insmod])
insmodset = {x["etudid"] for x in insmod}
# retire de insem ceux qui ne sont pas inscrits au module
ins = [i for i in insem if i["etudid"] in insmodset]
@ -155,14 +166,13 @@ def do_evaluation_etat(evaluation_id, partition_id=None, select_first_partition=
moy_num, median_num, mini_num, maxi_num = notes_moyenne_median_mini_maxi(notes)
if moy_num is None:
median, moy = "", ""
median_num, moy_num = None, None
mini, maxi = "", ""
mini_num, maxi_num = None, None
maxi_num = None
else:
median = scu.fmt_note(median_num)
moy = scu.fmt_note(moy_num)
mini = scu.fmt_note(mini_num)
maxi = scu.fmt_note(maxi_num)
moy = scu.fmt_note(moy_num, E["note_max"])
mini = scu.fmt_note(mini_num, E["note_max"])
maxi = scu.fmt_note(maxi_num, E["note_max"])
# cherche date derniere modif note
if len(etuds_notes_dict):
t = [x["date"] for x in etuds_notes_dict.values()]
@ -226,28 +236,22 @@ def do_evaluation_etat(evaluation_id, partition_id=None, select_first_partition=
# Calcul moyenne dans chaque groupe de TD
gr_moyennes = [] # group : {moy,median, nb_notes}
for group_id in GrNotes.keys():
notes = GrNotes[group_id]
for group_id, notes in GrNotes.items():
gr_moy, gr_median, gr_mini, gr_maxi = notes_moyenne_median_mini_maxi(notes)
gr_moyennes.append(
{
"group_id": group_id,
"group_name": groups[group_id]["group_name"],
"gr_moy_num": gr_moy,
"gr_moy": scu.fmt_note(gr_moy),
"gr_median_num": gr_median,
"gr_median": scu.fmt_note(gr_median),
"gr_mini": scu.fmt_note(gr_mini),
"gr_maxi": scu.fmt_note(gr_maxi),
"gr_mini_num": gr_mini,
"gr_maxi_num": gr_maxi,
"gr_moy": scu.fmt_note(gr_moy, E["note_max"]),
"gr_median": scu.fmt_note(gr_median, E["note_max"]),
"gr_mini": scu.fmt_note(gr_mini, E["note_max"]),
"gr_maxi": scu.fmt_note(gr_maxi, E["note_max"]),
"gr_nb_notes": len(notes),
"gr_nb_att": len([x for x in notes if x == scu.NOTES_ATTENTE]),
}
)
gr_moyennes.sort(key=operator.itemgetter("group_name"))
# retourne mapping
return {
"evaluation_id": evaluation_id,
"nb_inscrits": nb_inscrits,
@ -256,14 +260,11 @@ def do_evaluation_etat(evaluation_id, partition_id=None, select_first_partition=
"nb_abs": nb_abs,
"nb_neutre": nb_neutre,
"nb_att": nb_att,
"moy": moy,
"moy_num": moy_num,
"moy": moy, # chaine formattée, sur 20
"median": median,
"mini": mini,
"mini_num": mini_num,
"maxi": maxi,
"maxi_num": maxi_num,
"median_num": median_num,
"maxi_num": maxi_num, # note maximale, en nombre
"last_modif": last_modif,
"gr_incomplets": gr_incomplets,
"gr_moyennes": gr_moyennes,
@ -283,18 +284,19 @@ def do_evaluation_list_in_sem(formsemestre_id, with_etat=True):
[ {
'coefficient': 1.0,
'description': 'QCM et cas pratiques',
'etat': {'evalattente': False,
'etat': {
'evalattente': False,
'evalcomplete': True,
'evaluation_id': 'GEAEVAL82883',
'gr_incomplets': [],
'gr_moyennes': [{'gr_median': '12.00',
'gr_median_num' : 12.,
'gr_moy': '11.88',
'gr_moy_num' : 11.88,
'gr_nb_att': 0,
'gr_nb_notes': 166,
'group_id': 'GEAG266762',
'group_name': None}],
'gr_moyennes': [{
'gr_median': '12.00', # sur 20
'gr_moy': '11.88',
'gr_nb_att': 0,
'gr_nb_notes': 166,
'group_id': 'GEAG266762',
'group_name': None
}],
'groups': {'GEAG266762': {'etudid': 'GEAEID80603',
'group_id': 'GEAG266762',
'group_name': None,
@ -362,7 +364,7 @@ def _eval_etat(evals):
if last_modif is not None:
dates.append(e["etat"]["last_modif"])
if len(dates):
if dates:
dates = scu.sort_dates(dates)
last_modif = dates[-1] # date de derniere modif d'une note dans un module
else:

View File

@ -29,6 +29,7 @@
"""
from flask_login import current_user
# --- Exceptions
class ScoException(Exception):
"super classe de toutes les exceptions ScoDoc."
@ -44,6 +45,7 @@ class ScoInvalidCSRF(ScoException):
class ScoValueError(ScoException):
"Exception avec page d'erreur utilisateur, et qui stoque dest_url"
# mal nommée: super classe de toutes les exceptions avec page
# d'erreur gentille.
def __init__(self, msg, dest_url=None):
@ -75,7 +77,11 @@ class InvalidEtudId(NoteProcessError):
class ScoFormatError(ScoValueError):
pass
"Erreur lecture d'un fichier fourni par l'utilisateur"
def __init__(self, msg, filename="", dest_url=None):
super().__init__(msg, dest_url=dest_url)
self.filename = filename
class ScoInvalidParamError(ScoValueError):

View File

@ -127,9 +127,7 @@ def formation_export_dict(
ue_dict["apc_niveau_libelle"] = ue.niveau_competence.libelle
ue_dict["apc_niveau_annee"] = ue.niveau_competence.annee
ue_dict["apc_niveau_ordre"] = ue.niveau_competence.ordre
# Et le parcour:
if ue.parcour:
ue_dict["parcour"] = [ue.parcour.to_dict(with_annees=False)]
# pour les coefficients:
ue_dict["reference"] = ue.id if ue_reference_style == "id" else ue.acronyme
if not export_ids:
@ -266,8 +264,8 @@ def _formation_retreive_refcomp(f_dict: dict) -> int:
def _formation_retreive_apc_niveau(
referentiel_competence_id: int, ue_dict: dict
) -> int:
"""Recherche dans le ref. de comp. un niveau pour cette UE
utilise comme clé (libelle, annee, ordre)
"""Recherche dans le ref. de comp. un niveau pour cette UE.
Utilise (libelle, annee, ordre) comme clé.
"""
libelle = ue_dict.get("apc_niveau_libelle")
annee = ue_dict.get("apc_niveau_annee")
@ -365,13 +363,15 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False):
assert ue
if xml_ue_id:
ues_old2new[xml_ue_id] = ue_id
# élément optionnel présent dans les exports BUT:
ue_reference = ue_info[1].get("reference")
if ue_reference:
ue_reference_to_id[int(ue_reference)] = ue_id
# -- create matieres
# -- Create matieres
for mat_info in ue_info[2]:
# Backward compat: un seul parcours par UE (ScoDoc < 9.4.71)
if mat_info[0] == "parcour":
# Parcours (BUT)
code_parcours = mat_info[1]["code"]
@ -380,11 +380,30 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False):
referentiel_id=referentiel_competence_id,
).first()
if parcour:
ue.parcour = parcour
ue.parcours = [parcour]
db.session.add(ue)
else:
flash(f"Attention: parcours {code_parcours} inexistant !")
log(f"Warning: parcours {code_parcours} inexistant !")
continue
elif mat_info[0] == "parcours":
# Parcours (BUT), liste (ScoDoc > 9.4.70), avec ECTS en option
code_parcours = mat_info[1]["code"]
ue_parcour_ects = mat_info[1].get("ects")
parcour = ApcParcours.query.filter_by(
code=code_parcours,
referentiel_id=referentiel_competence_id,
).first()
if parcour:
ue.parcours.append(parcour)
else:
flash(f"Attention: parcours {code_parcours} inexistant !")
log(f"Warning: parcours {code_parcours} inexistant !")
if ue_parcour_ects is not None:
ue.set_ects(ue_parcour_ects, parcour)
db.session.add(ue)
continue
assert mat_info[0] == "matiere"
mat_info[1]["ue_id"] = ue_id
mat_id = sco_edit_matiere.do_matiere_create(mat_info[1])

54
app/scodoc/sco_formsemestre_status.py Normal file → Executable file
View File

@ -37,6 +37,7 @@ from flask import flash, redirect, render_template, url_for
from flask_login import current_user
from app import log
from app.but.cursus_but import formsemestre_warning_apc_setup
from app.comp import res_sem
from app.comp.res_common import ResultatsSemestre
from app.comp.res_compat import NotesTableCompat
@ -604,7 +605,7 @@ def formsemestre_description_table(
columns_ids += ["Coef."]
ues = [] # liste des UE, seulement en APC pour les coefs
else:
ues = formsemestre.query_ues().all()
ues = formsemestre.get_ues()
columns_ids += [f"ue_{ue.id}" for ue in ues]
if sco_preferences.get_preference("bul_show_ects", formsemestre_id):
columns_ids += ["ects"]
@ -645,26 +646,33 @@ def formsemestre_description_table(
ects_str = ue.ects
ue_info = {
"UE": ue.acronyme,
"Code": "",
"ects": ects_str,
"Module": ue.titre,
"_css_row_class": "table_row_ue",
"_UE_td_attrs": f'style="background-color: {ue.color} !important;"'
if ue.color
else "",
}
if use_ue_coefs:
ue_info["Coef."] = ue.coefficient
ue_info["Coef._class"] = "ue_coef"
rows.append(ue_info)
if ue.color:
for k in list(ue_info.keys()):
if not k.startswith("_"):
ue_info[
f"_{k}_td_attrs"
] = f'style="background-color: {ue.color} !important;"'
if not formsemestre.formation.is_apc():
# n'affiche la ligne UE qu'en formation classique
# car l'UE de rattachement n'a pas d'intérêt en BUT
rows.append(ue_info)
mod_inscrits = sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=modimpl.id
)
enseignants = ", ".join(ens.get_prenomnom() for ens in modimpl.enseignants)
l = {
row = {
"UE": modimpl.module.ue.acronyme,
"_UE_td_attrs": ue_info["_UE_td_attrs"],
"_UE_td_attrs": ue_info.get("_UE_td_attrs", ""),
"Code": modimpl.module.code or "",
"Module": modimpl.module.abbrev or modimpl.module.titre,
"_Module_class": "scotext",
@ -691,26 +699,32 @@ def formsemestre_description_table(
sum_coef += modimpl.module.coefficient
coef_dict = modimpl.module.get_ue_coef_dict()
for ue in ues:
l[f"ue_{ue.id}"] = coef_dict.get(ue.id, 0.0) or ""
row[f"ue_{ue.id}"] = coef_dict.get(ue.id, 0.0) or ""
if with_parcours:
l["parcours"] = ", ".join(
row["parcours"] = ", ".join(
sorted([pa.code for pa in modimpl.module.parcours])
)
rows.append(l)
rows.append(row)
if with_evals:
# Ajoute lignes pour evaluations
evals = nt.get_mod_evaluation_etat_list(modimpl.id)
evals.reverse() # ordre chronologique
# Ajoute etat:
eval_rows = []
for eval_dict in evals:
e = eval_dict.copy()
e["_description_target"] = url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e["evaluation_id"],
)
e["_jour_order"] = e["jour"].isoformat()
e["jour"] = e["jour"].strftime("%d/%m/%Y") if e["jour"] else ""
e["UE"] = l["UE"]
e["_UE_td_attrs"] = l["_UE_td_attrs"]
e["Code"] = l["Code"]
e["UE"] = row["UE"]
e["_UE_td_attrs"] = row["_UE_td_attrs"]
e["Code"] = row["Code"]
e["_css_row_class"] = "evaluation"
e["Module"] = "éval."
# Cosmetic: conversions pour affichage
@ -733,8 +747,9 @@ def formsemestre_description_table(
e[f"ue_{ue_id}"] = poids or ""
e[f"_ue_{ue_id}_class"] = "poids"
e[f"_ue_{ue_id}_help"] = "poids vers l'UE"
eval_rows.append(e)
rows += evals
rows += eval_rows
sums = {"_css_row_class": "moyenne sortbottom", "ects": sum_ects, "Coef.": sum_coef}
rows.append(sums)
@ -814,9 +829,9 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
</td>
<td>
<form action="{url_for(
"absences.SignaleAbsenceGrSemestre", scodoc_dept=g.scodoc_dept
"assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept
)}" method="get">
<input type="hidden" name="datefin" value="{
<input type="hidden" name="date" value="{
formsemestre.date_fin.strftime("%d/%m/%Y")}"/>
<input type="hidden" name="group_ids" value="%(group_id)s"/>
<input type="hidden" name="destination" value="{destination}"/>
@ -833,8 +848,8 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
</select>
<a href="{
url_for("absences.choix_semaine", scodoc_dept=g.scodoc_dept)
}?group_id=%(group_id)s">saisie par semaine</a>
url_for("assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept)
}?group_ids=%(group_id)s&jour={datetime.date.today().isoformat()}&formsemestre_id={formsemestre.id}">saisie par semaine</a>
</form></td>
"""
else:
@ -1057,6 +1072,7 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
formsemestre_status_head(
formsemestre_id=formsemestre_id, page_title="Tableau de bord"
),
formsemestre_warning_apc_setup(formsemestre, nt),
formsemestre_warning_etuds_sans_note(formsemestre, nt)
if can_change_all_notes
else "",
@ -1282,7 +1298,7 @@ def formsemestre_tableau_modules(
"""
)
if mod.module_type in (ModuleType.RESSOURCE, ModuleType.SAE):
coefs = mod.ue_coefs_list(ues=formsemestre.query_ues().all())
coefs = mod.ue_coefs_list(ues=formsemestre.get_ues())
H.append(f'<a class="invisible_link" href="#" title="{mod_descr}">')
for coef in coefs:
if coef[1] > 0:

View File

@ -606,7 +606,9 @@ def formsemestre_recap_parcours_table(
else:
# si l'étudiant n'est pas inscrit à un parcours mais que le semestre a plus d'UE
# signale un éventuel problème:
if nt.formsemestre.query_ues().count() > len(nt.etud_ues_ids(etudid)):
if len(nt.formsemestre.get_ues()) > len(
nt.etud_ues_ids(etudid)
): # XXX sans dispenses
parcours_name = f"""
<span class="code_parcours no_parcours">{scu.EMO_WARNING}&nbsp;pas de parcours
</span>"""

View File

@ -40,7 +40,7 @@ import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app import log
from app.models import ScolarNews, GroupDescr
from app.models.etudiants import input_civilite
from app.scodoc.sco_excel import COLORS
from app.scodoc.sco_formsemestre_inscriptions import (
do_formsemestre_inscription_with_modules,
@ -71,6 +71,8 @@ FORMAT_FILE = "format_import_etudiants.txt"
ADMISSION_MODIFIABLE_FIELDS = (
"code_nip",
"code_ine",
"prenom_etat_civil",
"civilite_etat_civil",
"date_naissance",
"lieu_naissance",
"bac",
@ -368,7 +370,7 @@ def scolars_import_excel_file(
# xxx Ad-hoc checks (should be in format description)
if titleslist[i].lower() == "sexe":
try:
val = sco_etud.input_civilite(val)
val = input_civilite(val)
except:
raise ScoValueError(
"valeur invalide pour 'SEXE' (doit etre 'M', 'F', ou 'MME', 'H', 'X' ou vide, mais pas '%s') ligne %d, colonne %s"

View File

@ -36,7 +36,13 @@ from flask_login import current_user
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns
from app.models import (
FormSemestre,
Identite,
Partition,
ScolarFormSemestreValidation,
UniteEns,
)
from app import log
from app.tables import list_etuds
@ -517,11 +523,23 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
(UniteEns.query.get(ue_id) for ue_id in ue_ids),
key=lambda u: (u.numero or 0, u.acronyme),
)
H.append("""<table><tr><th></th>""")
H.append(
"""<table id="but_ue_inscriptions" class="stripe compact">
<thead>
<tr><th>Nom</th><th>Parcours</th>
"""
)
for ue in ues:
H.append(f"""<th title="{ue.titre or ''}">{ue.acronyme}</th>""")
H.append("""</tr>""")
H.append(
"""</tr>
</thead>
<tbody>
"""
)
partition_parcours: Partition = Partition.query.filter_by(
formsemestre=res.formsemestre, partition_name=scu.PARTITION_PARCOURS
).first()
etuds = list_etuds.etuds_sorted_from_ids(table_inscr.keys())
for etud in etuds:
ues_etud = table_inscr[etud.id]
@ -534,6 +552,11 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
)}"
>{etud.nomprenom}</a></td>"""
)
# Parcours:
group = partition_parcours.get_etud_group(etud.id)
parcours_name = group.group_name if group else ""
H.append(f"""<td class="parcours">{parcours_name}</td>""")
# UEs:
for ue in ues:
td_class = ""
est_inscr = ues_etud.get(ue.id) # None si pas concerné
@ -568,31 +591,38 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
content = f"""<input type="checkbox"
{'checked' if est_inscr else ''}
{'disabled' if read_only else ''}
title="{etud.nomprenom} {'inscrit' if est_inscr else 'non inscrit'} à l'UE {ue.acronyme}. {expl_validation}",
title="{etud.nomprenom} {'inscrit' if est_inscr else 'non inscrit'} à l'UE {ue.acronyme}. {expl_validation}"
onchange="change_ue_inscr(this);"
data-url_inscr={
data-url_inscr="{
url_for("notes.etud_inscrit_ue",
scodoc_dept=g.scodoc_dept, etudid=etud.id,
formsemestre_id=res.formsemestre.id, ue_id=ue.id)
}
data-url_desinscr={
}"
data-url_desinscr="{
url_for("notes.etud_desinscrit_ue",
scodoc_dept=g.scodoc_dept, etudid=etud.id,
formsemestre_id=res.formsemestre.id, ue_id=ue.id)
}
}"
/>
"""
H.append(f"""<td{td_class}>{content}</td>""")
H.append(
"""</table>
"""
</tbody>
</table>
</form>
<div class="help">
L'inscription ou désinscription aux UEs du BUT n'affecte pas les inscriptions aux modules
<p>L'inscription ou désinscription aux UEs du BUT n'affecte pas les inscriptions aux modules
mais permet de "dispenser" un étudiant de suivre certaines UEs de son parcours.
Il peut s'agit d'étudiants redoublants ayant déjà acquis l'UE, ou d'autres cas particuliers.
La dispense d'UE est réversible à tout moment (avant le jury de fin de semestre)
</p>
<p>Il peut s'agit d'étudiants redoublants ayant déjà acquis l'UE, ou d'une UE
présente dans le semestre mais pas dans le parcours de l'étudiant, ou bien d'autres
cas particuliers.
</p>
<p>La dispense d'UE est réversible à tout moment (avant le jury de fin de semestre)
et n'affecte pas les notes saisies.
</p>
</div>
</div>
"""

View File

@ -176,6 +176,18 @@ def ficheEtud(etudid=None):
sco_etud.fill_etuds_info([etud_])
#
info = etud_
if etud.prenom_etat_civil:
info["etat_civil"] = (
"<h3>Etat-civil: "
+ etud.civilite_etat_civil_str
+ " "
+ etud.prenom_etat_civil
+ " "
+ etud.nom
+ "</h3>"
)
else:
info["etat_civil"] = ""
info["ScoURL"] = scu.ScoURL()
info["authuser"] = authuser
info["info_naissance"] = info["date_naissance"]
@ -325,18 +337,17 @@ def ficheEtud(etudid=None):
if not sco_permissions_check.can_suppress_annotation(a["id"]):
a["dellink"] = ""
else:
a["dellink"] = (
'<td class="annodel"><a href="doSuppressAnnotation?etudid=%s&annotation_id=%s">%s</a></td>'
% (
etudid,
a["id"],
scu.icontag(
"delete_img",
border="0",
alt="suppress",
title="Supprimer cette annotation",
),
)
a[
"dellink"
] = '<td class="annodel"><a href="doSuppressAnnotation?etudid=%s&annotation_id=%s">%s</a></td>' % (
etudid,
a["id"],
scu.icontag(
"delete_img",
border="0",
alt="suppress",
title="Supprimer cette annotation",
),
)
author = sco_users.user_info(a["author"])
alist.append(
@ -473,7 +484,7 @@ def ficheEtud(etudid=None):
<div class="ficheEtud" id="ficheEtud"><table>
<tr><td>
<h2>%(nomprenom)s (%(inscription)s)</h2>
%(etat_civil)s
<span>%(emaillink)s</span>
</td><td class="photocell">
<a href="etud_photo_orig_page?etudid=%(etudid)s">%(etudfoto)s</a>

4
app/scodoc/sco_photos.py Normal file → Executable file
View File

@ -148,11 +148,11 @@ def get_photo_image(etudid=None, size="small"):
filename = photo_pathname(etud.photo_filename, size=size)
if not filename:
filename = UNKNOWN_IMAGE_PATH
r = _http_jpeg_file(filename)
r = build_image_response(filename)
return r
def _http_jpeg_file(filename):
def build_image_response(filename):
"""returns an image as a Flask response"""
st = os.stat(filename)
last_modified = st.st_mtime # float timestamp

View File

@ -45,20 +45,20 @@ Au niveau du code interface, on défini pour chaque préférence:
- size: longueur du chap texte
- input_type: textarea, separator, ... type de widget TrivialFormulator a utiliser
- rows, rols: geometrie des textareas
- category: misc ou bul ou page_bulletins ou abs ou general ou portal
- category: misc ou bul ou page_bulletins ou abs ou general ou portal
ou pdf ou pvpdf ou ...
- only_global (default False): si vraie, ne peut pas etre associée a un seul semestre.
Les titres et sous-titres de chaque catégorie sont définis dans PREFS_CATEGORIES
On peut éditer les préférences d'une ou plusieurs catégories au niveau d'un
semestre ou au niveau global.
On peut éditer les préférences d'une ou plusieurs catégories au niveau d'un
semestre ou au niveau global.
* niveau global: changer les valeurs, liste de catégories.
* niveau d'un semestre:
présenter valeur courante: valeur ou "definie globalement" ou par defaut
lien "changer valeur globale"
------------------------------------------------------------------------------
Doc technique:
@ -66,10 +66,10 @@ Doc technique:
Toutes les préférences sont stockées dans la table sco_prefs, qui contient
des tuples (name, value, formsemestre_id).
Si formsemestre_id est NULL, la valeur concerne tous les semestres,
sinon, elle ne concerne que le semestre indiqué.
sinon, elle ne concerne que le semestre indiqué.
* Utilisation dans ScoDoc 9
- lire une valeur:
- lire une valeur:
get_preference(name, formsemestre_id)
nb: les valeurs sont des chaines, sauf:
. si le type est spécifié (float ou int)
@ -111,9 +111,7 @@ get_base_preferences(formsemestre_id)
"""
import flask
from flask import flash, g, request
# from flask_login import current_user
from flask import current_app, flash, g, request, url_for
from app.models import Departement
from app.scodoc import sco_cache
@ -206,7 +204,9 @@ PREF_CATEGORIES = (
("misc", {"title": "Divers"}),
("apc", {"title": "BUT et Approches par Compétences"}),
("abs", {"title": "Suivi des absences", "related": ("bul",)}),
("assi", {"title": "Gestion de l'assiduité"}),
("portal", {"title": "Liaison avec portail (Apogée, etc)"}),
("apogee", {"title": "Exports Apogée"}),
(
"pdf",
{
@ -234,7 +234,9 @@ PREF_CATEGORIES = (
"bul_margins",
{
"title": "Marges additionnelles des bulletins, en millimètres",
"subtitle": "Le bulletin de notes notes est toujours redimensionné pour occuper l'espace disponible entre les marges.",
"subtitle": """Le bulletin de notes notes est toujours redimensionné
pour occuper l'espace disponible entre les marges.
""",
"related": ("bul", "bul_mail", "pdf"),
},
),
@ -320,7 +322,9 @@ class BasePreferences(object):
{
"initvalue": "",
"title": "Nom de l'Institut",
"explanation": 'exemple "IUT de Villetaneuse". Peut être utilisé sur les bulletins.',
"explanation": """exemple "IUT de Villetaneuse".
Peut être utilisé sur les bulletins.
""",
"size": 40,
"category": "general",
"only_global": True,
@ -354,7 +358,9 @@ class BasePreferences(object):
"initvalue": "",
"title": "e-mails à qui notifier les opérations",
"size": 70,
"explanation": "adresses séparées par des virgules; notifie les opérations (saisies de notes, etc).",
"explanation": """adresses séparées par des virgules; notifie les opérations
(saisies de notes, etc).
""",
"category": "general",
"only_global": False, # peut être spécifique à un semestre
},
@ -366,9 +372,14 @@ class BasePreferences(object):
"initvalue": "",
"title": "Adresse mail origine",
"size": 40,
"explanation": """adresse expéditeur pour tous les envois par mails (bulletins,
comptes, etc.).
Si vide, utilise la config globale.""",
"explanation": f"""adresse expéditeur pour tous les envois par mail
(bulletins, notifications, etc.). Si vide, utilise la config globale.
Pour les comptes (mot de passe), voir la config globale accessible
en tant qu'administrateur depuis la <a class="stdlink" href="{
url_for("scodoc.index")
}">page d'accueil</a>.
""",
"category": "misc",
"only_global": True,
},
@ -588,6 +599,49 @@ class BasePreferences(object):
"category": "abs",
},
),
# Assiduités
(
"forcer_module",
{
"initvalue": 0,
"title": "Forcer la déclaration du module.",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "assi",
},
),
(
"forcer_present",
{
"initvalue": 0,
"title": "Forcer l'appel des présents",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "assi",
},
),
(
"periode_defaut",
{
"initvalue": 2.0,
"size": 10,
"title": "Durée par défaut d'un créneau",
"type": "float",
"category": "assi",
"only_global": True,
},
),
(
"etat_defaut",
{
"initvalue": "aucun",
"input_type": "menu",
"labels": ["aucun", "present", "retard", "absent"],
"allowed_values": ["aucun", "present", "retard", "absent"],
"title": "Définir l'état par défaut",
"category": "assi",
},
),
# portal
(
"portal_url",
@ -734,7 +788,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"only_global": True,
},
),
@ -746,7 +800,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"only_global": True,
},
),
@ -758,7 +812,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"only_global": True,
},
),
@ -770,7 +824,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"only_global": True,
},
),
@ -782,7 +836,7 @@ class BasePreferences(object):
"explanation": "si coché, exporte exporte étudiants même si pas décision de jury saisie (sinon laisse vide)",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"only_global": True,
},
),
@ -794,7 +848,19 @@ class BasePreferences(object):
"explanation": "si coché, exporte exporte étudiants en attente de ratrapage comme ATT (sinon laisse vide)",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"only_global": True,
},
),
(
"export_res_remove_typ_res",
{
"initvalue": 0,
"title": "Ne pas recopier la section APO_TYP_RES",
"explanation": "si coché, ne réécrit pas la section APO_TYP_RES (rarement utile, utiliser avec précaution)",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "apogee",
"only_global": True,
},
),
@ -1945,7 +2011,8 @@ class BasePreferences(object):
value = _get_pref_default_value_from_config(name, pref[1])
self.default[name] = value
self.prefs[None][name] = value
log(f"creating missing preference for {name}={value}")
if not current_app.testing:
log(f"creating missing preference for {name}={value}")
# add to db table
self._editor.create(
cnx, {"dept_id": self.dept_id, "name": name, "value": value}
@ -2222,7 +2289,6 @@ class SemPreferences:
raise ScoValueError(
"sem_preferences.edit doit etre appele sur un semestre !"
) # a bug !
sem = sco_formsemestre.get_formsemestre(self.formsemestre_id)
H = [
html_sco_header.html_sem_header(
"Préférences du semestre",

View File

@ -242,7 +242,19 @@ def formsemestre_recapcomplet(
</div>
"""
)
# Légende
H.append(
"""
<div class="table_recap_caption">
<div class="title">Codes utilisés dans cette table:</div>
<div class="captions">
<div><tt>~</tt></div><div>valeur manquante</div>
<div><tt>=</tt></div><div>UE dispensée</div>
<div><tt>nan</tt></div><div>valeur non disponible</div>
</div>
</div>
"""
)
H.append(html_sco_header.sco_footer())
# HTML or binary data ?
if len(H) > 1:

View File

@ -152,7 +152,7 @@ def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict):
absents = [] # etudid absents
tosuppress = [] # etudids avec ancienne note à supprimer
for (etudid, note) in notes:
for etudid, note in notes:
note = str(note).strip().upper()
try:
etudid = int(etudid) #
@ -536,7 +536,7 @@ def notes_add(
evaluation_id, getallstudents=True, include_demdef=True
)
}
for (etudid, value) in notes:
for etudid, value in notes:
if check_inscription and (etudid not in inscrits):
raise NoteProcessError(f"etudiant {etudid} non inscrit dans ce module")
if (value is not None) and not isinstance(value, float):
@ -556,7 +556,7 @@ def notes_add(
[]
) # etudids pour lesquels il y a une decision de jury et que la note change
try:
for (etudid, value) in notes:
for etudid, value in notes:
changed = False
if etudid not in notes_db:
# nouvelle note
@ -657,6 +657,7 @@ def notes_add(
formsemestre_id=M["formsemestre_id"]
) # > modif notes (exception)
sco_cache.EvaluationCache.delete(evaluation_id)
raise # XXX
raise ScoGenError("Erreur enregistrement note: merci de ré-essayer") from exc
if do_it:
cnx.commit()

View File

@ -84,15 +84,17 @@ class SemSet(dict):
self.semset_id = semset_id
self["semset_id"] = semset_id
self.sems = []
self.formsemestre_ids = []
self.formsemestres = [] # modernisation en cours...
self.is_apc = False
self.formsemestre_ids = set()
cnx = ndb.GetDBConnexion()
if semset_id: # read existing set
L = semset_list(cnx, args={"semset_id": semset_id})
if not L:
semsets = semset_list(cnx, args={"semset_id": semset_id})
if not semsets:
raise ScoValueError(f"Ensemble inexistant ! (semset {semset_id})")
self["title"] = L[0]["title"]
self["annee_scolaire"] = L[0]["annee_scolaire"]
self["sem_id"] = L[0]["sem_id"]
self["title"] = semsets[0]["title"]
self["annee_scolaire"] = semsets[0]["annee_scolaire"]
self["sem_id"] = semsets[0]["sem_id"]
r = ndb.SimpleDictFetch(
"SELECT formsemestre_id FROM notes_semset_formsemestre WHERE semset_id = %(semset_id)s",
{"semset_id": semset_id},
@ -123,8 +125,13 @@ class SemSet(dict):
def load_sems(self):
"""Load formsemestres"""
self.sems = []
self.formsemestres = []
for formsemestre_id in self.formsemestre_ids:
self.sems.append(sco_formsemestre.get_formsemestre(formsemestre_id))
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
self.formsemestres.append(formsemestre)
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
self.sems.append(sem)
self["is_apc"] = formsemestre.formation.is_apc()
if self.sems:
self["date_debut"] = min([sem["date_debut_iso"] for sem in self.sems])
@ -137,8 +144,15 @@ class SemSet(dict):
self["semtitles"] = [sem["titre_num"] for sem in self.sems]
# Construction du ou des lien(s) vers le semestre
pattern = '<a class="stdlink" href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titreannee)s</a>'
self["semlinks"] = [(pattern % sem) for sem in self.sems]
self["semlinks"] = [
f"""<a class="stdlink" href="{
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id)
}">{formsemestre.titre_annee()}</a>
"""
for formsemestre in self.formsemestres
]
self["semtitles_str"] = "<br>".join(self["semlinks"])
def fill_formsemestres(self):
@ -149,6 +163,8 @@ class SemSet(dict):
def add(self, formsemestre_id):
"Ajoute ce semestre à l'ensemble"
# check for valid formsemestre_id
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
# check
if formsemestre_id in self.formsemestre_ids:
return # already there
@ -159,6 +175,17 @@ class SemSet(dict):
f"can't add {formsemestre_id} to set {self.semset_id}: incompatible sem_id"
)
if self.formsemestre_ids and formsemestre.formation.is_apc() != self["is_apc"]:
raise ScoValueError(
"""On ne peut pas mélanger des semestres BUT/APC
avec des semestres ordinaires dans le même export.""",
dest_url=url_for(
"notes.apo_semset_maq_status",
scodoc_dept=g.scodoc_dept,
semset_id=self.semset_id,
),
)
ndb.SimpleQuery(
"""INSERT INTO notes_semset_formsemestre
(formsemestre_id, semset_id)
@ -242,17 +269,28 @@ class SemSet(dict):
def load_etuds(self):
self["etuds_without_nip"] = set() # etudids
self["jury_ok"] = True
self["jury_nb_missing"] = 0
is_apc = None
for sem in self.sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if is_apc is not None and is_apc != nt.is_apc:
raise ScoValueError(
"Incohérence: semestre APC (BUT) et ordinaires mélangés !"
)
else:
is_apc = nt.is_apc
sem["etuds"] = list(nt.identdict.values())
sem["nips"] = {e["code_nip"] for e in sem["etuds"] if e["code_nip"]}
sem["etuds_without_nip"] = {
e["etudid"] for e in sem["etuds"] if not e["code_nip"]
}
self["etuds_without_nip"] |= sem["etuds_without_nip"]
sem["jury_ok"] = nt.all_etuds_have_sem_decisions()
sem["etudids_no_jury"] = nt.etudids_without_decisions()
sem["jury_ok"] = not sem["etudids_no_jury"]
self["jury_ok"] &= sem["jury_ok"]
self["jury_nb_missing"] += len(sem["etudids_no_jury"])
self["is_apc"] = bool(is_apc)
def html_descr(self):
"""Short HTML description"""
@ -272,36 +310,21 @@ class SemSet(dict):
)
H.append("</p>")
H.append(
f"""<p>Période: <select name="periode" onchange="set_periode(this);">
<option value="1" {"selected" if self["sem_id"] == 1 else ""}>1re période (S1, S3)</option>
<option value="2" {"selected" if self["sem_id"] == 2 else ""}>2de période (S2, S4)</option>
<option value="0" {"selected" if self["sem_id"] == 0 else ""}>non semestrialisée (LP, ...)</option>
</select>
</p>
<script>
function set_periode(elt) {{
fetch(
"{ url_for("apiweb.semset_set_periode", scodoc_dept=g.scodoc_dept,
semset_id=self.semset_id )
}",
{{
method: "POST",
headers: {{
'Content-Type': 'application/json'
}},
body: JSON.stringify( elt.value )
}},
).then(sco_message("période modifiée"));
}};
</script>
"""
)
if self["sem_id"] == 1:
periode = "1re période (S1, S3)"
elif self["sem_id"] == 2:
periode = "2de période (S2, S4)"
else:
periode = "non semestrialisée (LP, ...). Incompatible avec BUT."
H.append(
f"<p>Etapes: <tt>{sco_formsemestre.etapes_apo_str(self.list_etapes())}</tt></p>"
f"""
<p>Période: <b>{periode}</b></p>
<p>Etapes: <tt>{sco_formsemestre.etapes_apo_str(self.list_etapes())}</tt></p>
<h4>Semestres de l'ensemble:</h4><ul class="semset_listsems">
"""
)
H.append("""<h4>Semestres de l'ensemble:</h4><ul class="semset_listsems">""")
for sem in self.sems:
H.append(
@ -364,8 +387,7 @@ class SemSet(dict):
"""
if sco_portal_apogee.has_portal():
return self.bilan.html_diagnostic()
else:
return ""
return ""
def get_semsets_list():
@ -423,13 +445,15 @@ def do_semset_add_sem(semset_id, formsemestre_id):
raise ScoValueError("empty semset_id")
if formsemestre_id == "":
raise ScoValueError("pas de semestre choisi !")
s = SemSet(semset_id=semset_id)
# check for valid formsemestre_id
_ = sco_formsemestre.get_formsemestre(formsemestre_id) # raise exc
s.add(formsemestre_id)
return flask.redirect("apo_semset_maq_status?semset_id=%s" % semset_id)
semset = SemSet(semset_id=semset_id)
semset.add(formsemestre_id)
return flask.redirect(
url_for(
"notes.apo_semset_maq_status",
scodoc_dept=g.scodoc_dept,
semset_id=semset_id,
)
)
def do_semset_remove_sem(semset_id, formsemestre_id):
@ -535,7 +559,7 @@ def semset_page(format="html"):
<select name="sem_id">
<option value="1">1re période (S1, S3)</option>
<option value="2">2de période (S2, S4)</option>
<option value="0">non semestrialisée (LP, ...)</option>
<option value="0">non semestrialisée (LP, ... mais pas pour le BUT !)</option>
</select>
<input type="text" name="title" size="32"/>
<input type="submit" value="Créer"/>

View File

@ -351,7 +351,7 @@ def check_modif_user(
# Unicité du cas_id
if cas_id:
cas_users = User.query.filter_by(cas_id=cas_id).all()
cas_users = User.query.filter_by(cas_id=str(cas_id)).all()
if edit:
if cas_users and (
len(cas_users) > 1 or cas_users[0].user_name != user_name

View File

@ -32,13 +32,14 @@ import base64
import bisect
import collections
import datetime
from enum import IntEnum
from enum import IntEnum, Enum
import io
import json
from hashlib import md5
import numbers
import os
import re
from shutil import get_terminal_size
import _thread
import time
import unicodedata
@ -50,14 +51,19 @@ from PIL import Image as PILImage
import pydot
import requests
from pytz import timezone
import dateutil.parser as dtparser
import flask
from flask import g, request
from flask import flash, url_for, make_response, jsonify
from flask import g, request, Response
from flask import flash, url_for, make_response
from flask_json import json_response
from werkzeug.http import HTTP_STATUS_CODES
from config import Config
from app import log
from app.scodoc.sco_vdi import ApoEtapeVDI
from app import log, ScoDocJSONEncoder
from app.scodoc.codes_cursus import NOTES_TOLERANCE, CODES_EXPL
from app.scodoc import sco_xml
import sco_version
@ -90,6 +96,161 @@ ETATS_INSCRIPTION = {
}
def print_progress_bar(
iteration,
total,
prefix="",
suffix="",
finish_msg="",
decimals=1,
length=100,
fill="",
autosize=False,
):
"""
Affiche une progress bar à un point donné (mettre dans une boucle pour rendre dynamique)
@params:
iteration - Required : index du point donné (Int)
total - Required : nombre total avant complétion (eg: len(List))
prefix - Optional : Préfix -> écrit à gauche de la barre (Str)
suffix - Optional : Suffix -> écrit à droite de la barre (Str)
decimals - Optional : nombres de chiffres après la virgule (Int)
length - Optional : taille de la barre en nombre de caractères (Int)
fill - Optional : charactère de remplissange de la barre (Str)
autosize - Optional : Choisir automatiquement la taille de la barre en fonction du terminal (Bool)
"""
percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
color = TerminalColor.RED
if 50 >= float(percent) > 25:
color = TerminalColor.MAGENTA
if 75 >= float(percent) > 50:
color = TerminalColor.BLUE
if 90 >= float(percent) > 75:
color = TerminalColor.CYAN
if 100 >= float(percent) > 90:
color = TerminalColor.GREEN
styling = f"{prefix} |{fill}| {percent}% {suffix}"
if autosize:
cols, _ = get_terminal_size(fallback=(length, 1))
length = cols - len(styling)
filled_length = int(length * iteration // total)
pg_bar = fill * filled_length + "-" * (length - filled_length)
print(f"\r{color}{styling.replace(fill, pg_bar)}{TerminalColor.RESET}", end="\r")
# Affiche une nouvelle ligne vide
if iteration == total:
print(f"\n{finish_msg}")
class TerminalColor:
"""Ensemble de couleur pour terminaux"""
BLUE = "\033[94m"
CYAN = "\033[96m"
GREEN = "\033[92m"
MAGENTA = "\033[95m"
RED = "\033[91m"
RESET = "\033[0m"
class BiDirectionalEnum(Enum):
"""Permet la recherche inverse d'un enum
Condition : les clés et les valeurs doivent être uniques
les clés doivent être en MAJUSCULES
"""
@classmethod
def contains(cls, attr: str):
"""Vérifie sur un attribut existe dans l'enum"""
return attr.upper() in cls._member_names_
@classmethod
def get(cls, attr: str, default: any = None):
"""Récupère une valeur à partir de son attribut"""
val = None
try:
val = cls[attr.upper()]
except (KeyError, AttributeError):
val = default
return val
@classmethod
def inverse(cls):
"""Retourne un dictionnaire représentant la map inverse de l'Enum"""
return cls._value2member_map_
class EtatAssiduite(int, BiDirectionalEnum):
"""Code des états d'assiduité"""
# Stockés en BD ne pas modifier
PRESENT = 0
RETARD = 1
ABSENT = 2
class EtatJustificatif(int, BiDirectionalEnum):
"""Code des états des justificatifs"""
# Stockés en BD ne pas modifier
VALIDE = 0
NON_VALIDE = 1
ATTENTE = 2
MODIFIE = 3
def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None:
"""
Vérifie si une date est au format iso
Retourne un booléen Vrai (ou un objet Datetime si convert = True)
si l'objet est au format iso
Retourne Faux si l'objet n'est pas au format et convert = False
Retourne None sinon
"""
try:
date: datetime.datetime = dtparser.isoparse(date)
return date if convert else True
except (dtparser.ParserError, ValueError, TypeError):
return None if convert else False
def localize_datetime(date: datetime.datetime or str) -> datetime.datetime:
"""Ajoute un timecode UTC à la date donnée."""
if isinstance(date, str):
date = is_iso_formated(date, convert=True)
new_date: datetime.datetime = date
if new_date.tzinfo is None:
try:
new_date = timezone("Europe/Paris").localize(date)
except OverflowError:
new_date = timezone("UTC").localize(date)
return new_date
def is_period_overlapping(
periode: tuple[datetime.datetime, datetime.datetime],
interval: tuple[datetime.datetime, datetime.datetime],
bornes: bool = True,
) -> bool:
"""
Vérifie si la période et l'interval s'intersectent
si strict == True : les extrémitées ne comptes pas
Retourne Vrai si c'est le cas, faux sinon
"""
p_deb, p_fin = periode
i_deb, i_fin = interval
if bornes:
return p_deb <= i_fin and p_fin >= i_deb
return p_deb < i_fin and p_fin > i_deb
# Types de modules
class ModuleType(IntEnum):
"""Code des types de module."""
@ -695,16 +856,6 @@ def sendPDFFile(data, filename): # DEPRECATED utiliser send_file
return send_file(data, filename=filename, mime=PDF_MIMETYPE, attached=True)
class ScoDocJSONEncoder(json.JSONEncoder):
def default(self, o): # pylint: disable=E0202
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat()
elif isinstance(o, ApoEtapeVDI):
return str(o)
else:
return json.JSONEncoder.default(self, o)
def sendJSON(data, attached=False, filename=None):
js = json.dumps(data, indent=1, cls=ScoDocJSONEncoder)
return send_file(
@ -816,24 +967,26 @@ def get_request_args():
return vals
def json_error(status_code, message=None):
"""Simple JSON response, for errors"""
def json_error(status_code, message=None) -> Response:
"""Simple JSON for errors.
If as-response, returns Flask's Response. Otherwise returns a dict.
"""
payload = {
"error": HTTP_STATUS_CODES.get(status_code, "Unknown error"),
"status": status_code,
}
if message:
payload["message"] = message
response = jsonify(payload)
response = json_response(status_=status_code, data_=payload)
response.status_code = status_code
log(f"Error: {response}")
return response
def json_ok_response(status_code=200, payload=None):
def json_ok_response(status_code=200, payload=None) -> Response:
"""Simple JSON respons for "success" """
payload = payload or {"OK": True}
response = jsonify(payload)
response = json_response(status_=status_code, data_=payload)
response.status_code = status_code
return response
@ -997,8 +1150,8 @@ def icontag(name, file_format="png", no_size=False, **attrs):
file_format,
),
)
im = PILImage.open(img_file)
width, height = im.size[0], im.size[1]
with PILImage.open(img_file) as image:
width, height = image.size[0], image.size[1]
ICONSIZES[name] = (width, height) # cache
else:
width, height = ICONSIZES[name]

View File

@ -33,7 +33,7 @@ from app.scodoc.sco_exceptions import ScoValueError
class ApoEtapeVDI(object):
_ETAPE_VDI_SEP = "!"
def __init__(self, etape_vdi=None, etape="", vdi=""):
def __init__(self, etape_vdi: str = None, etape: str = "", vdi: str = ""):
"""Build from string representation, e.g. 'V1RT!111'"""
if etape_vdi:
self.etape_vdi = etape_vdi
@ -52,6 +52,10 @@ class ApoEtapeVDI(object):
def __str__(self):
return self.etape_vdi
def __json__(self) -> str:
"json repr for flask_json"
return str(self)
def _cmp(self, other):
"""Test égalité de deux codes étapes.
Si le VDI des deux est spécifié, on l'utilise. Sinon, seul le code étape est pris en compte.

View File

@ -0,0 +1,489 @@
* {
box-sizing: border-box;
}
.selectors>* {
margin: 10px 0;
}
.selectors:disabled {
opacity: 0.5;
}
.no-display {
display: none !important;
}
/* === Gestion de la timeline === */
#tl_date {
visibility: hidden;
width: 0px;
height: 0px;
position: absolute;
left: 15%;
}
.infos {
position: relative;
width: fit-content;
}
#datestr {
cursor: pointer;
background-color: white;
border: 1px #444 solid;
border-radius: 5px;
padding: 5px;
}
#tl_slider {
width: 90%;
cursor: grab;
/* visibility: hidden; */
}
#datestr,
#time {
width: fit-content;
}
.ui-slider-handle.tl_handle {
background: none;
width: 25px;
height: 25px;
visibility: visible;
background-position: top;
background-size: cover;
border: none;
top: -180%;
cursor: grab;
}
#l_handle {
background-image: url(../icons/l_handle.svg);
}
#r_handle {
background-image: url(../icons/r_handle.svg);
}
.ui-slider-range.ui-widget-header.ui-corner-all {
background-color: #F9C768;
background-image: none;
opacity: 0.50;
visibility: visible;
}
/* === Gestion des etuds row === */
.etud_holder {
margin-top: 5vh;
}
.etud_row {
display: grid;
grid-template-columns: auto auto 1fr auto;
gap: 16px;
background-color: white;
border-radius: 15px;
padding: 4px 16px;
margin: 0.5% 0;
box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
-webkit-box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
-moz-box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
max-width: 800px;
}
.etud_row * {
display: flex;
justify-content: center;
align-items: center;
height: 50px;
}
/* --- Index --- */
.etud_row .index_field {
grid-column: 1;
}
/* --- Nom étud --- */
.etud_row .name_field {
grid-column: 2;
height: 100%;
}
.etud_row .name_field .name_set {
flex-direction: column;
align-items: flex-start;
margin: 0 5%;
}
.etud_row .name_field .name_set * {
padding: 0;
margin: 0;
}
.etud_row .name_field .name_set h4 {
font-size: small;
font-weight: 600;
}
.etud_row .name_field .name_set h5 {
font-size: x-small;
}
.etud_row .pdp {
border-radius: 15px;
}
/* --- Barre assiduités --- */
.etud_row .assiduites_bar {
display: grid;
grid-template-columns: 7px 1fr;
gap: 13px;
grid-column: 3;
position: relative;
}
.etud_row .assiduites_bar .filler {
height: 5px;
width: 90%;
background-color: white;
border: 1px solid #444;
}
.etud_row .assiduites_bar #prevDateAssi {
height: 7px;
width: 7px;
background-color: white;
border: 1px solid #444;
margin: 0px 8px;
}
.etud_row .assiduites_bar #prevDateAssi.single {
height: 9px;
width: 9px;
}
.etud_row.conflit {
background-color: #ff000061;
}
.etud_row .assiduites_bar .absent {
background-color: #F1A69C !important;
}
.etud_row .assiduites_bar .present {
background-color: #9CF1AF !important;
}
.etud_row .assiduites_bar .retard {
background-color: #F1D99C !important;
}
.etud_row .assiduites_bar .justified {
background-image: repeating-linear-gradient(135deg, transparent, transparent 4px, #7059FF 4px, #7059FF 8px);
}
.etud_row .assiduites_bar .invalid_justified {
background-image: repeating-linear-gradient(135deg, transparent, transparent 4px, #d61616 4px, #d61616 8px);
}
/* --- Boutons assiduités --- */
.etud_row .btns_field {
grid-column: 4;
}
.btns_field:disabled {
opacity: 0.7;
}
.etud_row .btns_field * {
margin: 0 5%;
cursor: pointer;
width: 35px;
height: 35px;
}
.rbtn {
-webkit-appearance: none;
appearance: none;
}
.rbtn::before {
content: "";
display: inline-block;
width: 35px;
height: 35px;
background-position: center;
background-size: cover;
}
.rbtn.present::before {
background-image: url(../icons/present.svg);
}
.rbtn.absent::before {
background-image: url(../icons/absent.svg);
}
.rbtn.retard::before {
background-image: url(../icons/retard.svg);
}
.rbtn:checked:before {
outline: 3px solid #7059FF;
border-radius: 5px;
}
.rbtn:focus {
outline: none !important;
}
/*<== Modal conflit ==>*/
.modal {
display: none;
position: fixed;
z-index: 500;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.4);
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
height: 30%;
position: relative;
border-radius: 10px;
}
.close {
color: #111;
position: absolute;
right: 5px;
top: 0px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
/* Ajout de styles pour la frise chronologique */
.modal-timeline {
display: flex;
flex-direction: column;
align-items: stretch;
margin-bottom: 20px;
}
.time-labels,
.assiduites-container {
display: flex;
justify-content: space-between;
position: relative;
}
.time-label {
font-size: 14px;
margin-bottom: 4px;
}
.assiduite {
position: absolute;
top: 20px;
cursor: pointer;
border-radius: 4px;
z-index: 10;
height: 100px;
padding: 4px;
}
.assiduite-info {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.assiduite-id,
.assiduite-period,
.assiduite-state,
.assiduite-user_id {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.assiduites-container {
min-height: 20px;
height: calc(50% - 60px);
/* Augmentation de la hauteur du conteneur des assiduités */
position: relative;
margin-bottom: 10px;
}
.action-buttons {
position: absolute;
text-align: center;
display: flex;
justify-content: space-evenly;
align-items: center;
height: 60px;
width: 100%;
bottom: 5%;
}
/* Ajout de la classe CSS pour la bordure en pointillés */
.assiduite.selected {
border: 2px dashed black;
}
.assiduite-special {
height: 120px;
position: absolute;
z-index: 5;
border: 2px solid #000;
background-color: rgba(36, 36, 36, 0.25);
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, rgba(81, 81, 81, 0.61) 5px, rgba(81, 81, 81, 0.61) 10px);
border-radius: 5px;
}
/*<== Info sur l'assiduité sélectionnée ==>*/
.modal-assiduite-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: max-content;
height: 30%;
position: relative;
border-radius: 10px;
display: none;
}
.modal-assiduite-content.show {
display: block;
}
.modal-assiduite-content .infos {
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: flex-start;
}
/*<=== Mass Action ==>*/
.mass-selection {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
margin: 2% 0;
}
.mass-selection span {
margin: 0 1%;
}
.mass-selection .rbtn {
background-color: transparent;
cursor: pointer;
}
/*<== Loader ==> */
.loader-container {
display: none;
/* Cacher le loader par défaut */
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
/* Fond semi-transparent pour bloquer les clics */
z-index: 9999;
/* Placer le loader au-dessus de tout le contenu */
}
.loader {
border: 6px solid #f3f3f3;
border-radius: 50%;
border-top: 6px solid #3498db;
width: 60px;
height: 60px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
.fieldsplit {
display: flex;
justify-content: flex-start;
align-items: center;
flex-direction: column;
}
.fieldsplit legend {
margin: 0;
}
#page-assiduite-content {
display: flex;
flex-wrap: wrap;
gap: 5%;
flex-direction: column;
}
#page-assiduite-content>* {
margin: 1.5% 0;
}
.rouge {
color: crimson;
}

View File

@ -0,0 +1,156 @@
div.les_parcours {
display: flex;
margin-left: 16px;
margin-bottom: 16px;
}
div.les_parcours>div {
font-size: 130%;
margin-top: 12px;
margin-left: 8px;
background-color: #09c;
opacity: 0.7;
border-radius: 4px;
text-align: center;
padding: 8px 16px;
}
div.les_parcours>div.focus {
opacity: 1;
}
div.les_parcours>div.link {
background-color: var(--sco-color-background);
color: navy;
}
div.les_parcours>div.parc>a:hover {
color: #ccc;
}
div.les_parcours>div.parc>a,
div.les_parcours>div.parc>a:visited {
color: white;
}
.parcour_formation {
margin-left: 16px;
margin-right: 16px;
margin-bottom: 16px;
min-width: 1200px;
max-width: 1600px;
}
.titre_parcours {
font-weight: bold;
font-size: 120%;
}
div.competence {
/* display: grid; */
margin-top: 12px;
}
.titre_competence {
/* grid-column-start: 1;
grid-column-end: span -1;
grid-row-start: 1;
grid-row-start: 2; */
border-bottom: 6px solid white;
font-weight: bold;
font-size: 110%;
text-align: center;
}
.niveaux {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
--arrow-width: 24px;
}
/* Flèches vers la droite */
.niveaux>div {
padding: 8px 16px;
position: relative;
}
.niveaux>div:not(:first-child) {
padding-left: calc(var(--arrow-width) + 8px);
}
.niveaux>div:not(:last-child)::after {
content: "";
position: absolute;
top: 0;
left: calc(100% - 1px);
bottom: 0;
width: var(--arrow-width);
background: var(--color);
clip-path: polygon(0 0, 100% 50%, 0 100%);
z-index: 1;
}
.niveau {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: auto auto;
}
.niveau>div {
padding-left: 8px;
padding-right: 8px;
}
.titre_niveau {
grid-column: 1 / span 2;
grid-row: 1 / 2;
padding-bottom: 6px;
}
span.parcs {
margin-left: 12px;
display: inline-block;
}
span.parc {
font-size: 75%;
font-weight: bold;
/* color: rgb(92, 87, 255); */
color: white;
margin-right: 8px;
padding: 4px;
background-color: #09c;
border-radius: 4px;
text-align: center;
}
div.ue {
grid-row-start: 2;
/* border: 1px dashed blue; */
}
div.ue.impair {
grid-column: 1 / 2;
}
div.ue.pair {
grid-column: 2 / 3;
}
.ue select {
color: black;
}
/* ne fonctionne pas
option.non_associe {
background-color: yellow;
color: red;
} */
.links {
margin-top: 16px;
margin-bottom: 8px;
}

View File

@ -1,17 +1,23 @@
:host {
font-family: Verdana;
background: rgb(14, 5, 73);
display: block;
padding: 12px 32px;
padding: 6px 32px;
color: #FFF;
max-width: 1000px;
margin: auto;
margin-left: 12px;
margin-top: 12px;
border-radius: 8px;
}
h1 {
font-weight: 100;
}
div.titre {
color: black;
margin-bottom: 8px;
}
/**********************/
/* Zone parcours */
/**********************/
@ -60,27 +66,29 @@ h1 {
}
.comp1 {
background: #a44
background: var(--col-c1-3);
}
.comp2 {
background: #84a
background: var(--col-c2-3);
}
.comp3 {
background: #a84
background: var(--col-c3-3);
}
.comp4 {
background: #8a4
background: var(--col-c4-3);
}
.comp5 {
background: #4a8
background: var(--col-c5-3);
color: #eee;
}
.comp6 {
background: #48a
background: var(--col-c6-3);
color: #eee;
}
.competences>.focus {

View File

@ -0,0 +1,180 @@
:root {
--col-c1-1: rgb(224, 201, 201);
--col-c1-2: rgb(231, 127, 130);
--col-c1-3: rgb(167, 0, 9);
--col-c2-1: rgb(240, 218, 198);
--col-c2-2: rgb(231, 142, 95);
--col-c2-3: rgb(231, 119, 64);
--col-c3-1: rgb(241, 227, 167);
--col-c3-2: rgb(238, 208, 86);
--col-c3-3: rgb(233, 174, 17);
--col-c4-1: rgb(218, 225, 205);
--col-c4-2: rgb(159, 207, 111);
--col-c4-3: rgb(124, 192, 64);
--col-c5-1: rgb(191, 206, 230);
--col-c5-2: rgb(119, 156, 208);
--col-c5-3: rgb(10, 22, 75);
--col-c6-1: rgb(203, 199, 176);
--col-c6-2: rgb(152, 143, 97);
--col-c6-3: rgb(13, 13, 13);
}
div.refcomp_show {
width: fit-content;
}
div.refcomp_show>div {
background: rgb(210, 210, 210);
border-radius: 8px;
margin-left: 12px;
}
div.table_niveaux_parcours {
margin-top: 12px;
color: #111;
border-radius: 8px;
padding: 8px;
}
div.liens {
margin-top: 3ex;
padding: 8px;
}
div.table_niveaux_parcours .titre {
font-weight: bold;
font-size: 110%;
margin-bottom: 12px;
}
table.table_niveaux_parcours tr th:first-child {
opacity: 0;
}
table.table_niveaux_parcours th {
text-align: center;
color: white;
padding-left: 4px;
padding-right: 4px;
}
table.table_niveaux_parcours tr.parcours_but {
color: #111;
}
table.table_niveaux_parcours tr.parcours_but td {
padding-top: 8px;
}
table.table_niveaux_parcours tr.parcours_but td b {
margin-right: 12px;
}
table.table_niveaux_parcours tr.annee_but {
text-align: center;
}
table.table_niveaux_parcours tr td:not(:first-child) {
width: 120px;
border: 1px solid #999;
}
table.table_niveaux_parcours tr.annee_but td:first-child {
width: 92px;
font-weight: bold;
color: #111;
}
table.table_niveaux_parcours tr.annee_but td.empty {
opacity: 0;
}
/* Les couleurs des niveaux de compétences du BO */
.comp-c1-1 {
background: var(--col-c1-1);
color: black;
}
.comp-c1-2 {
background: var(--col-c1-2);
color: black;
}
.comp-c1-3,
.comp-c1 {
background: var(--col-c1-3);
color: #eee;
}
.comp-c2-1 {
background: var(--col-c2-1);
}
.comp-c2-2 {
background: var(--col-c2-2);
}
.comp-c2-3,
.comp-c2 {
background: var(--col-c2-3);
}
.comp-c3-1 {
background: var(--col-c3-1);
}
.comp-c3-2 {
background: var(--col-c3-2);
}
.comp-c3-3,
.comp-c3 {
background: var(--col-c3-3);
}
.comp-c4-1 {
background: var(--col-c4-1);
}
.comp-c4-2 {
background: var(--col-c4-2);
}
.comp-c4-3,
.comp-c4 {
background: var(--col-c4-3);
}
.comp-c5-1 {
background: var(--col-c5-1);
color: black;
}
.comp-c5-2 {
background: var(--col-c5-2);
color: black;
}
.comp-c5-3,
.comp-c5 {
background: var(--col-c5-3);
color: #eee;
}
.comp-c6-1 {
background: var(--col-c6-1);
color: black;
}
.comp-c6-2 {
background: var(--col-c6-2);
color: black;
}
.comp-c6-3,
.comp-c6 {
background: var(--col-c6-3);
color: #eee;
}

View File

@ -5,6 +5,7 @@
--sco-content-min-width: 600px;
--sco-content-max-width: 1024px;
--sco-color-explication: rgb(10, 58, 140);
--sco-color-background: rgb(242, 242, 238);
}
html,
@ -12,7 +13,7 @@ body {
margin: 0;
padding: 0;
width: 100%;
background-color: rgb(242, 242, 238);
background-color: var(--sco-color-background);
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 12pt;
}
@ -63,6 +64,37 @@ div#gtrcontent {
display: None;
}
div.flashes {
transition: opacity 0.5s ease;
margin-top: 8px;
left: 50%;
position: fixed;
top: 8px;
transform: translateX(-50%);
width: auto;
z-index: 1000;
}
div.alert {
/*
position: absolute;
top: 10px;
right: 10px; */
}
div.alert-info {
color: #0019d7;
background-color: #68f36d;
border-color: #0a8d0c;
}
div.alert-error {
color: #ef0020;
background-color: #ffff00;
border-color: #8d0a17;
}
div.tab-content {
margin-top: 10px;
margin-left: 15px;
@ -191,7 +223,7 @@ div.head_message {
color: green;
}
.message_curtom {
.message_custom {
position: fixed;
bottom: 100%;
left: 50%;
@ -205,6 +237,18 @@ div.head_message {
transform: translate(-50%, 0);
}
div.message_error {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
padding: 20px;
border-radius: 10px 10px 10px 10px;
background: rgb(212, 0, 0);
color: #ffffff;
font-size: 24px;
}
div.passwd_warn {
font-weight: bold;
@ -231,9 +275,6 @@ p.footer {
border-top: 1px solid rgb(60, 60, 60);
}
div.part2 {
margin-top: 3ex;
}
/* ---- (left) SIDEBAR ----- */
@ -2017,6 +2058,7 @@ span.eval_coef_ue_titre {}
div.list_but_ue_inscriptions {
margin-top: 16px;
margin-bottom: 16px;
margin-right: 8px;
padding-left: 8px;
padding-bottom: 8px;
border-radius: 16px;
@ -2066,6 +2108,17 @@ form.list_but_ue_inscriptions td {
text-align: center;
}
table#but_ue_inscriptions {
margin-left: 16px;
width: auto;
}
div#but_ue_inscriptions_filter {
margin-left: 16px;
margin-bottom: 8px;
}
/* Formulaire edition des partitions */
form#editpart table {
border: 1px solid gray;
@ -2179,16 +2232,23 @@ span.explication {
div.formation_ue_list {
border: 1px solid black;
background-color: rgb(232, 249, 255);
margin-top: 5px;
margin-right: 12px;
padding-left: 5px;
}
div.formation_list_ues_titre {
padding-top: 6px;
padding-bottom: 6px;
padding-left: 24px;
padding-right: 24px;
font-size: 120%;
font-weight: bold;
border-top-right-radius: 18px;
border-top-left-radius: 18px;
background-color: #0051a9;
color: #eee;
}
div.formation_list_modules,
@ -2205,6 +2265,8 @@ div.formation_list_ues {
margin-top: 20px
}
div.formation_list_ues_content {}
div.formation_list_modules {
margin-top: 20px;
}
@ -2266,6 +2328,41 @@ span.notes_module_list_buts {
margin-bottom: 6px;
}
div.formation_parcs {
display: inline-flex;
margin-left: 8px;
margin-right: 8px;
column-gap: 8px;
}
div.formation_parcs>div {
font-size: 100%;
color: white;
background-color: #09c;
opacity: 0.7;
border-radius: 4px;
text-align: center;
padding: 4px 8px;
}
div.formation_parcs>div.focus {
opacity: 1;
}
div.formation_parcs>div>a:hover {
color: #ccc;
}
div.formation_parcs>div>a,
div.formation_parcs>div>a:visited {
color: white;
}
div.ue_choix_niveau>div.formation_parcs>div {
font-size: 80%;
}
div.ue_list_tit {
font-weight: bold;
margin-top: 8px;
@ -2476,6 +2573,19 @@ div.cont_ue_choix_niveau select.select_niveau_ue {
width: 490px;
}
div.ue_advanced {
background-color: rgb(244, 253, 255);
border: 1px solid blue;
border-radius: 10px;
padding: 10px;
margin-top: 10px;
margin-right: 15px;
}
div.ue_advanced h3 {
margin-top: 2px;
}
div#ue_list_modules {
background-color: rgb(251, 225, 165);
border: 1px solid blue;
@ -2661,6 +2771,30 @@ table.notes_recapcomplet a:hover {
text-decoration: underline;
}
div.table_recap_caption {
width: fit-content;
padding: 8px;
border-radius: 8px;
background-color: rgb(202, 255, 180);
}
div.table_recap_caption div.title {
font-weight: bold;
}
div.table_recap_caption div.captions {
display: grid;
grid-template-columns: 48px 200px;
}
div.table_recap_caption div.captions div:nth-child(odd) {
text-align: center;
}
div.table_recap_caption div.captions div:nth-child(even) {
font-style: italic;
}
/* bulletin */
div.notes_bulletin {
margin-right: 5px;

11
app/static/icons/absent.svg Executable file
View File

@ -0,0 +1,11 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#F1A69C"/>
<g opacity="0.5" clip-path="url(#clip0_120_4425)">
<path d="M67.2116 70L43 45.707L18.7885 70L15.0809 66.3043L39.305 41.9995L15.0809 17.6939L18.7885 14L43 38.2922L67.2116 14L70.9191 17.6939L46.695 41.9995L70.9191 66.3043L67.2116 70Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_120_4425">
<rect width="56" height="56" fill="white" transform="matrix(1 0 0 -1 15 70)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 547 B

13
app/static/icons/present.svg Executable file
View File

@ -0,0 +1,13 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#9CF1AF"/>
<g clip-path="url(#clip0_120_4405)">
<g opacity="0.5">
<path d="M70.7713 27.5875L36.0497 62.3091C35.7438 62.6149 35.2487 62.6149 34.9435 62.3091L15.2286 42.5935C14.9235 42.2891 14.9235 41.7939 15.2286 41.488L20.0191 36.6976C20.3249 36.3924 20.8201 36.3924 21.1252 36.6976L35.4973 51.069L64.8754 21.6909C65.1819 21.3858 65.6757 21.3858 65.9815 21.6909L70.7713 26.4814C71.0771 26.7865 71.0771 27.281 70.7713 27.5875Z" fill="black"/>
</g>
</g>
<defs>
<clipPath id="clip0_120_4405">
<rect width="56" height="56" fill="white" transform="translate(15 14)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 729 B

12
app/static/icons/retard.svg Executable file
View File

@ -0,0 +1,12 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#F1D99C"/>
<g opacity="0.5" clip-path="url(#clip0_120_4407)">
<path d="M55.2901 49.1836L44.1475 41.3918V28C44.1475 27.3688 43.6311 26.8524 43 26.8524C42.3688 26.8524 41.8524 27.3688 41.8524 28V42C41.8524 42.3787 42.036 42.7229 42.3459 42.941L53.9819 51.077C54.177 51.2147 54.4065 51.2836 54.636 51.2836C54.9918 51.2836 55.3475 51.1115 55.577 50.7787C55.9327 50.2623 55.8065 49.5508 55.2901 49.1836Z" fill="black"/>
<path d="M62.7836 22.2164C57.482 16.9148 50.459 14 43 14C35.541 14 28.518 16.9148 23.2164 22.2164C17.9148 27.518 15 34.541 15 42C15 49.459 17.9148 56.482 23.2164 61.7836C28.518 67.0852 35.541 70 43 70C50.459 70 57.482 67.0852 62.7836 61.7836C68.0852 56.482 71 49.459 71 42C71 34.541 68.0852 27.518 62.7836 22.2164ZM44.1475 67.682V63C44.1475 62.3689 43.6311 61.8525 43 61.8525C42.3689 61.8525 41.8525 62.3689 41.8525 63V67.682C28.5869 67.0967 17.9033 56.4131 17.318 43.1475H22C22.6311 43.1475 23.1475 42.6311 23.1475 42C23.1475 41.3689 22.6311 40.8525 22 40.8525H17.318C17.9033 27.5869 28.5869 16.9033 41.8525 16.318V21C41.8525 21.6311 42.3689 22.1475 43 22.1475C43.6311 22.1475 44.1475 21.6311 44.1475 21V16.318C57.4131 16.9033 68.0967 27.5869 68.682 40.8525H64C63.3689 40.8525 62.8525 41.3689 62.8525 42C62.8525 42.6311 63.3689 43.1475 64 43.1475H68.682C68.0967 56.4131 57.4131 67.0967 44.1475 67.682Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_120_4407">
<rect width="56" height="56" fill="white" transform="translate(15 14)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

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