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

View File

@ -1,8 +1,8 @@
"""api.__init__
"""
from flask_json import as_json
from flask import Blueprint
from flask import request, g, jsonify
from flask import request, g
from app import db
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException
@ -35,6 +35,7 @@ def requested_format(default_format="json", allowed_formats=None):
return None
@as_json
def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None):
"""
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]"
@ -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)
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@
"""
from datetime import datetime
from flask_json import as_json
from flask import g, jsonify, request
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})
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
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_set.append(data)
return jsonify(data_set)
return data_set
@bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_create(etudid: int = None):
@ -145,7 +148,7 @@ def justif_create(etudid: int = None):
else:
success[i] = obj
compute_assiduites_justified(Justificatif.query.filter_by(etudid=etudid), True)
return jsonify({"errors": errors, "success": success})
return {"errors": errors, "success": success}
def _create_singular(
@ -221,6 +224,7 @@ def _create_singular(
@api_web_bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_edit(justif_id: int):
@ -296,8 +300,7 @@ def justif_edit(justif_id: int):
db.session.add(justificatif_unique)
db.session.commit()
return jsonify(
{
return {
"couverture": {
"avant": avant_ids,
"après": compute_assiduites_justified(
@ -306,13 +309,13 @@ def justif_edit(justif_id: int):
),
}
}
)
@bp.route("/justificatif/delete", methods=["POST"])
@api_web_bp.route("/justificatif/delete", methods=["POST"])
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_delete():
@ -342,7 +345,7 @@ def justif_delete():
output["success"][f"{i}"] = {"OK": True}
db.session.commit()
return jsonify(output)
return output
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"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
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.commit()
return jsonify({"filename": fname})
return {"filename": fname}
except ScoValueError as err:
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"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_remove(justif_id: int = None):
@ -504,13 +509,14 @@ def justif_remove(justif_id: int = None):
except ScoValueError as err:
return json_error(404, err.args[0])
return jsonify({"response": "removed"})
return {"response": "removed"}
@bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_list(justif_id: int = None):
@ -534,7 +540,7 @@ def justif_list(justif_id: int = None):
archive_name, justificatif_unique.etudid
)
return jsonify(filenames)
return filenames
# Partie justification
@ -542,6 +548,7 @@ def justif_list(justif_id: int = None):
@api_web_bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_justifies(justif_id: int = None):
@ -557,7 +564,7 @@ def justif_justifies(justif_id: int = None):
assiduites_list: list[int] = scass.justifies(justificatif_unique)
return jsonify(assiduites_list)
return assiduites_list
# -- Utils --

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@
"""Matrices d'inscription aux modules d'un semestre
"""
import pandas as pd
import sqlalchemy as sa
from app import db
@ -12,6 +13,13 @@ from app import db
# sur test debug 116 etuds, 18 modules, on est autour de 250ms.
# On a testé trois approches, ci-dessous (et retenu la 1ere)
#
_load_modimpl_inscr_q = sa.text(
"""SELECT etudid, 1 AS ":moduleimpl_id"
FROM notes_moduleimpl_inscription
WHERE moduleimpl_id=:moduleimpl_id"""
)
def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame:
"""Charge la matrice des inscriptions aux modules du semestre
rows: etudid (inscrits au semestre, avec DEM et DEF)
@ -22,12 +30,11 @@ def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame:
moduleimpl_ids = [m.id for m in formsemestre.modimpls_sorted]
etudids = [inscr.etudid for inscr in formsemestre.inscriptions]
df = pd.DataFrame(index=etudids, dtype=int)
with db.engine.begin() as connection:
for moduleimpl_id in moduleimpl_ids:
ins_df = pd.read_sql_query(
"""SELECT etudid, 1 AS "%(moduleimpl_id)s"
FROM notes_moduleimpl_inscription
WHERE moduleimpl_id=%(moduleimpl_id)s""",
db.engine,
_load_modimpl_inscr_q,
connection,
params={"moduleimpl_id": moduleimpl_id},
index_col="etudid",
dtype=int,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,8 +21,6 @@ convention = {
metadata_obj = sqlalchemy.MetaData(naming_convention=convention)
from app.models.raw_sql_init import create_database_functions
from app.models.absences import Absence, AbsenceNotification, BilletAbsence
from app.models.departements import Departement
from app.models.etudiants import (

View File

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

View File

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

View File

@ -18,7 +18,7 @@ from app import models
from app.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat
from app.scodoc.sco_exceptions import ScoInvalidParamError
from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
import app.scodoc.sco_utils as scu
@ -30,6 +30,7 @@ class Identite(db.Model):
db.UniqueConstraint("dept_id", "code_nip"),
db.UniqueConstraint("dept_id", "code_ine"),
db.CheckConstraint("civilite IN ('M', 'F', 'X')"),
db.CheckConstraint("civilite_etat_civil IN ('M', 'F', 'X')"),
)
id = db.Column(db.Integer, primary_key=True)
@ -41,6 +42,12 @@ class Identite(db.Model):
nom_usuel = db.Column(db.Text())
"optionnel (si present, affiché à la place du nom)"
civilite = db.Column(db.String(1), nullable=False)
# données d'état-civil. Si présent remplace les données d'usage dans les documents officiels (bulletins, PV)
# cf nomprenom_etat_civil()
civilite_etat_civil = db.Column(db.String(1), nullable=False, server_default="X")
prenom_etat_civil = db.Column(db.Text(), nullable=False, server_default="")
date_naissance = db.Column(db.Date)
lieu_naissance = db.Column(db.Text())
dept_naissance = db.Column(db.Text())
@ -97,7 +104,7 @@ class Identite(db.Model):
def create_etud(cls, **args):
"Crée un étudiant, avec admission et adresse vides."
etud: Identite = cls(**args)
etud.adresses.append(Adresse())
etud.adresses.append(Adresse(typeadresse="domicile"))
etud.admission.append(Admission())
return etud
@ -108,6 +115,13 @@ class Identite(db.Model):
"""
return {"M": "M.", "F": "Mme", "X": ""}[self.civilite]
@property
def civilite_etat_civil_str(self):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
personnes ne souhaitant pas d'affichage).
"""
return {"M": "M.", "F": "Mme", "X": ""}[self.civilite_etat_civil]
def sex_nom(self, no_accents=False) -> str:
"'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'"
s = f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()}"
@ -154,6 +168,14 @@ class Identite(db.Model):
r.append("-".join([x.lower().capitalize() for x in fields]))
return " ".join(r)
@property
def etat_civil(self):
if self.prenom_etat_civil:
civ = {"M": "M.", "F": "Mme", "X": ""}[self.civilite_etat_civil]
return f"{civ} {self.prenom_etat_civil} {self.nom}"
else:
return self.nomprenom
@property
def nom_short(self):
"Nom et début du prénom pour table recap: 'DUPONT Pi.'"
@ -183,6 +205,50 @@ class Identite(db.Model):
reverse=True,
)
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"Convert fields in the given dict. No other side effect"
fs_uppercase = {"nom", "prenom", "prenom_etat_civil"}
fs_empty_stored_as_nulls = {
"nom",
"prenom",
"nom_usuel",
"date_naissance",
"lieu_naissance",
"dept_naissance",
"nationalite",
"statut",
"photo_filename",
"code_nip",
"code_ine",
}
args_dict = {}
for key, value in args.items():
if hasattr(cls, key):
# compat scodoc7 (mauvaise idée de l'époque)
if key in fs_empty_stored_as_nulls and value == "":
value = None
if key in fs_uppercase and value:
value = value.upper()
if key == "civilite" or key == "civilite_etat_civil":
value = input_civilite(value)
elif key == "boursier":
value = bool(value)
elif key == "date_naissance":
value = ndb.DateDMYtoISO(value)
args_dict[key] = value
return args_dict
def from_dict(self, args: dict):
"update fields given in dict. Add to session but don't commit."
args_dict = Identite.convert_dict_fields(args)
args_dict.pop("id", None)
args_dict.pop("etudid", None)
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.add(self)
def to_dict_short(self) -> dict:
"""Les champs essentiels"""
return {
@ -195,6 +261,8 @@ class Identite(db.Model):
"nom_usuel": self.nom_usuel,
"prenom": self.prenom,
"sort_key": self.sort_key,
"civilite_etat_civil": self.civilite_etat_civil,
"prenom_etat_civil": self.prenom_etat_civil,
}
def to_dict_scodoc7(self) -> dict:
@ -238,6 +306,8 @@ class Identite(db.Model):
"dept_naissance": self.dept_naissance or "",
"nationalite": self.nationalite or "",
"boursier": self.boursier or "",
"civilite_etat_civil": self.civilite_etat_civil,
"prenom_etat_civil": self.prenom_etat_civil,
}
if include_urls and has_request_context():
# test request context so we can use this func in tests under the flask shell
@ -454,10 +524,10 @@ class Identite(db.Model):
M. Pierre Dupont
"""
if with_paragraph:
return f"""{self.nomprenom}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
return f"""{self.etat_civil}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
line_sep}à {self.lieu_naissance or ""}"""
return self.nomprenom
return self.etat_civil
def photo_html(self, title=None, size="small") -> str:
"""HTML img tag for the photo, either in small size (h90)
@ -521,6 +591,37 @@ def make_etud_args(
return args
def input_civilite(s):
"""Converts external representation of civilite to internal:
'M', 'F', or 'X' (and nothing else).
Raises ScoValueError if conversion fails.
"""
s = s.upper().strip()
if s in ("M", "M.", "MR", "H"):
return "M"
elif s in ("F", "MLLE", "MLLE.", "MELLE", "MME"):
return "F"
elif s == "X" or not s:
return "X"
raise ScoValueError(f"valeur invalide pour la civilité: {s}")
PIVOT_YEAR = 70
def pivot_year(y) -> int:
"converti et calcule l'année si saisie à deux chiffres"
if y == "" or y is None:
return None
y = int(round(float(y)))
if y >= 0 and y < 100:
if y < PIVOT_YEAR:
y = y + 2000
else:
y = y + 1900
return y
class Adresse(db.Model):
"""Adresse d'un étudiant
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule)
@ -614,19 +715,51 @@ class Admission(db.Model):
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
if no_nulls:
for k in d.keys():
if d[k] is None:
for key, value in d.items():
if value is None:
col_type = getattr(
sqlalchemy.inspect(models.Admission).columns, "apb_groupe"
sqlalchemy.inspect(models.Admission).columns, key
).expression.type
if isinstance(col_type, sqlalchemy.Text):
d[k] = ""
d[key] = ""
elif isinstance(col_type, sqlalchemy.Integer):
d[k] = 0
d[key] = 0
elif isinstance(col_type, sqlalchemy.Boolean):
d[k] = False
d[key] = False
return d
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"Convert fields in the given dict. No other side effect"
fs_uppercase = {"bac", "specialite"}
args_dict = {}
for key, value in args.items():
if hasattr(cls, key):
if (
value == ""
): # les chaines vides donne des NULLS (scodoc7 convention)
value = None
if key in fs_uppercase and value:
value = value.upper()
if key == "civilite" or key == "civilite_etat_civil":
value = input_civilite(value)
elif key == "annee" or key == "annee_bac":
value = pivot_year(value)
elif key == "classement" or key == "apb_classement_gr":
value = ndb.int_null_is_null(value)
args_dict[key] = value
return args_dict
def from_dict(self, args: dict): # TODO à refactoriser dans une super-classe
"update fields given in dict. Add to session but don't commit."
args_dict = Admission.convert_dict_fields(args)
args_dict.pop("adm_id", None)
args_dict.pop("id", None)
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.add(self)
# Suivi scolarité / débouchés
class ItemSuivi(db.Model):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,7 @@ class Module(db.Model):
# pas un id mais le numéro du semestre: 1, 2, ...
# note: en APC, le semestre qui fait autorité est celui de l'UE
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
numero = db.Column(db.Integer) # ordre de présentation
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
# id de l'element pedagogique Apogee correspondant:
code_apogee = db.Column(db.String(APO_CODE_STR_LEN))
# Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -111,9 +111,7 @@ get_base_preferences(formsemestre_id)
"""
import flask
from flask import flash, g, request
# from flask_login import current_user
from flask import current_app, flash, g, request, url_for
from app.models import Departement
from app.scodoc import sco_cache
@ -208,6 +206,7 @@ PREF_CATEGORIES = (
("abs", {"title": "Suivi des absences", "related": ("bul",)}),
("assi", {"title": "Gestion de l'assiduité"}),
("portal", {"title": "Liaison avec portail (Apogée, etc)"}),
("apogee", {"title": "Exports Apogée"}),
(
"pdf",
{
@ -235,7 +234,9 @@ PREF_CATEGORIES = (
"bul_margins",
{
"title": "Marges additionnelles des bulletins, en millimètres",
"subtitle": "Le bulletin de notes notes est toujours redimensionné pour occuper l'espace disponible entre les marges.",
"subtitle": """Le bulletin de notes notes est toujours redimensionné
pour occuper l'espace disponible entre les marges.
""",
"related": ("bul", "bul_mail", "pdf"),
},
),
@ -321,7 +322,9 @@ class BasePreferences(object):
{
"initvalue": "",
"title": "Nom de l'Institut",
"explanation": 'exemple "IUT de Villetaneuse". Peut être utilisé sur les bulletins.',
"explanation": """exemple "IUT de Villetaneuse".
Peut être utilisé sur les bulletins.
""",
"size": 40,
"category": "general",
"only_global": True,
@ -355,7 +358,9 @@ class BasePreferences(object):
"initvalue": "",
"title": "e-mails à qui notifier les opérations",
"size": 70,
"explanation": "adresses séparées par des virgules; notifie les opérations (saisies de notes, etc).",
"explanation": """adresses séparées par des virgules; notifie les opérations
(saisies de notes, etc).
""",
"category": "general",
"only_global": False, # peut être spécifique à un semestre
},
@ -367,9 +372,14 @@ class BasePreferences(object):
"initvalue": "",
"title": "Adresse mail origine",
"size": 40,
"explanation": """adresse expéditeur pour tous les envois par mails (bulletins,
comptes, etc.).
Si vide, utilise la config globale.""",
"explanation": f"""adresse expéditeur pour tous les envois par mail
(bulletins, notifications, etc.). Si vide, utilise la config globale.
Pour les comptes (mot de passe), voir la config globale accessible
en tant qu'administrateur depuis la <a class="stdlink" href="{
url_for("scodoc.index")
}">page d'accueil</a>.
""",
"category": "misc",
"only_global": True,
},
@ -778,7 +788,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"only_global": True,
},
),
@ -790,7 +800,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"only_global": True,
},
),
@ -802,7 +812,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"only_global": True,
},
),
@ -814,7 +824,7 @@ class BasePreferences(object):
"explanation": "remplissage maquettes export Apogée",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"only_global": True,
},
),
@ -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)",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"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)",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "portal",
"category": "apogee",
"only_global": True,
},
),
(
"export_res_remove_typ_res",
{
"initvalue": 0,
"title": "Ne pas recopier la section APO_TYP_RES",
"explanation": "si coché, ne réécrit pas la section APO_TYP_RES (rarement utile, utiliser avec précaution)",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "apogee",
"only_global": True,
},
),
@ -1989,6 +2011,7 @@ class BasePreferences(object):
value = _get_pref_default_value_from_config(name, pref[1])
self.default[name] = value
self.prefs[None][name] = value
if not current_app.testing:
log(f"creating missing preference for {name}={value}")
# add to db table
self._editor.create(
@ -2266,7 +2289,6 @@ class SemPreferences:
raise ScoValueError(
"sem_preferences.edit doit etre appele sur un semestre !"
) # a bug !
sem = sco_formsemestre.get_formsemestre(self.formsemestre_id)
H = [
html_sco_header.html_sem_header(
"Préférences du semestre",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,6 @@ $().ready(function () {
});
update_bonus_description();
}
update_menus_niveau_competence();
});
function update_bonus_description() {
@ -37,69 +36,28 @@ function update_ue_list() {
});
}
function set_ue_parcour(elem) {
let ue_id = elem.dataset.ue_id;
let parcour_id = elem.value;
let set_ue_parcour_url = elem.dataset.setter;
$.post(set_ue_parcour_url,
{
ue_id: ue_id,
parcour_id: parcour_id,
function set_ue_parcour(checkbox) {
let url = checkbox.dataset.setter;
const checkboxes = document.querySelectorAll('#choix_parcours input[type="checkbox"]:checked');
const parcours_ids = [];
checkboxes.forEach(function (checkbox) {
parcours_ids.push(checkbox.value);
});
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
function (result) {
sco_message("UE associée au parcours");
update_menus_niveau_competence();
body: JSON.stringify(parcours_ids)
})
.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

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

View File

@ -15,8 +15,8 @@ class releveBUT extends HTMLElement {
/* Style du module */
const styles = document.createElement('link');
styles.setAttribute('rel', 'stylesheet');
if (location.href.split("/")[3] == "ScoDoc") {
styles.setAttribute('href', '/ScoDoc/static/css/releve-but.css'); // Scodoc
if (location.href.includes("ScoDoc")) {
styles.setAttribute('href', removeLastTwoComponents(getCurrentScriptPath()) + '/css/releve-but.css'); // Scodoc
} else {
styles.setAttribute('href', '/assets/styles/releve-but.css'); // Passerelle
}

View File

@ -67,17 +67,22 @@ $(function () {
}
});
// Affiche un message transitoire
function sco_message(msg) {
// Affiche un message transitoire (duration milliseconds, 0 means infinity)
function sco_message(msg, className = "message_custom", duration = 0) {
var div = document.createElement("div");
div.className = "message_curtom";
div.className = className;
div.innerHTML = msg;
document.querySelector("body").appendChild(div);
if (duration) {
setTimeout(() => {
div.remove();
}, 3000);
}
}
function sco_error_message(msg) {
sco_message(msg, className = "message_error", duration = 0);
}
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()
titre = f"C{niveau.competence.numero}" # à voir (nommer les compétences...)
row.add_cell(
f"c_{competence_id}_annee",
f"c_{competence_id}_{annee}",
titre,
validation_rcue.code,
group="cursus_" + annee,

View File

@ -74,7 +74,7 @@ class TableRecap(tb.Table):
# couples (modimpl, ue) effectivement présents dans la table:
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]
if res.formsemestre.etuds_inscriptions: # table non vide
@ -285,9 +285,9 @@ class TableRecap(tb.Table):
notes = res.modimpl_notes(modimpl.id, ue.id)
if np.isnan(notes).all():
# aucune note valide
row_min.add_cell(col_id, None, np.nan)
row_max.add_cell(col_id, None, np.nan)
moy = np.nan
row_min.add_cell(col_id, None, "")
row_max.add_cell(col_id, None, "")
moy = ""
else:
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)))
@ -297,7 +297,7 @@ class TableRecap(tb.Table):
None,
self.fmt_note(moy),
# 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 "")
@ -618,7 +618,7 @@ class RowRecap(tb.Row):
):
"""Ajoute cols moy_gen moy_ue et tous les modules..."""
etud = self.etud
table = self.table
table: TableRecap = self.table
res = table.res
# --- Si DEM ou DEF, ne montre aucun résultat d'UE ni moy. gen.
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
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_html
) = 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):
"Ajoute résultat UE au row (colonne col_ue)"
# sous-classé par JuryRow pour ajouter les codes
table = self.table
table: TableRecap = self.table
formsemestre: FormSemestre = table.res.formsemestre
table.group_titles[
"col_ue"
] = f"UEs du S{formsemestre.semestre_id} {formsemestre.annee_scolaire()}"
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 = []
if isinstance(val, float):
if val < table.barre_moy:

View File

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

View File

@ -10,10 +10,16 @@
{% include 'bul_head.j2' %}
<releve-but></releve-but>
<script src="{{sco.scu.STATIC_DIR}}/js/releve-but.js"></script>
{% include 'bul_foot.j2' %}
{% endblock %}
{% block scripts %}
{{super()}}
<script src="{{scu.STATIC_DIR}}/js/releve-but.js"></script>
<script>
let dataSrc = "{{bul_url|safe}}";
fetch(dataSrc)
@ -46,4 +52,5 @@
// });
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

@ -2,23 +2,27 @@
{% extends "sco_page.j2" %}
{% block styles %}
{{super()}}
<link href="{{scu.STATIC_DIR}}/css/refcomp_parcours_niveaux.css" rel="stylesheet" type="text/css" />
{% endblock %}
{% block app_content %}
<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">
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>.
</div>
<div class="refcomp_show">
<div class="part2">
<div>
<ref-competences></ref-competences>
</div>
{% include "but/refcomp_parcours_niveaux.j2" %}
<div class="liens">
<ul>
<li>Formations se référant à ce référentiel:
<ul>
@ -26,12 +30,15 @@
<li><a class="stdlink" href="{{
url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, formation_id=formation.id )
}}">{{ formation.get_titre_version() }}</a></li>
{% else %}
<li><em>aucune</em></li>
{% endfor %}
</ul>
</li>
<li><a class="stdlink" href="{{url_for('notes.refcomp_table', scodoc_dept=g.scodoc_dept)}}">Liste des référentiels</a>
</li>
</ul>
</div>
</div>
@ -40,6 +47,8 @@
{% block scripts %}
{{super()}}
<script src="{{scu.STATIC_DIR}}/js/ref_competences.js"></script>
<script>
$(function () {
let data_url = "{{data_source}}";

View File

@ -1,9 +1,18 @@
{# Message flask : utilisé uniquement par les anciennes pages ScoDoc #}
{# -*- mode: jinja-html -*- #}
<div class="head_message_container">
{# Messages flask (flash) #}
<div class="container flashes">
{% with messages = get_flashed_messages(with_categories=true) %}
{% 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 %}
{% endwith %}
</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