Compare commits

..

69 Commits

Author SHA1 Message Date
iziram
12583814cb Assiduites : Mise à jour suivi master (flask_json) 2023-05-17 21:16:23 +02:00
174baf326e Fix: bonus sport si aucune UE 2023-05-17 20:54:10 +02:00
b1a45b34f5 API: groups_auto_assignment 2023-05-17 20:54:10 +02:00
a6bc24aba2 Edition données admission 2023-05-17 20:54:10 +02:00
f645c878a8 adaptation bulletins classiques pour etat-civil 2023-05-17 20:54:10 +02:00
007f915d52 Modification création/édition étudiants 2023-05-17 20:54:10 +02:00
7c25098387 orthographe 2023-05-17 20:54:10 +02:00
af94a2a727 Fix: cas BUT semestre sans parcours mais UE avec parcours. 2023-05-17 20:54:10 +02:00
730f2e9cb7 Added missing Debian dependency: graphviz-dev 2023-05-17 20:53:57 +02:00
e355c2f780 Fix test unitaire jury BUT (bug dans le setup test) 2023-05-17 20:53:57 +02:00
da827e50ad Test BUT Info: ajout édtudiants 2023-05-17 20:53:57 +02:00
a9303f6274 test unitaire BUT Info: complète formation et config yaml 2023-05-17 20:53:57 +02:00
e0eb57300e petites amélioration formations BUT 2023-05-17 20:53:57 +02:00
aae94b4f10 Corrige édition coefs BUT 2023-05-17 20:53:57 +02:00
154e0e5cab Améliore import/export formations BUT (ects par parcours) 2023-05-17 20:53:57 +02:00
31856c857c Fix petits bugs parcours BUT, cosmetic 2023-05-17 20:53:57 +02:00
b93e76f99c Export Apo BUT: liste NAR 2023-05-17 20:53:57 +02:00
9ba44f5285 Préférences: section spéarée pour exports Apogée. Option pour supprimer la section APO_TYP_RES. 2023-05-17 20:53:57 +02:00
53118e2446 formsemestre_description: pas de ligne UE en APC 2023-05-17 20:53:57 +02:00
0f78a1288d Fix formsemestre_description #632 2023-05-17 20:53:57 +02:00
72dfcc086d Fix #631: cas_id numérique 2023-05-17 20:53:57 +02:00
598e037e38 Bareme notes moy/min/max promo. Fix #633 2023-05-17 20:53:57 +02:00
6d71b116b5 Fix JS access to css 2023-05-17 20:53:57 +02:00
ce69df4e08 change link to ref-competences.css 2023-05-17 20:53:57 +02:00
1f7d13c6cf change link to ref-competences.css 2023-05-17 20:53:57 +02:00
b349ff3d79 change link to ref-competences.css 2023-05-17 20:53:57 +02:00
466daad0c0 raccorde migration 2023-05-17 20:53:57 +02:00
a1e6465224 Etat civil: modifie contraintes dans script migration 2023-05-17 20:53:57 +02:00
a94686957c Ajout état-civil 2023-05-17 20:53:57 +02:00
e679f23065 Fichiers Apogée: code refactoring + test unitaire 2023-05-17 20:53:57 +02:00
1a2a15d7f6 Apo: export BUT element annuel 2023-05-17 20:53:57 +02:00
6ae31c3e9f Apo: modif semset pour BUT. Interdit changement période. 2023-05-17 20:53:57 +02:00
36f75ab0c4 Apo: améliore changement période 2023-05-17 20:53:57 +02:00
e36a83a48a Fix export Apo BUT/Simpair isolé 2023-05-17 20:53:57 +02:00
68e37a2ccd APC / Niveaux / templates: ameliroations mineures 2023-05-17 20:53:44 +02:00
c731e194ef scu dans templates 2023-05-17 20:53:20 +02:00
363e7e2952 cosmetic: remove flashed message after 5 secs 2023-05-17 20:53:20 +02:00
332e1a306a test BUT Info: ajout des UEs de BUT3 2023-05-17 20:53:20 +02:00
559f1882d1 Améliore présentation ref. comp. 2023-05-17 20:53:20 +02:00
6357dd999d Association parcours/UE: amélioration formulaire. Messages erreurs. Logique association UE/niveaux. test unitaire partiel. WIP. 2023-05-17 20:53:20 +02:00
04b7ff7658 mini-script d'essai de l'API 2023-05-17 20:53:20 +02:00
e3da4d51a5 cosmetic 2023-05-17 20:53:20 +02:00
3bfeebbcb2 Ajout explications sur édition partitions + un test unitaire 2023-05-17 20:53:20 +02:00
ffca42917d Modifie etud_parcours_ues_ids: si l'étudiant pas inscrit à un parcours, prend toutes les UEs 2023-05-17 20:53:20 +02:00
4cd3b71cfc Fix API partitions pour encodeur flask-json 2023-05-17 20:53:20 +02:00
a390cffe57 typo 2023-05-17 20:53:20 +02:00
38714e5d2a Associations UE / Parcours: UI 2023-05-17 20:53:20 +02:00
a533c40267 BUT: ECTS par UE dépendant du parcours. 2023-05-17 20:53:03 +02:00
657b1e1f1e Affichage/édition des programmes BUT/Niveaux de compétences. Tests. -- WIP 2023-05-17 20:53:03 +02:00
8bd5d83af0 Visualisation d'un parcours et ses UEs (WIP) 2023-05-17 20:52:28 +02:00
36a0784897 Génère JSON avec Flask-JSON. Abandonne jsonify. 2023-05-17 20:52:06 +02:00
0948734ff2 Formation BUT Info pour tests 2023-05-17 20:52:06 +02:00
3c16f44ed3 Fix form validation 2023-05-17 20:52:06 +02:00
16149ea6bc Change JSONEncoder: use Python's json module instead of Flask 2023-05-17 20:52:06 +02:00
59d10a01f7 Minor fix. Tests unitaires OK. 2023-05-17 20:52:06 +02:00
36abc54f7f DOWNGRADE to SQLAlchemy 1.4.47 2023-05-17 20:52:06 +02:00
94c9035c4e WIP: associations UEs / Competences, ref. comp., tests, refactoring. 2023-05-17 20:52:06 +02:00
c23738e5dc WIP: ajustements pour upgrade SQLAlchemy 2023-05-17 20:50:04 +02:00
bfa6973d4e WIP: migrating to SQlAlchemy 2.0.8 2023-05-17 20:50:04 +02:00
66a565d64a Upgrade all pip python libs 2023-05-17 20:50:04 +02:00
7361f2fa1a Précision sur adresse mail origine 2023-05-17 20:50:04 +02:00
e6595bdf30 BUT: améliore page éditon UEs 2023-05-17 20:48:51 +02:00
4f9638582a BUT: Calcul des UEs à valider par parcour. WIP: tets unitaire écrit mais ne passe pas (manque assoc UE à plusieurs parcours) 2023-05-17 20:48:51 +02:00
cf778eba85 Améliore présentation table niveaux parcours 2023-05-17 20:48:51 +02:00
63603463f1 Visualisation ref. comp.: ajoute table avec niveaux par parcours et année 2023-05-17 20:48:51 +02:00
39466bf9b5 Dispenses d'UE: corrige affichage en table recap. Intègre aux tests unitaires cursus. Légendes. 2023-05-17 20:48:51 +02:00
3ba5318b17 Améliore page inscriptions UEs BUT: indique parcours. 2023-05-17 20:48:51 +02:00
87aab32d2e Ajout test cursus GCCD (à compléter) 2023-05-17 20:48:51 +02:00
f40fae3463 Optim: retire années de parcours de ue.to_dict() 2023-05-17 20:48:51 +02:00
154 changed files with 7513 additions and 2074 deletions

View File

@ -3,6 +3,7 @@
import base64 import base64
import datetime import datetime
import json
import os import os
import socket import socket
import sys import sys
@ -12,16 +13,20 @@ import traceback
import logging import logging
from logging.handlers import SMTPHandler, WatchedFileHandler from logging.handlers import SMTPHandler, WatchedFileHandler
from threading import Thread from threading import Thread
import warnings
import warnings
from flask import current_app, g, request from flask import current_app, g, request
from flask import Flask 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 import render_template
from flask.json import JSONEncoder
# from flask.json import JSONEncoder
from flask.logging import default_handler from flask.logging import default_handler
from flask_bootstrap import Bootstrap from flask_bootstrap import Bootstrap
from flask_caching import Cache from flask_caching import Cache
from flask_json import FlaskJSON, json_response
from flask_login import LoginManager, current_user from flask_login import LoginManager, current_user
from flask_mail import Mail from flask_mail import Mail
from flask_migrate import Migrate from flask_migrate import Migrate
@ -29,9 +34,10 @@ from flask_moment import Moment
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from jinja2 import select_autoescape from jinja2 import select_autoescape
import sqlalchemy import sqlalchemy as sa
from flask_cas import CAS from flask_cas import CAS
import werkzeug.debug
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
AccessDenied, AccessDenied,
@ -42,6 +48,8 @@ from app.scodoc.sco_exceptions import (
ScoValueError, ScoValueError,
APIInvalidParams, APIInvalidParams,
) )
from app.scodoc.sco_vdi import ApoEtapeVDI
from config import DevConfig from config import DevConfig
import sco_version import sco_version
@ -134,18 +142,22 @@ def _async_dump(app, request_url: str):
def handle_invalid_usage(error): def handle_invalid_usage(error):
response = jsonify(error.to_dict()) response = json_response(data_=error.to_dict())
response.status_code = error.status_code response.status_code = error.status_code
return response return response
# JSON ENCODING # JSON ENCODING
class ScoDocJSONEncoder(JSONEncoder): # used by some internal finctions
def default(self, o): # the API is now using flask_son, NOT THIS ENCODER
if isinstance(o, (datetime.datetime, datetime.date)): class ScoDocJSONEncoder(json.JSONEncoder):
def default(self, o): # pylint: disable=E0202
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat() return o.isoformat()
elif isinstance(o, ApoEtapeVDI):
return super().default(o) return str(o)
else:
return json.JSONEncoder.default(self, o)
def render_raw_html(template_filename: str, **args) -> str: def render_raw_html(template_filename: str, **args) -> str:
@ -244,17 +256,33 @@ class ReverseProxied(object):
def create_app(config_class=DevConfig): def create_app(config_class=DevConfig):
app = Flask(__name__, static_url_path="/ScoDoc/static", static_folder="static") app = Flask(__name__, static_url_path="/ScoDoc/static", static_folder="static")
app.config.from_object(config_class)
from app.auth import cas from app.auth import cas
CAS(app, url_prefix="/cas", configuration_function=cas.set_cas_configuration) CAS(app, url_prefix="/cas", configuration_function=cas.set_cas_configuration)
app.wsgi_app = ReverseProxied(app.wsgi_app) 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 # Evite de logguer toutes les requetes dans notre log
logging.getLogger("werkzeug").disabled = True logging.getLogger("werkzeug").disabled = True
app.logger.setLevel(app.config["LOG_LEVEL"]) 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 # Vérifie/crée lien sym pour les URL statiques
link_filename = f"{app.root_path}/static/links/{sco_version.SCOVERSION}" link_filename = f"{app.root_path}/static/links/{sco_version.SCOVERSION}"
if not os.path.exists(link_filename): if not os.path.exists(link_filename):
@ -409,7 +437,7 @@ def create_app(config_class=DevConfig):
with app.app_context(): with app.app_context():
try: try:
set_cas_configuration(app) 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) # 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. # il se peut que la table scodoc_site_config n'existe pas encore.
pass pass
@ -421,7 +449,7 @@ def set_sco_dept(scodoc_dept: str, open_cnx=True):
# Check that dept exists # Check that dept exists
try: try:
dept = Departement.query.filter_by(acronym=scodoc_dept).first() dept = Departement.query.filter_by(acronym=scodoc_dept).first()
except sqlalchemy.exc.OperationalError: except sa.exc.OperationalError:
abort(503) abort(503)
if not dept: if not dept:
raise ScoValueError(f"Invalid dept: {scodoc_dept}") raise ScoValueError(f"Invalid dept: {scodoc_dept}")
@ -499,14 +527,15 @@ def truncate_database():
""" """
# use a stored SQL function, see createtables.sql # use a stored SQL function, see createtables.sql
try: try:
db.session.execute("SELECT truncate_tables('scodoc');") db.session.execute(sa.text("SELECT truncate_tables('scodoc');"))
db.session.commit() db.session.commit()
except: except:
db.session.rollback() db.session.rollback()
raise raise
# Remet les compteurs (séquences sql) à zéro # Remet les compteurs (séquences sql) à zéro
db.session.execute( db.session.execute(
""" sa.text(
"""
CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$ CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$
DECLARE DECLARE
statements CURSOR FOR statements CURSOR FOR
@ -522,6 +551,7 @@ def truncate_database():
SELECT reset_sequences('scodoc'); SELECT reset_sequences('scodoc');
""" """
)
) )
db.session.commit() db.session.commit()

View File

@ -1,8 +1,8 @@
"""api.__init__ """api.__init__
""" """
from flask_json import as_json
from flask import Blueprint from flask import Blueprint
from flask import request, g, jsonify from flask import request, g
from app import db from app import db
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException from app.scodoc.sco_exceptions import ScoException
@ -35,6 +35,7 @@ def requested_format(default_format="json", allowed_formats=None):
return None return None
@as_json
def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None): def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None):
""" """
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]" Retourne une réponse contenant la représentation api de l'objet "Model[model_id]"
@ -48,7 +49,7 @@ def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model
query = query.join(join_cls).filter_by(dept_id=g.scodoc_dept_id) query = query.join(join_cls).filter_by(dept_id=g.scodoc_dept_id)
unique: model_cls = query.first_or_404() unique: model_cls = query.first_or_404()
return jsonify(unique.to_dict(format_api=True)) return unique.to_dict(format_api=True)
from app.api import tokens from app.api import tokens

View File

@ -6,7 +6,7 @@
"""ScoDoc 9 API : Absences """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.api import api_bp as bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_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_groups import get_group_members
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
# TODO XXX revoir routes web API et calcul des droits # TODO XXX revoir routes web API et calcul des droits
@bp.route("/absences/etudid/<int:etudid>", methods=["GET"]) @bp.route("/absences/etudid/<int:etudid>", methods=["GET"])
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json
def absences(etudid: int = None): def absences(etudid: int = None):
""" """
Liste des absences de cet étudiant Liste des absences de cet étudiant
@ -57,12 +59,13 @@ def absences(etudid: int = None):
abs_list = sco_abs.list_abs_date(etud.id) abs_list = sco_abs.list_abs_date(etud.id)
for absence in abs_list: for absence in abs_list:
absence["jour"] = absence["jour"].isoformat() absence["jour"] = absence["jour"].isoformat()
return jsonify(abs_list) return abs_list
@bp.route("/absences/etudid/<int:etudid>/just", methods=["GET"]) @bp.route("/absences/etudid/<int:etudid>/just", methods=["GET"])
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json
def absences_just(etudid: int = None): def absences_just(etudid: int = None):
""" """
Retourne la liste des absences justifiées d'un étudiant donné 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: for absence in abs_just:
absence["jour"] = absence["jour"].isoformat() absence["jour"] = absence["jour"].isoformat()
return jsonify(abs_just) return abs_just
@bp.route( @bp.route(
@ -116,6 +119,7 @@ def absences_just(etudid: int = None):
) )
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json
def abs_groupe_etat(group_id: int, date_debut=None, date_fin=None): 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) 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) data.append(absence)
return jsonify(data) return data
# XXX TODO EV: A REVOIR (data json dans le POST + modifier les routes) # XXX TODO EV: A REVOIR (data json dans le POST + modifier les routes)

View File

@ -6,7 +6,8 @@
"""ScoDoc 9 API : Assiduités """ScoDoc 9 API : Assiduités
""" """
from datetime import datetime from datetime import datetime
from flask import g, jsonify, request from flask_json import as_json
from flask import g, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
import app.scodoc.sco_assiduites as scass import app.scodoc.sco_assiduites as scass
@ -52,6 +53,7 @@ def assiduite(assiduite_id: int = None):
@api_web_bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True}) @api_web_bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@login_required @login_required
@scodoc @scodoc
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def count_assiduites(etudid: int = None, with_query: bool = False): def count_assiduites(etudid: int = None, with_query: bool = False):
""" """
@ -109,10 +111,8 @@ def count_assiduites(etudid: int = None, with_query: bool = False):
if with_query: if with_query:
metric, filtered = _count_manager(request) metric, filtered = _count_manager(request)
return jsonify( return scass.get_assiduites_stats(
scass.get_assiduites_stats( assiduites=etud.assiduites, metric=metric, filtered=filtered
assiduites=etud.assiduites, metric=metric, filtered=filtered
)
) )
@ -122,6 +122,7 @@ def count_assiduites(etudid: int = None, with_query: bool = False):
@api_web_bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True}) @api_web_bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@login_required @login_required
@scodoc @scodoc
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def assiduites(etudid: int = None, with_query: bool = False): def assiduites(etudid: int = None, with_query: bool = False):
""" """
@ -178,13 +179,14 @@ def assiduites(etudid: int = None, with_query: bool = False):
data = ass.to_dict(format_api=True) data = ass.to_dict(format_api=True)
data_set.append(data) data_set.append(data)
return jsonify(data_set) return data_set
@bp.route("/assiduites/group/query", defaults={"with_query": True}) @bp.route("/assiduites/group/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/group/query", defaults={"with_query": True}) @api_web_bp.route("/assiduites/group/query", defaults={"with_query": True})
@login_required @login_required
@scodoc @scodoc
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def assiduites_group(with_query: bool = False): def assiduites_group(with_query: bool = False):
""" """
@ -252,7 +254,7 @@ def assiduites_group(with_query: bool = False):
data = ass.to_dict(format_api=True) data = ass.to_dict(format_api=True)
data_set.get(data["etudid"]).append(data) data_set.get(data["etudid"]).append(data)
return jsonify(data_set) return data_set
@bp.route( @bp.route(
@ -271,6 +273,7 @@ def assiduites_group(with_query: bool = False):
) )
@login_required @login_required
@scodoc @scodoc
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False): def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne toutes les assiduités du formsemestre""" """Retourne toutes les assiduités du formsemestre"""
@ -291,7 +294,7 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
data = ass.to_dict(format_api=True) data = ass.to_dict(format_api=True)
data_set.append(data) data_set.append(data)
return jsonify(data_set) return data_set
@bp.route( @bp.route(
@ -312,6 +315,7 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
) )
@login_required @login_required
@scodoc @scodoc
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def count_assiduites_formsemestre( def count_assiduites_formsemestre(
formsemestre_id: int = None, with_query: bool = False formsemestre_id: int = None, with_query: bool = False
@ -334,12 +338,13 @@ def count_assiduites_formsemestre(
if with_query: if with_query:
metric, filtered = _count_manager(request) metric, filtered = _count_manager(request)
return jsonify(scass.get_assiduites_stats(assiduites_query, metric, filtered)) return scass.get_assiduites_stats(assiduites_query, metric, filtered)
@bp.route("/assiduite/<int:etudid>/create", methods=["POST"]) @bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/<int:etudid>/create", methods=["POST"]) @api_web_bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@scodoc @scodoc
@as_json
@login_required @login_required
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
@ -382,12 +387,13 @@ def assiduite_create(etudid: int = None):
db.session.commit() db.session.commit()
return jsonify({"errors": errors, "success": success}) return {"errors": errors, "success": success}
@bp.route("/assiduites/create", methods=["POST"]) @bp.route("/assiduites/create", methods=["POST"])
@api_web_bp.route("/assiduites/create", methods=["POST"]) @api_web_bp.route("/assiduites/create", methods=["POST"])
@scodoc @scodoc
@as_json
@login_required @login_required
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
@ -435,7 +441,7 @@ def assiduites_create():
else: else:
success[i] = obj success[i] = obj
return jsonify({"errors": errors, "success": success}) return {"errors": errors, "success": success}
def _create_singular( def _create_singular(
@ -515,6 +521,7 @@ def _create_singular(
@api_web_bp.route("/assiduite/delete", methods=["POST"]) @api_web_bp.route("/assiduite/delete", methods=["POST"])
@login_required @login_required
@scodoc @scodoc
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
def assiduite_delete(): def assiduite_delete():
@ -543,7 +550,7 @@ def assiduite_delete():
else: else:
output["success"][f"{i}"] = {"OK": True} output["success"][f"{i}"] = {"OK": True}
db.session.commit() db.session.commit()
return jsonify(output) return output
def _delete_singular(assiduite_id: int, database): def _delete_singular(assiduite_id: int, database):
@ -558,6 +565,7 @@ def _delete_singular(assiduite_id: int, database):
@api_web_bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"]) @api_web_bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
@login_required @login_required
@scodoc @scodoc
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
def assiduite_edit(assiduite_id: int): def assiduite_edit(assiduite_id: int):
@ -625,13 +633,14 @@ def assiduite_edit(assiduite_id: int):
db.session.add(assiduite_unique) db.session.add(assiduite_unique)
db.session.commit() db.session.commit()
return jsonify({"OK": True}) return {"OK": True}
@bp.route("/assiduites/edit", methods=["POST"]) @bp.route("/assiduites/edit", methods=["POST"])
@api_web_bp.route("/assiduites/edit", methods=["POST"]) @api_web_bp.route("/assiduites/edit", methods=["POST"])
@login_required @login_required
@scodoc @scodoc
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
def assiduites_edit(): def assiduites_edit():
@ -666,7 +675,7 @@ def assiduites_edit():
db.session.commit() db.session.commit()
return jsonify({"errors": errors, "success": success}) return {"errors": errors, "success": success}
def _edit_singular(assiduite_unique, data): def _edit_singular(assiduite_unique, data):

View File

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

View File

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

View File

@ -9,7 +9,8 @@
""" """
from datetime import datetime 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 current_user
from flask_login import login_required from flask_login import login_required
from sqlalchemy import desc, or_ from sqlalchemy import desc, or_
@ -38,11 +39,11 @@ import app.scodoc.sco_photos as sco_photos
# @login_required # @login_required
# @scodoc # @scodoc
# @permission_required(Permission.ScoView) # @permission_required(Permission.ScoView)
# @as_json
# def api_function(arg: int): # def api_function(arg: int):
# """Une fonction quelconque de l'API""" # """Une fonction quelconque de l'API"""
# return jsonify( # return {"current_user": current_user.to_dict(), "arg": arg, "dept": g.scodoc_dept}
# {"current_user": current_user.to_dict(), "arg": arg, "dept": g.scodoc_dept} #
# )
@bp.route("/etudiants/courants", defaults={"long": False}) @bp.route("/etudiants/courants", defaults={"long": False})
@ -52,6 +53,7 @@ import app.scodoc.sco_photos as sco_photos
@login_required @login_required
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json
def etudiants_courants(long=False): def etudiants_courants(long=False):
""" """
La liste des étudiants des semestres "courants" (tous départements) La liste des étudiants des semestres "courants" (tous départements)
@ -97,7 +99,7 @@ def etudiants_courants(long=False):
data = [etud.to_dict_api() for etud in etuds] data = [etud.to_dict_api() for etud in etuds]
else: else:
data = [etud.to_dict_short() for etud in etuds] data = [etud.to_dict_short() for etud in etuds]
return jsonify(data) return data
@bp.route("/etudiant/etudid/<int:etudid>") @bp.route("/etudiant/etudid/<int:etudid>")
@ -109,6 +111,7 @@ def etudiants_courants(long=False):
@login_required @login_required
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json
def etudiant(etudid: int = None, nip: str = None, ine: str = None): def etudiant(etudid: int = None, nip: str = None, ine: str = None):
""" """
Retourne les informations de l'étudiant correspondant, ou 404 si non trouvé. Retourne les informations de l'étudiant correspondant, ou 404 si non trouvé.
@ -128,7 +131,7 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
message="étudiant inconnu", 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/etudid/<int:etudid>/photo")
@ -175,6 +178,7 @@ def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
@api_web_bp.route("/etudiants/ine/<string:ine>", methods=["GET"]) @api_web_bp.route("/etudiants/ine/<string:ine>", methods=["GET"])
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json
def etudiants(etudid: int = None, nip: str = None, ine: str = None): def etudiants(etudid: int = None, nip: str = None, ine: str = None):
""" """
Info sur le ou les étudiants correspondant. Comme /etudiant mais renvoie Info sur le ou les étudiants correspondant. Comme /etudiant mais renvoie
@ -200,7 +204,7 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
etuds = etuds.join(Departement).filter( etuds = etuds.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts) 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") @bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@ -211,6 +215,7 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
@api_web_bp.route("/etudiant/ine/<string:ine>/formsemestres") @api_web_bp.route("/etudiant/ine/<string:ine>/formsemestres")
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json
def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None): def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None):
""" """
Liste des semestres qu'un étudiant a suivi, triés par ordre chronologique. Liste des semestres qu'un étudiant a suivi, triés par ordre chronologique.
@ -243,7 +248,7 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None)
formsemestres = query.order_by(FormSemestre.date_debut) 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( @bp.route(
@ -302,7 +307,7 @@ def bulletin(
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404() formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404() dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
if g.scodoc_dept and dept.acronym != g.scodoc_dept: if g.scodoc_dept and dept.acronym != g.scodoc_dept:
return json_error(404, "formsemestre inexistant") return json_error(404, "formsemestre inexistant", as_response=True)
app.set_sco_dept(dept.acronym) app.set_sco_dept(dept.acronym)
if code_type == "nip": if code_type == "nip":
@ -340,6 +345,7 @@ def bulletin(
) )
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json
def etudiant_groups(formsemestre_id: int, etudid: int = None): def etudiant_groups(formsemestre_id: int, etudid: int = None):
""" """
Retourne la liste des groupes auxquels appartient l'étudiant dans le formsemestre indiqué Retourne la liste des groupes auxquels appartient l'étudiant dans le formsemestre indiqué
@ -389,4 +395,4 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
app.set_sco_dept(dept.acronym) app.set_sco_dept(dept.acronym)
data = sco_groups.get_etud_groups(etud.id, formsemestre.id) data = sco_groups.get_etud_groups(etud.id, formsemestre.id)
return jsonify(data) return data

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@
""" """
from datetime import datetime from datetime import datetime
from flask_json import as_json
from flask import g, jsonify, request from flask import g, jsonify, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
@ -57,6 +58,7 @@ def justificatif(justif_id: int = None):
@api_web_bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True}) @api_web_bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
@login_required @login_required
@scodoc @scodoc
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def justificatifs(etudid: int = None, with_query: bool = False): def justificatifs(etudid: int = None, with_query: bool = False):
""" """
@ -100,13 +102,14 @@ def justificatifs(etudid: int = None, with_query: bool = False):
data = just.to_dict(format_api=True) data = just.to_dict(format_api=True)
data_set.append(data) data_set.append(data)
return jsonify(data_set) return data_set
@bp.route("/justificatif/<int:etudid>/create", methods=["POST"]) @bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/justificatif/<int:etudid>/create", methods=["POST"]) @api_web_bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@scodoc @scodoc
@login_required @login_required
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
def justif_create(etudid: int = None): def justif_create(etudid: int = None):
@ -145,7 +148,7 @@ def justif_create(etudid: int = None):
else: else:
success[i] = obj success[i] = obj
compute_assiduites_justified(Justificatif.query.filter_by(etudid=etudid), True) compute_assiduites_justified(Justificatif.query.filter_by(etudid=etudid), True)
return jsonify({"errors": errors, "success": success}) return {"errors": errors, "success": success}
def _create_singular( def _create_singular(
@ -221,6 +224,7 @@ def _create_singular(
@api_web_bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"]) @api_web_bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@login_required @login_required
@scodoc @scodoc
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
def justif_edit(justif_id: int): def justif_edit(justif_id: int):
@ -296,23 +300,22 @@ def justif_edit(justif_id: int):
db.session.add(justificatif_unique) db.session.add(justificatif_unique)
db.session.commit() db.session.commit()
return jsonify( return {
{ "couverture": {
"couverture": { "avant": avant_ids,
"avant": avant_ids, "après": compute_assiduites_justified(
"après": compute_assiduites_justified( Justificatif.query.filter_by(etudid=justificatif_unique.etudid),
Justificatif.query.filter_by(etudid=justificatif_unique.etudid), True,
True, ),
),
}
} }
) }
@bp.route("/justificatif/delete", methods=["POST"]) @bp.route("/justificatif/delete", methods=["POST"])
@api_web_bp.route("/justificatif/delete", methods=["POST"]) @api_web_bp.route("/justificatif/delete", methods=["POST"])
@login_required @login_required
@scodoc @scodoc
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
def justif_delete(): def justif_delete():
@ -342,7 +345,7 @@ def justif_delete():
output["success"][f"{i}"] = {"OK": True} output["success"][f"{i}"] = {"OK": True}
db.session.commit() db.session.commit()
return jsonify(output) return output
def _delete_singular(justif_id: int, database): def _delete_singular(justif_id: int, database):
@ -371,6 +374,7 @@ def _delete_singular(justif_id: int, database):
@api_web_bp.route("/justificatif/<int:justif_id>/import", methods=["POST"]) @api_web_bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
@scodoc @scodoc
@login_required @login_required
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
def justif_import(justif_id: int = None): def justif_import(justif_id: int = None):
@ -407,7 +411,7 @@ def justif_import(justif_id: int = None):
db.session.add(justificatif_unique) db.session.add(justificatif_unique)
db.session.commit() db.session.commit()
return jsonify({"filename": fname}) return {"filename": fname}
except ScoValueError as err: except ScoValueError as err:
return json_error(404, err.args[0]) return json_error(404, err.args[0])
@ -447,6 +451,7 @@ def justif_export(justif_id: int = None, filename: str = None):
@api_web_bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"]) @api_web_bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
@scodoc @scodoc
@login_required @login_required
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
def justif_remove(justif_id: int = None): def justif_remove(justif_id: int = None):
@ -504,13 +509,14 @@ def justif_remove(justif_id: int = None):
except ScoValueError as err: except ScoValueError as err:
return json_error(404, err.args[0]) return json_error(404, err.args[0])
return jsonify({"response": "removed"}) return {"response": "removed"}
@bp.route("/justificatif/<int:justif_id>/list", methods=["GET"]) @bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/list", methods=["GET"]) @api_web_bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@scodoc @scodoc
@login_required @login_required
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
def justif_list(justif_id: int = None): def justif_list(justif_id: int = None):
@ -534,7 +540,7 @@ def justif_list(justif_id: int = None):
archive_name, justificatif_unique.etudid archive_name, justificatif_unique.etudid
) )
return jsonify(filenames) return filenames
# Partie justification # Partie justification
@ -542,6 +548,7 @@ def justif_list(justif_id: int = None):
@api_web_bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"]) @api_web_bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@scodoc @scodoc
@login_required @login_required
@as_json
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange) # @permission_required(Permission.ScoAssiduiteChange)
def justif_justifies(justif_id: int = None): def justif_justifies(justif_id: int = None):
@ -557,7 +564,7 @@ def justif_justifies(justif_id: int = None):
assiduites_list: list[int] = scass.justifies(justificatif_unique) assiduites_list: list[int] = scass.justifies(justificatif_unique)
return jsonify(assiduites_list) return assiduites_list
# -- Utils -- # -- Utils --

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,7 +30,7 @@ def after_cas_login():
flask.session.get("CAS_USERNAME"), flask.session.get("CAS_USERNAME"),
) )
if cas_id is not None: 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 and user.active:
if user.cas_allow_login: if user.cas_allow_login:
current_app.logger.info(f"CAS: login {user.user_name}") current_app.logger.info(f"CAS: login {user.user_name}")

View File

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

View File

@ -285,9 +285,9 @@ class BulletinBUT:
eval_notes[etud.id], eval_notes[etud.id],
note_max=e.note_max, note_max=e.note_max,
), ),
"min": fmt_note(notes_ok.min()), "min": fmt_note(notes_ok.min(), note_max=e.note_max),
"max": fmt_note(notes_ok.max()), "max": fmt_note(notes_ok.max(), note_max=e.note_max),
"moy": fmt_note(notes_ok.mean()), "moy": fmt_note(notes_ok.mean(), note_max=e.note_max),
}, },
"poids": poids, "poids": poids,
"url": url_for( "url": url_for(
@ -484,6 +484,7 @@ class BulletinBUT:
d["etudid"] = etud.id d["etudid"] = etud.id
d["etud"] = d["etudiant"] d["etud"] = d["etudiant"]
d["etud"]["nomprenom"] = etud.nomprenom d["etud"]["nomprenom"] = etud.nomprenom
d["etud"]["etat_civil"] = etud.etat_civil
d.update(self.res.sem) d.update(self.res.sem)
etud_etat = self.res.get_etud_etat(etud.id) etud_etat = self.res.get_etud_etat(etud.id)
d["filigranne"] = sco_bulletins_pdf.get_filigranne( d["filigranne"] = sco_bulletins_pdf.get_filigranne(

View File

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

View File

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

View File

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

View File

@ -106,6 +106,8 @@ class BonusSport:
if formsemestre.formation.is_apc(): if formsemestre.formation.is_apc():
# BUT # BUT
nb_ues_no_bonus = sem_modimpl_moys.shape[2] 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: # Duplique les inscriptions sur les UEs non bonus:
modimpl_inscr_spo_stacked = np.stack( modimpl_inscr_spo_stacked = np.stack(
[modimpl_inscr_spo] * nb_ues_no_bonus, axis=2 [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) # en APC, bonus_moy_arr est (nb_etuds, nb_ues_non_bonus)
if self.formsemestre.formation.is_apc(): if self.formsemestre.formation.is_apc():
# Bonus sur les UE et None sur moyenne générale # 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( self.bonus_ues = pd.DataFrame(
bonus_moy_arr, index=self.etuds_idx, columns=ues_idx, dtype=float bonus_moy_arr, index=self.etuds_idx, columns=ues_idx, dtype=float
) )
elif self.classic_use_bonus_ues: elif self.classic_use_bonus_ues:
# Formations classiques apppliquant le bonus sur les UEs # Formations classiques apppliquant le bonus sur les UEs
# ici bonus_moy_arr = ndarray 1d nb_etuds # 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( self.bonus_ues = pd.DataFrame(
np.stack([bonus_moy_arr] * len(ues_idx)).T, np.stack([bonus_moy_arr] * len(ues_idx)).T,
index=self.etuds_idx, index=self.etuds_idx,
@ -420,7 +422,7 @@ class BonusAmiens(BonusSportAdditif):
# # Bonus moyenne générale et sur les UE # # Bonus moyenne générale et sur les UE
# self.bonus_moy_gen = pd.Series(bonus, index=self.etuds_idx, dtype=float) # 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) # nb_ues_no_bonus = len(ues_idx)
# self.bonus_ues = pd.DataFrame( # self.bonus_ues = pd.DataFrame(
# np.stack([bonus] * nb_ues_no_bonus, axis=1), # 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] 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 # 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 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] ues_idx = [ue.id for ue in ues]
if self.formsemestre.formation.is_apc(): # --- BUT if self.formsemestre.formation.is_apc(): # --- BUT
@ -687,7 +689,7 @@ class BonusCalais(BonusSportAdditif):
else: else:
self.classic_use_bonus_ues = True # pour les LP self.classic_use_bonus_ues = True # pour les LP
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
ues = self.formsemestre.query_ues(with_sport=False).all() ues = self.formsemestre.get_ues(with_sport=False)
ues_sans_bs = [ ues_sans_bs = [
ue for ue in ues if ue.acronyme[-2:].upper() != "BS" ue for ue in ues if ue.acronyme[-2:].upper() != "BS"
] # les 2 derniers cars forcés en majus ] # les 2 derniers cars forcés en majus
@ -788,7 +790,7 @@ class BonusIUTRennes1(BonusSportAdditif):
sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] 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 # 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 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( bonus_moy_arr = np.where(
note_bonus_max > self.seuil_moy_gen, note_bonus_max > self.seuil_moy_gen,

View File

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

View File

@ -7,6 +7,7 @@
"""Stockage des décisions de jury """Stockage des décisions de jury
""" """
import pandas as pd import pandas as pd
import sqlalchemy as sa
from app import db from app import db
from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns 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 # 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' ) # and ( SFV.code = 'ADM' or SFV.code = 'ADJ' or SFV.code = 'CMP' )
query = """ query = sa.text(
"""
SELECT DISTINCT SFV.*, ue.ue_code SELECT DISTINCT SFV.*, ue.ue_code
FROM FROM
notes_ue ue, notes_ue ue,
@ -144,21 +146,22 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
WHERE ue.formation_id = nf.id WHERE ue.formation_id = nf.id
and nf.formation_code = nf2.formation_code 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.etudid = SFV.etudid
and ins.formsemestre_id = %(formsemestre_id)s and ins.formsemestre_id = :formsemestre_id
and SFV.ue_id = ue.id and SFV.ue_id = ue.id
and SFV.code = 'ADM' and SFV.code = 'ADM'
and ( (sem.id = SFV.formsemestre_id and ( (sem.id = SFV.formsemestre_id
and sem.date_debut < %(date_debut)s and sem.date_debut < :date_debut
and sem.semestre_id = %(semestre_id)s ) and sem.semestre_id = :semestre_id )
or ( or (
((SFV.formsemestre_id is NULL) OR (SFV.is_external)) -- les UE externes ou "anterieures" ((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 = { params = {
"formation_id": formsemestre.formation.id, "formation_id": formsemestre.formation.id,
"formsemestre_id": formsemestre.id, "formsemestre_id": formsemestre.id,
@ -166,5 +169,6 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
"date_debut": formsemestre.date_debut, "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 return df

View File

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

View File

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

View File

@ -16,6 +16,7 @@ from app.comp.res_compat import NotesTableCompat
from app.comp.bonus_spo import BonusSport from app.comp.bonus_spo import BonusSport
from app.models import ScoDocSiteConfig from app.models import ScoDocSiteConfig
from app.models.moduleimpls import ModuleImpl from app.models.moduleimpls import ModuleImpl
from app.models.but_refcomp import ApcParcours, ApcNiveau
from app.models.ues import DispenseUE, UniteEns from app.models.ues import DispenseUE, UniteEns
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
@ -41,6 +42,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
"""ndarray (etuds x modimpl x ue)""" """ndarray (etuds x modimpl x ue)"""
self.etuds_parcour_id = None self.etuds_parcour_id = None
"""Parcours de chaque étudiant { etudid : parcour_id }""" """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(): if not self.load_cached():
t0 = time.time() t0 = time.time()
@ -227,7 +230,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
} }
self.etuds_parcour_id = etuds_parcour_id self.etuds_parcour_id = etuds_parcour_id
ue_ids = [ue.id for ue in self.ues if ue.type != UE_SPORT] 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: if self.formsemestre.formation.referentiel_competence is None:
return pd.DataFrame( return pd.DataFrame(
1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float 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 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 # 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} ue_by_parcours = {} # parcours_id : {ue_id:0|1}
for ( for (
parcour parcour
) in self.formsemestre.formation.referentiel_competence.parcours.all() + [None]: ) in self.formsemestre.formation.referentiel_competence.parcours.all() + [None]:
ue_by_parcours[None if parcour is None else parcour.id] = { ue_by_parcours[None if parcour is None else parcour.id] = {
ue.id: 1.0 ue.id: 1.0
for ue in self.formsemestre.formation.query_ues_parcour( for ue in self.formsemestre.formation.query_ues_parcour(parcour).filter(
parcour UniteEns.semestre_idx == self.formsemestre.semestre_id
).filter_by(semestre_idx=self.formsemestre.semestre_id) )
if ue.id in ue_ids_set
} }
# #
for etudid in etuds_parcour_id: for etudid in etuds_parcour_id:
@ -259,10 +266,46 @@ class ResultatsSemestreBUT(NotesTableCompat):
def etud_ues_ids(self, etudid: int) -> list[int]: def etud_ues_ids(self, etudid: int) -> list[int]:
"""Liste des id d'UE auxquelles l'étudiant est inscrit (sans bonus). """Liste des id d'UE auxquelles l'étudiant est inscrit (sans bonus).
(surchargée ici pour prendre en compte les parcours) (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] s = self.ues_inscr_parcours_df.loc[etudid]
return s.index[s.notna()] 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): def etud_has_decision(self, etudid):
"""True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre. """True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre.
prend aussi en compte les autorisations de passage. prend aussi en compte les autorisations de passage.

View File

@ -10,6 +10,8 @@
from collections import Counter, defaultdict from collections import Counter, defaultdict
from collections.abc import Generator from collections.abc import Generator
from functools import cached_property from functools import cached_property
from operator import attrgetter
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@ -87,6 +89,7 @@ class ResultatsSemestre(ResultatsCache):
self.autorisations_inscription = None self.autorisations_inscription = None
self.moyennes_matieres = {} self.moyennes_matieres = {}
"""Moyennes de matières, si calculées. { matiere_id : Series, index etudid }""" """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): def __repr__(self):
return f"<{self.__class__.__name__}(formsemestre='{self.formsemestre}')>" return f"<{self.__class__.__name__}(formsemestre='{self.formsemestre}')>"
@ -124,6 +127,13 @@ class ResultatsSemestre(ResultatsCache):
# car tous les étudiants sont inscrits à toutes les UE # car tous les étudiants sont inscrits à toutes les UE
return [ue.id for ue in self.ues if ue.type != UE_SPORT] 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]: def etud_ues(self, etudid: int) -> Generator[UniteEns]:
"""Liste des UE auxquelles l'étudiant est inscrit """Liste des UE auxquelles l'étudiant est inscrit
(sans bonus, en BUT prend en compte le parcours de l'étudiant).""" (sans bonus, en BUT prend en compte le parcours de l'étudiant)."""
@ -154,7 +164,7 @@ class ResultatsSemestre(ResultatsCache):
(indices des DataFrames). (indices des DataFrames).
Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs. 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 @cached_property
def ressources(self): def ressources(self):
@ -225,7 +235,7 @@ class ResultatsSemestre(ResultatsCache):
for modimpl in self.formsemestre.modimpls_sorted for modimpl in self.formsemestre.modimpls_sorted
if self.modimpl_inscr_df[modimpl.id][etudid] 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 return ues
def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]: 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 # Quand il y a une capitalisation, vérifie toutes les UEs
sum_notes_ue = 0.0 sum_notes_ue = 0.0
sum_coefs_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) ue_cap = self.get_etud_ue_status(etudid, ue.id)
if ue_cap is None: if ue_cap is None:
continue continue
@ -341,7 +351,9 @@ class ResultatsSemestre(ResultatsCache):
"""L'état de l'UE pour cet étudiant. """L'état de l'UE pour cet étudiant.
Result: dict, ou None si l'UE n'est pas dans ce semestre. Result: dict, ou None si l'UE n'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: if ue.type == UE_SPORT:
return { return {
"is_capitalized": False, "is_capitalized": False,
@ -351,7 +363,7 @@ class ResultatsSemestre(ResultatsCache):
"cur_moy_ue": 0.0, "cur_moy_ue": 0.0,
"moy": 0.0, "moy": 0.0,
"event_date": None, "event_date": None,
"ue": ue.to_dict(), "ue": ue_dict,
"formsemestre_id": None, "formsemestre_id": None,
"capitalized_ue_id": None, "capitalized_ue_id": None,
"ects_pot": 0.0, "ects_pot": 0.0,
@ -420,7 +432,7 @@ class ResultatsSemestre(ResultatsCache):
"cur_moy_ue": cur_moy_ue, "cur_moy_ue": cur_moy_ue,
"moy": moy_ue, "moy": moy_ue,
"event_date": ue_cap["event_date"] if is_capitalized else None, "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, "formsemestre_id": ue_cap["formsemestre_id"] if is_capitalized else None,
"capitalized_ue_id": ue_cap["ue_id"] if is_capitalized else None, "capitalized_ue_id": ue_cap["ue_id"] if is_capitalized else None,
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,8 +6,10 @@
"""ScoDoc 9 models : Référentiel Compétence BUT 2021 """ScoDoc 9 models : Référentiel Compétence BUT 2021
""" """
from datetime import datetime 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 from sqlalchemy.orm import class_mapper
import sqlalchemy import sqlalchemy
@ -84,6 +86,7 @@ class ApcReferentielCompetences(db.Model, XMLModel):
backref="referentiel", backref="referentiel",
lazy="dynamic", lazy="dynamic",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="ApcParcours.numero, ApcParcours.code",
) )
formations = db.relationship( formations = db.relationship(
"Formation", "Formation",
@ -129,11 +132,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
} }
def get_niveaux_by_parcours( def get_niveaux_by_parcours(
self, annee: int, parcour: "ApcParcours" = None self, annee: int, parcours: list["ApcParcours"] = None
) -> tuple[list["ApcParcours"], dict]: ) -> tuple[list["ApcParcours"], dict]:
""" """
Construit la liste des niveaux de compétences pour chaque parcours 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. 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() parcours_ref = self.parcours.order_by(ApcParcours.numero).all()
if parcour is None: if parcours is None:
parcours = parcours_ref parcours = parcours_ref
else:
parcours = [parcour]
niveaux_by_parcours = { niveaux_by_parcours = {
parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self) parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self)
for parcour in parcours_ref for parcour in parcours_ref
@ -205,9 +206,27 @@ class ApcReferentielCompetences(db.Model, XMLModel):
for competence in parcours[0].query_competences() for competence in parcours[0].query_competences()
if competence.id in ids 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): class ApcCompetence(db.Model, XMLModel):
"Compétence" "Compétence"
@ -223,7 +242,7 @@ class ApcCompetence(db.Model, XMLModel):
titre = db.Column(db.Text(), nullable=False, index=True) titre = db.Column(db.Text(), nullable=False, index=True)
titre_long = db.Column(db.Text()) titre_long = db.Column(db.Text())
couleur = 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 _xml_attribs = { # xml_attrib : attribute
"id": "id_orebut", "id": "id_orebut",
"nom_court": "titre", # was name "nom_court": "titre", # was name
@ -289,6 +308,7 @@ class ApcSituationPro(db.Model, XMLModel):
nullable=False, nullable=False,
) )
libelle = db.Column(db.Text(), nullable=False) libelle = db.Column(db.Text(), nullable=False)
# aucun attribut (le text devient le libellé) # aucun attribut (le text devient le libellé)
def to_dict(self): def to_dict(self):
return {"libelle": self.libelle} return {"libelle": self.libelle}
@ -358,15 +378,39 @@ class ApcNiveau(db.Model, XMLModel):
"competence": self.competence.to_dict_bul(), "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 @classmethod
def niveaux_annee_de_parcours( def niveaux_annee_de_parcours(
cls, cls,
parcour: "ApcParcours", parcour: "ApcParcours",
annee: int, annee: int,
referentiel_competence: ApcReferentielCompetences = None, referentiel_competence: ApcReferentielCompetences = None,
competence: ApcCompetence = None,
) -> list["ApcNiveau"]: ) -> list["ApcNiveau"]:
"""Les niveaux de l'année du parcours """Les niveaux de l'année du parcours
Si le parcour est None, tous les niveaux de l'année 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}: if annee not in {1, 2, 3}:
raise ValueError("annee invalide pour un parcours BUT") raise ValueError("annee invalide pour un parcours BUT")
@ -377,22 +421,31 @@ class ApcNiveau(db.Model, XMLModel):
raise ScoNoReferentielCompetences() raise ScoNoReferentielCompetences()
if not parcour: if not parcour:
annee_formation = f"BUT{annee}" annee_formation = f"BUT{annee}"
return ApcNiveau.query.filter( query = ApcNiveau.query.filter(
ApcNiveau.annee == annee_formation, ApcNiveau.annee == annee_formation,
ApcCompetence.id == ApcNiveau.competence_id, ApcCompetence.id == ApcNiveau.competence_id,
ApcCompetence.referentiel_id == referentiel_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: if not annee_parcour:
return [] return []
parcour_niveaux: list[ if competence is None:
ApcParcoursNiveauCompetence parcour_niveaux: list[
] = annee_parcour.niveaux_competences ApcParcoursNiveauCompetence
niveaux: list[ApcNiveau] = [ ] = annee_parcour.niveaux_competences
pn.competence.niveaux.filter_by(ordre=pn.niveau).first() niveaux: list[ApcNiveau] = [
for pn in parcour_niveaux 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 return niveaux
@ -433,7 +486,7 @@ class ApcAppCritique(db.Model, XMLModel):
ref_comp: ApcReferentielCompetences, ref_comp: ApcReferentielCompetences,
annee: str, annee: str,
competence: ApcCompetence = None, competence: ApcCompetence = None,
) -> flask_sqlalchemy.BaseQuery: ) -> Query:
"Liste les AC de tous les parcours de ref_comp pour l'année indiquée" "Liste les AC de tous les parcours de ref_comp pour l'année indiquée"
assert annee in {"BUT1", "BUT2", "BUT3"} assert annee in {"BUT1", "BUT2", "BUT3"}
query = cls.query.filter( query = cls.query.filter(
@ -505,7 +558,7 @@ class ApcParcours(db.Model, XMLModel):
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"), db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
nullable=False, 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) code = db.Column(db.Text(), nullable=False)
libelle = db.Column(db.Text(), nullable=False) libelle = db.Column(db.Text(), nullable=False)
annees = db.relationship( annees = db.relationship(
@ -514,7 +567,6 @@ class ApcParcours(db.Model, XMLModel):
lazy="dynamic", lazy="dynamic",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
ues = db.relationship("UniteEns", back_populates="parcour")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>" 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} d["annees"] = {x.ordre: x.to_dict() for x in self.annees}
return d return d
def query_competences(self) -> flask_sqlalchemy.BaseQuery: def query_competences(self) -> Query:
"Les compétences associées à ce parcours" "Les compétences associées à ce parcours"
return ( return (
ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours) ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
@ -540,6 +592,16 @@ class ApcParcours(db.Model, XMLModel):
.order_by(ApcCompetence.numero) .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): class ApcAnneeParcours(db.Model, XMLModel):
id = db.Column(db.Integer, primary_key=True) 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" "numéro de l'année: 1, 2, 3"
def __repr__(self): 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): def to_dict(self):
return { return {

View File

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

View File

@ -18,7 +18,7 @@ from app import models
from app.scodoc import notesdb as ndb from app.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat from app.scodoc.sco_bac import Baccalaureat
from app.scodoc.sco_exceptions import ScoInvalidParamError from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
import app.scodoc.sco_utils as scu 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_nip"),
db.UniqueConstraint("dept_id", "code_ine"), db.UniqueConstraint("dept_id", "code_ine"),
db.CheckConstraint("civilite IN ('M', 'F', 'X')"), db.CheckConstraint("civilite IN ('M', 'F', 'X')"),
db.CheckConstraint("civilite_etat_civil IN ('M', 'F', 'X')"),
) )
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -41,6 +42,12 @@ class Identite(db.Model):
nom_usuel = db.Column(db.Text()) nom_usuel = db.Column(db.Text())
"optionnel (si present, affiché à la place du nom)" "optionnel (si present, affiché à la place du nom)"
civilite = db.Column(db.String(1), nullable=False) 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) date_naissance = db.Column(db.Date)
lieu_naissance = db.Column(db.Text()) lieu_naissance = db.Column(db.Text())
dept_naissance = db.Column(db.Text()) dept_naissance = db.Column(db.Text())
@ -97,7 +104,7 @@ class Identite(db.Model):
def create_etud(cls, **args): def create_etud(cls, **args):
"Crée un étudiant, avec admission et adresse vides." "Crée un étudiant, avec admission et adresse vides."
etud: Identite = cls(**args) etud: Identite = cls(**args)
etud.adresses.append(Adresse()) etud.adresses.append(Adresse(typeadresse="domicile"))
etud.admission.append(Admission()) etud.admission.append(Admission())
return etud return etud
@ -108,6 +115,13 @@ class Identite(db.Model):
""" """
return {"M": "M.", "F": "Mme", "X": ""}[self.civilite] 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: def sex_nom(self, no_accents=False) -> str:
"'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'" "'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'"
s = f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()}" s = f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()}"
@ -154,6 +168,14 @@ class Identite(db.Model):
r.append("-".join([x.lower().capitalize() for x in fields])) r.append("-".join([x.lower().capitalize() for x in fields]))
return " ".join(r) 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 @property
def nom_short(self): def nom_short(self):
"Nom et début du prénom pour table recap: 'DUPONT Pi.'" "Nom et début du prénom pour table recap: 'DUPONT Pi.'"
@ -183,6 +205,50 @@ class Identite(db.Model):
reverse=True, 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: def to_dict_short(self) -> dict:
"""Les champs essentiels""" """Les champs essentiels"""
return { return {
@ -195,6 +261,8 @@ class Identite(db.Model):
"nom_usuel": self.nom_usuel, "nom_usuel": self.nom_usuel,
"prenom": self.prenom, "prenom": self.prenom,
"sort_key": self.sort_key, "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: def to_dict_scodoc7(self) -> dict:
@ -238,6 +306,8 @@ class Identite(db.Model):
"dept_naissance": self.dept_naissance or "", "dept_naissance": self.dept_naissance or "",
"nationalite": self.nationalite or "", "nationalite": self.nationalite or "",
"boursier": self.boursier 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(): if include_urls and has_request_context():
# test request context so we can use this func in tests under the flask shell # test request context so we can use this func in tests under the flask shell
@ -454,10 +524,10 @@ class Identite(db.Model):
M. Pierre Dupont M. Pierre Dupont
""" """
if with_paragraph: if with_paragraph:
return f"""{self.nomprenom}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le { return f"""{self.etat_civil}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{ self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
line_sep}à {self.lieu_naissance or ""}""" line_sep}à {self.lieu_naissance or ""}"""
return self.nomprenom return self.etat_civil
def photo_html(self, title=None, size="small") -> str: def photo_html(self, title=None, size="small") -> str:
"""HTML img tag for the photo, either in small size (h90) """HTML img tag for the photo, either in small size (h90)
@ -521,6 +591,37 @@ def make_etud_args(
return 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): class Adresse(db.Model):
"""Adresse d'un étudiant """Adresse d'un étudiant
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule) (le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule)
@ -614,19 +715,51 @@ class Admission(db.Model):
d = dict(self.__dict__) d = dict(self.__dict__)
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
if no_nulls: if no_nulls:
for k in d.keys(): for key, value in d.items():
if d[k] is None: if value is None:
col_type = getattr( col_type = getattr(
sqlalchemy.inspect(models.Admission).columns, "apb_groupe" sqlalchemy.inspect(models.Admission).columns, key
).expression.type ).expression.type
if isinstance(col_type, sqlalchemy.Text): if isinstance(col_type, sqlalchemy.Text):
d[k] = "" d[key] = ""
elif isinstance(col_type, sqlalchemy.Integer): elif isinstance(col_type, sqlalchemy.Integer):
d[k] = 0 d[key] = 0
elif isinstance(col_type, sqlalchemy.Boolean): elif isinstance(col_type, sqlalchemy.Boolean):
d[k] = False d[key] = False
return d 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 # Suivi scolarité / débouchés
class ItemSuivi(db.Model): class ItemSuivi(db.Model):

View File

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

View File

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

View File

@ -12,11 +12,11 @@
""" """
import datetime import datetime
from functools import cached_property from functools import cached_property
from operator import attrgetter
from flask_login import current_user from flask_login import current_user
import flask_sqlalchemy
from flask import flash, g from flask import flash, g
from sqlalchemy import and_, or_
from sqlalchemy.sql import text from sqlalchemy.sql import text
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -24,10 +24,7 @@ from app import db, log
from app.auth.models import User from app.auth.models import User
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models.but_refcomp import ( from app.models.but_refcomp import (
ApcAnneeParcours,
ApcNiveau,
ApcParcours, ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences, ApcReferentielCompetences,
parcours_formsemestre, 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_utils import MONTH_NAMES_ABBREV
from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_vdi import ApoEtapeVDI
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
class FormSemestre(db.Model): class FormSemestre(db.Model):
"""Mise en oeuvre d'un semestre de formation""" """Mise en oeuvre d'un semestre de formation"""
@ -111,6 +110,10 @@ class FormSemestre(db.Model):
elt_annee_apo = db.Column(db.Text()) elt_annee_apo = db.Column(db.Text())
"code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'" "code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
# 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: # Relations:
etapes = db.relationship( etapes = db.relationship(
"FormSemestreEtape", cascade="all,delete", backref="formsemestre" "FormSemestreEtape", cascade="all,delete", backref="formsemestre"
@ -149,6 +152,7 @@ class FormSemestre(db.Model):
secondary=parcours_formsemestre, secondary=parcours_formsemestre,
lazy="subquery", lazy="subquery",
backref=db.backref("formsemestres", lazy=True), backref=db.backref("formsemestres", lazy=True),
order_by=(ApcParcours.numero, ApcParcours.code),
) )
def __init__(self, **kwargs): def __init__(self, **kwargs):
@ -195,11 +199,14 @@ class FormSemestre(db.Model):
d["date_fin"] = d["date_fin_iso"] = "" d["date_fin"] = d["date_fin_iso"] = ""
d["responsables"] = [u.id for u in self.responsables] d["responsables"] = [u.id for u in self.responsables]
d["titre_formation"] = self.titre_formation() 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["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
d["departement"] = self.departement.to_dict() d["departement"] = self.departement.to_dict()
d["formation"] = self.formation.to_dict() d["formation"] = self.formation.to_dict()
d["etape_apo"] = self.etapes_apo_str() 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 return d
def to_dict_api(self): def to_dict_api(self):
@ -281,60 +288,45 @@ class FormSemestre(db.Model):
) )
return r or [] 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. """UE des modules de ce semestre, triées par numéro.
- Formations classiques: les UEs auxquelles appartiennent - Formations classiques: les UEs auxquelles appartiennent
les modules mis en place dans ce semestre. les modules mis en place dans ce semestre.
- Formations APC / BUT: les UEs de la formation qui - Formations APC / BUT: les UEs de la formation qui
- ont le même numéro de semestre que ce formsemestre - ont le même numéro de semestre que ce formsemestre;
- sont associées à l'un des parcours de ce formsemestre (ou à aucun) - et sont associées à l'un des parcours de ce formsemestre
(ou à aucun, donc tronc commun).
""" """
if self.formation.get_cursus().APC_SAE: formation: Formation = self.formation
sem_ues = UniteEns.query.filter_by( if formation.is_apc():
formation=self.formation, semestre_idx=self.semestre_id # UEs de tronc commun (sans parcours indiqué)
) sem_ues = {
if self.parcours: ue.id: ue
# Prend toutes les UE de l'un des parcours du sem., ou déclarées sans parcours for ue in formation.query_ues_parcour(
sem_ues = sem_ues.filter( None, with_sport=with_sport
(UniteEns.parcour == None) ).filter(UniteEns.semestre_idx == self.semestre_id)
| (UniteEns.parcour_id.in_([p.id for p in self.parcours])) }
# 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: else:
sem_ues = db.session.query(UniteEns).filter( sem_ues = db.session.query(UniteEns).filter(
ModuleImpl.formsemestre_id == self.id, ModuleImpl.formsemestre_id == self.id,
Module.id == ModuleImpl.module_id, Module.id == ModuleImpl.module_id,
UniteEns.id == Module.ue_id, UniteEns.id == Module.ue_id,
) )
if not with_sport: if not with_sport:
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT) sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
return sem_ues.order_by(UniteEns.numero) return sem_ues.order_by(UniteEns.numero).all()
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),
),
),
)
@cached_property @cached_property
def modimpls_sorted(self) -> list[ModuleImpl]: def modimpls_sorted(self) -> list[ModuleImpl]:
@ -937,7 +929,7 @@ class FormSemestreEtape(db.Model):
def __repr__(self): def __repr__(self):
return f"<Etape {self.id} apo={self.etape_apo!r}>" return f"<Etape {self.id} apo={self.etape_apo!r}>"
def as_apovdi(self): def as_apovdi(self) -> ApoEtapeVDI:
return ApoEtapeVDI(self.etape_apo) return ApoEtapeVDI(self.etape_apo)
@ -960,7 +952,7 @@ class FormationModalite(db.Model):
) # code ) # code
titre = db.Column(db.Text()) # texte explicatif titre = db.Column(db.Text()) # texte explicatif
# numero = ordre de presentation) # numero = ordre de presentation)
numero = db.Column(db.Integer) numero = db.Column(db.Integer, nullable=False, default=0)
@staticmethod @staticmethod
def insert_modalites(): def insert_modalites():

View File

@ -7,6 +7,7 @@
"""ScoDoc models: Groups & partitions """ScoDoc models: Groups & partitions
""" """
from operator import attrgetter
from app import db from app import db
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
@ -29,7 +30,7 @@ class Partition(db.Model):
# "TD", "TP", ... (NULL for 'all') # "TD", "TP", ... (NULL for 'all')
partition_name = db.Column(db.String(SHORT_STR_LEN)) partition_name = db.Column(db.String(SHORT_STR_LEN))
# Numero = ordre de presentation) # Numero = ordre de presentation)
numero = db.Column(db.Integer) numero = db.Column(db.Integer, nullable=False, default=0)
# Calculer le rang ? # Calculer le rang ?
bul_show_rank = db.Column( bul_show_rank = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" 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" "Vrai s'il s'agit de la partition de parcours"
return self.partition_name == scu.PARTITION_PARCOURS return self.partition_name == scu.PARTITION_PARCOURS
def to_dict(self, with_groups=False) -> dict: def to_dict(self, with_groups=False, str_keys: bool = False) -> dict:
"""as a dict, with or without groups""" """as a dict, with or without groups.
If str_keys, convert integer dict keys to strings (useful for JSON)
"""
d = dict(self.__dict__) d = dict(self.__dict__)
d["partition_id"] = self.id d["partition_id"] = self.id
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
d.pop("formsemestre", None) d.pop("formsemestre", None)
if with_groups: 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 # un dict et non plus une liste, pour JSON
d["groups"] = { if str_keys:
group.id: group.to_dict(with_partition=False) for group in groups 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 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): class GroupDescr(db.Model):
"""Description d'un groupe d'une partition""" """Description d'un groupe d'une partition"""
@ -112,7 +130,7 @@ class GroupDescr(db.Model):
# "A", "C2", ... (NULL for 'all'): # "A", "C2", ... (NULL for 'all'):
group_name = db.Column(db.String(GROUPNAME_STR_LEN)) group_name = db.Column(db.String(GROUPNAME_STR_LEN))
# Numero = ordre de presentation # Numero = ordre de presentation
numero = db.Column(db.Integer) numero = db.Column(db.Integer, nullable=False, default=0)
etuds = db.relationship( etuds = db.relationship(
"Identite", "Identite",

View File

@ -2,7 +2,7 @@
"""ScoDoc models: moduleimpls """ScoDoc models: moduleimpls
""" """
import pandas as pd import pandas as pd
import flask_sqlalchemy from flask_sqlalchemy.query import Query
from app import db from app import db
from app.auth.models import User from app.auth.models import User
@ -179,7 +179,7 @@ class ModuleImplInscription(db.Model):
@classmethod @classmethod
def etud_modimpls_in_ue( def etud_modimpls_in_ue(
cls, formsemestre_id: int, etudid: int, ue_id: int cls, formsemestre_id: int, etudid: int, ue_id: int
) -> flask_sqlalchemy.BaseQuery: ) -> Query:
"""moduleimpls de l'UE auxquels l'étudiant est inscrit. """moduleimpls de l'UE auxquels l'étudiant est inscrit.
(Attention: inutile en APC, il faut considérer les coefficients) (Attention: inutile en APC, il faut considérer les coefficients)
""" """

View File

@ -33,7 +33,7 @@ class Module(db.Model):
# pas un id mais le numéro du semestre: 1, 2, ... # pas un id mais le numéro du semestre: 1, 2, ...
# note: en APC, le semestre qui fait autorité est celui de l'UE # 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") 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: # id de l'element pedagogique Apogee correspondant:
code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) code_apogee = db.Column(db.String(APO_CODE_STR_LEN))
# Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum) # Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)

View File

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

View File

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

View File

@ -21,7 +21,7 @@ class UniteEns(db.Model):
ue_id = db.synonym("id") ue_id = db.synonym("id")
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
acronyme = db.Column(db.Text(), nullable=False) 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()) titre = db.Column(db.Text())
# Le semestre_idx n'est pas un id mais le numéro du semestre: 1, 2, ... # 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 # 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()"), server_default=db.text("notes_newid_ucod()"),
nullable=False, 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") is_external = db.Column(db.Boolean(), default=False, server_default="false")
# id de l'element pedagogique Apogee correspondant: # id de l'element pedagogique Apogee correspondant:
code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) 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") niveau_competence = db.relationship("ApcNiveau", back_populates="ues")
# Une ue appartient soit à tous les parcours (tronc commun), soit à un seul: # Une UE appartient soit à tous les parcours (tronc commun), soit à un sous-ensemble
parcour_id = db.Column( parcours = db.relationship(
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="SET NULL"), index=True ApcParcours, secondary="ue_parcours", backref=db.backref("ues", lazy=True)
) )
parcour = db.relationship("ApcParcours", back_populates="ues")
# relations # relations
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue") matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
@ -101,10 +100,9 @@ class UniteEns(db.Model):
return ue return ue
def to_dict(self, convert_objects=False, with_module_ue_coefs=True): def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
"""as a dict, with the same conversions as in ScoDoc7 """as a dict, with the same conversions as in ScoDoc7.
(except ECTS: keep None)
If convert_objects, convert all attributes to native types If convert_objects, convert all attributes to native types
(suitable jor json encoding). (suitable for json encoding).
""" """
e = dict(self.__dict__) e = dict(self.__dict__)
e.pop("_sa_instance_state", None) e.pop("_sa_instance_state", None)
@ -112,10 +110,19 @@ class UniteEns(db.Model):
# ScoDoc7 output_formators # ScoDoc7 output_formators
e["ue_id"] = self.id e["ue_id"] = self.id
e["numero"] = e["numero"] if e["numero"] else 0 e["numero"] = e["numero"] if e["numero"] else 0
e["ects"] = e["ects"]
e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0 e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0
e["code_apogee"] = e["code_apogee"] or "" # pas de None 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 with_module_ue_coefs:
if convert_objects: if convert_objects:
e["module_ue_coefs"] = [ e["module_ue_coefs"] = [
@ -163,6 +170,44 @@ class UniteEns(db.Model):
db.session.add(self) db.session.add(self)
db.session.commit() 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): def get_ressources(self):
"Liste des modules ressources rattachés à cette UE" "Liste des modules ressources rattachés à cette UE"
return self.modules.filter_by(module_type=scu.ModuleType.RESSOURCE).all() 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 {x.strip() for x in self.code_apogee.split(",") if x}
return set() return set()
def _check_apc_conflict(self, new_niveau_id: int, new_parcour_id: int): def _parcours_niveaux_ids(self, parcours=list[ApcParcours]) -> set[int]:
"raises ScoFormationConflict si (niveau, parcours) pas unique dans ce semestre" """set des ids de niveaux communs à tous les parcours listés"""
# Les UE du même semestre que nous: return set.intersection(
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) n.id
for oue in ues_sem for n in self.niveau_competence.niveaux_annee_de_parcours(
if oue.id != self.id parcour, self.annee(), self.formation.referentiel_competence
): )
log( }
f"set_ue_niveau_competence: {self}: ({new_niveau_id}, {new_parcour_id}) déjà associé" for parcour in parcours
) ]
raise ScoFormationConflict() )
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é. """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. Assure que ce soit la seule dans son parcours.
Sinon, raises ScoFormationConflict. Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie. 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: if niveau is not None:
self._check_apc_conflict(niveau.id, self.parcour_id) if self.niveau_competence_id is not None:
# Le niveau est-il dans le parcours ? Sinon, erreur return (
if self.parcour and niveau.id not in ( False,
n.id f"""{self.acronyme} déjà associée à un niveau de compétences ({
for n in niveau.niveaux_annee_de_parcours( self.id}, {self.niveau_competence_id})""",
self.parcour, self.annee(), self.formation.referentiel_competence
) )
if (
niveau.competence.referentiel.id
!= self.formation.referentiel_competence.id
): ):
log( return (
f"set_niveau_competence: niveau {niveau} hors parcours {self.parcour}" 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 self.niveau_competence = niveau
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
# Invalidation du cache # Invalidation du cache
self.formation.invalidate_cached_sems() self.formation.invalidate_cached_sems()
log(f"ue.set_niveau_competence( {self}, {niveau} )") log(f"ue.set_niveau_competence( {self}, {niveau} )")
return True, ""
def set_parcour(self, parcour: ApcParcours): def set_parcours(self, parcours: list[ApcParcours]) -> tuple[bool, str]:
"""Associe cette UE au parcours indiqué. """Associe cette UE aux parcours indiqués.
Assure que ce soit la seule dans son parcours. Si un niveau est déjà associé, vérifie sa cohérence.
Sinon, raises ScoFormationConflict. Renvoie (True, "") si ok, sinon (False, error_message)
Si niveau est None, désassocie.
""" """
if (parcour is not None) and self.niveau_competence is not None: msg = ""
self._check_apc_conflict(self.niveau_competence.id, parcour.id) # Le niveau est-il dans tous ces parcours ? Sinon, l'enlève
self.parcour = parcour prev_niveau = self.niveau_competence
# Le niveau est-il dans ce parcours ? Sinon, l'enlève
if ( if (
parcour parcours
and self.niveau_competence and self.niveau_competence
and self.niveau_competence.id and self.niveau_competence.id not in self._parcours_niveaux_ids(parcours)
not in (
n.id
for n in self.niveau_competence.niveaux_annee_de_parcours(
parcour, self.annee(), self.formation.referentiel_competence
)
)
): ):
self.niveau_competence = None 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.add(self)
db.session.commit() db.session.commit()
# Invalidation du cache # Invalidation du cache
self.formation.invalidate_cached_sems() 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): class DispenseUE(db.Model):
"""Dispense d'UE """Dispense d'UE
Utilisé en APC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée Utilisé en APC (BUT) pour indiquer
qu'ils ne refont pas. - 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: 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 n'est pas affectée par les décisions de jury (pas effacée)
- elle est associée à un formsemestre - elle est associée à un formsemestre

View File

@ -275,6 +275,7 @@ DEVENIRS_NEXT2 = {NEXT_OR_NEXT2: 1, NEXT2: 1}
NO_SEMESTRE_ID = -1 # code semestre si pas de semestres NO_SEMESTRE_ID = -1 # code semestre si pas de semestres
# Règles gestion cursus # Règles gestion cursus
class DUTRule(object): class DUTRule(object):
def __init__(self, rule_id, premise, conclusion): def __init__(self, rule_id, premise, conclusion):
@ -298,7 +299,7 @@ class DUTRule(object):
# Types de cursus # 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: class TypeCursus:

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -64,7 +64,7 @@ from flask import flash, g, request, url_for
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from config import Config from config import Config
from app import log from app import log, ScoDocJSONEncoder
from app.but import jury_but_pv from app.but import jury_but_pv
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
@ -365,7 +365,7 @@ def do_formsemestre_archive(
# Bulletins en JSON # Bulletins en JSON
data = gen_formsemestre_recapcomplet_json(formsemestre_id, xml_with_decisions=True) 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: if data:
PVArchive.store(archive_id, "Bulletins.json", data_js) PVArchive.store(archive_id, "Bulletins.json", data_js)
# Décisions de jury, en XLS # Décisions de jury, en XLS

View File

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

View File

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

View File

@ -33,6 +33,7 @@ import json
from flask import abort from flask import abort
from app import ScoDocJSONEncoder
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import but_validations from app.models import but_validations
@ -74,7 +75,7 @@ def make_json_formsemestre_bulletinetud(
version=version, 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() # (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"]: if prefs["bul_show_minmax_eval"] or prefs["bul_show_moypromo"]:
etat = sco_evaluations.do_evaluation_etat(e["evaluation_id"]) etat = sco_evaluations.do_evaluation_etat(e["evaluation_id"])
if prefs["bul_show_minmax_eval"]: if prefs["bul_show_minmax_eval"]:
eval_dict["min"] = scu.fmt_note(etat["mini"]) eval_dict["min"] = etat["mini"] # chaine, sur 20
eval_dict["max"] = scu.fmt_note(etat["maxi"]) eval_dict["max"] = etat["maxi"]
if prefs["bul_show_moypromo"]: if prefs["bul_show_moypromo"]:
eval_dict["moy"] = scu.fmt_note(etat["moy"]) eval_dict["moy"] = etat["moy"]
mod_dict["evaluation"].append(eval_dict) mod_dict["evaluation"].append(eval_dict)

View File

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

View File

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

View File

@ -30,6 +30,7 @@
""" """
import re import re
import sqlalchemy as sa
import flask import flask
from flask import flash, render_template, url_for from flask import flash, render_template, url_for
from flask import g, request from flask import g, request
@ -127,7 +128,7 @@ def do_ue_create(args):
): ):
# évite les conflits de code # évite les conflits de code
while True: while True:
cursor = db.session.execute("select notes_newid_ucod();") cursor = db.session.execute(sa.text("select notes_newid_ucod();"))
code = cursor.fetchone()[0] code = cursor.fetchone()[0]
if UniteEns.query.filter_by(ue_code=code).count() == 0: if UniteEns.query.filter_by(ue_code=code).count() == 0:
break break
@ -368,7 +369,12 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"min_value": 0, "min_value": 0,
"max_value": 1000, "max_value": 1000,
"title": "ECTS", "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 "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", cancelbutton="Revenir à la formation",
) )
if tf[0] == 0: if tf[0] == 0:
niveau_competence_div = "" ue_parcours_div = ""
if ue and is_apc: 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: if ue and ue.modules.count() and ue.semestre_idx is not None:
modules_div = f"""<div id="ue_list_modules"> modules_div = f"""<div id="ue_list_modules">
<div><b>{ue.modules.count()} modules sont rattachés <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) "\n".join(H)
+ tf[1] + tf[1]
+ clone_form + clone_form
+ niveau_competence_div + ue_parcours_div
+ modules_div + modules_div
+ bonus_div + bonus_div
+ ue_div + ue_div
@ -737,8 +743,10 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
) )
H = [ H = [
html_sco_header.sco_header( html_sco_header.sco_header(
cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css"], cssstyles=html_sco_header.BOOTSTRAP_MULTISELECT_CSS
javascripts=[ + ["libjs/jQuery-tagEditor/jquery.tag-editor.css"],
javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS
+ [
"libjs/jinplace-1.2.1.min.js", "libjs/jinplace-1.2.1.min.js",
"js/ue_list.js", "js/ue_list.js",
"js/edit_ue.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', <a href="{url_for('notes.refcomp_show',
scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id)}" scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id)}"
class="stdlink"> class="stdlink">
{formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long} {formation.referentiel_competence.type_titre}
{formation.referentiel_competence.specialite_long}
</a>&nbsp;""" </a>&nbsp;"""
msg_refcomp = "changer" msg_refcomp = "changer"
H.append(f"""<ul><li>{descr_refcomp}""") 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: elif formation.referentiel_competence is not None:
H.append("""(non modifiable car utilisé par des semestres)""") H.append("""(non modifiable car utilisé par des semestres)""")
H.append("</li>")
if formation.referentiel_competence is not None:
H.append(
"""<li>Parcours, compétences et UEs&nbsp;:
<div class="formation_parcs">
"""
)
for parc in formation.referentiel_competence.parcours:
H.append(
f"""<div><a href="{url_for("notes.parcour_formation",
scodoc_dept=g.scodoc_dept, formation_id=formation.id, parcour_id=parc.id )
}">{parc.code}</a></div>"""
)
H.append("""</div></li>""")
H.append( H.append(
f"""</li> f"""
<li> <a class="stdlink" href="{ <li> <a class="stdlink" href="{
url_for('notes.edit_modules_ue_coefs', url_for('notes.edit_modules_ue_coefs',
scodoc_dept=g.scodoc_dept, formation_id=formation_id, semestre_idx=semestre_idx) 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="formation_ue_list">
<div class="ue_list_tit">Programme pédagogique:</div> <div class="ue_list_tit">Programme pédagogique:</div>
<form> <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> </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: if ues and ues[0]["ue_id"] != ue_id:
raise ScoValueError( raise ScoValueError(
f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé ! 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: # On ne peut pas supprimer le code UE:
if "ue_code" in args and not args["ue_code"]: if "ue_code" in args and not args["ue_code"]:

View File

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

View File

@ -32,13 +32,13 @@ import io
from zipfile import ZipFile from zipfile import ZipFile
import flask 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 import app.scodoc.sco_utils as scu
from app import log from app import log
from app.models import Formation from app.models import Formation
from app.scodoc import html_sco_header 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_etape_apogee
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
from app.scodoc import sco_portal_apogee 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_semset
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.scodoc.gen_tables import GenTable 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 from app.scodoc.sco_exceptions import ScoValueError
@ -240,7 +240,11 @@ def apo_semset_maq_status(
if semset["jury_ok"]: if semset["jury_ok"]:
H.append("""<li>Décisions de jury saisies</li>""") H.append("""<li>Décisions de jury saisies</li>""")
else: 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: if ok_for_export:
H.append("""<li>%d étudiants, prêt pour l'export.</li>""" % len(nips_ok)) 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: if semset and ok_for_export:
H.append( 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="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">') H.append('<div id="param_export_res">')
@ -372,7 +375,7 @@ def apo_semset_maq_status(
H.append("</div>") H.append("</div>")
# Aide: # Aide:
H.append( H.append(
""" f"""
<p><a class="stdlink" href="semset_page">Retour aux ensembles de semestres</a></p> <p><a class="stdlink" href="semset_page">Retour aux ensembles de semestres</a></p>
<div class="pas_help"> <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. l'export des résultats après les jurys, puis de remplir et exporter ces fichiers.
</p> </p>
<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>
<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"> <pre class="small_pre_acc">
XX-APO_TITRES-XX XX-APO_TITRES-XX
apoC_annee 2007/2008 apoC_annee 2007/2008
@ -427,7 +432,6 @@ def apo_semset_maq_status(
</p> </p>
</div> </div>
""" """
% (APO_INPUT_ENCODING,)
) )
H.append(html_sco_header.sco_footer()) H.append(html_sco_header.sco_footer())
return "\n".join(H) return "\n".join(H)
@ -446,21 +450,25 @@ def table_apo_csv_list(semset):
# Ajoute qq infos pour affichage: # Ajoute qq infos pour affichage:
csv_data = sco_etape_apogee.apo_csv_get(t["etape_apo"], annee_scolaire, sem_id) 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"]) 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["nb_etuds"] = len(apo_data.etuds)
t["date_str"] = t["date"].strftime("%d/%m/%Y à %H:%M") t["date_str"] = t["date"].strftime("%d/%m/%Y à %H:%M")
view_link = "view_apo_csv?etape_apo=%s&semset_id=%s" % ( view_link = url_for(
t["etape_apo"], "notes.view_apo_csv",
semset["semset_id"], scodoc_dept=g.scodoc_dept,
etape_apo=t["etape_apo"],
semset_id=semset["semset_id"],
) )
t["_filename_target"] = view_link t["_filename_target"] = view_link
t["_etape_apo_target"] = view_link t["_etape_apo_target"] = view_link
t["suppress"] = scu.icontag( t["suppress"] = scu.icontag(
"delete_small_img", border="0", alt="supprimer", title="Supprimer" "delete_small_img", border="0", alt="supprimer", title="Supprimer"
) )
t["_suppress_target"] = "view_apo_csv_delete?etape_apo=%s&semset_id=%s" % ( t["_suppress_target"] = url_for(
t["etape_apo"], "notes.view_apo_csv_delete",
semset["semset_id"], scodoc_dept=g.scodoc_dept,
etape_apo=t["etape_apo"],
semset_id=semset["semset_id"],
) )
columns_ids = ["filename", "etape_apo", "date_str", "nb_etuds"] 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(): for etud in etuds.values():
etud_sco = sco_etud.get_etud_info(code_nip=etud["nip"], filled=True) etud_sco = sco_etud.get_etud_info(code_nip=etud["nip"], filled=True)
if etud_sco: if etud_sco:
e = etud_sco[0]
etud["inscriptions_scodoc"] = ", ".join( 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( f"""<a href="{
s=sem, e=e url_for('notes.formsemestre_bulletinetud',
) scodoc_dept=g.scodoc_dept,
for sem in e["sems"] 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"]) tgt = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"])
e["_nom_target"] = tgt e["_nom_target"] = tgt
e["_prenom_target"] = tgt e["_prenom_target"] = tgt
e["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"],) e["_nom_td_attrs"] = f"""id="{e['etudid']}" class="etudinfo" """
e["_prenom_td_attrs"] = 'id="pre-%s" class="etudinfo"' % (e["etudid"],) e["_prenom_td_attrs"] = f"""id="pre-{e['etudid']}" class="etudinfo" """
return _view_etuds_page( return _view_etuds_page(
semset_id, 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: # 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"])) 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( tab = GenTable(
titles={ titles={
"nip": "Code NIP", "nip": "Code NIP",
@ -579,14 +584,23 @@ def _view_etuds_page(semset_id, title="", etuds=[], keys=(), format="html"):
if format != "html": if format != "html":
return tab.make_page(format=format) 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( {tab.html()}
"""<p><a href="apo_semset_maq_status?semset_id=%s">Retour à la page d'export Apogée</a>"""
% semset_id
)
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): 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: if autodetect:
# check encoding (although documentation states that users SHOULD upload LATIN1) # 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: if message:
log(f"view_apo_csv_store: {message}") log(f"view_apo_csv_store: {message}")
else: else:
@ -623,7 +637,7 @@ def view_apo_csv_store(semset_id="", csvfile=None, data: bytes = "", autodetect=
f""" f"""
Erreur: l'encodage du fichier est mal détecté. Erreur: l'encodage du fichier est mal détecté.
Essayez sans auto-détection, ou vérifiez le codage et le contenu 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, dest_url=dest_url,
) from exc ) from exc
@ -631,7 +645,7 @@ def view_apo_csv_store(semset_id="", csvfile=None, data: bytes = "", autodetect=
raise ScoValueError( raise ScoValueError(
f""" f"""
Erreur: l'encodage du fichier est incorrect. 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, dest_url=dest_url,
) from exc ) from exc
@ -640,20 +654,20 @@ def view_apo_csv_store(semset_id="", csvfile=None, data: bytes = "", autodetect=
apo_data = sco_apogee_csv.ApoData( apo_data = sco_apogee_csv.ApoData(
data_str, periode=semset["sem_id"] data_str, periode=semset["sem_id"]
) # parse le fichier -> exceptions ) # 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"]: if apo_data.etape not in semset["etapes"]:
raise ScoValueError( 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"]) sco_etape_apogee.apo_csv_store(data_str, semset["annee_scolaire"], semset["sem_id"])
return flask.redirect( return flask.redirect(dest_url)
url_for(
"notes.apo_semset_maq_status",
scodoc_dept=g.scodoc_dept,
semset_id=semset_id,
)
)
def view_apo_csv_download_and_store(etape_apo="", semset_id=""): 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}" dest_url = f"apo_semset_maq_status?semset_id={semset_id}"
if not dialog_confirmed: if not dialog_confirmed:
return scu.confirm_dialog( return scu.confirm_dialog(
"""<h2>Confirmer la suppression du fichier étape <tt>%s</tt>?</h2> f"""<h2>Confirmer la suppression du fichier étape <tt>{etape_apo}</tt>?</h2>
<p>La suppression sera définitive.</p>""" <p>La suppression sera définitive.</p>""",
% (etape_apo,),
dest_url="", dest_url="",
cancel_url=dest_url, cancel_url=dest_url,
parameters={"semset_id": semset_id, "etape_apo": etape_apo}, parameters={"semset_id": semset_id, "etape_apo": etape_apo},
@ -727,24 +740,24 @@ def view_apo_csv(etape_apo="", semset_id="", format="html"):
H = [ H = [
html_sco_header.sco_header( 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, init_qtip=True,
javascripts=["js/etud_info.js"], javascripts=["js/etud_info.js"],
), ),
"""<h2>Etudiants dans la maquette Apogée %s</h2>""" % etape_apo, f"""<h2>Étudiants dans la maquette Apogée {etape_apo}</h2>
"""<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>""" <p>Pour l'ensemble <a class="stdlink" href="{
% semset, 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 # Liste des étudiants (sans les résultats pour le moment): TODO
etuds = apo_data.etuds etuds = apo_data.etuds
@ -789,12 +802,21 @@ def view_apo_csv(etape_apo="", semset_id="", format="html"):
return tab.make_page(format=format) return tab.make_page(format=format)
H += [ H += [
tab.html(), f"""
"""<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>""" {tab.html()}
% (etape_apo, semset_id), <p><a class="stdlink" href="{
"""<div><a class="stdlink" href="apo_semset_maq_status?semset_id=%s">Retour</a> url_for("notes.view_apo_csv",
</div>""" scodoc_dept=g.scodoc_dept,
% semset_id, 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(), html_sco_header.sco_footer(),
] ]
@ -809,7 +831,7 @@ def apo_csv_export_results(
block_export_res_ues=False, block_export_res_ues=False,
block_export_res_modules=False, block_export_res_modules=False,
block_export_res_sdj=False, block_export_res_sdj=False,
): ) -> Response:
"""Remplit les fichiers CSV archivés """Remplit les fichiers CSV archivés
et donne un ZIP avec tous les résultats. et donne un ZIP avec tous les résultats.
""" """
@ -833,31 +855,28 @@ def apo_csv_export_results(
periode = semset["sem_id"] periode = semset["sem_id"]
data = io.BytesIO() data = io.BytesIO()
dest_zip = ZipFile(data, "w") with ZipFile(data, "w") as dest_zip:
etapes_apo = sco_etape_apogee.apo_csv_list_stored_etapes(
etapes_apo = sco_etape_apogee.apo_csv_list_stored_etapes( annee_scolaire, periode, etapes=semset.list_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,
) )
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) data.seek(0)
basename = ( basename = (
sco_preferences.get_preference("DeptName") sco_preferences.get_preference("DeptName")
+ str(annee_scolaire) + f"{annee_scolaire}-{periode}-"
+ "-%s-" % periode
+ "-".join(etapes_apo) + "-".join(etapes_apo)
) )
basename = scu.unescape_html(basename) basename = scu.unescape_html(basename)

View File

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

View File

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

View File

@ -79,7 +79,7 @@ def evaluation_create_form(
mod = modimpl_o["module"] mod = modimpl_o["module"]
formsemestre_id = modimpl_o["formsemestre_id"] formsemestre_id = modimpl_o["formsemestre_id"]
formsemestre = modimpl.formsemestre 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_malus = mod["module_type"] == ModuleType.MALUS
is_apc = mod["module_type"] in (ModuleType.RESSOURCE, ModuleType.SAE) is_apc = mod["module_type"] in (ModuleType.RESSOURCE, ModuleType.SAE)
preferences = sco_preferences.SemPreferences(formsemestre.id) preferences = sco_preferences.SemPreferences(formsemestre.id)

View File

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

View File

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

View File

@ -127,9 +127,7 @@ def formation_export_dict(
ue_dict["apc_niveau_libelle"] = ue.niveau_competence.libelle ue_dict["apc_niveau_libelle"] = ue.niveau_competence.libelle
ue_dict["apc_niveau_annee"] = ue.niveau_competence.annee ue_dict["apc_niveau_annee"] = ue.niveau_competence.annee
ue_dict["apc_niveau_ordre"] = ue.niveau_competence.ordre 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: # pour les coefficients:
ue_dict["reference"] = ue.id if ue_reference_style == "id" else ue.acronyme ue_dict["reference"] = ue.id if ue_reference_style == "id" else ue.acronyme
if not export_ids: if not export_ids:
@ -266,8 +264,8 @@ def _formation_retreive_refcomp(f_dict: dict) -> int:
def _formation_retreive_apc_niveau( def _formation_retreive_apc_niveau(
referentiel_competence_id: int, ue_dict: dict referentiel_competence_id: int, ue_dict: dict
) -> int: ) -> int:
"""Recherche dans le ref. de comp. un niveau pour cette UE """Recherche dans le ref. de comp. un niveau pour cette UE.
utilise comme clé (libelle, annee, ordre) Utilise (libelle, annee, ordre) comme clé.
""" """
libelle = ue_dict.get("apc_niveau_libelle") libelle = ue_dict.get("apc_niveau_libelle")
annee = ue_dict.get("apc_niveau_annee") 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 assert ue
if xml_ue_id: if xml_ue_id:
ues_old2new[xml_ue_id] = ue_id ues_old2new[xml_ue_id] = ue_id
# élément optionnel présent dans les exports BUT: # élément optionnel présent dans les exports BUT:
ue_reference = ue_info[1].get("reference") ue_reference = ue_info[1].get("reference")
if ue_reference: if ue_reference:
ue_reference_to_id[int(ue_reference)] = ue_id ue_reference_to_id[int(ue_reference)] = ue_id
# -- create matieres # -- Create matieres
for mat_info in ue_info[2]: for mat_info in ue_info[2]:
# Backward compat: un seul parcours par UE (ScoDoc < 9.4.71)
if mat_info[0] == "parcour": if mat_info[0] == "parcour":
# Parcours (BUT) # Parcours (BUT)
code_parcours = mat_info[1]["code"] 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, referentiel_id=referentiel_competence_id,
).first() ).first()
if parcour: if parcour:
ue.parcour = parcour ue.parcours = [parcour]
db.session.add(ue) db.session.add(ue)
else: else:
flash(f"Attention: parcours {code_parcours} inexistant !")
log(f"Warning: parcours {code_parcours} inexistant !") log(f"Warning: parcours {code_parcours} inexistant !")
continue 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" assert mat_info[0] == "matiere"
mat_info[1]["ue_id"] = ue_id mat_info[1]["ue_id"] = ue_id
mat_id = sco_edit_matiere.do_matiere_create(mat_info[1]) mat_id = sco_edit_matiere.do_matiere_create(mat_info[1])

View File

@ -37,6 +37,7 @@ from flask import flash, redirect, render_template, url_for
from flask_login import current_user from flask_login import current_user
from app import log from app import log
from app.but.cursus_but import formsemestre_warning_apc_setup
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_common import ResultatsSemestre from app.comp.res_common import ResultatsSemestre
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
@ -604,7 +605,7 @@ def formsemestre_description_table(
columns_ids += ["Coef."] columns_ids += ["Coef."]
ues = [] # liste des UE, seulement en APC pour les coefs ues = [] # liste des UE, seulement en APC pour les coefs
else: else:
ues = formsemestre.query_ues().all() ues = formsemestre.get_ues()
columns_ids += [f"ue_{ue.id}" for ue in ues] columns_ids += [f"ue_{ue.id}" for ue in ues]
if sco_preferences.get_preference("bul_show_ects", formsemestre_id): if sco_preferences.get_preference("bul_show_ects", formsemestre_id):
columns_ids += ["ects"] columns_ids += ["ects"]
@ -645,26 +646,33 @@ def formsemestre_description_table(
ects_str = ue.ects ects_str = ue.ects
ue_info = { ue_info = {
"UE": ue.acronyme, "UE": ue.acronyme,
"Code": "",
"ects": ects_str, "ects": ects_str,
"Module": ue.titre, "Module": ue.titre,
"_css_row_class": "table_row_ue", "_css_row_class": "table_row_ue",
"_UE_td_attrs": f'style="background-color: {ue.color} !important;"'
if ue.color
else "",
} }
if use_ue_coefs: if use_ue_coefs:
ue_info["Coef."] = ue.coefficient ue_info["Coef."] = ue.coefficient
ue_info["Coef._class"] = "ue_coef" 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( mod_inscrits = sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=modimpl.id moduleimpl_id=modimpl.id
) )
enseignants = ", ".join(ens.get_prenomnom() for ens in modimpl.enseignants) enseignants = ", ".join(ens.get_prenomnom() for ens in modimpl.enseignants)
l = { row = {
"UE": modimpl.module.ue.acronyme, "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 "", "Code": modimpl.module.code or "",
"Module": modimpl.module.abbrev or modimpl.module.titre, "Module": modimpl.module.abbrev or modimpl.module.titre,
"_Module_class": "scotext", "_Module_class": "scotext",
@ -691,26 +699,32 @@ def formsemestre_description_table(
sum_coef += modimpl.module.coefficient sum_coef += modimpl.module.coefficient
coef_dict = modimpl.module.get_ue_coef_dict() coef_dict = modimpl.module.get_ue_coef_dict()
for ue in ues: 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: if with_parcours:
l["parcours"] = ", ".join( row["parcours"] = ", ".join(
sorted([pa.code for pa in modimpl.module.parcours]) sorted([pa.code for pa in modimpl.module.parcours])
) )
rows.append(l) rows.append(row)
if with_evals: if with_evals:
# Ajoute lignes pour evaluations # Ajoute lignes pour evaluations
evals = nt.get_mod_evaluation_etat_list(modimpl.id) evals = nt.get_mod_evaluation_etat_list(modimpl.id)
evals.reverse() # ordre chronologique evals.reverse() # ordre chronologique
# Ajoute etat: # Ajoute etat:
eval_rows = []
for eval_dict in evals: for eval_dict in evals:
e = eval_dict.copy() 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_order"] = e["jour"].isoformat()
e["jour"] = e["jour"].strftime("%d/%m/%Y") if e["jour"] else "" e["jour"] = e["jour"].strftime("%d/%m/%Y") if e["jour"] else ""
e["UE"] = l["UE"] e["UE"] = row["UE"]
e["_UE_td_attrs"] = l["_UE_td_attrs"] e["_UE_td_attrs"] = row["_UE_td_attrs"]
e["Code"] = l["Code"] e["Code"] = row["Code"]
e["_css_row_class"] = "evaluation" e["_css_row_class"] = "evaluation"
e["Module"] = "éval." e["Module"] = "éval."
# Cosmetic: conversions pour affichage # Cosmetic: conversions pour affichage
@ -733,8 +747,9 @@ def formsemestre_description_table(
e[f"ue_{ue_id}"] = poids or "" e[f"ue_{ue_id}"] = poids or ""
e[f"_ue_{ue_id}_class"] = "poids" e[f"_ue_{ue_id}_class"] = "poids"
e[f"_ue_{ue_id}_help"] = "poids vers l'UE" 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} sums = {"_css_row_class": "moyenne sortbottom", "ects": sum_ects, "Coef.": sum_coef}
rows.append(sums) rows.append(sums)
@ -1057,6 +1072,7 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
formsemestre_status_head( formsemestre_status_head(
formsemestre_id=formsemestre_id, page_title="Tableau de bord" formsemestre_id=formsemestre_id, page_title="Tableau de bord"
), ),
formsemestre_warning_apc_setup(formsemestre, nt),
formsemestre_warning_etuds_sans_note(formsemestre, nt) formsemestre_warning_etuds_sans_note(formsemestre, nt)
if can_change_all_notes if can_change_all_notes
else "", else "",
@ -1282,7 +1298,7 @@ def formsemestre_tableau_modules(
""" """
) )
if mod.module_type in (ModuleType.RESSOURCE, ModuleType.SAE): 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}">') H.append(f'<a class="invisible_link" href="#" title="{mod_descr}">')
for coef in coefs: for coef in coefs:
if coef[1] > 0: if coef[1] > 0:

View File

@ -606,7 +606,9 @@ def formsemestre_recap_parcours_table(
else: else:
# si l'étudiant n'est pas inscrit à un parcours mais que le semestre a plus d'UE # si l'étudiant n'est pas inscrit à un parcours mais que le semestre a plus d'UE
# signale un éventuel problème: # 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""" parcours_name = f"""
<span class="code_parcours no_parcours">{scu.EMO_WARNING}&nbsp;pas de parcours <span class="code_parcours no_parcours">{scu.EMO_WARNING}&nbsp;pas de parcours
</span>""" </span>"""

View File

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

View File

@ -36,7 +36,13 @@ from flask_login import current_user
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat 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 import log
from app.tables import list_etuds 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), (UniteEns.query.get(ue_id) for ue_id in ue_ids),
key=lambda u: (u.numero or 0, u.acronyme), 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: for ue in ues:
H.append(f"""<th title="{ue.titre or ''}">{ue.acronyme}</th>""") 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()) etuds = list_etuds.etuds_sorted_from_ids(table_inscr.keys())
for etud in etuds: for etud in etuds:
ues_etud = table_inscr[etud.id] 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>""" >{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: for ue in ues:
td_class = "" td_class = ""
est_inscr = ues_etud.get(ue.id) # None si pas concerné 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" content = f"""<input type="checkbox"
{'checked' if est_inscr else ''} {'checked' if est_inscr else ''}
{'disabled' if read_only 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);" onchange="change_ue_inscr(this);"
data-url_inscr={ data-url_inscr="{
url_for("notes.etud_inscrit_ue", url_for("notes.etud_inscrit_ue",
scodoc_dept=g.scodoc_dept, etudid=etud.id, scodoc_dept=g.scodoc_dept, etudid=etud.id,
formsemestre_id=res.formsemestre.id, ue_id=ue.id) formsemestre_id=res.formsemestre.id, ue_id=ue.id)
} }"
data-url_desinscr={ data-url_desinscr="{
url_for("notes.etud_desinscrit_ue", url_for("notes.etud_desinscrit_ue",
scodoc_dept=g.scodoc_dept, etudid=etud.id, scodoc_dept=g.scodoc_dept, etudid=etud.id,
formsemestre_id=res.formsemestre.id, ue_id=ue.id) formsemestre_id=res.formsemestre.id, ue_id=ue.id)
} }"
/> />
""" """
H.append(f"""<td{td_class}>{content}</td>""") H.append(f"""<td{td_class}>{content}</td>""")
H.append( H.append(
"""</table> """
</tbody>
</table>
</form> </form>
<div class="help"> <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. 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. </p>
La dispense d'UE est réversible à tout moment (avant le jury de fin de semestre) <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. et n'affecte pas les notes saisies.
</p>
</div> </div>
</div> </div>
""" """

View File

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

View File

@ -45,20 +45,20 @@ Au niveau du code interface, on défini pour chaque préférence:
- size: longueur du chap texte - size: longueur du chap texte
- input_type: textarea, separator, ... type de widget TrivialFormulator a utiliser - input_type: textarea, separator, ... type de widget TrivialFormulator a utiliser
- rows, rols: geometrie des textareas - 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 ... ou pdf ou pvpdf ou ...
- only_global (default False): si vraie, ne peut pas etre associée a un seul semestre. - 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 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 On peut éditer les préférences d'une ou plusieurs catégories au niveau d'un
semestre ou au niveau global. semestre ou au niveau global.
* niveau global: changer les valeurs, liste de catégories. * niveau global: changer les valeurs, liste de catégories.
* niveau d'un semestre: * niveau d'un semestre:
présenter valeur courante: valeur ou "definie globalement" ou par defaut présenter valeur courante: valeur ou "definie globalement" ou par defaut
lien "changer valeur globale" lien "changer valeur globale"
------------------------------------------------------------------------------ ------------------------------------------------------------------------------
Doc technique: Doc technique:
@ -66,10 +66,10 @@ Doc technique:
Toutes les préférences sont stockées dans la table sco_prefs, qui contient Toutes les préférences sont stockées dans la table sco_prefs, qui contient
des tuples (name, value, formsemestre_id). des tuples (name, value, formsemestre_id).
Si formsemestre_id est NULL, la valeur concerne tous les semestres, 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 * Utilisation dans ScoDoc 9
- lire une valeur: - lire une valeur:
get_preference(name, formsemestre_id) get_preference(name, formsemestre_id)
nb: les valeurs sont des chaines, sauf: nb: les valeurs sont des chaines, sauf:
. si le type est spécifié (float ou int) . si le type est spécifié (float ou int)
@ -111,9 +111,7 @@ get_base_preferences(formsemestre_id)
""" """
import flask import flask
from flask import flash, g, request from flask import current_app, flash, g, request, url_for
# from flask_login import current_user
from app.models import Departement from app.models import Departement
from app.scodoc import sco_cache from app.scodoc import sco_cache
@ -208,6 +206,7 @@ PREF_CATEGORIES = (
("abs", {"title": "Suivi des absences", "related": ("bul",)}), ("abs", {"title": "Suivi des absences", "related": ("bul",)}),
("assi", {"title": "Gestion de l'assiduité"}), ("assi", {"title": "Gestion de l'assiduité"}),
("portal", {"title": "Liaison avec portail (Apogée, etc)"}), ("portal", {"title": "Liaison avec portail (Apogée, etc)"}),
("apogee", {"title": "Exports Apogée"}),
( (
"pdf", "pdf",
{ {
@ -235,7 +234,9 @@ PREF_CATEGORIES = (
"bul_margins", "bul_margins",
{ {
"title": "Marges additionnelles des bulletins, en millimètres", "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"), "related": ("bul", "bul_mail", "pdf"),
}, },
), ),
@ -321,7 +322,9 @@ class BasePreferences(object):
{ {
"initvalue": "", "initvalue": "",
"title": "Nom de l'Institut", "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, "size": 40,
"category": "general", "category": "general",
"only_global": True, "only_global": True,
@ -355,7 +358,9 @@ class BasePreferences(object):
"initvalue": "", "initvalue": "",
"title": "e-mails à qui notifier les opérations", "title": "e-mails à qui notifier les opérations",
"size": 70, "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", "category": "general",
"only_global": False, # peut être spécifique à un semestre "only_global": False, # peut être spécifique à un semestre
}, },
@ -367,9 +372,14 @@ class BasePreferences(object):
"initvalue": "", "initvalue": "",
"title": "Adresse mail origine", "title": "Adresse mail origine",
"size": 40, "size": 40,
"explanation": """adresse expéditeur pour tous les envois par mails (bulletins, "explanation": f"""adresse expéditeur pour tous les envois par mail
comptes, etc.). (bulletins, notifications, etc.). Si vide, utilise la config globale.
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", "category": "misc",
"only_global": True, "only_global": True,
}, },
@ -778,7 +788,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée", "explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"labels": ["non", "oui"], "labels": ["non", "oui"],
"category": "portal", "category": "apogee",
"only_global": True, "only_global": True,
}, },
), ),
@ -790,7 +800,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée", "explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"labels": ["non", "oui"], "labels": ["non", "oui"],
"category": "portal", "category": "apogee",
"only_global": True, "only_global": True,
}, },
), ),
@ -802,7 +812,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée", "explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"labels": ["non", "oui"], "labels": ["non", "oui"],
"category": "portal", "category": "apogee",
"only_global": True, "only_global": True,
}, },
), ),
@ -814,7 +824,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée", "explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"labels": ["non", "oui"], "labels": ["non", "oui"],
"category": "portal", "category": "apogee",
"only_global": True, "only_global": True,
}, },
), ),
@ -826,7 +836,7 @@ class BasePreferences(object):
"explanation": "si coché, exporte exporte étudiants même si pas décision de jury saisie (sinon laisse vide)", "explanation": "si coché, exporte exporte étudiants même si pas décision de jury saisie (sinon laisse vide)",
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"labels": ["non", "oui"], "labels": ["non", "oui"],
"category": "portal", "category": "apogee",
"only_global": True, "only_global": True,
}, },
), ),
@ -838,7 +848,19 @@ class BasePreferences(object):
"explanation": "si coché, exporte exporte étudiants en attente de ratrapage comme ATT (sinon laisse vide)", "explanation": "si coché, exporte exporte étudiants en attente de ratrapage comme ATT (sinon laisse vide)",
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"labels": ["non", "oui"], "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, "only_global": True,
}, },
), ),
@ -1989,7 +2011,8 @@ class BasePreferences(object):
value = _get_pref_default_value_from_config(name, pref[1]) value = _get_pref_default_value_from_config(name, pref[1])
self.default[name] = value self.default[name] = value
self.prefs[None][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 # add to db table
self._editor.create( self._editor.create(
cnx, {"dept_id": self.dept_id, "name": name, "value": value} cnx, {"dept_id": self.dept_id, "name": name, "value": value}
@ -2266,7 +2289,6 @@ class SemPreferences:
raise ScoValueError( raise ScoValueError(
"sem_preferences.edit doit etre appele sur un semestre !" "sem_preferences.edit doit etre appele sur un semestre !"
) # a bug ! ) # a bug !
sem = sco_formsemestre.get_formsemestre(self.formsemestre_id)
H = [ H = [
html_sco_header.html_sem_header( html_sco_header.html_sem_header(
"Préférences du semestre", "Préférences du semestre",

View File

@ -242,7 +242,19 @@ def formsemestre_recapcomplet(
</div> </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()) H.append(html_sco_header.sco_footer())
# HTML or binary data ? # HTML or binary data ?
if len(H) > 1: if len(H) > 1:

View File

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

View File

@ -84,15 +84,17 @@ class SemSet(dict):
self.semset_id = semset_id self.semset_id = semset_id
self["semset_id"] = semset_id self["semset_id"] = semset_id
self.sems = [] self.sems = []
self.formsemestre_ids = [] self.formsemestres = [] # modernisation en cours...
self.is_apc = False
self.formsemestre_ids = set()
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
if semset_id: # read existing set if semset_id: # read existing set
L = semset_list(cnx, args={"semset_id": semset_id}) semsets = semset_list(cnx, args={"semset_id": semset_id})
if not L: if not semsets:
raise ScoValueError(f"Ensemble inexistant ! (semset {semset_id})") raise ScoValueError(f"Ensemble inexistant ! (semset {semset_id})")
self["title"] = L[0]["title"] self["title"] = semsets[0]["title"]
self["annee_scolaire"] = L[0]["annee_scolaire"] self["annee_scolaire"] = semsets[0]["annee_scolaire"]
self["sem_id"] = L[0]["sem_id"] self["sem_id"] = semsets[0]["sem_id"]
r = ndb.SimpleDictFetch( r = ndb.SimpleDictFetch(
"SELECT formsemestre_id FROM notes_semset_formsemestre WHERE semset_id = %(semset_id)s", "SELECT formsemestre_id FROM notes_semset_formsemestre WHERE semset_id = %(semset_id)s",
{"semset_id": semset_id}, {"semset_id": semset_id},
@ -123,8 +125,13 @@ class SemSet(dict):
def load_sems(self): def load_sems(self):
"""Load formsemestres""" """Load formsemestres"""
self.sems = [] self.sems = []
self.formsemestres = []
for formsemestre_id in self.formsemestre_ids: 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: if self.sems:
self["date_debut"] = min([sem["date_debut_iso"] for sem in 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] self["semtitles"] = [sem["titre_num"] for sem in self.sems]
# Construction du ou des lien(s) vers le semestre # 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"] = [
self["semlinks"] = [(pattern % sem) for sem in self.sems] 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"]) self["semtitles_str"] = "<br>".join(self["semlinks"])
def fill_formsemestres(self): def fill_formsemestres(self):
@ -149,6 +163,8 @@ class SemSet(dict):
def add(self, formsemestre_id): def add(self, formsemestre_id):
"Ajoute ce semestre à l'ensemble" "Ajoute ce semestre à l'ensemble"
# check for valid formsemestre_id
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
# check # check
if formsemestre_id in self.formsemestre_ids: if formsemestre_id in self.formsemestre_ids:
return # already there return # already there
@ -159,6 +175,17 @@ class SemSet(dict):
f"can't add {formsemestre_id} to set {self.semset_id}: incompatible sem_id" 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( ndb.SimpleQuery(
"""INSERT INTO notes_semset_formsemestre """INSERT INTO notes_semset_formsemestre
(formsemestre_id, semset_id) (formsemestre_id, semset_id)
@ -242,17 +269,28 @@ class SemSet(dict):
def load_etuds(self): def load_etuds(self):
self["etuds_without_nip"] = set() # etudids self["etuds_without_nip"] = set() # etudids
self["jury_ok"] = True self["jury_ok"] = True
self["jury_nb_missing"] = 0
is_apc = None
for sem in self.sems: for sem in self.sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) 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["etuds"] = list(nt.identdict.values())
sem["nips"] = {e["code_nip"] for e in sem["etuds"] if e["code_nip"]} sem["nips"] = {e["code_nip"] for e in sem["etuds"] if e["code_nip"]}
sem["etuds_without_nip"] = { sem["etuds_without_nip"] = {
e["etudid"] for e in sem["etuds"] if not e["code_nip"] e["etudid"] for e in sem["etuds"] if not e["code_nip"]
} }
self["etuds_without_nip"] |= sem["etuds_without_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_ok"] &= sem["jury_ok"]
self["jury_nb_missing"] += len(sem["etudids_no_jury"])
self["is_apc"] = bool(is_apc)
def html_descr(self): def html_descr(self):
"""Short HTML description""" """Short HTML description"""
@ -272,36 +310,21 @@ class SemSet(dict):
) )
H.append("</p>") H.append("</p>")
H.append( if self["sem_id"] == 1:
f"""<p>Période: <select name="periode" onchange="set_periode(this);"> periode = "1re période (S1, S3)"
<option value="1" {"selected" if self["sem_id"] == 1 else ""}>1re période (S1, S3)</option> elif self["sem_id"] == 2:
<option value="2" {"selected" if self["sem_id"] == 2 else ""}>2de période (S2, S4)</option> periode = "2de période (S2, S4)"
<option value="0" {"selected" if self["sem_id"] == 0 else ""}>non semestrialisée (LP, ...)</option> else:
</select> periode = "non semestrialisée (LP, ...). Incompatible avec BUT."
</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>
"""
)
H.append( 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: for sem in self.sems:
H.append( H.append(
@ -364,8 +387,7 @@ class SemSet(dict):
""" """
if sco_portal_apogee.has_portal(): if sco_portal_apogee.has_portal():
return self.bilan.html_diagnostic() return self.bilan.html_diagnostic()
else: return ""
return ""
def get_semsets_list(): def get_semsets_list():
@ -423,13 +445,15 @@ def do_semset_add_sem(semset_id, formsemestre_id):
raise ScoValueError("empty semset_id") raise ScoValueError("empty semset_id")
if formsemestre_id == "": if formsemestre_id == "":
raise ScoValueError("pas de semestre choisi !") raise ScoValueError("pas de semestre choisi !")
s = SemSet(semset_id=semset_id) semset = SemSet(semset_id=semset_id)
# check for valid formsemestre_id semset.add(formsemestre_id)
_ = sco_formsemestre.get_formsemestre(formsemestre_id) # raise exc return flask.redirect(
url_for(
s.add(formsemestre_id) "notes.apo_semset_maq_status",
scodoc_dept=g.scodoc_dept,
return flask.redirect("apo_semset_maq_status?semset_id=%s" % semset_id) semset_id=semset_id,
)
)
def do_semset_remove_sem(semset_id, formsemestre_id): def do_semset_remove_sem(semset_id, formsemestre_id):
@ -535,7 +559,7 @@ def semset_page(format="html"):
<select name="sem_id"> <select name="sem_id">
<option value="1">1re période (S1, S3)</option> <option value="1">1re période (S1, S3)</option>
<option value="2">2de période (S2, S4)</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> </select>
<input type="text" name="title" size="32"/> <input type="text" name="title" size="32"/>
<input type="submit" value="Créer"/> <input type="submit" value="Créer"/>

View File

@ -351,7 +351,7 @@ def check_modif_user(
# Unicité du cas_id # Unicité du cas_id
if 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 edit:
if cas_users and ( if cas_users and (
len(cas_users) > 1 or cas_users[0].user_name != user_name len(cas_users) > 1 or cas_users[0].user_name != user_name

View File

@ -56,13 +56,14 @@ from pytz import timezone
import dateutil.parser as dtparser import dateutil.parser as dtparser
import flask import flask
from flask import g, request from flask import g, request, Response
from flask import flash, url_for, make_response, jsonify from flask import flash, url_for, make_response
from flask_json import json_response
from werkzeug.http import HTTP_STATUS_CODES from werkzeug.http import HTTP_STATUS_CODES
from config import Config from config import Config
from app import log from app import log, ScoDocJSONEncoder
from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.codes_cursus import NOTES_TOLERANCE, CODES_EXPL from app.scodoc.codes_cursus import NOTES_TOLERANCE, CODES_EXPL
from app.scodoc import sco_xml from app.scodoc import sco_xml
import sco_version import sco_version
@ -855,16 +856,6 @@ def sendPDFFile(data, filename): # DEPRECATED utiliser send_file
return send_file(data, filename=filename, mime=PDF_MIMETYPE, attached=True) 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): def sendJSON(data, attached=False, filename=None):
js = json.dumps(data, indent=1, cls=ScoDocJSONEncoder) js = json.dumps(data, indent=1, cls=ScoDocJSONEncoder)
return send_file( return send_file(
@ -976,24 +967,26 @@ def get_request_args():
return vals return vals
def json_error(status_code, message=None): def json_error(status_code, message=None) -> Response:
"""Simple JSON response, for errors""" """Simple JSON for errors.
If as-response, returns Flask's Response. Otherwise returns a dict.
"""
payload = { payload = {
"error": HTTP_STATUS_CODES.get(status_code, "Unknown error"), "error": HTTP_STATUS_CODES.get(status_code, "Unknown error"),
"status": status_code, "status": status_code,
} }
if message: if message:
payload["message"] = message payload["message"] = message
response = jsonify(payload) response = json_response(status_=status_code, data_=payload)
response.status_code = status_code response.status_code = status_code
log(f"Error: {response}") log(f"Error: {response}")
return 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" """ """Simple JSON respons for "success" """
payload = payload or {"OK": True} payload = payload or {"OK": True}
response = jsonify(payload) response = json_response(status_=status_code, data_=payload)
response.status_code = status_code response.status_code = status_code
return response return response
@ -1157,8 +1150,8 @@ def icontag(name, file_format="png", no_size=False, **attrs):
file_format, file_format,
), ),
) )
im = PILImage.open(img_file) with PILImage.open(img_file) as image:
width, height = im.size[0], im.size[1] width, height = image.size[0], image.size[1]
ICONSIZES[name] = (width, height) # cache ICONSIZES[name] = (width, height) # cache
else: else:
width, height = ICONSIZES[name] width, height = ICONSIZES[name]

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@
--sco-content-min-width: 600px; --sco-content-min-width: 600px;
--sco-content-max-width: 1024px; --sco-content-max-width: 1024px;
--sco-color-explication: rgb(10, 58, 140); --sco-color-explication: rgb(10, 58, 140);
--sco-color-background: rgb(242, 242, 238);
} }
html, html,
@ -12,7 +13,7 @@ body {
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100%; width: 100%;
background-color: rgb(242, 242, 238); background-color: var(--sco-color-background);
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 12pt; font-size: 12pt;
} }
@ -63,6 +64,37 @@ div#gtrcontent {
display: None; 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 { div.tab-content {
margin-top: 10px; margin-top: 10px;
margin-left: 15px; margin-left: 15px;
@ -191,7 +223,7 @@ div.head_message {
color: green; color: green;
} }
.message_curtom { .message_custom {
position: fixed; position: fixed;
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
@ -205,6 +237,18 @@ div.head_message {
transform: translate(-50%, 0); 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 { div.passwd_warn {
font-weight: bold; font-weight: bold;
@ -231,9 +275,6 @@ p.footer {
border-top: 1px solid rgb(60, 60, 60); border-top: 1px solid rgb(60, 60, 60);
} }
div.part2 {
margin-top: 3ex;
}
/* ---- (left) SIDEBAR ----- */ /* ---- (left) SIDEBAR ----- */
@ -2017,6 +2058,7 @@ span.eval_coef_ue_titre {}
div.list_but_ue_inscriptions { div.list_but_ue_inscriptions {
margin-top: 16px; margin-top: 16px;
margin-bottom: 16px; margin-bottom: 16px;
margin-right: 8px;
padding-left: 8px; padding-left: 8px;
padding-bottom: 8px; padding-bottom: 8px;
border-radius: 16px; border-radius: 16px;
@ -2066,6 +2108,17 @@ form.list_but_ue_inscriptions td {
text-align: center; 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 */ /* Formulaire edition des partitions */
form#editpart table { form#editpart table {
border: 1px solid gray; border: 1px solid gray;
@ -2179,16 +2232,23 @@ span.explication {
div.formation_ue_list { div.formation_ue_list {
border: 1px solid black; border: 1px solid black;
background-color: rgb(232, 249, 255);
margin-top: 5px; margin-top: 5px;
margin-right: 12px; margin-right: 12px;
padding-left: 5px; padding-left: 5px;
} }
div.formation_list_ues_titre { div.formation_list_ues_titre {
padding-top: 6px;
padding-bottom: 6px;
padding-left: 24px; padding-left: 24px;
padding-right: 24px; padding-right: 24px;
font-size: 120%; font-size: 120%;
font-weight: bold; font-weight: bold;
border-top-right-radius: 18px;
border-top-left-radius: 18px;
background-color: #0051a9;
color: #eee;
} }
div.formation_list_modules, div.formation_list_modules,
@ -2205,6 +2265,8 @@ div.formation_list_ues {
margin-top: 20px margin-top: 20px
} }
div.formation_list_ues_content {}
div.formation_list_modules { div.formation_list_modules {
margin-top: 20px; margin-top: 20px;
} }
@ -2266,6 +2328,41 @@ span.notes_module_list_buts {
margin-bottom: 6px; 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 { div.ue_list_tit {
font-weight: bold; font-weight: bold;
margin-top: 8px; margin-top: 8px;
@ -2476,6 +2573,19 @@ div.cont_ue_choix_niveau select.select_niveau_ue {
width: 490px; 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 { div#ue_list_modules {
background-color: rgb(251, 225, 165); background-color: rgb(251, 225, 165);
border: 1px solid blue; border: 1px solid blue;
@ -2661,6 +2771,30 @@ table.notes_recapcomplet a:hover {
text-decoration: underline; 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 */ /* bulletin */
div.notes_bulletin { div.notes_bulletin {
margin-right: 5px; margin-right: 5px;

View File

@ -11,7 +11,6 @@ $().ready(function () {
}); });
update_bonus_description(); update_bonus_description();
} }
update_menus_niveau_competence();
}); });
function update_bonus_description() { function update_bonus_description() {
@ -37,69 +36,28 @@ function update_ue_list() {
}); });
} }
function set_ue_parcour(elem) { function set_ue_parcour(checkbox) {
let ue_id = elem.dataset.ue_id; let url = checkbox.dataset.setter;
let parcour_id = elem.value; const checkboxes = document.querySelectorAll('#choix_parcours input[type="checkbox"]:checked');
let set_ue_parcour_url = elem.dataset.setter; const parcours_ids = [];
$.post(set_ue_parcour_url, checkboxes.forEach(function (checkbox) {
{ parcours_ids.push(checkbox.value);
ue_id: ue_id, });
parcour_id: parcour_id,
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}, },
function (result) { body: JSON.stringify(parcours_ids)
sco_message("UE associée au parcours"); })
update_menus_niveau_competence(); .then(response => response.json())
} .then(data => {
); if (data.status == 404) {
sco_error_message(data.message);
} else {
sco_message(data.message);
}
});
} }
function set_ue_niveau_competence(elem) {
let ue_id = elem.dataset.ue_id;
let niveau_id = elem.value;
let set_ue_niveau_competence_url = elem.dataset.setter;
$.post(set_ue_niveau_competence_url,
{
ue_id: ue_id,
niveau_id: niveau_id,
},
function (result) {
sco_message("niveau de compétence enregistré");
update_menus_niveau_competence();
}
);
}
// Met à jour les niveaux utilisés (disabled) ou non affectés
// dans les menus d'association UE <-> niveau
function update_menus_niveau_competence() {
// let selected_niveaux = [];
// document.querySelectorAll("form.form_ue_choix_niveau select").forEach(
// elem => { selected_niveaux.push(elem.value); }
// );
// document.querySelectorAll("form.form_ue_choix_niveau select").forEach(
// elem => {
// for (let i = 0; i < elem.options.length; i++) {
// elem.options[i].disabled = (i != elem.options.selectedIndex)
// && (selected_niveaux.indexOf(elem.options[i].value) != -1)
// && (elem.options[i].value != "");
// }
// }
// );
// nouveau:
document.querySelectorAll("select.select_niveau_ue").forEach(
elem => {
let ue_id = elem.dataset.ue_id;
$.get("get_ue_niveaux_options_html",
{
ue_id: ue_id,
},
function (result) {
elem.innerHTML = result;
}
);
}
);
}

View File

@ -14,4 +14,24 @@ function change_ue_inscr(elt) {
sco_message("changement inscription UE enregistré"); sco_message("changement inscription UE enregistré");
} }
); );
} }
$(function () {
$("table#but_ue_inscriptions").DataTable(
{
paging: false,
searching: true,
info: false,
autoWidth: false,
fixedHeader: {
header: true,
footer: false
},
orderCellsTop: true, // cellules ligne 1 pour tri
aaSorting: [], // Prevent initial sorting
"oLanguage": {
"sSearch": "Chercher :"
}
}
);
});

View File

@ -1,3 +1,4 @@
class ref_competences extends HTMLElement { class ref_competences extends HTMLElement {
constructor() { constructor() {
super(); super();
@ -5,6 +6,7 @@ class ref_competences extends HTMLElement {
/* Template de base */ /* Template de base */
this.shadow.innerHTML = ` this.shadow.innerHTML = `
<div class=titre>Cliquer sur un parcours pour afficher ses niveaux de compétences</div>
<div class=parcours></div> <div class=parcours></div>
<div class=competences></div> <div class=competences></div>
<div class=ACs></div> <div class=ACs></div>
@ -13,11 +15,7 @@ class ref_competences extends HTMLElement {
/* Style du module */ /* Style du module */
const styles = document.createElement('link'); const styles = document.createElement('link');
styles.setAttribute('rel', 'stylesheet'); styles.setAttribute('rel', 'stylesheet');
if (location.href.split("/")[3] == "ScoDoc") { styles.setAttribute('href', removeLastTwoComponents(getCurrentScriptPath()) + '/css/ref-competences.css');
styles.setAttribute('href', '/ScoDoc/static/css/ref-competences.css');
} else {
styles.setAttribute('href', 'ref-competences.css');
}
this.shadow.appendChild(styles); this.shadow.appendChild(styles);
} }
@ -31,7 +29,7 @@ class ref_competences extends HTMLElement {
let parcoursDIV = this.shadow.querySelector(".parcours"); let parcoursDIV = this.shadow.querySelector(".parcours");
Object.entries(this.data.parcours).forEach(([cle, parcours]) => { Object.entries(this.data.parcours).forEach(([cle, parcours]) => {
let div = document.createElement("div"); let div = document.createElement("div");
div.innerText = parcours.libelle; div.innerHTML = `<a title="${parcours.libelle}">${parcours.code}</a>`;
div.addEventListener("click", (event) => { this.competences(event, cle) }) div.addEventListener("click", (event) => { this.competences(event, cle) })
parcoursDIV.appendChild(div); parcoursDIV.appendChild(div);
}) })

View File

@ -15,8 +15,8 @@ class releveBUT extends HTMLElement {
/* Style du module */ /* Style du module */
const styles = document.createElement('link'); const styles = document.createElement('link');
styles.setAttribute('rel', 'stylesheet'); styles.setAttribute('rel', 'stylesheet');
if (location.href.split("/")[3] == "ScoDoc") { if (location.href.includes("ScoDoc")) {
styles.setAttribute('href', '/ScoDoc/static/css/releve-but.css'); // Scodoc styles.setAttribute('href', removeLastTwoComponents(getCurrentScriptPath()) + '/css/releve-but.css'); // Scodoc
} else { } else {
styles.setAttribute('href', '/assets/styles/releve-but.css'); // Passerelle styles.setAttribute('href', '/assets/styles/releve-but.css'); // Passerelle
} }
@ -212,8 +212,8 @@ class releveBUT extends HTMLElement {
this.shadow.querySelector("#identite_etudiant").innerHTML = ` <a href="${data.etudiant.fiche_url}">${data.etudiant.nomprenom}</a> `; this.shadow.querySelector("#identite_etudiant").innerHTML = ` <a href="${data.etudiant.fiche_url}">${data.etudiant.nomprenom}</a> `;
this.shadow.querySelector(".dateInscription").innerHTML += this.ISOToDate(data.semestre.inscription); this.shadow.querySelector(".dateInscription").innerHTML += this.ISOToDate(data.semestre.inscription);
let output = ''; let output = '';
if(!data.options.block_moyenne_generale){ if (!data.options.block_moyenne_generale) {
output += ` output += `
<div> <div>
<div class=enteteSemestre>Moyenne</div><div class=enteteSemestre>${data.semestre.notes.value}</div> <div class=enteteSemestre>Moyenne</div><div class=enteteSemestre>${data.semestre.notes.value}</div>
<div class=rang>Rang :</div><div class=rang>${data.semestre.rang.value} / ${data.semestre.rang.total}</div> <div class=rang>Rang :</div><div class=rang>${data.semestre.rang.value} / ${data.semestre.rang.total}</div>
@ -333,7 +333,7 @@ class releveBUT extends HTMLElement {
} else { } else {
output += ` output += `
<div> <div>
<div class="ue ${dataUE.date_capitalisation?"capitalisee":""}"> <div class="ue ${dataUE.date_capitalisation ? "capitalisee" : ""}">
<h3> <h3>
${ue}${(dataUE.titre) ? " - " + dataUE.titre : ""} ${ue}${(dataUE.titre) ? " - " + dataUE.titre : ""}
</h3> </h3>
@ -341,7 +341,7 @@ class releveBUT extends HTMLElement {
<div class=moyenne>Moyenne&nbsp;:&nbsp;${dataUE.moyenne?.value || dataUE.moyenne || "-"}</div> <div class=moyenne>Moyenne&nbsp;:&nbsp;${dataUE.moyenne?.value || dataUE.moyenne || "-"}</div>
<div class=ue_rang>Rang&nbsp;:&nbsp;${dataUE.moyenne?.rang}&nbsp;/&nbsp;${dataUE.moyenne?.total}</div> <div class=ue_rang>Rang&nbsp;:&nbsp;${dataUE.moyenne?.rang}&nbsp;/&nbsp;${dataUE.moyenne?.total}</div>
<div class=info>`; <div class=info>`;
if(!dataUE.date_capitalisation){ if (!dataUE.date_capitalisation) {
output += ` Bonus&nbsp;:&nbsp;${dataUE.bonus || 0}&nbsp;- output += ` Bonus&nbsp;:&nbsp;${dataUE.bonus || 0}&nbsp;-
Malus&nbsp;:&nbsp;${dataUE.malus || 0}`; Malus&nbsp;:&nbsp;${dataUE.malus || 0}`;
} else { } else {
@ -359,12 +359,12 @@ class releveBUT extends HTMLElement {
</div>*/ </div>*/
output += "</div>"; output += "</div>";
if(!dataUE.date_capitalisation){ if (!dataUE.date_capitalisation) {
output += output +=
this.synthese(data, dataUE.ressources) + this.synthese(data, dataUE.ressources) +
this.synthese(data, dataUE.saes); this.synthese(data, dataUE.saes);
} }
output += "</div>"; output += "</div>";
} }
}); });

View File

@ -67,17 +67,22 @@ $(function () {
} }
}); });
// Affiche un message transitoire // Affiche un message transitoire (duration milliseconds, 0 means infinity)
function sco_message(msg) { function sco_message(msg, className = "message_custom", duration = 0) {
var div = document.createElement("div"); var div = document.createElement("div");
div.className = "message_curtom"; div.className = className;
div.innerHTML = msg; div.innerHTML = msg;
document.querySelector("body").appendChild(div); document.querySelector("body").appendChild(div);
setTimeout(() => { if (duration) {
div.remove(); setTimeout(() => {
}, 3000); div.remove();
}, 3000);
}
} }
function sco_error_message(msg) {
sco_message(msg, className = "message_error", duration = 0);
}
function get_query_args() { function get_query_args() {
@ -256,3 +261,27 @@ class ScoFieldEditor {
} }
} }
function getCurrentScriptPath() {
// Get all the script elements on the page
var scripts = document.getElementsByTagName('script');
// Find the last script element (which is the currently executing script)
var currentScript = scripts[scripts.length - 1];
// Retrieve the src attribute of the script element
var scriptPath = currentScript.src;
return scriptPath;
}
function removeLastTwoComponents(path) {
// Split the path into individual components
var components = path.split('/');
// Remove the last two components (filename and enclosing directory)
components.splice(-2);
// Join the remaining components back into a path
var newPath = components.join('/');
return newPath;
}

View File

@ -154,7 +154,7 @@ class TableJury(TableRecap):
niveau: ApcNiveau = validation_rcue.niveau() niveau: ApcNiveau = validation_rcue.niveau()
titre = f"C{niveau.competence.numero}" # à voir (nommer les compétences...) titre = f"C{niveau.competence.numero}" # à voir (nommer les compétences...)
row.add_cell( row.add_cell(
f"c_{competence_id}_annee", f"c_{competence_id}_{annee}",
titre, titre,
validation_rcue.code, validation_rcue.code,
group="cursus_" + annee, group="cursus_" + annee,

View File

@ -74,7 +74,7 @@ class TableRecap(tb.Table):
# couples (modimpl, ue) effectivement présents dans la table: # couples (modimpl, ue) effectivement présents dans la table:
self.modimpl_ue_ids = set() self.modimpl_ue_ids = set()
ues = res.formsemestre.query_ues(with_sport=True) # avec bonus ues = res.formsemestre.get_ues(with_sport=True) # avec bonus
ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT] ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
if res.formsemestre.etuds_inscriptions: # table non vide if res.formsemestre.etuds_inscriptions: # table non vide
@ -285,9 +285,9 @@ class TableRecap(tb.Table):
notes = res.modimpl_notes(modimpl.id, ue.id) notes = res.modimpl_notes(modimpl.id, ue.id)
if np.isnan(notes).all(): if np.isnan(notes).all():
# aucune note valide # aucune note valide
row_min.add_cell(col_id, None, np.nan) row_min.add_cell(col_id, None, "")
row_max.add_cell(col_id, None, np.nan) row_max.add_cell(col_id, None, "")
moy = np.nan moy = ""
else: else:
row_min.add_cell(col_id, None, self.fmt_note(np.nanmin(notes))) row_min.add_cell(col_id, None, self.fmt_note(np.nanmin(notes)))
row_max.add_cell(col_id, None, self.fmt_note(np.nanmax(notes))) row_max.add_cell(col_id, None, self.fmt_note(np.nanmax(notes)))
@ -297,7 +297,7 @@ class TableRecap(tb.Table):
None, None,
self.fmt_note(moy), self.fmt_note(moy),
# aucune note dans ce module ? # aucune note dans ce module ?
classes=["col_empty" if np.isnan(moy) else ""], classes=["col_empty" if (moy == "" or np.isnan(moy)) else ""],
) )
row_apo.add_cell(col_id, None, modimpl.module.code_apogee or "") row_apo.add_cell(col_id, None, modimpl.module.code_apogee or "")
@ -618,7 +618,7 @@ class RowRecap(tb.Row):
): ):
"""Ajoute cols moy_gen moy_ue et tous les modules...""" """Ajoute cols moy_gen moy_ue et tous les modules..."""
etud = self.etud etud = self.etud
table = self.table table: TableRecap = self.table
res = table.res res = table.res
# --- Si DEM ou DEF, ne montre aucun résultat d'UE ni moy. gen. # --- Si DEM ou DEF, ne montre aucun résultat d'UE ni moy. gen.
if res.get_etud_etat(etud.id) != scu.INSCRIT: if res.get_etud_etat(etud.id) != scu.INSCRIT:
@ -676,7 +676,7 @@ class RowRecap(tb.Row):
# Les moyennes des modules (ou ressources et SAÉs) dans cette UE # Les moyennes des modules (ou ressources et SAÉs) dans cette UE
self.add_ue_modimpls_cols(ue, ue_status["is_capitalized"]) self.add_ue_modimpls_cols(ue, ue_status["is_capitalized"])
self.nb_ues_etud_parcours = len(res.etud_ues_ids(etud.id)) self.nb_ues_etud_parcours = len(res.etud_parcours_ues_ids(etud.id))
ue_valid_txt = ( ue_valid_txt = (
ue_valid_txt_html ue_valid_txt_html
) = f"{self.nb_ues_validables}/{self.nb_ues_etud_parcours}" ) = f"{self.nb_ues_validables}/{self.nb_ues_etud_parcours}"
@ -701,13 +701,17 @@ class RowRecap(tb.Row):
def add_ue_cols(self, ue: UniteEns, ue_status: dict, col_group: str = None): def add_ue_cols(self, ue: UniteEns, ue_status: dict, col_group: str = None):
"Ajoute résultat UE au row (colonne col_ue)" "Ajoute résultat UE au row (colonne col_ue)"
# sous-classé par JuryRow pour ajouter les codes # sous-classé par JuryRow pour ajouter les codes
table = self.table table: TableRecap = self.table
formsemestre: FormSemestre = table.res.formsemestre formsemestre: FormSemestre = table.res.formsemestre
table.group_titles[ table.group_titles[
"col_ue" "col_ue"
] = f"UEs du S{formsemestre.semestre_id} {formsemestre.annee_scolaire()}" ] = f"UEs du S{formsemestre.semestre_id} {formsemestre.annee_scolaire()}"
col_id = f"moy_ue_{ue.id}" col_id = f"moy_ue_{ue.id}"
val = ue_status["moy"] val = (
ue_status["moy"]
if (self.etud.id, ue.id) not in table.res.dispense_ues
else "="
)
note_classes = [] note_classes = []
if isinstance(val, float): if isinstance(val, float):
if val < table.barre_moy: if val < table.barre_moy:

View File

@ -62,16 +62,23 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container flashes">
{% with messages = get_flashed_messages(with_categories=true) %} {% include "flashed_messages.j2" %}
{% for category, message in messages %} </div>
<div class="alert alert-info alert-{{ category }}" role="alert">{{ message }}</div>
{% endfor %}
{% endwith %}
{# application content needs to be provided in the app_content block #} {# application content needs to be provided in the app_content block #}
<div class="container">
{% block app_content %}{% endblock %} {% block app_content %}{% endblock %}
</div> </div>
<script>
setTimeout(function() {
var flashes = document.getElementsByClassName("flashes")[0];
if (flashes) {
flashes.style.display = "none";
}
}, 5000);
</script>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}

View File

@ -7,43 +7,50 @@
{% block app_content %} {% block app_content %}
{% include 'bul_head.j2' %} {% include 'bul_head.j2' %}
<releve-but></releve-but> <releve-but></releve-but>
<script src="{{sco.scu.STATIC_DIR}}/js/releve-but.js"></script>
{% include 'bul_foot.j2' %} {% include 'bul_foot.j2' %}
<script> {% endblock %}
let dataSrc = "{{bul_url|safe}}";
fetch(dataSrc)
.then(r => { return r.json() })
.then(json => {
let releve = document.querySelector("releve-but");
releve.showData = json;
// Syle custom à ajouter
let style = document.createElement("style");
style.textContent = `
.module>div,
.dateInscription,
.numerosEtudiant,
.dateNaissance{
display: none;
}`;
releve.shadowRoot.appendChild(style);
});
// .catch(error => {
// let div = document.createElement("div");
// div.innerText = "Une erreur s'est produite lors du transfert des données.";
// div.style.fontSize = "24px";
// div.style.color = "#d93030";
// let releve = document.querySelector("releve-but"); {% block scripts %}
// releve.after(div); {{super()}}
// releve.remove();
// throw 'Fin du script - données invalides'; <script src="{{scu.STATIC_DIR}}/js/releve-but.js"></script>
// });
document.querySelector("html").style.scrollBehavior = "smooth"; <script>
</script> let dataSrc = "{{bul_url|safe}}";
{% endblock %} fetch(dataSrc)
.then(r => { return r.json() })
.then(json => {
let releve = document.querySelector("releve-but");
releve.showData = json;
// Syle custom à ajouter
let style = document.createElement("style");
style.textContent = `
.module>div,
.dateInscription,
.numerosEtudiant,
.dateNaissance{
display: none;
}`;
releve.shadowRoot.appendChild(style);
});
// .catch(error => {
// let div = document.createElement("div");
// div.innerText = "Une erreur s'est produite lors du transfert des données.";
// div.style.fontSize = "24px";
// div.style.color = "#d93030";
// let releve = document.querySelector("releve-but");
// releve.after(div);
// releve.remove();
// throw 'Fin du script - données invalides';
// });
document.querySelector("html").style.scrollBehavior = "smooth";
</script>
{% endblock %}

View File

@ -0,0 +1,189 @@
{% extends "sco_page.j2" %}
{% block styles %}
{{super()}}
<link href="{{scu.STATIC_DIR}}/css/refcomp_parcours_niveaux.css" rel="stylesheet" type="text/css" />
<link href="{{scu.STATIC_DIR}}/css/parcour_formation.css" rel="stylesheet" type="text/css" />
{% endblock %}
{% macro menu_ue(niv, sem="pair", sem_idx=0) -%}
{% if niv['niveau'] %}
{% if current_user.has_permission(sco.Permission.ScoChangeFormation) %}
<select name="ue_niv_{{niv['niveau'].id}}" id="ue_niv_{{niv['niveau'].id}}"
onchange="assoc_ue_niveau(event,
{{niv['niveau'].id}}, {{parcour.id}}
);"
{% if niv['ue_'+sem] %}
data-ue_id="{{niv['ue_'+sem].id}}"
{% else %}
data-ue_id=""
{% endif %}
>
{%- if not niv['ue_'+sem] -%}
<option value="" class="non_associe">UE de S{{sem_idx}} ?</option>
{%-else-%}
<option value="">Désassocier</option>
{%-endif-%}
{% for ue in niv['ues_'+sem] %}
<option value="{{ue.id}}"
{% if niv['ue_'+sem] and niv['ue_'+sem].id == ue.id -%}
selected
{%- endif %}
>{{ue.acronyme}}</option>
{% endfor %}
</select>
{% else %}
{# Vue en lecture seule #}
{% if niv['ue_'+sem] %}
{{ niv['ue_'+sem].acronyme }}
{% else %}
<span class="fontred">{{scu.EMO_WARNING|safe}} non associé</span>
{% endif %}
{% endif %}
{% endif %}
{%- endmacro %}
{% block app_content %}
<h2>{{formation.to_html()}}</h2>
{# Liens vers les différents parcours #}
<div class="les_parcours">
{% for parc in formation.referentiel_competence.parcours %}
<div class="parc {{'focus' if parcour and parc.id == parcour.id else ''}}">
<a href="{{
url_for('notes.parcour_formation', scodoc_dept=g.scodoc_dept,
parcour_id=parc.id, formation_id=formation.id )
}}">{{parc.code}}</a>
</div>
{% endfor %}
<div class="link">
<a class="stdlink" target="_blank" href="{{
url_for('notes.refcomp_show',
scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id )
}}">référentiel de compétences</a>
</div>
<div class="link"><a class="stdlink" href="{{
url_for('notes.ue_table',
scodoc_dept=g.scodoc_dept, formation_id=formation.id )
}}">formation</a>
</div>
</div>
{# Description d'un parcours #}
{% if parcour %}
<div class="parcour_formation">
<div class="titre_parcours">Parcours {{parcour.code}} « {{parcour.libelle}} »</div>
{% for comp in competences_parcour %}
{% set color_idx = 1 + loop.index0 % 6 %}
<div class="competence comp-c{{color_idx}}">
<div class="titre_competence tc">
Compétence {{comp['competence'].numero}}&nbsp;: {{comp['competence'].titre}}
</div>
<div class="niveaux">
{% for annee, niv in comp['niveaux'].items() %}
<div class="niveau comp-c{{color_idx}}-{{annee}}"
style="--color: var(--col-c{{color_idx}}-{{annee}});">
<div class="titre_niveau n{{annee}}">
<span class="parcs">
{% if niv['niveau'].is_tronc_commun %}
<span class="parc">TC</span>
{% elif niv['niveau'].parcours|length > 1 %}
<span class="parc">
{% set virg = joiner(", ") %}
{% for p in niv['niveau'].parcours %}
{{ virg() }}{{p.code}}
{% endfor %}
</span>
{% endif %}
</span>
{{niv['niveau'].libelle if niv['niveau'] else ''}}
</div>
<div class="ue impair u{{annee}}1">
{{ menu_ue(niv, "impair", 2*annee-1) }}
</div>
<div class="ue pair u{{annee}}1">
{{ menu_ue(niv, "pair", 2*annee) }}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div>
Choisissez un parcours...
</div>
{% endif %}
{% if parcour %}
<div class="help">
<p> Cette page représente le parcours <span class="parc">{{parcour.code}}</span>
du référentiel de compétence {{formation.referentiel_competence.specialite}}, et permet
d'associer à chaque semestre d'un niveau de compétence une UE de la formation
<a class="stdlink"
href="{{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, formation_id=formation.id )
}}">{{formation.to_html()}}
</a>.</p>
<p>Le symbole <span class="parc">TC</span> désigne un niveau du tronc commun
(c'est à dire présent dans tous les parcours de la spécialité). </p>
<p>Ce formulaire ne vérifie pas si l'UE est bien conçue pour ce parcours.</p>
<p>Les modifications sont enregistrées au fur et à mesure.</p>
</div>
{% endif %}
<script>
function assoc_ue_niveau(event, niveau_id) {
let ue_id = event.target.value;
let url = "";
let must_reload = false;
if (ue_id == "") {
/* Dé-associe */
ue_id = event.target.dataset.ue_id;
const desassoc_url = '{{
url_for(
"apiweb.desassoc_ue_niveau",
scodoc_dept=g.scodoc_dept,
ue_id=11111
)
}}';
url = desassoc_url.replace('11111', ue_id);
must_reload=true;
} else {
const assoc_url = '{{
url_for(
"apiweb.assoc_ue_niveau",
scodoc_dept=g.scodoc_dept,
ue_id=11111, niveau_id=22222
)
}}';
url = assoc_url.replace('11111', ue_id).replace('22222', niveau_id);
}
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
/* body: JSON.stringify( {} ) */
})
.then(response => response.json())
.then(data => {
if (data.status) {
/* sco_message(data.message); */
/* revert menu to initial state */
event.target.value = event.target.dataset.ue_id;
}
location.reload();
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,28 @@
<div class="table_niveaux_parcours">
<div class="titre">Niveaux de compétences des parcours</div>
<table class="table_niveaux_parcours">
<thead>
<tr>
<th></th>
{% for comp in ref.competences %}
<th class="comp-c{{1 + loop.index0 % 6}}">{{comp.titre}}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% set nb_cols = ref.competences.count() + 1 %}
{% for parcour in ref.parcours %}
<tr class="parcours_but"><td colspan="{{nb_cols}}">Parcours <b>{{parcour.code}}</b> <i>{{parcour.libelle}}</i></td></tr>
{% for annee in [ 1, 2, 3] %}
<tr class="annee_but">
<td>BUT{{annee}}</td>
{% for comp in ref.competences %}
{% set niveau = table_niveaux_parcours[parcour.id][annee][comp.id] %}
<td class="comp-c{{1 + loop.index0 % 6}} {% if not niveau %}empty{% endif %}">{{ niveau }}</td>
{% endfor %}
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>

View File

@ -1,24 +1,28 @@
{# -*- mode: jinja-html -*- #} {# -*- mode: jinja-html -*- #}
{% extends "sco_page.j2" %} {% extends "sco_page.j2" %}
{% block styles %} {% block styles %}
{{super()}} {{super()}}
<link href="{{scu.STATIC_DIR}}/css/refcomp_parcours_niveaux.css" rel="stylesheet" type="text/css" />
{% endblock %} {% endblock %}
{% block app_content %} {% block app_content %}
<h2>Référentiel de compétences {{ref.type_titre}} {{ref.specialite_long}}</h2> <h2>Référentiel de compétences {{ref.type_titre}} {{ref.specialite_long}}</h2>
<ref-competences></ref-competences>
<script src="{{sco.scu.STATIC_DIR}}/js/ref_competences.js"></script>
<div class="help"> <div class="help">
Référentiel chargé le {{ref.scodoc_date_loaded.strftime("%d/%m/%Y à %H:%M") if ref.scodoc_date_loaded else ""}} à Référentiel chargé le {{ref.scodoc_date_loaded.strftime("%d/%m/%Y à %H:%M") if ref.scodoc_date_loaded else ""}} à
partir du fichier <tt>{{ref.scodoc_orig_filename or "(inconnu)"}}</tt>. partir du fichier <tt>{{ref.scodoc_orig_filename or "(inconnu)"}}</tt>.
</div> </div>
<div class="refcomp_show">
<div class="part2"> <div>
<ref-competences></ref-competences>
</div>
{% include "but/refcomp_parcours_niveaux.j2" %}
<div class="liens">
<ul> <ul>
<li>Formations se référant à ce référentiel: <li>Formations se référant à ce référentiel:
<ul> <ul>
@ -26,12 +30,15 @@
<li><a class="stdlink" href="{{ <li><a class="stdlink" href="{{
url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, formation_id=formation.id ) url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, formation_id=formation.id )
}}">{{ formation.get_titre_version() }}</a></li> }}">{{ formation.get_titre_version() }}</a></li>
{% else %}
<li><em>aucune</em></li>
{% endfor %} {% endfor %}
</ul> </ul>
</li> </li>
<li><a class="stdlink" href="{{url_for('notes.refcomp_table', scodoc_dept=g.scodoc_dept)}}">Liste des référentiels</a> <li><a class="stdlink" href="{{url_for('notes.refcomp_table', scodoc_dept=g.scodoc_dept)}}">Liste des référentiels</a>
</li> </li>
</ul> </ul>
</div>
</div> </div>
@ -40,6 +47,8 @@
{% block scripts %} {% block scripts %}
{{super()}} {{super()}}
<script src="{{scu.STATIC_DIR}}/js/ref_competences.js"></script>
<script> <script>
$(function () { $(function () {
let data_url = "{{data_source}}"; let data_url = "{{data_source}}";

View File

@ -1,9 +1,18 @@
{# Message flask : utilisé uniquement par les anciennes pages ScoDoc #} {# Messages flask (flash) #}
{# -*- mode: jinja-html -*- #}
<div class="head_message_container"> <div class="container flashes">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="head_message alert-info alert-{{ category }}" role="alert">{{ message }}</div> <div class="alert alert-info alert-{{ category }}" role="alert">{{ message }}</div>
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}
</div> </div>
<script>
setTimeout(function() {
var flashes = document.getElementsByClassName("flashes")[0];
if (flashes) {
flashes.style.display = "none";
}
}, 5000);
</script>

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