WIP: PR assiduites -> bac à sable prod #649
|
@ -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()
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'hygi\u00e8ne informatique et \u00e0 la cybers\u00e9curit\u00e9",
|
||||
"titre": "Se sensibiliser \u00e0 l'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}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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 :</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 :</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>""")
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
où 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>
|
||||
"""
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
# empty but required for pylint
|
|
@ -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()
|
|
@ -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(
|
||||
|
|
|
@ -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})
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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}n°{self.code_nip or ""}{line_sep}né{self.e} le {
|
||||
return f"""{self.etat_civil}{line_sep}n°{self.code_nip or ""}{line_sep}né{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):
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
"""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
|
@ -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
|
||||
|
|
|
@ -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("")
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
"""
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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> """
|
||||
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 :
|
||||
<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"]:
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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} pas de parcours
|
||||
</span>"""
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
"""
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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
Loading…
Reference in New Issue