Amélioration affichage ( Ticket #562 ) #563

Closed
pascal.bouron wants to merge 2 commits from (deleted):master into master
588 changed files with 44302 additions and 60903 deletions
Showing only changes of commit 5ccd1036ef - Show all commits

View File

@ -3,6 +3,7 @@
import base64
import datetime
import json
import os
import socket
import sys
@ -12,30 +13,42 @@ import traceback
import logging
from logging.handlers import SMTPHandler, WatchedFileHandler
from threading import Thread
import warnings
from flask import current_app, g, request
from flask import Flask
from flask import abort, flash, has_request_context, jsonify
from flask import abort, flash, has_request_context
from flask import render_template
from flask.json import JSONEncoder
# from flask.json import JSONEncoder
from flask.logging import default_handler
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
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_bootstrap import Bootstrap
from flask_migrate import Migrate
from flask_moment import Moment
from flask_caching import Cache
import sqlalchemy
from flask_sqlalchemy import SQLAlchemy
from jinja2 import select_autoescape
import sqlalchemy as sa
from flask_cas import CAS
import werkzeug.debug
from app.scodoc.sco_exceptions import (
AccessDenied,
ScoBugCatcher,
ScoException,
ScoGenError,
ScoInvalidCSRF,
ScoValueError,
APIInvalidParams,
)
from app.scodoc.sco_vdi import ApoEtapeVDI
from config import DevConfig
import sco_version
@ -61,11 +74,20 @@ cache = Cache(
def handle_sco_value_error(exc):
return render_template("sco_value_error.html", exc=exc), 404
return render_template("sco_value_error.j2", exc=exc), 404
def handle_access_denied(exc):
return render_template("error_access_denied.html", exc=exc), 403
return render_template("error_access_denied.j2", exc=exc), 403
def handle_invalid_csrf(exc):
"""Form submit with invalid CSRF token"""
# logout user and go back to login page with an error message
from app import auth
auth.logic.logout()
return render_template("error_csrf.j2", exc=exc), 404
def internal_server_error(exc):
@ -73,9 +95,13 @@ def internal_server_error(exc):
# note that we set the 500 status explicitly
from app.scodoc import sco_utils as scu
# Invalide tous les caches
log("internal_server_error: clearing caches")
clear_scodoc_cache()
return (
render_template(
"error_500.html",
"error_500.j2",
SCOVERSION=sco_version.SCOVERSION,
date=datetime.datetime.now().isoformat(),
exc=exc,
@ -115,23 +141,27 @@ 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:
"""Load and render an HTML file _without_ using Flask
Necessary for 503 error mesage, when DB is down and Flask may be broken.
Necessary for 503 error message, when DB is down and Flask may be broken.
"""
template_path = os.path.join(
current_app.config["SCODOC_DIR"],
@ -146,7 +176,7 @@ def render_raw_html(template_filename: str, **args) -> str:
def postgresql_server_error(e):
"""Erreur de connection au serveur postgresql (voir notesdb.open_db_connection)"""
return render_raw_html("error_503.html", SCOVERSION=sco_version.SCOVERSION), 503
return render_raw_html("error_503.j2", SCOVERSION=sco_version.SCOVERSION), 503
class LogRequestFormatter(logging.Formatter):
@ -225,15 +255,33 @@ class ReverseProxied(object):
def create_app(config_class=DevConfig):
app = Flask(__name__, static_url_path="/ScoDoc/static", static_folder="static")
app.config.from_object(config_class)
from app.auth import cas
CAS(app, url_prefix="/cas", configuration_function=cas.set_cas_configuration)
app.wsgi_app = ReverseProxied(app.wsgi_app)
app.json_encoder = ScoDocJSONEncoder
app.logger.setLevel(logging.INFO)
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
# Evite de logguer toutes les requetes dans notre log
logging.getLogger("werkzeug").disabled = True
app.config.from_object(config_class)
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):
@ -244,6 +292,7 @@ def create_app(config_class=DevConfig):
migrate.init_app(app, db)
login.init_app(app)
mail.init_app(app)
app.extensions["mail"].debug = 0 # disable copy of mails to stderr
bootstrap.init_app(app)
moment.init_app(app)
cache.init_app(app)
@ -254,6 +303,7 @@ def create_app(config_class=DevConfig):
app.register_error_handler(ScoGenError, handle_sco_value_error)
app.register_error_handler(ScoValueError, handle_sco_value_error)
app.register_error_handler(ScoBugCatcher, handle_sco_bug)
app.register_error_handler(ScoInvalidCSRF, handle_invalid_csrf)
app.register_error_handler(AccessDenied, handle_access_denied)
app.register_error_handler(500, internal_server_error)
app.register_error_handler(503, postgresql_server_error)
@ -275,6 +325,9 @@ def create_app(config_class=DevConfig):
from app.api import api_bp
from app.api import api_web_bp
# Enable autoescaping of all templates, including .j2
app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True)
# https://scodoc.fr/ScoDoc
app.register_blueprint(scodoc_bp)
# https://scodoc.fr/ScoDoc/RT/Scolarite/...
@ -374,6 +427,15 @@ def create_app(config_class=DevConfig):
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorExample)
from app.auth.cas import set_cas_configuration
with app.app_context():
try:
set_cas_configuration(app)
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
return app
@ -382,7 +444,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}")
@ -460,14 +522,15 @@ def truncate_database():
"""
# use a stored SQL function, see createtables.sql
try:
db.session.execute("SELECT truncate_tables('scodoc');")
db.session.execute(sa.text("SELECT truncate_tables('scodoc');"))
db.session.commit()
except:
db.session.rollback()
raise
# Remet les compteurs (séquences sql) à zéro
db.session.execute(
"""
sa.text(
"""
CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$
DECLARE
statements CURSOR FOR
@ -483,6 +546,7 @@ def truncate_database():
SELECT reset_sequences('scodoc');
"""
)
)
db.session.commit()
@ -530,10 +594,9 @@ def log_call_stack():
# Alarms by email:
def send_scodoc_alarm(subject, txt):
from app.scodoc import sco_preferences
from app import email
sender = sco_preferences.get_preference("email_from_addr")
sender = email.get_from_addr()
email.send_email(subject, sender, ["exception@scodoc.org"], txt)
@ -550,3 +613,22 @@ def scodoc_flash_status_messages():
f"Mode test: mails redirigés vers {email_test_mode_address}",
category="warning",
)
def critical_error(msg):
"""Handle a critical error: flush all caches, display message to the user"""
import app.scodoc.sco_utils as scu
log(f"\n*** CRITICAL ERROR: {msg}")
send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
clear_scodoc_cache()
raise ScoValueError(
f"""
Une erreur est survenue.
Si le problème persiste, merci de contacter le support ScoDoc via
{scu.SCO_DISCORD_ASSISTANCE}
{msg}
"""
)

View File

@ -9,6 +9,9 @@ from app.scodoc.sco_exceptions import ScoException
api_bp = Blueprint("api", __name__)
api_web_bp = Blueprint("apiweb", __name__)
# HTTP ERROR STATUS
API_CLIENT_ERROR = 400 # erreur dans les paramètres fournis par le client
@api_bp.errorhandler(ScoException)
@api_bp.errorhandler(404)
@ -43,5 +46,6 @@ from app.api import (
jury,
logos,
partitions,
semset,
users,
)

View File

@ -6,9 +6,10 @@
"""ScoDoc 9 API : Absences
"""
from flask import jsonify
from flask_json import as_json
from app.api import api_bp as bp
from app import db
from app.api import api_bp as bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required
from app.models import Identite
@ -19,10 +20,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
@ -49,7 +52,7 @@ def absences(etudid: int = None):
}
]
"""
etud = Identite.query.get(etudid)
etud = db.session.get(Identite, etudid)
if etud is None:
return json_error(404, message="etudiant inexistant")
# Absences de l'étudiant
@ -57,12 +60,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é
@ -93,7 +97,7 @@ def absences_just(etudid: int = None):
}
]
"""
etud = Identite.query.get(etudid)
etud = db.session.get(Identite, etudid)
if etud is None:
return json_error(404, message="etudiant inexistant")
@ -103,7 +107,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 +120,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 +172,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

@ -8,10 +8,10 @@
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
import app
from app import db
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
@ -27,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"])
@ -38,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
@ -48,12 +50,9 @@ def billets_absence_create():
justified = data.get("justified", False)
if None in (etudid, abs_begin, abs_end):
return json_error(
404, message="Paramètre manquant: etudid, abs_bein, abs_end requis"
404, message="Paramètre manquant: etudid, abs_begin, abs_end requis"
)
query = Identite.query.filter_by(etudid=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud = query.first_or_404()
etud = Identite.get_etud(etudid)
billet = BilletAbsence(
etudid=etud.id,
abs_begin=abs_begin,
@ -64,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"])
@ -72,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)
@ -81,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,12 +12,13 @@
"""
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
from app import db
from app.api import api_bp as bp
from app.api import api_bp as bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required
from app.models import Departement, FormSemestre
@ -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.
@ -105,19 +111,20 @@ def departement_create():
data = request.get_json(force=True) # may raise 400 Bad Request
acronym = str(data.get("acronym", ""))
if not acronym:
return json_error(404, "missing acronym")
return json_error(API_CLIENT_ERROR, "missing acronym")
visible = bool(data.get("visible", True))
try:
dept = departements.create_dept(acronym, visible=visible)
except ScoValueError as exc:
return json_error(404, exc.args[0] if exc.args else "")
return jsonify(dept.to_dict())
return json_error(500, exc.args[0] if exc.args else "")
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é
@ -130,12 +137,12 @@ def departement_edit(acronym):
data = request.get_json(force=True) # may raise 400 Bad Request
visible = bool(data.get("visible", None))
if visible is None:
return json_error(404, "missing argument: visible")
return json_error(API_CLIENT_ERROR, "missing argument: visible")
visible = bool(visible)
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

@ -8,15 +8,17 @@
API : accès aux étudiants
"""
from datetime import datetime
from operator import attrgetter
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_
from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import VARCHAR
import app
from app.api import api_bp as bp, api_web_bp
from app.scodoc.sco_utils import json_error
from app.api import tools
from app.decorators import scodoc, permission_required
from app.models import (
@ -30,6 +32,8 @@ from app.scodoc import sco_bulletins
from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error, suppress_accents
# Un exemple:
# @bp.route("/api_function/<int:arg>")
@ -37,11 +41,11 @@ from app.scodoc.sco_permissions import Permission
# @login_required
# @scodoc
# @permission_required(Permission.ScoView)
# @as_json
# def api_function(arg: int):
# """Une fonction quelconque de l'API"""
# return jsonify(
# {"current_user": current_user.to_dict(), "arg": arg, "dept": g.scodoc_dept}
# )
# return {"current_user": current_user.to_dict(), "arg": arg, "dept": g.scodoc_dept}
#
@bp.route("/etudiants/courants", defaults={"long": False})
@ -51,6 +55,7 @@ from app.scodoc.sco_permissions import Permission
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants_courants(long=False):
"""
La liste des étudiants des semestres "courants" (tous départements)
@ -96,7 +101,7 @@ def etudiants_courants(long=False):
data = [etud.to_dict_api() for etud in etuds]
else:
data = [etud.to_dict_short() for etud in etuds]
return jsonify(data)
return data
@bp.route("/etudiant/etudid/<int:etudid>")
@ -108,6 +113,7 @@ def etudiants_courants(long=False):
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiant(etudid: int = None, nip: str = None, ine: str = None):
"""
Retourne les informations de l'étudiant correspondant, ou 404 si non trouvé.
@ -127,7 +133,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()
@bp.route("/etudiants/etudid/<int:etudid>", methods=["GET"])
@ -138,6 +144,7 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
@api_web_bp.route("/etudiants/ine/<string:ine>", methods=["GET"])
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants(etudid: int = None, nip: str = None, ine: str = None):
"""
Info sur le ou les étudiants correspondant. Comme /etudiant mais renvoie
@ -160,10 +167,37 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
)
if not None in allowed_depts:
# restreint aux départements autorisés:
etuds = etuds.join(Departement).filter(
query = query.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("/etudiants/name/<string:start>")
@api_web_bp.route("/etudiants/name/<string:start>")
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants_by_name(start: str = "", min_len=3, limit=32):
"""Liste des étudiants dont le nom débute par start.
Si start fait moins de min_len=3 caractères, liste vide.
La casse et les accents sont ignorés.
"""
if len(start) < min_len:
return []
start = suppress_accents(start).lower()
query = Identite.query.filter(
func.lower(func.unaccent(Identite.nom, type_=VARCHAR)).ilike(start + "%")
)
allowed_depts = current_user.get_depts_with_permission(Permission.ScoView)
if not None in allowed_depts:
# restreint aux départements autorisés:
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit)
# Note: on raffine le tri pour les caractères spéciaux et nom usuel ici:
return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))]
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@ -174,6 +208,7 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
@api_web_bp.route("/etudiant/ine/<string:ine>/formsemestres")
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None):
"""
Liste des semestres qu'un étudiant a suivi, triés par ordre chronologique.
@ -206,7 +241,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(
@ -219,6 +254,10 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None)
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf",
defaults={"pdf": True},
)
@bp.route(
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf/nosig",
defaults={"pdf": True, "with_img_signatures_pdf": False},
)
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin",
)
@ -229,6 +268,10 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None)
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf",
defaults={"pdf": True},
)
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf/nosig",
defaults={"pdf": True, "with_img_signatures_pdf": False},
)
@scodoc
@permission_required(Permission.ScoView)
def bulletin(
@ -237,6 +280,7 @@ def bulletin(
formsemestre_id: int = None,
version: str = "long",
pdf: bool = False,
with_img_signatures_pdf: bool = True,
):
"""
Retourne le bulletin d'un étudiant en fonction de son id et d'un semestre donné
@ -256,7 +300,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 non trouve")
return json_error(404, "formsemestre inexistant", as_response=True)
app.set_sco_dept(dept.acronym)
if code_type == "nip":
@ -276,7 +320,11 @@ def bulletin(
return json_error(404, message="etudiant inexistant")
if pdf:
pdf_response, _ = do_formsemestre_bulletinetud(
formsemestre, etud.id, version=version, format="pdf"
formsemestre,
etud,
version=version,
format="pdf",
with_img_signatures_pdf=with_img_signatures_pdf,
)
return pdf_response
return sco_bulletins.get_formsemestre_bulletin_etud_json(
@ -290,6 +338,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é
@ -339,4 +388,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,16 +8,16 @@
ScoDoc 9 API : accès aux évaluations
"""
from flask import g, jsonify
from flask import g, request
from flask_json import as_json
from flask_login import login_required
import app
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
from app.scodoc.sco_utils import json_error
from app.models import Evaluation, ModuleImpl, FormSemestre
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_evaluation_db, sco_saisie_notes
from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu
@ -27,6 +27,7 @@ import app.scodoc.sco_utils as scu
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def evaluation(evaluation_id: int):
"""Description d'une évaluation.
@ -57,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")
@ -65,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
@ -80,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")
@ -88,26 +90,25 @@ 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
Retourne la liste des notes de l'évaluation
evaluation_id : l'id d'une évaluation
evaluation_id : l'id de l'évaluation
Exemple de résultat :
{
"1": {
"id": 1,
"etudid": 10,
"11": {
"etudid": 11,
"evaluation_id": 1,
"value": 15.0,
"comment": "",
"date": "Wed, 20 Apr 2022 06:49:05 GMT",
"uid": 2
},
"2": {
"id": 2,
"etudid": 1,
"12": {
"etudid": 12,
"evaluation_id": 1,
"value": 12.0,
"comment": "",
@ -137,4 +138,46 @@ def evaluation_notes(evaluation_id: int):
note["note_max"] = evaluation.note_max
del note["id"]
return jsonify(notes)
# in JS, keys must be string, not integers
return {str(etudid): note for etudid, note in notes.items()}
@bp.route("/evaluation/<int:evaluation_id>/notes/set", methods=["POST"])
@api_web_bp.route("/evaluation/<int:evaluation_id>/notes/set", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEnsView)
@as_json
def evaluation_set_notes(evaluation_id: int):
"""Écriture de notes dans une évaluation.
The request content type should be "application/json",
and contains:
{
'notes' : [ [etudid, value], ... ],
'comment' : optional string
}
Result:
- nb_changed: nombre de notes changées
- nb_suppress: nombre de notes effacées
- etudids_with_decision: liste des etudiants dont la note a changé
alors qu'ils ont une décision de jury enregistrée.
"""
query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept:
query = (
query.join(ModuleImpl)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
)
evaluation = query.first_or_404()
dept = evaluation.moduleimpl.formsemestre.departement
app.set_sco_dept(dept.acronym)
data = request.get_json(force=True) # may raise 400 Bad Request
notes = data.get("notes")
if notes is None:
return scu.json_error(404, "no notes")
if not isinstance(notes, list):
return scu.json_error(404, "invalid notes argument (must be a list)")
return sco_saisie_notes.save_notes(
evaluation, notes, comment=data.get("comment", "")
)

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,11 +7,15 @@
"""
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.api import api_bp as bp, api_web_bp
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
from app.comp import res_sem
@ -27,11 +31,13 @@ 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
from app.scodoc.sco_utils import ModuleType
import app.scodoc.sco_utils as scu
from app.tables.recap import TableRecap
@bp.route("/formsemestre/<int:formsemestre_id>")
@ -39,6 +45,7 @@ import app.scodoc.sco_utils as scu
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_infos(formsemestre_id: int):
"""
Information sur le formsemestre indiqué.
@ -80,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")
@ -88,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
@ -112,7 +120,7 @@ def formsemestres_query():
try:
annee_scolaire_int = int(annee_scolaire)
except ValueError:
return json_error(404, "invalid annee_scolaire: not int")
return json_error(API_CLIENT_ERROR, "invalid annee_scolaire: not int")
debut_annee = scu.date_debut_anne_scolaire(annee_scolaire_int)
fin_annee = scu.date_fin_anne_scolaire(annee_scolaire_int)
formsemestres = formsemestres.filter(
@ -124,7 +132,7 @@ def formsemestres_query():
try:
dept_id = int(dept_id)
except ValueError:
return json_error(404, "invalid dept_id: not int")
return json_error(404, "invalid dept_id: integer expected")
formsemestres = formsemestres.filter_by(dept_id=dept_id)
if etape_apo is not None:
formsemestres = formsemestres.join(FormSemestreEtape).filter(
@ -143,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")
@ -153,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é
@ -176,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")
@ -184,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
@ -253,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: [],
@ -263,15 +273,13 @@ def formsemestre_programme(formsemestre_id: int):
for modimpl in formsemestre.modimpls_sorted:
d = modimpl.to_dict(convert_objects=True)
m_list[modimpl.module.module_type].append(d)
return jsonify(
{
"ues": [ue.to_dict(convert_objects=True) for ue in ues],
"ressources": m_list[ModuleType.RESSOURCE],
"saes": m_list[ModuleType.SAE],
"modules": m_list[ModuleType.STANDARD],
"malus": m_list[ModuleType.MALUS],
}
)
return {
"ues": [ue.to_dict(convert_objects=True) for ue in ues],
"ressources": m_list[ModuleType.RESSOURCE],
"saes": m_list[ModuleType.SAE],
"modules": m_list[ModuleType.STANDARD],
"malus": m_list[ModuleType.MALUS],
}
@bp.route(
@ -309,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
):
@ -344,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")
@ -352,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.
@ -431,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
@ -453,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")
@ -461,13 +471,14 @@ 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.
"""
format_spec = request.args.get("format", None)
if format_spec is not None and format_spec != "raw":
return json_error(404, "invalid format specification")
return json_error(API_CLIENT_ERROR, "invalid format specification")
convert_values = format_spec != "raw"
query = FormSemestre.query.filter_by(id=formsemestre_id)
@ -476,16 +487,55 @@ def formsemestre_resultat(formsemestre_id: int):
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
app.set_sco_dept(formsemestre.departement.acronym)
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
rows, footer_rows, titles, column_ids = res.get_table_recap(
convert_values=convert_values,
include_evaluations=False,
mode_jury=False,
allow_html=False,
table = TableRecap(
res, convert_values=convert_values, include_evaluations=False, mode_jury=False
)
# Supprime les champs inutiles (mise en forme)
table = [{k: row[k] for k in row if not k[0] == "_"} for row in rows]
# Ajoute les groupes
rows = table.to_list()
# Ajoute le groupe de chaque partition:
etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id)
for row in table:
for row in rows:
row["partitions"] = etud_groups.get(row["etudid"], {})
return jsonify(table)
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

@ -5,21 +5,38 @@
##############################################################################
"""
ScoDoc 9 API : jury
ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions
"""
from flask import g, jsonify, request
from flask_login import login_required
import datetime
from flask import flash, g, request, url_for
from flask_json import as_json
from flask_login import current_user, login_required
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR, tools
from app.decorators import scodoc, permission_required
from app.scodoc.sco_exceptions import ScoException
from app.scodoc.sco_utils import json_error
from app.but import jury_but_recap
from app.models import FormSemestre, FormSemestreInscription, Identite
from app.but import jury_but_results
from app.models import (
ApcParcours,
ApcValidationAnnee,
ApcValidationRCUE,
Formation,
FormSemestre,
Identite,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
ScolarNews,
Scolog,
UniteEns,
)
from app.scodoc import codes_cursus
from app.scodoc import sco_cache
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@bp.route("/formsemestre/<int:formsemestre_id>/decisions_jury")
@ -27,13 +44,308 @@ 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:
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
if formsemestre.formation.is_apc():
app.set_sco_dept(formsemestre.departement.acronym)
rows = jury_but_recap.get_jury_but_results(formsemestre)
return jsonify(rows)
rows = jury_but_results.get_jury_but_results(formsemestre)
return rows
else:
raise ScoException("non implemente")
def _news_delete_jury_etud(etud: Identite):
"génère news sur effacement décision"
# n'utilise pas g.scodoc_dept, pas toujours dispo en mode API
url = url_for(
"scolar.ficheEtud", scodoc_dept=etud.departement.acronym, etudid=etud.id
)
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=etud.id,
text=f"""Suppression décision jury pour <a href="{url}">{etud.nomprenom}</a>""",
url=url,
)
@bp.route(
"/etudiant/<int:etudid>/jury/validation_ue/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_ue/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def validation_ue_delete(etudid: int, validation_id: int):
"Efface cette validation"
return _validation_ue_delete(etudid, validation_id)
@bp.route(
"/etudiant/<int:etudid>/jury/validation_formsemestre/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_formsemestre/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def validation_formsemestre_delete(etudid: int, validation_id: int):
"Efface cette validation"
# c'est la même chose (formations classiques)
return _validation_ue_delete(etudid, validation_id)
def _validation_ue_delete(etudid: int, validation_id: int):
"Efface cette validation (semestres classiques ou UEs)"
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ScolarFormSemestreValidation.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
# Vérification de la permission:
# A le droit de supprimer cette validation: le chef de dept ou quelqu'un ayant
# le droit de saisir des décisions de jury dans le formsemestre concerné s'il y en a un
# (c'est le cas pour les validations de jury, mais pas pour les "antérieures" non
# rattachées à un formsemestre)
if not g.scodoc_dept: # accès API
if not current_user.has_permission(Permission.ScoEtudInscrit):
return json_error(403, "opération non autorisée (117)")
else:
if validation.formsemestre:
if (
validation.formsemestre.dept_id != g.scodoc_dept_id
) or not validation.formsemestre.can_edit_jury():
return json_error(403, "opération non autorisée (123)")
elif not current_user.has_permission(Permission.ScoEtudInscrit):
# Validation non rattachée à un semestre: on doit être chef
return json_error(403, "opération non autorisée (126)")
log(f"validation_ue_delete: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud)
return "ok"
@bp.route(
"/etudiant/<int:etudid>/jury/autorisation_inscription/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/autorisation_inscription/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudInscrit)
@as_json
def autorisation_inscription_delete(etudid: int, validation_id: int):
"Efface cette validation"
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ScolarAutorisationInscription.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
log(f"autorisation_inscription_delete: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud)
return "ok"
@bp.route(
"/etudiant/<int:etudid>/jury/validation_rcue/record",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_rcue/record",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudInscrit)
@as_json
def validation_rcue_record(etudid: int):
"""Enregistre une validation de RCUE.
Si une validation existe déjà pour ce RCUE, la remplace.
The request content type should be "application/json":
{
"code" : str,
"ue1_id" : int,
"ue2_id" : int,
// Optionnel:
"formsemestre_id" : int,
"date" : date_iso, // si non spécifié, now()
"parcours_id" :int,
}
"""
etud = tools.get_etud(etudid)
if etud is None:
return json_error(404, "étudiant inconnu")
data = request.get_json(force=True) # may raise 400 Bad Request
code = data.get("code")
if code is None:
return json_error(API_CLIENT_ERROR, "missing argument: code")
if code not in codes_cursus.CODES_JURY_RCUE:
return json_error(API_CLIENT_ERROR, "invalid code value")
ue1_id = data.get("ue1_id")
if ue1_id is None:
return json_error(API_CLIENT_ERROR, "missing argument: ue1_id")
try:
ue1_id = int(ue1_id)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid value for ue1_id")
ue2_id = data.get("ue2_id")
if ue2_id is None:
return json_error(API_CLIENT_ERROR, "missing argument: ue2_id")
try:
ue2_id = int(ue2_id)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid value for ue2_id")
formsemestre_id = data.get("formsemestre_id")
date_validation_str = data.get("date", datetime.datetime.now().isoformat())
parcours_id = data.get("parcours_id")
#
query = UniteEns.query.filter_by(id=ue1_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue1: UniteEns = query.first_or_404()
query = UniteEns.query.filter_by(id=ue2_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue2: UniteEns = query.first_or_404()
if ue1.niveau_competence_id != ue2.niveau_competence_id:
return json_error(
API_CLIENT_ERROR, "UEs non associees au meme niveau de competence"
)
if formsemestre_id is not None:
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404()
if (formsemestre.formation_id != ue1.formation_id) or (
formsemestre.formation_id != ue2.formation_id
):
return json_error(
API_CLIENT_ERROR, "ues et semestre ne sont pas de la meme formation"
)
else:
formsemestre = None
try:
date_validation = datetime.datetime.fromisoformat(date_validation_str)
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid date string")
if parcours_id is not None:
parcours: ApcParcours = ApcParcours.query.get_or_404(parcours_id)
if parcours.referentiel_id != ue1.niveau_competence.competence.referentiel_id:
return json_error(API_CLIENT_ERROR, "niveau et parcours incompatibles")
# Une validation pour ce niveau de compétence existe-elle ?
validation = (
ApcValidationRCUE.query.filter_by(etudid=etudid)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
.filter_by(niveau_competence_id=ue2.niveau_competence_id)
.first()
)
if validation:
validation.code = code
validation.date = date_validation
validation.formsemestre_id = formsemestre_id
validation.parcours_id = parcours_id
validation.ue1_id = ue1_id
validation.ue2_id = ue2_id
operation = "update"
else:
validation = ApcValidationRCUE(
code=code,
date=date_validation,
etudid=etudid,
formsemestre_id=formsemestre_id,
parcours_id=parcours_id,
ue1_id=ue1_id,
ue2_id=ue2_id,
)
operation = "record"
db.session.add(validation)
# invalider bulletins (les autres résultats ne dépendent pas des RCUEs):
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
Scolog.logdb(
method="validation_rcue_record",
etudid=etudid,
msg=f"Enregistrement {validation}",
commit=True,
)
log(f"{operation} {validation}")
return validation.to_dict()
@bp.route(
"/etudiant/<int:etudid>/jury/validation_rcue/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_rcue/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudInscrit)
@as_json
def validation_rcue_delete(etudid: int, validation_id: int):
"Efface cette validation"
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ApcValidationRCUE.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
log(f"validation_ue_delete: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud)
return "ok"
@bp.route(
"/etudiant/<int:etudid>/jury/validation_annee_but/<int:validation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<int:etudid>/jury/validation_annee_but/<int:validation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.ScoEtudInscrit)
@as_json
def validation_annee_but_delete(etudid: int, validation_id: int):
"Efface cette validation"
etud = tools.get_etud(etudid)
if etud is None:
return "étudiant inconnu", 404
validation = ApcValidationAnnee.query.filter_by(
id=validation_id, etudid=etudid
).first_or_404()
log(f"validation_annee_but: etuid={etudid} {validation}")
db.session.delete(validation)
sco_cache.invalidate_formsemestre_etud(etud)
db.session.commit()
_news_delete_jury_etud(etud)
return "ok"

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,18 +7,25 @@
"""
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 sqlalchemy as sa
from sqlalchemy.exc import IntegrityError
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.decorators import scodoc, permission_required
from app.scodoc.sco_utils import json_error
from app.models import FormSemestre, FormSemestreInscription, Identite
from app.models import GroupDescr, Partition
from app.models import GroupDescr, Partition, Scolog
from app.models.groups import group_membership
from app.scodoc import sco_cache
from app.scodoc import sco_groups
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
@ -28,6 +35,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.
@ -52,7 +60,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")
@ -60,6 +68,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
@ -84,14 +93,12 @@ def formsemestre_partitions(formsemestre_id: int):
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
partitions = sorted(formsemestre.partitions, key=lambda p: p.numero or 0)
return jsonify(
{
partition.id: partition.to_dict(with_groups=True)
for partition in partitions
if partition.partition_name is not None
}
)
partitions = sorted(formsemestre.partitions, key=attrgetter("numero"))
return {
str(partition.id): partition.to_dict(with_groups=True, str_keys=True)
for partition in partitions
if partition.partition_name is not None
}
@bp.route("/group/<int:group_id>/etudiants")
@ -99,6 +106,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
@ -125,7 +133,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")
@ -133,11 +141,12 @@ 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")
if etat not in {None, scu.INSCRIT, scu.DEMISSION, scu.DEF}:
return json_error(404, "etat: valeur invalide")
return json_error(API_CLIENT_ERROR, "etat: valeur invalide")
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
query = (
@ -153,7 +162,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"])
@ -161,6 +170,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)
@ -170,25 +180,18 @@ def set_etud_group(etudid: int, group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if etud.id not in {e.id for e in group.partition.formsemestre.etuds}:
return json_error(404, "etud non inscrit au formsemestre du groupe")
groups = (
GroupDescr.query.filter_by(partition_id=group.partition.id)
.join(group_membership)
.filter_by(etudid=etudid)
)
ok = False
for other_group in groups:
if other_group.id == group_id:
ok = True
else:
other_group.etuds.remove(etud)
if not ok:
group.etuds.append(etud)
log(f"set_etud_group({etud}, {group})")
db.session.commit()
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return jsonify({"group_id": group_id, "etudid": etudid})
try:
sco_groups.change_etud_group_in_partition(etudid, group)
except ScoValueError as exc:
return json_error(404, exc.args[0])
except IntegrityError:
return json_error(404, "échec de l'enregistrement")
return {"group_id": group_id, "etudid": etudid}
@bp.route("/group/<int:group_id>/remove_etudiant/<int:etudid>", methods=["POST"])
@ -198,6 +201,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)
@ -207,11 +211,21 @@ def group_remove_etud(group_id: int, etudid: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if etud in group.etuds:
group.etuds.remove(etud)
db.session.commit()
Scolog.logdb(
method="group_remove_etud",
etudid=etud.id,
msg=f"Retrait du groupe {group.group_name} de {group.partition.partition_name}",
commit=True,
)
# Update parcours
group.partition.formsemestre.update_inscriptions_parcours_from_groups()
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return jsonify({"group_id": group_id, "etudid": etudid})
return {"group_id": group_id, "etudid": etudid}
@bp.route(
@ -223,6 +237,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)
@ -232,17 +247,33 @@ def partition_remove_etud(partition_id: int, etudid: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition = query.first_or_404()
groups = (
GroupDescr.query.filter_by(partition_id=partition_id)
.join(group_membership)
.filter_by(etudid=etudid)
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
db.session.execute(
sa.text(
"""DELETE FROM group_membership
WHERE etudid=:etudid
and group_id IN (
SELECT id FROM group_descr WHERE partition_id = :partition_id
);
"""
),
{"etudid": etudid, "partition_id": partition_id},
)
Scolog.logdb(
method="partition_remove_etud",
etudid=etud.id,
msg=f"Retrait de la partition {partition.partition_name}",
commit=False,
)
for group in groups:
group.etuds.remove(etud)
db.session.commit()
# Update parcours
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"])
@ -250,7 +281,8 @@ def partition_remove_etud(partition_id: int, etudid: int):
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
def group_create(partition_id: int):
@as_json
def group_create(partition_id: int): # partition-group-create
"""Création d'un groupe dans une partition
The request content type should be "application/json":
@ -262,14 +294,16 @@ def group_create(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.groups_editable:
return json_error(404, "partition non editable")
return json_error(403, "partition non editable")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is None:
return json_error(404, "missing group name or invalid data format")
return json_error(API_CLIENT_ERROR, "missing group name or invalid data format")
if not GroupDescr.check_name(partition, group_name):
return json_error(404, "invalid group_name")
return json_error(API_CLIENT_ERROR, "invalid group_name")
group_name = group_name.strip()
group = GroupDescr(group_name=group_name, partition_id=partition_id)
@ -278,7 +312,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"])
@ -286,6 +320,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)
@ -294,15 +329,17 @@ def group_delete(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group: GroupDescr = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable:
return json_error(404, "partition non editable")
return json_error(403, "partition non editable")
formsemestre_id = group.partition.formsemestre_id
log(f"deleting {group}")
db.session.delete(group)
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"])
@ -310,6 +347,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)
@ -318,21 +356,23 @@ def group_edit(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group: GroupDescr = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable:
return json_error(404, "partition non editable")
return json_error(403, "partition non editable")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is not None:
group_name = group_name.strip()
if not GroupDescr.check_name(group.partition, group_name, existing=True):
return json_error(404, "invalid group_name")
return json_error(API_CLIENT_ERROR, "invalid group_name")
group.group_name = group_name
db.session.add(group)
db.session.commit()
log(f"modified {group}")
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"])
@ -342,6 +382,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
@ -358,17 +399,23 @@ def partition_create(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)
if not formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
data = request.get_json(force=True) # may raise 400 Bad Request
partition_name = data.get("partition_name")
if partition_name is None:
return json_error(404, "missing partition_name or invalid data format")
return json_error(
API_CLIENT_ERROR, "missing partition_name or invalid data format"
)
if partition_name == scu.PARTITION_PARCOURS:
return json_error(404, f"invalid partition_name {scu.PARTITION_PARCOURS}")
return json_error(
API_CLIENT_ERROR, f"invalid partition_name {scu.PARTITION_PARCOURS}"
)
if not Partition.check_name(formsemestre, partition_name):
return json_error(404, "invalid partition_name")
return json_error(API_CLIENT_ERROR, "invalid partition_name")
numero = data.get("numero", 0)
if not isinstance(numero, int):
return json_error(404, "invalid type for numero")
return json_error(API_CLIENT_ERROR, "invalid type for numero")
args = {
"formsemestre_id": formsemestre_id,
"partition_name": partition_name.strip(),
@ -379,7 +426,7 @@ def partition_create(formsemestre_id: int):
boolean_field, False if boolean_field != "groups_editable" else True
)
if not isinstance(value, bool):
return json_error(404, f"invalid type for {boolean_field}")
return json_error(API_CLIENT_ERROR, f"invalid type for {boolean_field}")
args[boolean_field] = value
partition = Partition(**args)
@ -388,7 +435,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"])
@ -398,6 +445,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, ...]
@ -406,28 +454,28 @@ def formsemestre_order_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)
if not formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
partition_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(partition_ids, int) and not all(
isinstance(x, int) for x in partition_ids
):
return json_error(
404,
API_CLIENT_ERROR,
message="paramètre liste des partitions invalide",
)
for p_id, numero in zip(partition_ids, range(len(partition_ids))):
p = Partition.query.get_or_404(p_id)
p.numero = numero
db.session.add(p)
partition = Partition.query.get_or_404(p_id)
partition.numero = numero
db.session.add(partition)
db.session.commit()
app.set_sco_dept(formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(formsemestre_id)
return jsonify(
[
partition.to_dict()
for partition in formsemestre.partitions.order_by(Partition.numero)
if partition.partition_name is not None
]
)
return [
partition.to_dict()
for partition in formsemestre.partitions.order_by(Partition.numero)
if partition.partition_name is not None
]
@bp.route("/partition/<int:partition_id>/groups/order", methods=["POST"])
@ -435,6 +483,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, ...]
@ -443,12 +492,14 @@ def partition_order_groups(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
group_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(group_ids, int) and not all(
isinstance(x, int) for x in group_ids
):
return json_error(
404,
API_CLIENT_ERROR,
message="paramètre liste de groupe invalide",
)
for group_id, numero in zip(group_ids, range(len(group_ids))):
@ -459,7 +510,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"])
@ -467,6 +518,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
@ -484,24 +536,28 @@ def partition_edit(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
data = request.get_json(force=True) # may raise 400 Bad Request
modified = False
partition_name = data.get("partition_name")
#
if partition_name is not None and partition_name != partition.partition_name:
if partition.is_parcours():
return json_error(404, f"can't rename {scu.PARTITION_PARCOURS}")
return json_error(
API_CLIENT_ERROR, f"can't rename {scu.PARTITION_PARCOURS}"
)
if not Partition.check_name(
partition.formsemestre, partition_name, existing=True
):
return json_error(404, "invalid partition_name")
return json_error(API_CLIENT_ERROR, "invalid partition_name")
partition.partition_name = partition_name.strip()
modified = True
numero = data.get("numero")
if numero is not None and numero != partition.numero:
if not isinstance(numero, int):
return json_error(404, "invalid type for numero")
return json_error(API_CLIENT_ERROR, "invalid type for numero")
partition.numero = numero
modified = True
@ -509,9 +565,11 @@ def partition_edit(partition_id: int):
value = data.get(boolean_field)
if value is not None and value != getattr(partition, boolean_field):
if not isinstance(value, bool):
return json_error(404, f"invalid type for {boolean_field}")
return json_error(API_CLIENT_ERROR, f"invalid type for {boolean_field}")
if boolean_field == "groups_editable" and partition.is_parcours():
return json_error(404, f"can't change {scu.PARTITION_PARCOURS}")
return json_error(
API_CLIENT_ERROR, f"can't change {scu.PARTITION_PARCOURS}"
)
setattr(partition, boolean_field, value)
modified = True
@ -522,7 +580,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"])
@ -530,6 +588,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).
@ -542,8 +601,12 @@ def partition_delete(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.partition_name:
return json_error(404, "ne peut pas supprimer la partition par défaut")
return json_error(
API_CLIENT_ERROR, "ne peut pas supprimer la partition par défaut"
)
is_parcours = partition.is_parcours()
formsemestre: FormSemestre = partition.formsemestre
log(f"deleting partition {partition}")
@ -553,4 +616,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}

40
app/api/semset.py Normal file
View File

@ -0,0 +1,40 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
ScoDoc 9 API : accès aux formsemestres
"""
# 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
# 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,18 +6,21 @@ 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"])
@token_auth.login_required
def revoke_token():
"révoque le jeton de l'utilisateur courant"
token_auth.current_user().revoke_token()
user = token_auth.current_user()
user.revoke_token()
db.session.commit()
log(f"API: revoking token for {user}")
return "", 204

View File

@ -9,11 +9,12 @@
"""
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, log
from app.api import api_bp as bp, api_web_bp
from app import db
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_error
from app.auth.models import User, Role, UserRole
from app.auth.models import is_valid_password
@ -29,11 +30,12 @@ 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
"""
user: User = User.query.get(uid)
user: User = db.session.get(User, uid)
if user is None:
return json_error(404, "user not found")
if g.scodoc_dept:
@ -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:
@ -187,14 +193,14 @@ def user_password(uid: int):
if not password:
return json_error(404, "user_password: missing password")
if not is_valid_password(password):
return json_error(400, "user_password: invalid password")
return json_error(API_CLIENT_ERROR, "user_password: invalid password")
allowed_depts = current_user.get_depts_with_permission(Permission.ScoUsersAdmin)
if (None not in allowed_depts) and ((user.dept not in allowed_depts)):
return json_error(403, "user_password: departement non autorise")
user.set_password(password)
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

@ -6,3 +6,4 @@ from flask import Blueprint
bp = Blueprint("auth", __name__)
from app.auth import routes
from app.auth import cas

251
app/auth/cas.py Normal file
View File

@ -0,0 +1,251 @@
# -*- coding: UTF-8 -*
"""
auth.cas.py
"""
import datetime
import flask
from flask import current_app, flash, url_for
from flask_login import current_user, login_user
from app import db
from app.auth import bp
from app.auth.models import User
from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_excel
from app.scodoc.sco_exceptions import ScoValueError, AccessDenied
import app.scodoc.sco_utils as scu
# after_cas_login/after_cas_logout : routes appelées par redirect depuis le serveur CAS.
@bp.route("/after_cas_login")
def after_cas_login():
"Called by CAS after CAS authentication"
# Ici on a les infos dans flask.session["CAS_ATTRIBUTES"]
if ScoDocSiteConfig.is_cas_enabled() and ("CAS_ATTRIBUTES" in flask.session):
# Lookup user:
cas_id = flask.session["CAS_ATTRIBUTES"].get(
"cas:" + ScoDocSiteConfig.get("cas_attribute_id"),
flask.session.get("CAS_USERNAME"),
)
if cas_id is not None:
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}")
if login_user(user):
flask.session[
"scodoc_cas_login_date"
] = datetime.datetime.now().isoformat()
user.cas_last_login = datetime.datetime.utcnow()
db.session.add(user)
db.session.commit()
return flask.redirect(url_for("scodoc.index"))
else:
current_app.logger.info(
f"CAS login denied for {user.user_name} (not allowed to use CAS)"
)
else:
current_app.logger.info(
f"""CAS login denied for {
user.user_name if user else ""
} cas_id={cas_id} (unknown or inactive)"""
)
else:
current_app.logger.info(
f"""CAS attribute '{ScoDocSiteConfig.get("cas_attribute_id")}' not found !
(check your ScoDoc config)"""
)
# Echec:
flash("échec de l'authentification")
return flask.redirect(url_for("auth.login"))
@bp.route("/after_cas_logout")
def after_cas_logout():
"Called by CAS after CAS logout"
flash("Vous êtes déconnecté")
current_app.logger.info("after_cas_logout")
return flask.redirect(url_for("scodoc.index"))
def cas_error_callback(message):
"Called by CAS when an error occurs, with a message"
raise ScoValueError(f"Erreur authentification CAS: {message}")
def set_cas_configuration(app: flask.app.Flask = None):
"""Force la configuration du module flask_cas à partir des paramètres de
la config de ScoDoc.
Appelé au démarrage et à chaque modif des paramètres.
"""
app = app or current_app
if ScoDocSiteConfig.is_cas_enabled():
current_app.logger.debug("CAS: set_cas_configuration")
app.config["CAS_SERVER"] = ScoDocSiteConfig.get("cas_server")
app.config["CAS_LOGIN_ROUTE"] = ScoDocSiteConfig.get("cas_login_route", "/cas")
app.config["CAS_LOGOUT_ROUTE"] = ScoDocSiteConfig.get(
"cas_logout_route", "/cas/logout"
)
app.config["CAS_VALIDATE_ROUTE"] = ScoDocSiteConfig.get(
"cas_validate_route", "/cas/serviceValidate"
)
app.config["CAS_AFTER_LOGIN"] = "auth.after_cas_login"
app.config["CAS_AFTER_LOGOUT"] = "auth.after_cas_logout"
app.config["CAS_ERROR_CALLBACK"] = cas_error_callback
app.config["CAS_SSL_VERIFY"] = ScoDocSiteConfig.get("cas_ssl_verify")
app.config["CAS_SSL_CERTIFICATE"] = ScoDocSiteConfig.get("cas_ssl_certificate")
else:
app.config.pop("CAS_SERVER", None)
app.config.pop("CAS_AFTER_LOGIN", None)
app.config.pop("CAS_AFTER_LOGOUT", None)
app.config.pop("CAS_SSL_VERIFY", None)
app.config.pop("CAS_SSL_CERTIFICATE", None)
CAS_USER_INFO_IDS = (
"user_name",
"nom",
"prenom",
"email",
"roles_string",
"active",
"dept",
"cas_id",
"cas_allow_login",
"cas_allow_scodoc_login",
"email_institutionnel",
)
CAS_USER_INFO_COMMENTS = (
"""user_name:
L'identifiant (login).
""",
"",
"",
"",
"Pour info: 0 si compte inactif",
"""Pour info: roles:
chaînes séparées par _:
1. Le rôle (Ens, Secr ou Admin)
2. Le département (en majuscule)
""",
"""dept:
Le département d'appartenance de l'utilisateur. Vide si l'utilisateur
intervient dans plusieurs départements.
""",
"""cas_id:
identifiant de l'utilisateur sur CAS (requis pour CAS).
""",
"""cas_allow_login:
autorise la connexion via CAS (optionnel, faux par défaut)
""",
"""cas_allow_scodoc_login
autorise connexion via ScoDoc même si CAS obligatoire (optionnel, faux par défaut)
""",
"""email_institutionnel
optionnel, le mail officiel de l'utilisateur.
Maximum 120 caractères.""",
)
def cas_users_generate_excel_sample() -> bytes:
"""generate an excel document suitable to import users CAS information"""
style = sco_excel.excel_make_style(bold=True)
titles = CAS_USER_INFO_IDS
titles_styles = [style] * len(titles)
# Extrait tous les utilisateurs (tous dept et statuts)
rows = []
for user in User.query.order_by(User.user_name):
u_dict = user.to_dict()
rows.append([u_dict.get(k) for k in CAS_USER_INFO_IDS])
return sco_excel.excel_simple_table(
lines=rows,
titles=titles,
titles_styles=titles_styles,
sheet_name="Utilisateurs ScoDoc",
comments=CAS_USER_INFO_COMMENTS,
)
def cas_users_import_excel_file(datafile) -> int:
"""
Import users CAS configuration from Excel file.
May change cas_id, cas_allow_login, cas_allow_scodoc_login
and active.
:param datafile: stream to be imported
:return: nb de comptes utilisateurs modifiés
"""
from app.scodoc import sco_import_users
if not current_user.is_administrator():
raise AccessDenied(f"invalid user ({current_user}) must be SuperAdmin")
current_app.logger.info("cas_users_import_excel_file by {current_user}")
users_infos = sco_import_users.read_users_excel_file(
datafile, titles=CAS_USER_INFO_IDS
)
return cas_users_import_data(users_infos=users_infos)
def cas_users_import_data(users_infos: list[dict]) -> int:
"""Import informations configuration CAS
users est une liste de dict, on utilise seulement les champs:
- user_name : la clé, l'utilisateur DOIT déjà exister
- cas_id : l'ID CAS a enregistrer.
- cas_allow_login
- cas_allow_scodoc_login
Les éventuels autres champs sont ignorés.
Return: nb de comptes modifiés.
"""
nb_modif = 0
users = []
for info in users_infos:
user: User = User.query.filter_by(user_name=info["user_name"]).first()
if not user:
db.session.rollback() # au cas où auto-flush
raise ScoValueError(f"""Utilisateur '{info["user_name"]}' inexistant""")
modif = False
new_cas_id = info["cas_id"].strip()
if new_cas_id != (user.cas_id or ""):
# check unicity
other = User.query.filter_by(cas_id=new_cas_id).first()
if other and other.id != user.id:
db.session.rollback() # au cas où auto-flush
raise ScoValueError(f"cas_id {new_cas_id} dupliqué")
user.cas_id = info["cas_id"].strip() or None
modif = True
val = scu.to_bool(info["cas_allow_login"])
if val != user.cas_allow_login:
user.cas_allow_login = val
modif = True
val = scu.to_bool(info["cas_allow_scodoc_login"])
if val != user.cas_allow_scodoc_login:
user.cas_allow_scodoc_login = val
modif = True
val = scu.to_bool(info["active"])
if val != (user.active or False):
user.active = val
modif = True
if modif:
nb_modif += 1
# Record modifications
for user in users:
try:
db.session.add(user)
except Exception as exc:
db.session.rollback()
raise ScoValueError(
"Erreur (1) durant l'importation des modifications"
) from exc
try:
db.session.commit()
except Exception as exc:
db.session.rollback()
raise ScoValueError(
"Erreur (2) durant l'importation des modifications"
) from exc
return nb_modif

View File

@ -1,15 +1,20 @@
# -*- coding: UTF-8 -*
from flask import render_template, current_app
from flask_babel import _
from app.email import send_email
from flask import render_template
from app.auth.models import User
from app.email import get_from_addr, send_email
def send_password_reset_email(user):
def send_password_reset_email(user: User):
"""Send message allowing to reset password"""
recipients = user.get_emails()
if not recipients:
return
token = user.get_reset_password_token()
send_email(
"[ScoDoc] Réinitialisation de votre mot de passe",
sender=current_app.config["SCODOC_MAIL_FROM"],
recipients=[user.email],
sender=get_from_addr(),
recipients=recipients,
text_body=render_template("email/reset_password.txt", user=user, token=token),
html_body=render_template("email/reset_password.html", user=user, token=token),
html_body=render_template("email/reset_password.j2", user=user, token=token),
)

View File

@ -1,13 +1,12 @@
# -*- coding: UTF-8 -*
"""Formulaires authentification
TODO: à revoir complètement pour reprendre ZScoUsers et les pages d'authentification
"""
from urllib.parse import urlparse, urljoin
from flask import request, url_for, redirect
from flask_wtf import FlaskForm
from wtforms import BooleanField, HiddenField, PasswordField, StringField, SubmitField
from wtforms.fields.simple import FileField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
from app.auth.models import User, is_valid_password
@ -98,3 +97,12 @@ class ResetPasswordForm(FlaskForm):
class DeactivateUserForm(FlaskForm):
submit = SubmitField("Modifier l'utilisateur")
cancel = SubmitField(label="Annuler", render_kw={"formnovalidate": True})
class CASUsersImportConfigForm(FlaskForm):
user_config_file = FileField(
label="Fichier Excel à réimporter",
description="""fichier avec les paramètres CAS renseignés""",
)
submit = SubmitField("Importer le fichier utilisateurs")
cancel = SubmitField(label="Annuler", render_kw={"formnovalidate": True})

View File

@ -5,12 +5,14 @@
import http
import flask
from flask import g, redirect, request, url_for
from flask import current_app, g, redirect, request, url_for
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
import flask_login
from app import login
from app.scodoc.sco_utils import json_error
from app import db, login
from app.auth.models import User
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_utils import json_error
basic_auth = HTTPBasicAuth()
token_auth = HTTPTokenAuth()
@ -37,7 +39,7 @@ def basic_auth_error(status):
@login.user_loader
def load_user(uid: str) -> User:
"flask-login: accès à un utilisateur"
return User.query.get(int(uid))
return db.session.get(User, int(uid))
@token_auth.verify_token
@ -83,3 +85,15 @@ def unauthorized():
if request.blueprint == "api" or request.blueprint == "apiweb":
return json_error(http.HTTPStatus.UNAUTHORIZED, "Non autorise (logic)")
return redirect(url_for("auth.login"))
def logout() -> flask.Response:
"""Logout the current user: If CAS session, logout from CAS. Redirect."""
if flask_login.current_user:
user_name = getattr(flask_login.current_user, "user_name", "anonymous")
current_app.logger.info(f"logout user {user_name}")
flask_login.logout_user()
if ScoDocSiteConfig.is_cas_enabled() and flask.session.get("scodoc_cas_login_date"):
flask.session.pop("scodoc_cas_login_date", None)
return redirect(url_for("cas.logout"))
return redirect(url_for("scodoc.index"))

View File

@ -19,9 +19,10 @@ from werkzeug.security import generate_password_hash, check_password_hash
import jwt
from app import db, log, login
from app import db, email, log, login
from app.models import Departement
from app.models import SHORT_STR_LEN
from app.models import SHORT_STR_LEN, USERNAME_STR_LEN
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
@ -31,7 +32,7 @@ from app.scodoc import sco_etud # a deplacer dans scu
VALID_LOGIN_EXP = re.compile(r"^[a-zA-Z0-9@\\\-_\.]+$")
def is_valid_password(cleartxt):
def is_valid_password(cleartxt) -> bool:
"""Check password.
returns True if OK.
"""
@ -48,17 +49,45 @@ def is_valid_password(cleartxt):
return False
def invalid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is invalid"
return (
(len(user_name) < 2)
or (len(user_name) >= USERNAME_STR_LEN)
or not VALID_LOGIN_EXP.match(user_name)
)
class User(UserMixin, db.Model):
"""ScoDoc users, handled by Flask / SQLAlchemy"""
id = db.Column(db.Integer, primary_key=True)
user_name = db.Column(db.String(64), index=True, unique=True)
user_name = db.Column(db.String(USERNAME_STR_LEN), index=True, unique=True)
"le login"
email = db.Column(db.String(120))
nom = db.Column(db.String(64))
prenom = db.Column(db.String(64))
"email à utiliser par ScoDoc"
email_institutionnel = db.Column(db.String(120))
"email dans l'établissement, facultatif"
nom = db.Column(db.String(USERNAME_STR_LEN))
prenom = db.Column(db.String(USERNAME_STR_LEN))
dept = db.Column(db.String(SHORT_STR_LEN), index=True)
"acronyme du département de l'utilisateur"
active = db.Column(db.Boolean, default=True, index=True)
"si faux, compte utilisateur désactivé"
cas_id = db.Column(db.Text(), index=True, unique=True, nullable=True)
"uid sur le CAS (id, mail ou autre attribut, selon config.cas_attribute_id)"
cas_allow_login = db.Column(
db.Boolean, default=False, server_default="false", nullable=False
)
"Peut-on se logguer via le CAS ?"
cas_allow_scodoc_login = db.Column(
db.Boolean, default=False, server_default="false", nullable=False
)
"""Si CAS forcé (cas_force), peut-on se logguer sur ScoDoc directement ?
(le rôle ScoSuperAdmin peut toujours, mettre à True pour les utilisateur API)
"""
cas_last_login = db.Column(db.DateTime, nullable=True)
"""date du dernier login via CAS"""
password_hash = db.Column(db.String(128))
password_scodoc7 = db.Column(db.String(42))
@ -67,6 +96,8 @@ class User(UserMixin, db.Model):
date_created = db.Column(db.DateTime, default=datetime.utcnow)
date_expiration = db.Column(db.DateTime, default=None)
passwd_temp = db.Column(db.Boolean, default=False)
"""champ obsolete. Si connexion alors que passwd_temp est vrai,
efface mot de passe et redirige vers accueil."""
token = db.Column(db.Text(), index=True, unique=True)
token_expiration = db.Column(db.DateTime)
@ -86,7 +117,7 @@ class User(UserMixin, db.Model):
self.roles = []
self.user_roles = []
# check login:
if kwargs.get("user_name") and not VALID_LOGIN_EXP.match(kwargs["user_name"]):
if kwargs.get("user_name") and invalid_user_name(kwargs["user_name"]):
raise ValueError(f"invalid user_name: {kwargs['user_name']}")
super(User, self).__init__(**kwargs)
# Ajoute roles:
@ -103,7 +134,8 @@ class User(UserMixin, db.Model):
# current_app.logger.info("creating user with roles={}".format(self.roles))
def __repr__(self):
return f"<User {self.user_name} id={self.id} dept={self.dept}{' (inactive)' if not self.active else ''}>"
return f"""<User {self.user_name} id={self.id} dept={self.dept}{
' (inactive)' if not self.active else ''}>"""
def __str__(self):
return self.user_name
@ -115,30 +147,56 @@ class User(UserMixin, db.Model):
self.password_hash = generate_password_hash(password)
else:
self.password_hash = None
# La création d'un mot de passe efface l'éventuel mot de passe historique
self.password_scodoc7 = None
self.passwd_temp = False
def check_password(self, password):
def check_password(self, password: str) -> bool:
"""Check given password vs current one.
Returns `True` if the password matched, `False` otherwise.
"""
if not self.active: # inactived users can't login
current_app.logger.warning(
f"auth: login attempt from inactive account {self}"
)
return False
if (not self.password_hash) and self.password_scodoc7:
# Special case: user freshly migrated from ScoDoc7
if scu.check_scodoc7_password(self.password_scodoc7, password):
current_app.logger.warning(
f"migrating legacy ScoDoc7 password for {self}"
)
self.set_password(password)
self.password_scodoc7 = None
db.session.add(self)
db.session.commit()
return True
if self.passwd_temp:
# Anciens comptes ScoDoc 7 non migrés
# désactive le compte par sécurité.
current_app.logger.warning(f"auth: desactivating legacy account {self}")
self.active = False
self.passwd_temp = True
db.session.add(self)
db.session.commit()
send_notif_desactivation_user(self)
return False
# if CAS activated and forced, allow only super-user and users with cas_allow_scodoc_login
if ScoDocSiteConfig.is_cas_enabled() and ScoDocSiteConfig.get("cas_force"):
if (not self.is_administrator()) and not self.cas_allow_scodoc_login:
return False
if not self.password_hash: # user without password can't login
if self.password_scodoc7:
# Special case: user freshly migrated from ScoDoc7
return self._migrate_scodoc7_password(password)
return False
return check_password_hash(self.password_hash, password)
def _migrate_scodoc7_password(self, password) -> bool:
"""After migration, rehash password."""
if scu.check_scodoc7_password(self.password_scodoc7, password):
current_app.logger.warning(
f"auth: migrating legacy ScoDoc7 password for {self}"
)
self.set_password(password)
self.password_scodoc7 = None
db.session.add(self)
db.session.commit()
return True
return False
def get_reset_password_token(self, expires_in=600):
"Un token pour réinitialiser son mot de passe"
return jwt.encode(
@ -155,7 +213,7 @@ class User(UserMixin, db.Model):
token, current_app.config["SECRET_KEY"], algorithms=["HS256"]
)
except jwt.exceptions.ExpiredSignatureError:
log(f"verify_reset_password_token: token expired")
log("verify_reset_password_token: token expired")
except:
return None
try:
@ -167,7 +225,7 @@ class User(UserMixin, db.Model):
return None
except (TypeError, KeyError):
return None
return User.query.get(user_id)
return db.session.get(User, user_id)
def to_dict(self, include_email=True):
"""l'utilisateur comme un dict, avec des champs supplémentaires"""
@ -184,6 +242,12 @@ class User(UserMixin, db.Model):
"dept": self.dept,
"id": self.id,
"active": self.active,
"cas_id": self.cas_id,
"cas_allow_login": self.cas_allow_login,
"cas_allow_scodoc_login": self.cas_allow_scodoc_login,
"cas_last_login": self.cas_last_login.isoformat() + "Z"
if self.cas_last_login
else None,
"status_txt": "actif" if self.active else "fermé",
"last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None,
"nom": (self.nom or ""), # sco8
@ -200,22 +264,39 @@ class User(UserMixin, db.Model):
}
if include_email:
data["email"] = self.email or ""
data["email_institutionnel"] = self.email_institutionnel or ""
return data
def from_dict(self, data, new_user=False):
def from_dict(self, data: dict, new_user=False):
"""Set users' attributes from given dict values.
Roles must be encoded as "roles_string", like "Ens_RT, Secr_CJ"
"""
for field in ["nom", "prenom", "dept", "active", "email", "date_expiration"]:
for field in [
"nom",
"prenom",
"dept",
"active",
"email",
"email_institutionnel",
"date_expiration",
"cas_id",
]:
if field in data:
setattr(self, field, data[field] or None)
# required boolean fields
for field in [
"cas_allow_login",
"cas_allow_scodoc_login",
]:
setattr(self, field, scu.to_bool(data.get(field, False)))
if new_user:
if "user_name" in data:
# never change name of existing users
self.user_name = data["user_name"]
if "password" in data:
self.set_password(data["password"])
if not VALID_LOGIN_EXP.match(self.user_name):
if invalid_user_name(self.user_name):
raise ValueError(f"invalid user_name: {self.user_name}")
# Roles: roles_string is "Ens_RT, Secr_RT, ..."
if "roles_string" in data:
@ -241,7 +322,7 @@ class User(UserMixin, db.Model):
@staticmethod
def check_token(token):
"""Retreive user for given token, chek token's validity
"""Retreive user for given token, check token's validity
and returns the user object.
"""
user = User.query.filter_by(token=token).first()
@ -255,6 +336,15 @@ class User(UserMixin, db.Model):
return self._departement.id
return None
def get_emails(self):
"List mail adresses to contact this user"
mails = []
if self.email:
mails.append(self.email)
if self.email_institutionnel:
mails.append(self.email_institutionnel)
return mails
# Permissions management:
def has_permission(self, perm: int, dept=False):
"""Check if user has permission `perm` in given `dept`.
@ -286,7 +376,9 @@ class User(UserMixin, db.Model):
"""
if not isinstance(role, Role):
raise ScoValueError("add_role: rôle invalide")
self.user_roles.append(UserRole(user=self, role=role, dept=dept))
user_role = UserRole(user=self, role=role, dept=dept)
db.session.add(user_role)
self.user_roles.append(user_role)
def add_roles(self, roles: "list[Role]", dept: str):
"""Add roles to this user.
@ -310,7 +402,7 @@ class User(UserMixin, db.Model):
"""string repr. of user's roles (with depts)
e.g. "Ens_RT, Ens_Info, Secr_CJ"
"""
return ",".join(
return ", ".join(
f"{r.role.name or ''}_{r.dept or ''}"
for r in self.user_roles
if r is not None
@ -339,24 +431,17 @@ class User(UserMixin, db.Model):
"""nomplogin est le nom en majuscules suivi du prénom et du login
e.g. Dupont Pierre (dupont)
"""
if self.nom:
n = sco_etud.format_nom(self.nom)
else:
n = self.user_name.upper()
return "%s %s (%s)" % (
n,
sco_etud.format_prenom(self.prenom),
self.user_name,
)
nom = sco_etud.format_nom(self.nom) if self.nom else self.user_name.upper()
return f"{nom} {sco_etud.format_prenom(self.prenom)} ({self.user_name})"
@staticmethod
def get_user_id_from_nomplogin(nomplogin: str) -> Optional[int]:
"""Returns id from the string "Dupont Pierre (dupont)"
or None if user does not exist
"""
m = re.match(r".*\((.*)\)", nomplogin.strip())
if m:
user_name = m.group(1)
match = re.match(r".*\((.*)\)", nomplogin.strip())
if match:
user_name = match.group(1)
u = User.query.filter_by(user_name=user_name).first()
if u:
return u.id
@ -393,6 +478,8 @@ class User(UserMixin, db.Model):
class AnonymousUser(AnonymousUserMixin):
"Notre utilisateur anonyme"
def has_permission(self, perm, dept=None):
return False
@ -509,7 +596,7 @@ class UserRole(db.Model):
)
def __repr__(self):
return "<UserRole u={} r={} dept={}>".format(self.user, self.role, self.dept)
return f"<UserRole u={self.user} r={self.role} dept={self.dept}>"
@staticmethod
def role_dept_from_string(role_dept: str):
@ -517,18 +604,21 @@ class UserRole(db.Model):
role_dept, of the forme "Role_Dept".
role is a Role instance, dept is a string, or None.
"""
fields = role_dept.split("_", 1) # maxsplit=1, le dept peut contenir un "_"
fields = role_dept.strip().split("_", 1)
# maxsplit=1, le dept peut contenir un "_"
if len(fields) != 2:
current_app.logger.warning(
f"role_dept_from_string: Invalid role_dept '{role_dept}'"
f"auth: role_dept_from_string: Invalid role_dept '{role_dept}'"
)
raise ScoValueError("Invalid role_dept")
role_name, dept = fields
dept = dept.strip() if dept else ""
if dept == "":
dept = None
role = Role.query.filter_by(name=role_name).first()
if role is None:
raise ScoValueError("role %s does not exists" % role_name)
raise ScoValueError(f"role {role_name} does not exists")
return (role, dept)
@ -545,3 +635,22 @@ def get_super_admin():
)
assert admin_user
return admin_user
def send_notif_desactivation_user(user: User):
"""Envoi un message mail de notification à l'admin et à l'adresse du compte désactivé"""
recipients = user.get_emails() + [current_app.config.get("SCODOC_ADMIN_MAIL")]
txt = [
f"""Le compte ScoDoc '{user.user_name}' associé à votre adresse <{user.email}>""",
"""a été désactivé par le système car son mot de passe n'était pas valide.\n""",
"""Contactez votre responsable pour le ré-activer.\n""",
"""Ceci est un message automatique, ne pas répondre.""",
]
txt = "\n".join(txt)
email.send_email(
f"ScoDoc: désactivation automatique du compte {user.user_name}",
email.get_from_addr(),
recipients,
txt,
)
return txt

View File

@ -3,54 +3,88 @@
auth.routes.py
"""
import flask
from flask import current_app, flash, render_template
from flask import redirect, url_for, request
from flask_login import login_user, logout_user, current_user
from flask_login import login_user, current_user
from sqlalchemy import func
from app import db
from app.auth import bp
from app.auth import bp, cas, logic
from app.auth.forms import (
CASUsersImportConfigForm,
LoginForm,
UserCreationForm,
ResetPasswordRequestForm,
ResetPasswordForm,
ResetPasswordRequestForm,
UserCreationForm,
)
from app.auth.models import Role
from app.auth.models import User
from app.auth.models import Role, User, invalid_user_name
from app.auth.email import send_password_reset_email
from app.decorators import admin_required
from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_utils as scu
_ = lambda x: x # sans babel
_l = _
@bp.route("/login", methods=["GET", "POST"])
def login():
"ScoDoc Login form"
if current_user.is_authenticated:
return redirect(url_for("scodoc.index"))
def _login_form():
"""le formulaire de login, avec un lien CAS s'il est configuré."""
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(user_name=form.user_name.data).first()
# note: ceci est la première requête SQL déclenchée par un utilisateur arrivant
if invalid_user_name(form.user_name.data):
user = None
else:
user = User.query.filter_by(user_name=form.user_name.data).first()
if user is None or not user.check_password(form.password.data):
current_app.logger.info("login: invalid (%s)", form.user_name.data)
flash(_("Nom ou mot de passe invalide"))
return redirect(url_for("auth.login"))
login_user(user, remember=form.remember_me.data)
current_app.logger.info("login: success (%s)", form.user_name.data)
return form.redirect("scodoc.index")
message = request.args.get("message", "")
return render_template(
"auth/login.html", title=_("Sign In"), form=form, message=message
"auth/login.j2",
title=_("Sign In"),
form=form,
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
)
@bp.route("/login", methods=["GET", "POST"])
def login():
"""ScoDoc Login form
Si paramètre cas_force, redirige vers le CAS.
"""
if current_user.is_authenticated:
return redirect(url_for("scodoc.index"))
if ScoDocSiteConfig.get("cas_force"):
current_app.logger.info("login: forcing CAS")
return redirect(url_for("cas.login"))
return _login_form()
@bp.route("/login_scodoc", methods=["GET", "POST"])
def login_scodoc():
"""ScoDoc Login form.
Formulaire login, sans redirection immédiate sur CAS si ce dernier est configuré.
Sans CAS, ce formulaire est identique à /login
"""
if current_user.is_authenticated:
return redirect(url_for("scodoc.index"))
return _login_form()
@bp.route("/logout")
def logout():
"Logout current user and redirect to home page"
logout_user()
return redirect(url_for("scodoc.index"))
def logout() -> flask.Response:
"Logout a scodoc user. If CAS session, logout from CAS. Redirect."
return logic.logout()
@bp.route("/create_user", methods=["GET", "POST"])
@ -65,9 +99,7 @@ def create_user():
db.session.commit()
flash(f"Utilisateur {user.user_name} créé")
return redirect(url_for("scodoc.index"))
return render_template(
"auth/register.html", title="Création utilisateur", form=form
)
return render_template("auth/register.j2", title="Création utilisateur", form=form)
@bp.route("/reset_password_request", methods=["GET", "POST"])
@ -98,7 +130,10 @@ def reset_password_request():
)
return redirect(url_for("auth.login"))
return render_template(
"auth/reset_password_request.html", title=_("Reset Password"), form=form
"auth/reset_password_request.j2",
title=_("Reset Password"),
form=form,
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
)
@ -116,7 +151,7 @@ def reset_password(token):
db.session.commit()
flash(_("Votre mot de passe a été changé."))
return redirect(url_for("auth.login"))
return render_template("auth/reset_password.html", form=form, user=user)
return render_template("auth/reset_password.j2", form=form, user=user)
@bp.route("/reset_standard_roles_permissions", methods=["GET", "POST"])
@ -126,3 +161,34 @@ def reset_standard_roles_permissions():
Role.reset_standard_roles_permissions()
flash("rôles standards réinitialisés !")
return redirect(url_for("scodoc.configuration"))
@bp.route("/cas_users_generate_excel_sample")
@admin_required
def cas_users_generate_excel_sample():
"une feuille excel pour importation config CAS"
data = cas.cas_users_generate_excel_sample()
return scu.send_file(data, "ImportConfigCAS", scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE)
@bp.route("/cas_users_import_config", methods=["GET", "POST"])
@admin_required
def cas_users_import_config():
"""Import utilisateurs depuis feuille Excel"""
form = CASUsersImportConfigForm()
if form.validate_on_submit():
if form.cancel.data: # cancel button
return redirect(url_for("scodoc.configuration"))
datafile = request.files[form.user_config_file.name]
nb_modif = cas.cas_users_import_excel_file(datafile)
current_app.logger.info(f"cas_users_import_config: {nb_modif} comptes modifiés")
flash(f"Config. CAS de {nb_modif} comptes modifiée.")
return redirect(url_for("scodoc.configuration"))
return render_template(
"auth/cas_users_import_config.j2",
title=_("Importation configuration CAS utilisateurs"),
form=form,
)
return

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.scodoc import sco_codes_parcours
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 != sco_codes_parcours.UE_STANDARD:
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

@ -12,6 +12,7 @@ import datetime
import numpy as np
from flask import g, has_request_context, url_for
from app import db
from app.comp.res_but import ResultatsSemestreBUT
from app.models import Evaluation, FormSemestre, Identite
from app.models.groups import GroupDescr
@ -19,10 +20,10 @@ from app.models.ues import UniteEns
from app.scodoc import sco_bulletins, sco_utils as scu
from app.scodoc import sco_bulletins_json
from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
from app.scodoc import sco_groups
from app.scodoc import sco_preferences
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF
from app.scodoc.codes_cursus import UE_SPORT, DEF
from app.scodoc.sco_utils import fmt_note
@ -157,8 +158,8 @@ class BulletinBUT:
for _, ue_capitalisee in self.res.validations.ue_capitalisees.loc[
[etud.id]
].iterrows():
if sco_codes_parcours.code_ue_validant(ue_capitalisee.code):
ue = UniteEns.query.get(ue_capitalisee.ue_id) # XXX cacher ?
if codes_cursus.code_ue_validant(ue_capitalisee.code):
ue = db.session.get(UniteEns, ue_capitalisee.ue_id) # XXX cacher ?
# déjà capitalisé ? montre la meilleure
if ue.acronyme in d:
moy_cap = d[ue.acronyme]["moyenne_num"] or 0.0
@ -187,6 +188,8 @@ class BulletinBUT:
)
if ue_capitalisee.formsemestre_id
else None,
"ressources": {}, # sans détail en BUT
"saes": {},
}
if self.prefs["bul_show_ects"]:
d[ue.acronyme]["ECTS"] = {
@ -283,9 +286,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(
@ -385,7 +388,7 @@ class BulletinBUT:
"injustifie": nbabs - nbabsjust,
"total": nbabs,
}
decisions_ues = self.res.get_etud_decision_ues(etud.id) or {}
decisions_ues = self.res.get_etud_decisions_ue(etud.id) or {}
if self.prefs["bul_show_ects"]:
ects_tot = res.etud_ects_tot_sem(etud.id)
ects_acquis = res.get_etud_ects_valides(etud.id, decisions_ues)
@ -473,6 +476,7 @@ class BulletinBUT:
def bulletin_etud_complet(self, etud: Identite, version="long") -> dict:
"""Bulletin dict complet avec toutes les infos pour les bulletins BUT pdf
(pas utilisé pour json/html)
Résultat compatible avec celui de sco_bulletins.formsemestre_bulletinetud_dict
"""
d = self.bulletin_etud(
@ -481,6 +485,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(
@ -501,7 +506,7 @@ class BulletinBUT:
# --- Decision Jury
infos, dpv = sco_bulletins.etud_descr_situation_semestre(
etud.id,
self.res.formsemestre.id,
self.res.formsemestre,
format="html",
show_date_inscr=self.prefs["bul_show_date_inscr"],
show_decisions=self.prefs["bul_show_decision"],

View File

@ -5,6 +5,20 @@
##############################################################################
"""Génération bulletin BUT au format PDF standard
La génération du bulletin PDF suit le chemin suivant:
- vue formsemestre_bulletinetud -> sco_bulletins.formsemestre_bulletinetud
bul_dict = bulletin_but.BulletinBUT(formsemestre).bulletin_etud_complet(etud)
- sco_bulletins_generator.make_formsemestre_bulletinetud(infos)
- instance de BulletinGeneratorStandardBUT(infos)
- BulletinGeneratorStandardBUT.generate(format="pdf")
sco_bulletins_generator.BulletinGenerator.generate()
.generate_pdf()
.bul_table() (ci-dessous)
"""
from reportlab.lib.colors import blue
from reportlab.lib.units import cm, mm
@ -12,7 +26,7 @@ from reportlab.platypus import Paragraph, Spacer
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc import gen_tables
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc import sco_utils as scu
@ -65,7 +79,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
return objects
def but_table_synthese_ues(self, title_bg=(182, 235, 255)):
def but_table_synthese_ues(
self, title_bg=(182, 235, 255), title_ue_cap_bg=(150, 207, 147)
):
"""La table de synthèse; pour chaque UE, liste des ressources et SAÉs avec leurs notes
et leurs coefs.
Renvoie: colkeys, P, pdf_style, colWidths
@ -74,6 +90,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
- pdf_style : commandes table Platypus
- largeurs de colonnes pour PDF
"""
# nb: self.infos a ici été donné par BulletinBUT.bulletin_etud_complet()
col_widths = {
"titre": None,
"min": 1.5 * cm,
@ -95,6 +112,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
col_keys += ["coef", "moyenne"]
# Couleur fond:
title_bg = tuple(x / 255.0 for x in title_bg)
title_ue_cap_bg = tuple(x / 255.0 for x in title_ue_cap_bg)
# elems pour générer table avec gen_table (liste de dicts)
rows = [
# Ligne de titres
@ -141,9 +159,17 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
blue,
),
]
for ue_acronym, ue in self.infos["ues"].items():
self.ue_rows(rows, ue_acronym, ue, title_bg)
ues = self.infos["ues"]
ues_capitalisees = self.infos.get("ues_capitalisees", {})
ues_tup = sorted(
list(ues.items()) + list(ues_capitalisees.items()),
key=lambda x: x[1]["numero"],
)
for ue_acronym, ue in ues_tup:
is_capitalized = "date_capitalisation" in ue
self._ue_rows(
rows, ue_acronym, ue, title_ue_cap_bg if is_capitalized else title_bg
)
# Global pdf style commands:
pdf_style = [
@ -152,19 +178,19 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
]
return col_keys, rows, pdf_style, col_widths
def ue_rows(self, rows: list, ue_acronym: str, ue: dict, title_bg: tuple):
def _ue_rows(self, rows: list, ue_acronym: str, ue: dict, title_bg: tuple):
"Décrit une UE dans la table synthèse: titre, sous-titre et liste modules"
if (ue["type"] == UE_SPORT) and len(ue.get("modules", [])) == 0:
# ne mentionne l'UE que s'il y a des modules
return
# 1er ligne titre UE
moy_ue = ue.get("moyenne")
moy_ue = ue.get("moyenne", "-")
if isinstance(moy_ue, dict):
moy_ue = moy_ue.get("value", "-") if moy_ue is not None else "-"
t = {
"titre": f"{ue_acronym} - {ue['titre']}",
"moyenne": Paragraph(
f"""<para align=right><b>{moy_ue.get("value", "-")
if moy_ue is not None else "-"
}</b></para>"""
f"""<para align=right><b>{moy_ue or "-"}</b></para>"""
),
"_css_row_class": "note_bold",
"_pdf_row_markup": ["b"],
@ -196,25 +222,40 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
# case Bonus/Malus/Rang "bmr"
fields_bmr = []
try:
value = float(ue["bonus"])
value = float(ue.get("bonus", 0.0))
if value != 0:
fields_bmr.append(f"Bonus: {ue['bonus']}")
except ValueError:
pass
try:
value = float(ue["malus"])
value = float(ue.get("malus", 0.0))
if value != 0:
fields_bmr.append(f"Malus: {ue['malus']}")
except ValueError:
pass
if self.preferences["bul_show_ue_rangs"]:
fields_bmr.append(
f"Rang: {ue['moyenne']['rang']} / {ue['moyenne']['total']}"
moy_ue = ue.get("moyenne", "-")
if isinstance(moy_ue, dict): # UE non capitalisées
if self.preferences["bul_show_ue_rangs"]:
fields_bmr.append(
f"Rang: {ue['moyenne']['rang']} / {ue['moyenne']['total']}"
)
ue_min, ue_max, ue_moy = (
ue["moyenne"]["min"],
ue["moyenne"]["max"],
ue["moyenne"]["moy"],
)
else: # UE capitalisée
ue_min, ue_max, ue_moy = "", "", moy_ue
date_capitalisation = ue.get("date_capitalisation")
if date_capitalisation:
fields_bmr.append(
f"""Capitalisée le {date_capitalisation.strftime("%d/%m/%Y")}"""
)
t = {
"titre": " - ".join(fields_bmr),
"coef": ects_txt,
"_coef_pdf": Paragraph(f"""<para align=left>{ects_txt}</para>"""),
"_coef_pdf": Paragraph(f"""<para align=right>{ects_txt}</para>"""),
"_coef_colspan": 2,
"_pdf_style": [
("BACKGROUND", (0, 0), (-1, 0), title_bg),
@ -222,9 +263,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
# ligne au dessus du bonus/malus, gris clair
("LINEABOVE", (0, 0), (-1, 0), self.PDF_LINEWIDTH, (0.7, 0.7, 0.7)),
],
"min": ue["moyenne"]["min"],
"max": ue["moyenne"]["max"],
"moy": ue["moyenne"]["moy"],
"min": ue_min,
"max": ue_max,
"moy": ue_moy,
}
rows.append(t)

View File

@ -43,7 +43,7 @@ from app.but import bulletin_but
from app.models import FormSemestre, Identite
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_ue
from app.scodoc import sco_etud
from app.scodoc import sco_photos
@ -65,11 +65,10 @@ def bulletin_but_xml_compat(
from app.scodoc import sco_bulletins
log(
"bulletin_but_xml_compat( formsemestre_id=%s, etudid=%s )"
% (formsemestre_id, etudid)
f"bulletin_but_xml_compat( formsemestre_id={formsemestre_id}, etudid={etudid} )"
)
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
etud: Identite = Identite.query.get_or_404(etudid)
etud = Identite.get_etud(etudid)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
results = bulletin_but.ResultatsSemestreBUT(formsemestre)
nb_inscrits = results.get_inscriptions_counts()[scu.INSCRIT]
# etat_inscription = etud.inscription_etat(formsemestre.id)
@ -159,7 +158,7 @@ def bulletin_but_xml_compat(
code_apogee=quote_xml_attr(ue.code_apogee or ""),
)
doc.append(x_ue)
if ue.type != sco_codes_parcours.UE_SPORT:
if ue.type != codes_cursus.UE_SPORT:
v = results.etud_moy_ue[ue.id][etud.id]
vmin = results.etud_moy_ue[ue.id].min()
vmax = results.etud_moy_ue[ue.id].max()
@ -253,7 +252,7 @@ def bulletin_but_xml_compat(
):
infos, dpv = sco_bulletins.etud_descr_situation_semestre(
etudid,
formsemestre_id,
formsemestre,
format="xml",
show_uevalid=sco_preferences.get_preference(
"bul_show_uevalid", formsemestre_id

View File

@ -13,46 +13,45 @@ Classe raccordant avec ScoDoc 7:
avec la même interface.
"""
from typing import Union
import collections
from operator import attrgetter
from flask import g, url_for
from app import db
from app import log
from app import db, log
from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat
from app.comp import res_sem
from app.models import formsemestre
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences,
)
from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
RegroupementCoherentUE,
)
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import sco_codes_parcours as sco_codes
from app.scodoc.sco_codes_parcours import RED, UE_STANDARD
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException, ScoValueError
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
from app.scodoc import sco_cursus_dut
class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
"""Pour compat ScoDoc 7: à revoir pour le BUT"""
def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT):
super().__init__(etud, formsemestre_id, res)
# Ajustements pour le BUT
@ -65,3 +64,538 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
def parcours_validated(self):
"True si le parcours est validé"
return False # XXX TODO
class EtudCursusBUT:
"""L'état de l'étudiant dans son cursus BUT
Liste des niveaux validés/à valider
(utilisé pour le résumé sur la fiche étudiant)
"""
def __init__(self, etud: Identite, formation: Formation):
"""formation indique la spécialité préparée"""
# Vérifie que l'étudiant est bien inscrit à un sem. de cette formation
if formation.id not in (
ins.formsemestre.formation.id for ins in etud.formsemestre_inscriptions
):
raise ScoValueError(
f"{etud.nomprenom} non inscrit dans {formation.titre} v{formation.version}"
)
if not formation.referentiel_competence:
raise ScoNoReferentielCompetences(formation=formation)
#
self.etud = etud
self.formation = formation
self.inscriptions = sorted(
[
ins
for ins in etud.formsemestre_inscriptions
if ins.formsemestre.formation.referentiel_competence
and (
ins.formsemestre.formation.referentiel_competence.id
== formation.referentiel_competence.id
)
],
key=lambda s: (s.formsemestre.semestre_id, s.formsemestre.date_debut),
)
"Liste des inscriptions aux sem. de la formation, triées par indice et chronologie"
self.parcour: ApcParcours = self.inscriptions[-1].parcour
"Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
self.niveaux_by_annee: dict[int, list[ApcNiveau]] = {}
"{ annee:int : 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
)[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_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[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 to_dict(self):
"""
{
competence_id : {
annee : meilleure_validation
}
}
"""
# XXX lent, provisoirement utilisé par TableJury.add_but_competences()
return {
competence.id: {
annee: self.validation_par_competence_et_annee.get(
competence.id, {}
).get(annee)
for annee in ("BUT1", "BUT2", "BUT3")
}
for competence in self.competences.values()
}
# XXX TODO OPTIMISATION ACCESS TABLE JURY
def to_dict_codes(self) -> dict[int, dict[str, int]]:
"""
{
competence_id : {
annee : { validation }
}
}
validation est un petit dict avec niveau_id, etc.
"""
d = {}
for competence in self.competences.values():
d[competence.id] = {}
for annee in ("BUT1", "BUT2", "BUT3"):
validation_rcue: ApcValidationRCUE = (
self.validation_par_competence_et_annee.get(competence.id, {}).get(
annee
)
)
d[competence.id][annee] = (
validation_rcue.to_dict_codes() if validation_rcue else None
)
return d
def competence_annee_has_niveau(self, competence_id: int, annee: str) -> bool:
"vrai si la compétence à un niveau dans cette annee ('BUT1') pour le parcour de cet etud"
# slow, utile pour affichage fiche
return annee in [n.annee for n in self.competences[competence_id].niveaux]
def load_validation_by_niveau(self) -> dict[int, list[ApcValidationRCUE]]:
"""Cherche les validations de jury enregistrées pour chaque niveau
Résultat: { niveau_id : [ ApcValidationRCUE ] }
meilleure validation pour ce niveau
"""
validations_by_niveau = collections.defaultdict(lambda: [])
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=self.etud):
validations_by_niveau[validation_rcue.niveau().id].append(validation_rcue)
validation_by_niveau = {
niveau_id: sorted(
validations, key=lambda v: sco_codes.BUT_CODES_ORDER[v.code]
)[0]
for niveau_id, validations in validations_by_niveau.items()
if validations
}
return validation_by_niveau
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] = db.session.get(
ApcParcours, 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_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[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_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[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 but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float:
"""Nombre d'ECTS validés par etud dans le BUT de référentiel indiqué.
Ne prend que les UE associées à des niveaux de compétences,
et ne les compte qu'une fois même en cas de redoublement avec re-validation.
"""
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.filter(ScolarFormSemestreValidation.ue_id != None)
.join(UniteEns)
.join(ApcNiveau)
.join(ApcCompetence)
.filter_by(referentiel_id=referentiel_competence_id)
)
ects_dict = {}
for v in validations:
key = (v.ue.semestre_idx, v.ue.niveau_competence.id)
if v.code in CODES_UE_VALIDES:
ects_dict[key] = v.ue.ects
return sum(ects_dict.values()) if ects_dict else 0.0
def etud_ues_de_but1_non_validees(
etud: Identite, formation: Formation, parcour: ApcParcours
) -> list[UniteEns]:
"""Liste des UEs de S1 et S2 non validées, dans son parcours"""
# Les UEs avec décisions, dans les S1 ou S2 d'une formation de même code:
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.filter(ScolarFormSemestreValidation.ue_id != None)
.join(UniteEns)
.filter(db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2))
.join(Formation)
.filter_by(formation_code=formation.formation_code)
)
codes_validations_by_ue_code = collections.defaultdict(list)
for v in validations:
codes_validations_by_ue_code[v.ue.ue_code].append(v.code)
# Les UEs du parcours en S1 et S2:
ues = formation.query_ues_parcour(parcour).filter(
db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2)
)
# Liste triée des ues non validées
return sorted(
[
ue
for ue in ues
if not any(
(
code_ue_validant(code)
for code in codes_validations_by_ue_code[ue.ue_code]
)
)
],
key=attrgetter("numero", "acronyme"),
)
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>
"""
def ue_associee_au_niveau_du_parcours(
ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
) -> UniteEns:
"L'UE associée à ce niveau, ou None"
ues = [ue for ue in ues_possibles if ue.niveau_competence_id == niveau.id]
if len(ues) > 1:
# plusieurs UEs associées à ce niveau: élimine celles sans parcours
ues_pair_avec_parcours = [ue for ue in ues if ue.parcours]
if ues_pair_avec_parcours:
ues = ues_pair_avec_parcours
if len(ues) > 1:
log(f"_niveau_ues: {len(ues)} associées au niveau {niveau} / {sem_name}")
return ues[0] if ues else None
def parcour_formation_competences(parcour: ApcParcours, formation: Formation) -> list:
"""
[
{
'competence' : ApcCompetence,
'niveaux' : {
1 : { ... },
2 : { ... },
3 : {
'niveau' : ApcNiveau,
'ue_impair' : UniteEns, # actuellement associée
'ues_impair' : list[UniteEns], # choix possibles
'ue_pair' : UniteEns,
'ues_pair' : list[UniteEns],
}
}
}
]
"""
refcomp: ApcReferentielCompetences = formation.referentiel_competence
def _niveau_ues(competence: ApcCompetence, annee: int) -> dict:
"""niveau et ues pour cette compétence de cette année du parcours.
Si parcour est None, les niveaux du tronc commun
"""
if parcour is not None:
# L'étudiant est inscrit à un parcours: cherche les niveaux
niveaux = ApcNiveau.niveaux_annee_de_parcours(
parcour, annee, competence=competence
)
else:
# sans parcours, on cherche les niveaux du Tronc Commun de cette année
niveaux = [
niveau
for niveau in refcomp.get_niveaux_by_parcours(annee)[1]["TC"]
if niveau.competence_id == competence.id
]
if len(niveaux) > 0:
if len(niveaux) > 1:
log(
f"""_niveau_ues: plus d'un niveau pour {competence}
annee {annee} {("parcours " + parcour.code) if parcour else ""}"""
)
niveau = niveaux[0]
elif len(niveaux) == 0:
return {
"niveau": None,
"ue_pair": None,
"ue_impair": None,
"ues_pair": [],
"ues_impair": [],
}
# Toutes les UEs de la formation dans ce parcours ou tronc commun
ues = [
ue
for ue in formation.ues
if (
(not ue.parcours)
or (parcour is not None and (parcour.id in (p.id for p in ue.parcours)))
)
and ue.type == UE_STANDARD
]
ues_pair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee)]
ues_impair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee - 1)]
# UE associée au niveau dans ce parcours
ue_pair = ue_associee_au_niveau_du_parcours(
ues_pair_possibles, niveau, f"S{2*annee}"
)
ue_impair = ue_associee_au_niveau_du_parcours(
ues_impair_possibles, niveau, f"S{2*annee-1}"
)
return {
"niveau": niveau,
"ue_pair": ue_pair,
"ues_pair": [
ue
for ue in ues_pair_possibles
if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
],
"ue_impair": ue_impair,
"ues_impair": [
ue
for ue in ues_impair_possibles
if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
],
}
competences = [
{
"competence": competence,
"niveaux": {annee: _niveau_ues(competence, annee) for annee in (1, 2, 3)},
}
for competence in (
parcour.query_competences()
if parcour
else refcomp.competences.order_by(ApcCompetence.numero)
)
]
return competences

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(

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@ from openpyxl.styles import Font, Border, Side, Alignment, PatternFill
from app import log
from app.but import jury_but
from app.but.cursus_but import but_ects_valides
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.scodoc.gen_tables import GenTable
@ -26,78 +27,34 @@ def _descr_cursus_but(etud: Identite) -> str:
# prend simplement tous les semestre de type APC, ce qui sera faux si
# l'étudiant change de spécialité au sein du même département
# (ce qui ne peut normalement pas se produire)
indices = sorted(
inscriptions = sorted(
[
ins.formsemestre.semestre_id
if ins.formsemestre.semestre_id is not None
else -1
ins
for ins in etud.formsemestre_inscriptions
if ins.formsemestre.formation.is_apc()
]
],
key=lambda i: i.formsemestre.date_debut,
)
indices = [
ins.formsemestre.semestre_id if ins.formsemestre.semestre_id is not None else -1
for ins in inscriptions
]
return ", ".join(f"S{indice}" for indice in indices)
def pvjury_table_but(formsemestre_id: int, format="html"):
def pvjury_page_but(formsemestre_id: int, fmt="html"):
"""Page récapitulant les décisions de jury BUT
formsemestre peut être pair ou impair
"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
assert formsemestre.formation.is_apc()
title = "Procès-verbal de jury BUT annuel"
if format == "html":
title = "Procès-verbal de jury BUT"
if fmt == "html":
line_sep = "<br>"
else:
line_sep = "\n"
# remplace pour le BUT la fonction sco_pvjury.pvjury_table
annee_but = (formsemestre.semestre_id + 1) // 2
titles = {
"nom": "Nom",
"cursus": "Cursus",
"ues": "UE validées",
"niveaux": "Niveaux de compétences validés",
"decision_but": f"Décision BUT{annee_but}",
"diplome": "Résultat au diplôme",
"devenir": "Devenir",
"observations": "Observations",
}
rows = []
for etudid in formsemestre.etuds_inscriptions:
etud: Identite = Identite.query.get(etudid)
try:
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if deca.annee_but != annee_but: # wtf ?
log(
f"pvjury_table_but: inconsistent annee_but {deca.annee_but} != {annee_but}"
)
continue
except ScoValueError:
deca = None
row = {
"nom": etud.etat_civil_pv(line_sep=line_sep),
"_nom_order": etud.sort_key,
"_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_target": url_for(
"scolar.ficheEtud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
),
"cursus": _descr_cursus_but(etud),
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
if deca
else "-",
"decision_but": deca.code_valide if deca else "",
"devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else "",
}
rows.append(row)
rows.sort(key=lambda x: x["_nom_order"])
rows, titles = pvjury_table_but(formsemestre, line_sep=line_sep)
# Style excel... passages à la ligne sur \n
xls_style_base = sco_excel.excel_make_style()
@ -109,10 +66,11 @@ def pvjury_table_but(formsemestre_id: int, format="html"):
columns_ids=titles.keys(),
html_caption=title,
html_class="pvjury_table_but table_leftalign",
html_title=f"""<div style="margin-bottom: 8px;"><span style="font-size: 120%; font-weight: bold;">{title}</span>
html_title=f"""<div style="margin-bottom: 8px;"><span
style="font-size: 120%; font-weight: bold;">{title}</span>
<span style="padding-left: 20px;">
<a href="{url_for("notes.pvjury_table_but",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, format="xlsx")}"
<a href="{url_for("notes.pvjury_page_but",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, fmt="xlsx")}"
class="stdlink">version excel</a></span></div>
""",
@ -136,4 +94,83 @@ def pvjury_table_but(formsemestre_id: int, format="html"):
},
xls_style_base=xls_style_base,
)
return tab.make_page(format=format, javascripts=["js/etud_info.js"], init_qtip=True)
return tab.make_page(format=fmt, javascripts=["js/etud_info.js"], init_qtip=True)
def pvjury_table_but(
formsemestre: FormSemestre,
etudids: list[int] = None,
line_sep: str = "\n",
only_diplome=False,
anonymous=False,
with_paragraph_nom=False,
) -> tuple[list[dict], dict]:
"""Table avec résultats jury BUT pour PV.
Si etudids est None, prend tous les étudiants inscrits.
"""
# remplace pour le BUT la fonction sco_pv_forms.pvjury_table
annee_but = (formsemestre.semestre_id + 1) // 2
referentiel_competence_id = formsemestre.formation.referentiel_competence_id
if referentiel_competence_id is None:
raise ScoValueError(
"pas de référentiel de compétences associé à la formation de ce semestre !"
)
titles = {
"nom": "Code" if anonymous else "Nom",
"cursus": "Cursus",
"ects": "ECTS",
"ues": "UE validées",
"niveaux": "Niveaux de compétences validés",
"decision_but": f"Décision BUT{annee_but}",
"diplome": "Résultat au diplôme",
"devenir": "Devenir",
"observations": "Observations",
}
rows = []
formsemestre_etudids = formsemestre.etuds_inscriptions.keys()
if etudids is None:
etudids = formsemestre_etudids
for etudid in etudids:
if not etudid in formsemestre_etudids:
continue # garde fou
etud = Identite.get_etud(etudid)
try:
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if deca.annee_but != annee_but: # wtf ?
log(
f"pvjury_table_but: inconsistent annee_but {deca.annee_but} != {annee_but}"
)
continue
except ScoValueError:
deca = None
row = {
"nom": etud.code_ine or etud.code_nip or etud.id
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
else etud.etat_civil_pv(
line_sep=line_sep, with_paragraph=with_paragraph_nom
),
"_nom_order": etud.sort_key,
"_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_target": url_for(
"scolar.ficheEtud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
),
"cursus": _descr_cursus_but(etud),
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {but_ects_valides(etud, referentiel_competence_id ):g}""",
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
if deca
else "-",
"decision_but": deca.code_valide if deca else "",
"devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else "",
}
if deca.valide_diplome() or not only_diplome:
rows.append(row)
rows.sort(key=lambda x: x["_nom_order"])
return rows, titles

View File

@ -1,553 +0,0 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury BUT: table recap annuelle et liens saisie
"""
import collections
import time
import numpy as np
from flask import g, url_for
from app.but import jury_but
from app.but.jury_but import (
DecisionsProposeesAnnee,
DecisionsProposeesRCUE,
DecisionsProposeesUE,
)
from app.comp.res_but import ResultatsSemestreBUT
from app.comp import res_sem
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.scodoc import html_sco_header
from app.scodoc.sco_codes_parcours import (
BUT_BARRE_RCUE,
BUT_BARRE_UE,
BUT_BARRE_UE8,
BUT_RCUE_SUFFISANT,
)
from app.scodoc import sco_formsemestre_status
from app.scodoc import sco_pvjury
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
def formsemestre_saisie_jury_but(
formsemestre2: FormSemestre,
read_only: bool = False,
selected_etudid: int = None,
mode="jury",
) -> str:
"""formsemestre est un semestre PAIR
Si readonly, ne montre pas le lien "saisir la décision"
=> page html complète
Si mode == "recap", table recap des codes, sans liens de saisie.
"""
# Quick & Dirty
# pour chaque etud de res2 trié
# S1: UE1, ..., UEn
# S2: UE1, ..., UEn
#
# UE1_s1, UE1_s2, moy_rcue, UE2... , Nbrcue_validables, Nbrcue<8, passage_de_droit, valide_moitie_rcue
#
# Pour chaque etud de res2 trié
# DecisionsProposeesAnnee(etud, formsemestre2)
# Pour le 1er etud, faire un check_ues_ready_jury(self) -> page d'erreur
# -> rcue .ue_1, .ue_2 -> stroe moy ues, rcue.moy_rcue, etc
# XXX if formsemestre2.semestre_id % 2 != 0:
# raise ScoValueError("Cette page ne fonctionne que sur les semestres pairs")
if formsemestre2.formation.referentiel_competence is None:
raise ScoNoReferentielCompetences(formation=formsemestre2.formation)
rows, titles, column_ids, jury_stats = get_jury_but_table(
formsemestre2, read_only=read_only, mode=mode
)
if not rows:
return (
'<div class="table_recap"><div class="message">aucun étudiant !</div></div>'
)
filename = scu.sanitize_filename(
f"""jury-but-{formsemestre2.titre_num()}-{time.strftime("%Y-%m-%d")}"""
)
klass = "table_jury_but_bilan" if mode == "recap" else ""
table_html = build_table_jury_but_html(
filename, rows, titles, column_ids, selected_etudid=selected_etudid, klass=klass
)
H = [
html_sco_header.sco_header(
page_title=f"{formsemestre2.sem_modalite()}: jury BUT annuel",
no_side_bar=True,
init_qtip=True,
javascripts=["js/etud_info.js", "js/table_recap.js"],
),
sco_formsemestre_status.formsemestre_status_head(
formsemestre_id=formsemestre2.id
),
]
if mode == "recap":
H.append(
f"""<h3>Décisions de jury enregistrées pour les étudiants de ce semestre</h3>
<div class="table_jury_but_links">
<div>
<ul>
<li><a href="{url_for(
"notes.pvjury_table_but",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
}" class="stdlink">Tableau PV de jury</a>
</li>
<li><a href="{url_for(
"notes.formsemestre_lettres_individuelles",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
}" class="stdlink">Courriers individuels (classeur pdf)</a>
</li>
</div>
</div>
"""
)
H.append(
f"""
{table_html}
<div class="table_jury_but_links">
"""
)
if (mode == "recap") and not read_only:
H.append(
f"""
<p><a class="stdlink" href="{url_for(
"notes.formsemestre_saisie_jury",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
}">Saisie des décisions du jury</a>
</p>"""
)
else:
H.append(
f"""
<p><a class="stdlink" href="{url_for(
"notes.formsemestre_validation_auto_but",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
}">Calcul automatique des décisions du jury</a>
</p>
<p><a class="stdlink" href="{url_for(
"notes.formsemestre_jury_but_recap",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
}">Tableau récapitulatif des décisions du jury</a>
</p>
"""
)
H.append(
f"""
</div>
<div class="jury_stats">
<div>Nb d'étudiants avec décision annuelle:
{sum(jury_stats["codes_annuels"].values())} / {jury_stats["nb_etuds"]}
</div>
<div><b>Codes annuels octroyés:</b></div>
<table class="jury_stats_codes">
"""
)
for code in sorted(jury_stats["codes_annuels"].keys()):
H.append(
f"""<tr>
<td>{code}</td>
<td style="text-align:right">{jury_stats["codes_annuels"][code]}</td>
<td style="text-align:right">{
(100*jury_stats["codes_annuels"][code] / jury_stats["nb_etuds"]):2.1f}%
</td>
</tr>"""
)
H.append(
f"""
</table>
</div>
{html_sco_header.sco_footer()}
"""
)
return "\n".join(H)
def build_table_jury_but_html(
filename: str, rows, titles, column_ids, selected_etudid: int = None, klass=""
) -> str:
"""assemble la table html"""
footer_rows = [] # inutilisé pour l'instant
H = [
f"""<div class="table_recap"><table class="table_recap apc jury table_jury_but {klass}"
data-filename="{filename}">"""
]
# header
H.append(
f"""
<thead>
{scu.gen_row(column_ids, titles, "th")}
</thead>
"""
)
# body
H.append("<tbody>")
for row in rows:
H.append(f"{scu.gen_row(column_ids, row, selected_etudid=selected_etudid)}\n")
H.append("</tbody>\n")
# footer
H.append("<tfoot>")
idx_last = len(footer_rows) - 1
for i, row in enumerate(footer_rows):
H.append(f'{scu.gen_row(column_ids, row, "th" if i == idx_last else "td")}\n')
H.append(
"""
</tfoot>
</table>
</div>
"""
)
return "".join(H)
class RowCollector:
"""Une ligne de la table"""
def __init__(
self,
cells: dict = None,
titles: dict = None,
convert_values=True,
column_classes: dict = None,
):
self.titles = titles
self.row = cells or {} # col_id : str
self.column_classes = column_classes # col_id : str, css class
self.idx = 0
self.last_etud_cell_idx = 0
if convert_values:
self.fmt_note = scu.fmt_note
else:
self.fmt_note = lambda x: x
def __setitem__(self, key, value):
self.row[key] = value
def __getitem__(self, key):
return self.row[key]
def get_row_dict(self):
"La ligne, comme un dict"
# create empty cells
for col_id in self.titles:
if col_id not in self.row:
self.row[col_id] = ""
klass = self.column_classes.get(col_id)
if klass:
self.row[f"_{col_id}_class"] = klass
return self.row
def add_cell(
self,
col_id: str,
title: str,
content: str,
classes: str = "",
idx: int = None,
column_class="",
):
"""Add a row to our table. classes is a list of css class names"""
self.idx = idx if idx is not None else self.idx
self.row[col_id] = content
if classes:
self.row[f"_{col_id}_class"] = classes + f" c{self.idx}"
if not col_id in self.titles:
self.titles[col_id] = title
self.titles[f"_{col_id}_col_order"] = self.idx
if classes:
self.titles[f"_{col_id}_class"] = classes
self.column_classes[col_id] = column_class
self.idx += 1
def add_etud_cells(
self, etud: Identite, formsemestre: FormSemestre, with_links=True
):
"Les cells code, nom, prénom etc."
# --- Codes (seront cachés, mais exportés en excel)
self.add_cell("etudid", "etudid", etud.id, "codes")
self.add_cell("code_nip", "code_nip", etud.code_nip or "", "codes")
# --- Identité étudiant (adapté de res_common/get_table_recap, à factoriser XXX TODO)
self.add_cell("civilite_str", "Civ.", etud.civilite_str, "identite_detail")
self.add_cell("nom_disp", "Nom", etud.nom_disp(), "identite_detail")
self["_nom_disp_order"] = etud.sort_key
self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
self.add_cell("nom_short", "Nom", etud.nom_short, "identite_court")
if with_links:
self["_nom_short_order"] = etud.sort_key
self["_nom_short_target"] = url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
etudid=etud.id,
)
self["_nom_short_target_attrs"] = f'class="etudinfo" id="{etud.id}"'
self["_nom_disp_target"] = self["_nom_short_target"]
self["_nom_disp_target_attrs"] = self["_nom_short_target_attrs"]
self.last_etud_cell_idx = self.idx
def add_ue_cells(self, dec_ue: DecisionsProposeesUE):
"cell de moyenne d'UE"
col_id = f"moy_ue_{dec_ue.ue.id}"
note_class = ""
val = dec_ue.moy_ue
if isinstance(val, float):
if val < BUT_BARRE_UE:
note_class = " moy_inf"
elif val >= BUT_BARRE_UE:
note_class = " moy_ue_valid"
if val < BUT_BARRE_UE8:
note_class = " moy_ue_warning" # notes très basses
self.add_cell(
col_id,
dec_ue.ue.acronyme,
self.fmt_note(val),
"col_ue" + note_class,
column_class="col_ue",
)
self.add_cell(
col_id + "_code",
dec_ue.ue.acronyme,
dec_ue.code_valide or "",
"col_ue_code recorded_code",
column_class="col_ue",
)
def add_rcue_cells(self, dec_rcue: DecisionsProposeesRCUE):
"2 cells: moyenne du RCUE, code enregistré"
rcue = dec_rcue.rcue
col_id = f"moy_rcue_{rcue.ue_1.niveau_competence_id}" # le niveau_id
note_class = ""
val = rcue.moy_rcue
if isinstance(val, float):
if val < BUT_BARRE_RCUE:
note_class = " moy_ue_inf"
elif val >= BUT_BARRE_RCUE:
note_class = " moy_ue_valid"
if val < BUT_RCUE_SUFFISANT:
note_class = " moy_ue_warning" # notes très basses
self.add_cell(
col_id,
f"<div>{rcue.ue_1.acronyme}</div><div>{rcue.ue_2.acronyme}</div>",
self.fmt_note(val),
"col_rcue" + note_class,
column_class="col_rcue",
)
self.add_cell(
col_id + "_code",
f"<div>{rcue.ue_1.acronyme}</div><div>{rcue.ue_2.acronyme}</div>",
dec_rcue.code_valide or "",
"col_rcue_code recorded_code",
column_class="col_rcue",
)
def add_nb_rcues_cell(self, deca: DecisionsProposeesAnnee):
"cell avec nb niveaux validables / total"
klass = " "
if deca.nb_rcues_under_8 > 0:
klass += "moy_ue_warning"
elif deca.nb_validables < deca.nb_competences:
klass += "moy_ue_inf"
else:
klass += "moy_ue_valid"
self.add_cell(
"rcues_validables",
"RCUEs",
f"""{deca.nb_validables}/{deca.nb_competences}"""
+ ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""),
"col_rcue col_rcues_validables" + klass,
)
self["_rcues_validables_data"] = {
"etudid": deca.etud.id,
"nomprenom": deca.etud.nomprenom,
}
if len(deca.rcues_annee) > 0:
# permet un tri par nb de niveaux validables + moyenne gen indicative S_pair
if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen:
moy = deca.res_pair.etud_moy_gen[deca.etud.id]
if np.isnan(moy):
moy_gen_d = "x"
else:
moy_gen_d = f"{int(moy*1000):05}"
else:
moy_gen_d = "x"
self["_rcues_validables_order"] = f"{deca.nb_validables:04d}-{moy_gen_d}"
else:
# etudiants sans RCUE: pas de semestre impair, ...
# les classe à la fin
self[
"_rcues_validables_order"
] = f"{deca.nb_validables:04d}-00000-{deca.etud.sort_key}"
def get_jury_but_table(
formsemestre2: FormSemestre, read_only: bool = False, mode="jury", with_links=True
) -> tuple[list[dict], list[str], list[str], dict]:
"""Construit la table des résultats annuels pour le jury BUT
=> rows_dict, titles, column_ids, jury_stats
jury_stats est un dict donnant des comptages sur le jury.
"""
res2: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre2)
titles = {} # column_id : title
jury_stats = {
"nb_etuds": len(formsemestre2.etuds_inscriptions),
"codes_annuels": collections.Counter(),
}
column_classes = {}
rows = []
for etudid in formsemestre2.etuds_inscriptions:
etud: Identite = Identite.query.get(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre2)
row = RowCollector(titles=titles, column_classes=column_classes)
row.add_etud_cells(etud, formsemestre2, with_links=with_links)
row.idx = 100 # laisse place pour les colonnes de groupes
# --- Nombre de niveaux
row.add_nb_rcues_cell(deca)
# --- Les RCUEs
for rcue in deca.rcues_annee:
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
row.add_ue_cells(deca.decisions_ues[rcue.ue_1.id])
row.add_ue_cells(deca.decisions_ues[rcue.ue_2.id])
row.add_rcue_cells(dec_rcue)
# --- Les ECTS validés
ects_valides = 0.0
if deca.res_impair:
ects_valides += deca.res_impair.get_etud_ects_valides(etudid)
if deca.res_pair:
ects_valides += deca.res_pair.get_etud_ects_valides(etudid)
row.add_cell(
"ects_annee",
"ECTS",
f"""{int(ects_valides)}""",
"col_code_annee",
)
# --- Le code annuel existant
row.add_cell(
"code_annee",
"Année",
f"""{deca.code_valide or ''}""",
"col_code_annee",
)
if deca.code_valide:
jury_stats["codes_annuels"][deca.code_valide] += 1
# --- Le lien de saisie
if mode != "recap" and with_links:
row.add_cell(
"lien_saisie",
"",
f"""
<a href="{url_for(
'notes.formsemestre_validation_but',
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
formsemestre_id=formsemestre2.id,
)}" class="stdlink">
{"voir" if read_only else ("modif." if deca.code_valide else "saisie")}
décision</a>
"""
if deca.inscription_etat == scu.INSCRIT
else deca.inscription_etat,
"col_lien_saisie_but",
)
rows.append(row)
rows_dict = [row.get_row_dict() for row in rows]
if len(rows_dict) > 0:
col_idx = res2.recap_add_partitions(
rows_dict, titles, col_idx=row.last_etud_cell_idx + 1
)
res2.recap_add_cursus(rows_dict, titles, col_idx=col_idx + 1)
column_ids = [title for title in titles if not title.startswith("_")]
column_ids.sort(key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000))
rows_dict.sort(key=lambda row: row["_nom_disp_order"])
return rows_dict, titles, column_ids, jury_stats
def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:
"""Liste des résultats jury BUT sous forme de dict, pour API"""
if formsemestre.formation.referentiel_competence is None:
# pas de ref. comp., donc pas de decisions de jury (ne lance pas d'exception)
return []
dpv = sco_pvjury.dict_pvjury(formsemestre.id)
rows = []
for etudid in formsemestre.etuds_inscriptions:
rows.append(get_jury_but_etud_result(formsemestre, dpv, etudid))
return rows
def get_jury_but_etud_result(
formsemestre: FormSemestre, dpv: dict, etudid: int
) -> dict:
"""Résultats de jury d'un étudiant sur un semestre pair de BUT"""
etud: Identite = Identite.query.get(etudid)
dec_etud = dpv["decisions_dict"][etudid]
if formsemestre.formation.is_apc():
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
else:
deca = None
row = {
"etudid": etud.id,
"code_nip": etud.code_nip,
"code_ine": etud.code_ine,
"is_apc": dpv["is_apc"], # BUT ou classic ?
"etat": dec_etud["etat"], # I ou D ou DEF
"nb_competences": deca.nb_competences if deca else 0,
}
# --- Les RCUEs
rcue_list = []
if deca:
for rcue in deca.rcues_annee:
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
rcue_dict = {
"ue_1": {
"ue_id": rcue.ue_1.id,
"moy": None
if (dec_ue1.moy_ue is None or np.isnan(dec_ue1.moy_ue))
else dec_ue1.moy_ue,
"code": dec_ue1.code_valide,
},
"ue_2": {
"ue_id": rcue.ue_2.id,
"moy": None
if (dec_ue2.moy_ue is None or np.isnan(dec_ue2.moy_ue))
else dec_ue2.moy_ue,
"code": dec_ue2.code_valide,
},
"moy": rcue.moy_rcue,
"code": dec_rcue.code_valide,
}
rcue_list.append(rcue_dict)
row["rcues"] = rcue_list
# --- Les UEs
ue_list = []
if dec_etud["decisions_ue"]:
for ue_id, ue_dec in dec_etud["decisions_ue"].items():
ue_dict = {
"ue_id": ue_id,
"code": ue_dec["code"],
"ects": ue_dec["ects"],
}
ue_list.append(ue_dict)
row["ues"] = ue_list
# --- Le semestre (pour les formations classiques)
if dec_etud["decision_sem"]:
row["semestre"] = {"code": dec_etud["decision_sem"].get("code")}
else:
row["semestre"] = {} # APC, ...
# --- Autorisations
row["autorisations"] = dec_etud["autorisations"]
return row

View File

@ -0,0 +1,94 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury BUT et classiques: récupération des résults pour API
"""
import numpy as np
from app.but import jury_but
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.scodoc import sco_pv_dict
def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:
"""Liste des résultats jury BUT sous forme de dict, pour API"""
if formsemestre.formation.referentiel_competence is None:
# pas de ref. comp., donc pas de decisions de jury (ne lance pas d'exception)
return []
dpv = sco_pv_dict.dict_pvjury(formsemestre.id)
rows = []
for etudid in formsemestre.etuds_inscriptions:
rows.append(_get_jury_but_etud_result(formsemestre, dpv, etudid))
return rows
def _get_jury_but_etud_result(
formsemestre: FormSemestre, dpv: dict, etudid: int
) -> dict:
"""Résultats de jury d'un étudiant sur un semestre pair de BUT"""
etud = Identite.get_etud(etudid)
dec_etud = dpv["decisions_dict"][etudid]
if formsemestre.formation.is_apc():
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
else:
deca = None
row = {
"etudid": etud.id,
"code_nip": etud.code_nip,
"code_ine": etud.code_ine,
"is_apc": dpv["is_apc"], # BUT ou classic ?
"etat": dec_etud["etat"], # I ou D ou DEF
"nb_competences": deca.nb_competences if deca else 0,
}
# --- Les RCUEs
rcue_list = []
if deca:
for dec_rcue in deca.get_decisions_rcues_annee():
rcue = dec_rcue.rcue
if rcue.complete: # n'exporte que les RCUEs complets
dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
rcue_dict = {
"ue_1": {
"ue_id": rcue.ue_1.id,
"moy": None
if (dec_ue1.moy_ue is None or np.isnan(dec_ue1.moy_ue))
else dec_ue1.moy_ue,
"code": dec_ue1.code_valide,
},
"ue_2": {
"ue_id": rcue.ue_2.id,
"moy": None
if (dec_ue2.moy_ue is None or np.isnan(dec_ue2.moy_ue))
else dec_ue2.moy_ue,
"code": dec_ue2.code_valide,
},
"moy": rcue.moy_rcue,
"code": dec_rcue.code_valide,
}
rcue_list.append(rcue_dict)
row["rcues"] = rcue_list
# --- Les UEs
ue_list = []
if dec_etud["decisions_ue"]:
for ue_id, ue_dec in dec_etud["decisions_ue"].items():
ue_dict = {
"ue_id": ue_id,
"code": ue_dec["code"],
"ects": ue_dec["ects"],
}
ue_list.append(ue_dict)
row["ues"] = ue_list
# --- Le semestre (pour les formations classiques)
if dec_etud["decision_sem"]:
row["semestre"] = {"code": dec_etud["decision_sem"].get("code")}
else:
row["semestre"] = {} # APC, ...
# --- Autorisations
row["autorisations"] = dec_etud["autorisations"]
return row

View File

@ -6,41 +6,47 @@
"""Jury BUT: calcul des décisions de jury annuelles "automatiques"
"""
from flask import g, url_for
from app import db
from app.but import jury_but
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.models import Identite, FormSemestre, ScolarNews
from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but(
formsemestre: FormSemestre, only_adm: bool = True, no_overwrite: bool = True
formsemestre: FormSemestre, only_adm: bool = True
) -> int:
"""Calcul automatique des décisions de jury sur une année BUT.
Ne modifie jamais de décisions de l'année scolaire précédente, même
"""Calcul automatique des décisions de jury sur une "année" BUT.
- N'enregistre jamais de décisions de l'année scolaire précédente, même
si on a des RCUE "à cheval".
Normalement, only_adm est True et on n'enregistre que les décisions ADM (de droit).
Si only_adm est faux, on enregistre la première décision proposée par ScoDoc
(mode à n'utiliser que pour les tests)
- Normalement, only_adm est True et on n'enregistre que les décisions validantes
de droit: ADM ou CMP.
En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc
(mode à n'utiliser que pour les tests unitaires vérifiant la saisie des jurys)
Si no_overwrite est vrai (défaut), ne -écrit jamais les codes déjà enregistrés
(utiliser faux pour certains tests)
Returns: nombre d'étudiants "admis"
Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code.
"""
if not formsemestre.formation.is_apc():
raise ScoValueError("fonction réservée aux formations BUT")
nb_admis = 0
nb_etud_modif = 0
with sco_cache.DeferredSemCacheManager():
for etudid in formsemestre.etuds_inscriptions:
etud: Identite = Identite.query.get(etudid)
etud = Identite.get_etud(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if deca.admis: # année réussie
nb_admis += 1
if deca.admis or not only_adm:
deca.record_all(no_overwrite=no_overwrite)
nb_etud_modif += deca.record_all(only_validantes=only_adm)
db.session.commit()
return nb_admis
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=formsemestre.id,
text=f"""Calcul jury automatique du semestre {formsemestre.html_link_status()}""",
url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
)
return nb_etud_modif

View File

@ -11,7 +11,7 @@ import re
import numpy as np
import flask
from flask import flash, url_for
from flask import flash, render_template, url_for
from flask import g, request
from app import db
@ -31,9 +31,13 @@ from app.models import (
UniteEns,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
ScolarNews,
)
from app.models.config import ScoDocSiteConfig
from app.scodoc import html_sco_header
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
@ -43,21 +47,20 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
"""
H = []
H.append("""<div class="but_section_annee">""")
H.append(
f"""
<div>
<b>Décision de jury pour l'année :</b> {
_gen_but_select("code_annee", deca.codes, deca.code_valide,
disabled=True, klass="manual")
}
<span>({deca.code_valide or 'non'} enregistrée)</span>
if deca.jury_annuel:
H.append(
f"""
<div class="but_section_annee">
<div>
<b>Décision de jury pour l'année :</b> {
_gen_but_select("code_annee", deca.codes, deca.code_valide,
disabled=True, klass="manual")
}
<span>({deca.code_valide or 'non'} enregistrée)</span>
</div>
</div>
"""
)
div_explanation = f"""<div class="but_explanation">{deca.explanation}</div>"""
H.append("""</div>""")
)
formsemestre_1 = deca.formsemestre_impair
formsemestre_2 = deca.formsemestre_pair
@ -74,7 +77,7 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
<div class="titre_niveaux">
<b>Niveaux de compétences et unités d'enseignement du BUT{deca.annee_but}</b>
</div>
{div_explanation}
<div class="but_explanation">{deca.explanation}</div>
<div class="but_annee">
<div class="titre"></div>
<div class="titre">{"S" +str(formsemestre_1.semestre_id)
@ -90,35 +93,25 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
<div class="titre">RCUE</div>
"""
)
for niveau in deca.niveaux_competences:
for dec_rcue in deca.get_decisions_rcues_annee():
rcue = dec_rcue.rcue
niveau = rcue.niveau
H.append(
f"""<div class="but_niveau_titre">
<div title="{niveau.competence.titre_long}">{niveau.competence.titre}</div>
</div>"""
)
dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) # peut être None
ues = [
ue
for ue in deca.ues_impair
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
]
ue_impair = ues[0] if ues else None
ues = [
ue
for ue in deca.ues_pair
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
]
ue_pair = ues[0] if ues else None
ue_impair, ue_pair = rcue.ue_1, rcue.ue_2
# Les UEs à afficher,
# qui seront toujours en readonly sur le formsemestre de l'année précédente du redoublant
# qui
ues_ro = [
(
ue_impair,
(deca.a_cheval and deca.formsemestre_id != deca.formsemestre_impair.id),
rcue.ue_cur_impair is None,
),
(
ue_pair,
deca.a_cheval and deca.formsemestre_id != deca.formsemestre_pair.id,
rcue.ue_cur_pair is None,
),
]
# Ordonne selon les dates des 2 semestres considérés:
@ -152,17 +145,22 @@ def _gen_but_select(
code_valide: str,
disabled: bool = False,
klass: str = "",
data: dict = {},
data: dict = None,
code_valide_label: str = "",
) -> str:
"Le menu html select avec les codes"
# if disabled: # mauvaise idée car le disabled est traité en JS
# return f"""<div class="but_code {klass}">{code_valide}</div>"""
data = data or {}
options_htm = "\n".join(
[
f"""<option value="{code}"
{'selected' if code == code_valide else ''}
class="{'recorded' if code == code_valide else ''}"
>{code}</option>"""
>{code
if ((code != code_valide) or not code_valide_label)
else code_valide_label
}</option>"""
for code in codes
]
)
@ -196,24 +194,59 @@ def _gen_but_niveau_ue(
<div>UE en cours
{ "sans notes" if np.isnan(dec_ue.moy_ue)
else
("avec moyenne" + scu.fmt_note(dec_ue.moy_ue))
("avec moyenne <b>" + scu.fmt_note(dec_ue.moy_ue) + "</b>")
}
</div>
</div>
"""
elif dec_ue.formsemestre is None:
# Validation d'UE antérieure (semestre hors année scolaire courante)
if dec_ue.validation:
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.validation.moy_ue)}</span>"""
scoplement = f"""<div class="scoplement">
<div>
<b>UE {ue.acronyme} antérieure </b>
<span>validée {dec_ue.validation.code}
le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
</span>
</div>
<div>Non reprise dans l'année en cours</div>
</div>
"""
else:
moy_ue_str = """<span>-</span>"""
scoplement = """<div class="scoplement">
<div>
<b>Pas d'UE en cours ou validée dans cette compétence de ce côté.</b>
</div>
</div>
"""
else:
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
if dec_ue.code_valide:
scoplement = f"""<div class="scoplement">
Code {dec_ue.code_valide} enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
date_str = (
f"""enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
à {dec_ue.validation.event_date.strftime("%Hh%M")}
"""
if dec_ue.validation and dec_ue.validation.event_date
else ""
)
scoplement = f"""<div class="scoplement">
<div>Code {dec_ue.code_valide} {date_str}
</div>
</div>
"""
else:
scoplement = ""
return f"""<div class="but_niveau_ue {
'recorded' if dec_ue.code_valide is not None else ''}
ue_class = "" # 'recorded' if dec_ue.code_valide is not None else ''
if dec_ue.code_valide is not None and dec_ue.codes:
if dec_ue.code_valide == dec_ue.codes[0]:
ue_class = "recorded"
else:
ue_class = "recorded_different"
return f"""<div class="but_niveau_ue {ue_class}
{'annee_prec' if annee_prec else ''}
">
<div title="{ue.titre}">{ue.acronyme}</div>
@ -234,7 +267,7 @@ def _gen_but_niveau_ue(
def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
if dec_rcue is None:
if dec_rcue is None or not dec_rcue.rcue.complete:
return """
<div class="but_niveau_rcue niveau_vide with_scoplement">
<div></div>
@ -242,13 +275,25 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
</div>
"""
scoplement = (
f"""<div class="scoplement">{
dec_rcue.validation.to_html()
}</div>"""
if dec_rcue.validation
else ""
)
code_propose_menu = dec_rcue.code_valide # le code enregistré
code_valide_label = code_propose_menu
if dec_rcue.validation:
if dec_rcue.code_valide == dec_rcue.codes[0]:
descr_validation = dec_rcue.validation.html()
else: # on une validation enregistrée différence de celle proposée
descr_validation = f"""Décision recommandée: <b>{dec_rcue.codes[0]}.</b>
Il y avait {dec_rcue.validation.html()}"""
if (
sco_codes.BUT_CODES_ORDER[dec_rcue.codes[0]]
> sco_codes.BUT_CODES_ORDER[dec_rcue.code_valide]
):
code_propose_menu = dec_rcue.codes[0]
code_valide_label = (
f"{dec_rcue.codes[0]} (actuel {dec_rcue.code_valide})"
)
scoplement = f"""<div class="scoplement">{descr_validation}</div>"""
else:
scoplement = "" # "pas de validation"
# Déjà enregistré ?
niveau_rcue_class = ""
@ -268,10 +313,11 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
<div class="but_code">
{_gen_but_select("code_rcue_"+str(niveau.id),
dec_rcue.codes,
dec_rcue.code_valide,
code_propose_menu,
disabled=True,
klass="manual code_rcue",
data = { "niveau_id" : str(niveau.id)}
data = { "niveau_id" : str(niveau.id)},
code_valide_label = code_valide_label,
)}
</div>
</div>
@ -284,12 +330,12 @@ def jury_but_semestriel(
read_only: bool,
navigation_div: str = "",
) -> str:
"""Formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)"""
"""Page: formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)."""
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
parcour, ues = jury_but.list_ue_parcour_etud(formsemestre, etud, res)
inscription_etat = etud.inscription_etat(formsemestre.id)
semestre_terminal = (
formsemestre.semestre_id >= formsemestre.formation.get_parcours().NB_SEM
formsemestre.semestre_id >= formsemestre.formation.get_cursus().NB_SEM
)
autorisations_passage = ScolarAutorisationInscription.query.filter_by(
etudid=etud.id,
@ -326,6 +372,9 @@ def jury_but_semestriel(
if not formsemestre.semestre_id + 1 in (
a.semestre_id for a in autorisations_passage
):
ScolarAutorisationInscription.delete_autorisation_etud(
etud.id, formsemestre.id
)
ScolarAutorisationInscription.autorise_etud(
etud.id,
formsemestre.formation.formation_code,
@ -346,6 +395,16 @@ def jury_but_semestriel(
flash(
f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée"
)
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=formsemestre.id,
text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""",
url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
)
return flask.redirect(
url_for(
"notes.formsemestre_validation_but",
@ -373,23 +432,23 @@ def jury_but_semestriel(
f"""
<div class="jury_but">
<div>
<div class="bull_head">
<div>
<div class="titre_parcours">Jury BUT S{formsemestre.id}
- Parcours {(parcour.libelle if parcour else False) or "non spécifié"}
<div class="bull_head">
<div>
<div class="titre_parcours">Jury BUT S{formsemestre.id}
- Parcours {(parcour.libelle if parcour else False) or "non spécifié"}
</div>
<div class="nom_etud">{etud.nomprenom}</div>
</div>
<div class="bull_photo"><a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
</div>
</div>
<div class="nom_etud">{etud.nomprenom}</div>
</div>
<div class="bull_photo"><a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
</div>
</div>
<h3>Jury sur un semestre BUT isolé (ne concerne que les UEs)</h3>
{warning}
<h3>Jury sur un semestre BUT isolé (ne concerne que les UEs)</h3>
{warning}
</div>
<form method="post" id="jury_but">
<form method="post" class="jury_but_box" id="jury_but">
""",
]
@ -449,24 +508,37 @@ def jury_but_semestriel(
)
H.append("</div>") # but_annee
div_autorisations_passage = (
f"""
<div class="but_autorisations_passage">
<span>Autorisé à passer en&nbsp;:</span>
{ ", ".join( ["S" + str(a.semestre_id or '') for a in autorisations_passage ] )}
</div>
"""
if autorisations_passage
else """<div class="but_autorisations_passage but_explanation">pas d'autorisations de passage enregistrées.</div>"""
)
H.append(div_autorisations_passage)
if read_only:
H.append(
"""<div class="but_explanation">
Vous n'avez pas la permission de modifier ces décisions.
Les champs entourés en vert sont enregistrés.</div>"""
f"""<div class="but_explanation">
{"Vous n'avez pas la permission de modifier ces décisions."
if formsemestre.etat
else "Semestre verrouillé."}
Les champs entourés en vert sont enregistrés.
</div>
"""
)
else:
if formsemestre.semestre_id < formsemestre.formation.get_parcours().NB_SEM:
if formsemestre.semestre_id < formsemestre.formation.get_cursus().NB_SEM:
H.append(
f"""
<div class="but_settings">
<input type="checkbox" name="autorisation_passage" value="1" {
"checked" if est_autorise_a_passer else ""}>
<em>autoriser à passer dans le semestre S{formsemestre.semestre_id+1}</em>
{("(autorisations enregistrées: " + ' '.join(
'S' + str(a.semestre_id or '') for a in autorisations_passage) + ")"
) if autorisations_passage else ""}
</input>
<input type="checkbox" name="autorisation_passage" value="1" {
"checked" if est_autorise_a_passer else ""}>
<em>autoriser à passer dans le semestre S{formsemestre.semestre_id+1}</em>
</input>
</div>
"""
)
@ -480,7 +552,19 @@ def jury_but_semestriel(
</div>
"""
)
H.append(navigation_div)
H.append(navigation_div)
H.append("</div>")
H.append(
render_template(
"but/documentation_codes_jury.j2",
nom_univ=f"""Export {sco_preferences.get_preference("InstituteName")
or sco_preferences.get_preference("UnivName")
or "Apogée"}""",
codes=ScoDocSiteConfig.get_codes_apo_dict(),
)
)
return "\n".join(H)
@ -489,7 +573,7 @@ def infos_fiche_etud_html(etudid: int) -> str:
"""Section html pour fiche etudiant
provisoire pour BUT 2022
"""
etud: Identite = Identite.query.get_or_404(etudid)
etud = Identite.get_etud(etudid)
inscriptions = (
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
.filter(

View File

@ -0,0 +1,67 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury édition manuelle des décisions (correction d'erreurs, parcours hors normes)
Non spécifique au BUT.
"""
from flask import render_template
import sqlalchemy as sa
from app.models import (
ApcValidationAnnee,
ApcValidationRCUE,
Identite,
UniteEns,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
)
from app.views import ScoData
def jury_delete_manual(etud: Identite):
"""Vue (réservée au chef de dept.)
présentant *toutes* les décisions de jury concernant cet étudiant
et permettant de les supprimer une à une.
"""
sem_vals = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, ue_id=None
).order_by(ScolarFormSemestreValidation.event_date)
ue_vals = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.join(UniteEns)
.order_by(
sa.extract("year", ScolarFormSemestreValidation.event_date),
UniteEns.semestre_idx,
UniteEns.numero,
UniteEns.acronyme,
)
)
autorisations = ScolarAutorisationInscription.query.filter_by(
etudid=etud.id
).order_by(
ScolarAutorisationInscription.semestre_id, ScolarAutorisationInscription.date
)
rcue_vals = (
ApcValidationRCUE.query.filter_by(etudid=etud.id)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
.order_by(UniteEns.semestre_idx, UniteEns.numero, ApcValidationRCUE.date)
)
annee_but_vals = ApcValidationAnnee.query.filter_by(etudid=etud.id).order_by(
ApcValidationAnnee.ordre, ApcValidationAnnee.date
)
return render_template(
"jury/jury_delete_manual.j2",
etud=etud,
sem_vals=sem_vals,
ue_vals=ue_vals,
autorisations=autorisations,
rcue_vals=rcue_vals,
annee_but_vals=annee_but_vals,
sco=ScoData(),
title=f"Toutes les décisions de jury enregistrées pour {etud.html_link_fiche()}",
)

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

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

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

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

View File

@ -18,8 +18,8 @@ import pandas as pd
from flask import g
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.sco_codes_parcours import ParcoursDUT, ParcoursDUTMono
from app.scodoc.codes_cursus import UE_SPORT, UE_STANDARD
from app.scodoc.codes_cursus import CursusDUT, CursusDUTMono
from app.scodoc.sco_utils import ModuleType
@ -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
@ -192,7 +194,7 @@ class BonusSportAdditif(BonusSport):
# les points au dessus du seuil sont comptés (defaut: seuil_moy_gen):
seuil_comptage = None
proportion_point = 0.05 # multiplie les points au dessus du seuil
bonux_max = 20.0 # le bonus ne peut dépasser 20 points
bonus_max = 20.0 # le bonus ne peut dépasser 20 points
bonus_min = 0.0 # et ne peut pas être négatif
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
@ -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),
@ -430,6 +432,25 @@ class BonusAmiens(BonusSportAdditif):
# )
class BonusBesanconVesoul(BonusSportAdditif):
"""Bonus IUT Besançon - Vesoul pour les UE libres
<p>Le bonus est compris entre 0 et 0,2 points.
et est reporté sur les moyennes d'UE.
</p>
<p>La valeur saisie doit être entre 0 et 0,2: toute valeur
supérieure à 0,2 entraine un bonus de 0,2.
</p>
"""
name = "bonus_besancon_vesoul"
displayed_name = "IUT de Besançon - Vesoul"
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 0.0 # tous les points sont comptés
proportion_point = 1
bonus_max = 0.2
class BonusBethune(BonusSportMultiplicatif):
"""
Calcul bonus modules optionnels (sport, culture), règle IUT de Béthune.
@ -581,7 +602,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
@ -647,7 +668,10 @@ class BonusCalais(BonusSportAdditif):
dans la limite de 10 points. 6% de ces points cumulés s'ajoutent :
<ul>
<li><b>en DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant.
<li><b>en BUT et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b> (ex : UE2.1BS, UE32BS)
</li>
<li><b>en BUT et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b>
(ex : UE2.1BS, UE32BS)
</li>
</ul>
"""
@ -658,17 +682,17 @@ class BonusCalais(BonusSportAdditif):
proportion_point = 0.06 # 6%
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
parcours = self.formsemestre.formation.get_parcours()
parcours = self.formsemestre.formation.get_cursus()
# Variantes de DUT ?
if (
isinstance(parcours, ParcoursDUT)
or parcours.TYPE_PARCOURS == ParcoursDUTMono.TYPE_PARCOURS
isinstance(parcours, CursusDUT)
or parcours.TYPE_CURSUS == CursusDUTMono.TYPE_CURSUS
): # DUT
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
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
@ -719,6 +743,7 @@ class BonusGrenobleIUT1(BonusSportMultiplicatif):
name = "bonus_iut1grenoble_2017"
displayed_name = "IUT de Grenoble 1"
# C'est un bonus "multiplicatif": on l'exprime en additif,
# sur chaque moyenne d'UE m_0
# Augmenter de 5% correspond à multiplier par a=1.05
@ -761,6 +786,7 @@ class BonusIUTRennes1(BonusSportAdditif):
seuil_moy_gen = 10.0
proportion_point = 1 / 20.0
classic_use_bonus_ues = False
# S'applique aussi en classic, sur la moy. gen.
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus"""
@ -769,7 +795,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,
@ -801,23 +827,39 @@ class BonusStMalo(BonusIUTRennes1):
class BonusLaRocheSurYon(BonusSportAdditif):
"""Bonus IUT de La Roche-sur-Yon
Si une note de bonus est saisie, l'étudiant est gratifié de 0,2 points
sur sa moyenne générale ou, en BUT, sur la moyenne de chaque UE.
<p>
<b>La note saisie s'applique directement</b>: si on saisit 0,2, un bonus de 0,2 points est appliqué
aux moyennes.
La valeur maximale du bonus est 1 point. Il est appliqué sur les moyennes d'UEs en BUT,
ou sur la moyenne générale dans les autres formations.
</p>
<p>Pour les <b>semestres antérieurs à janvier 2023</b>: si une note de bonus est saisie,
l'étudiant est gratifié de 0,2 points sur sa moyenne générale ou, en BUT, sur la
moyenne de chaque UE.
</p>
"""
name = "bonus_larochesuryon"
displayed_name = "IUT de La Roche-sur-Yon"
seuil_moy_gen = 0.0
seuil_comptage = 0.0
proportion_point = 1e10 # le moindre point sature le bonus
bonus_max = 0.2 # à 0.2
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus, avec réglage différent suivant la date"""
if self.formsemestre.date_debut > datetime.date(2022, 12, 31):
self.proportion_point = 1.0
self.bonus_max = 1
else: # ancienne règle
self.proportion_point = 1e10 # le moindre point sature le bonus
self.bonus_max = 0.2 # à 0.2
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
class BonusLaRochelle(BonusSportAdditif):
"""Calcul bonus modules optionnels (sport, culture), règle IUT de La Rochelle.
<ul>
<li>Si la note de sport est comprise entre 0 et 10 : pas dajout de point.</li>
<li>Si la note de sport est comprise entre 0 et 10 : pas d'ajout de point.</li>
<li>Si la note de sport est comprise entre 10 et 20 :
<ul>
<li>Pour le BUT, application pour chaque UE du semestre :
@ -877,15 +919,15 @@ class BonusLeHavre(BonusSportAdditif):
<p>
Les enseignements optionnels de langue, préprofessionnalisation,
PIX (compétences numériques), l'entrepreneuriat étudiant, l'engagement
bénévole au sein dassociation dès lors quune grille dévaluation des
bénévole au sein d'association dès lors qu'une grille d'évaluation des
compétences existe ainsi que les activités sportives et culturelles
seront traités au niveau semestriel.
</p><p>
Le maximum de bonification quun étudiant peut obtenir sur sa moyenne
Le maximum de bonification qu'un étudiant peut obtenir sur sa moyenne
est plafonné à 0.5 point.
</p><p>
Lorsquun étudiant suit plus de deux matières qui donnent droit à
bonification, létudiant choisit les deux notes à retenir.
Lorsqu'un étudiant suit plus de deux matières qui donnent droit à
bonification, l'étudiant choisit les deux notes à retenir.
</p><p>
Les points bonus ne sont acquis que pour une note supérieure à 10/20.
</p><p>
@ -894,7 +936,7 @@ class BonusLeHavre(BonusSportAdditif):
Pour chaque matière (max. 2) donnant lieu à bonification :<br>
Bonification = (N-10) x 0,05,
N étant la note de lactivité sur 20.
N étant la note de l'activité sur 20.
</p>
"""
@ -1034,6 +1076,36 @@ class BonusLyon(BonusSportAdditif):
)
class BonusLyon3(BonusSportAdditif):
"""IUT de Lyon 3 (septembre 2022)
<p>Nous avons deux types de bonifications : sport et/ou culture
</p>
<p>
Pour chaque point au-dessus de 10 obtenu en sport ou en culture nous
ajoutons 0,03 points à toutes les moyennes dUE du semestre. Exemple : 16 en
sport ajoute 6*0,03 = 0,18 points à toutes les moyennes dUE du semestre.
</p>
<p>
Les bonifications sport et culture peuvent se cumuler dans la limite de 0,3
points ajoutés aux moyennes des UE. Exemple : 17 en sport et 16 en culture
conduisent au calcul (7 + 6)*0,03 = 0,39 qui dépasse 0,3. La bonification
dans ce cas ne sera que de 0,3 points ajoutés à toutes les moyennes dUE du
semestre.
</p>
<p>
Dans Scodoc on déclarera une UE Sport&Culture dans laquelle on aura un
module pour le Sport et un autre pour la Culture avec pour chaque module la
note sur 20 obtenue en sport ou en culture par létudiant.
</p>
"""
name = "bonus_lyon3"
displayed_name = "IUT de Lyon 3"
proportion_point = 0.03
bonus_max = 0.3
class BonusMantes(BonusSportAdditif):
"""Calcul bonus modules optionnels (investissement, ...), IUT de Mantes en Yvelines.
@ -1106,13 +1178,13 @@ class BonusOrleans(BonusSportAdditif):
"""Calcul bonus modules optionnels (sport, culture), règle IUT d'Orléans
<p><b>Cadre général :</b>
En reconnaissance de l'engagement des étudiants dans la vie associative,
sociale ou professionnelle, lIUT dOrléans accorde, sous conditions,
sociale ou professionnelle, l'IUT d'Orléans accorde, sous conditions,
une bonification aux étudiants inscrits qui en font la demande en début
dannée universitaire.
d'année universitaire.
</p>
<p>Cet engagement doit être régulier et correspondre à une activité réelle
et sérieuse qui bénéficie à toute la communauté étudiante de lIUT,
de lUniversité ou à lensemble de la collectivité.</p>
et sérieuse qui bénéficie à toute la communauté étudiante de l'IUT,
de l'Université ou à l'ensemble de la collectivité.</p>
<p><b>Bonification :</b>
Pour les DUT et LP, cette bonification interviendra sur la moyenne générale
des semestres pairs :
@ -1178,6 +1250,89 @@ class BonusRoanne(BonusSportAdditif):
proportion_point = 1
class BonusSceaux(BonusSportAdditif): # atypique
"""IUT de Sceaux
LIUT de Sceaux (Université de Paris-Saclay) propose aux étudiants un seul enseignement
non rattaché aux UE : loption Sport.
<p>
Cette option donne à létudiant qui la suit une bonification qui sapplique uniquement
si sa note est supérieure à 10.
</p>
<p>
Cette bonification sapplique sur lensemble des UE dun semestre de la façon suivante :
</p>
<p>
<tt>
[ (Note 10) / Nb UE du semestre ] / Total des coefficients de chaque UE
</tt>
</p>
<p>
Exemple : un étudiant qui a obtenu 16/20 à loption Sport en S1
(composé par exemple de 3 UE:UE1.1, UE1.2 et UE1.3)
aurait les bonifications suivantes :
</p>
<ul>
<li>UE1.1 (Total des coefficients : 15) Bonification UE1.1 = <tt>[ (16 10) / 3 ] /15
</tt>
</li>
<li>UE1.2 (Total des coefficients : 14) Bonification UE1.2 = <tt>[ (16 10) / 3 ] /14
</tt>
</li>
<li>UE1.3 (Total des coefficients : 12,5) Bonification UE1.3 = <tt>[ (16 10) / 3 ] /12,5
</tt>
</li>
</ul>
"""
name = "bonus_iut_sceaux"
displayed_name = "IUT de Sceaux"
proportion_point = 1.0
def __init__(
self,
formsemestre: "FormSemestre",
sem_modimpl_moys: np.array,
ues: list,
modimpl_inscr_df: pd.DataFrame,
modimpl_coefs: np.array,
etud_moy_gen,
etud_moy_ue,
):
# Pour ce bonus, il faut conserver:
# - le nombre d'UEs
self.nb_ues = len([ue for ue in ues if ue.type != UE_SPORT])
# - le total des coefs de chaque UE
# modimpl_coefs : DataFrame, lignes modimpl, col UEs (sans sport)
self.sum_coefs_ues = modimpl_coefs.sum() # Series, index ue_id
super().__init__(
formsemestre,
sem_modimpl_moys,
ues,
modimpl_inscr_df,
modimpl_coefs,
etud_moy_gen,
etud_moy_ue,
)
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""Calcul du bonus IUT de Sceaux 2023
sem_modimpl_moys_inscrits: les notes de sport
En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus)
En classic: ndarray (nb_etuds, nb_mod_sport)
Attention: si la somme des coefs de modules dans une UE est nulle, on a un bonus Inf
(moyenne d'UE cappée à 20).
"""
if (0 in sem_modimpl_moys_inscrits.shape) or (self.nb_ues == 0):
# pas d'étudiants ou pas d'UE ou pas de module...
return
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
if self.bonus_ues is not None:
self.bonus_ues = (self.bonus_ues / self.nb_ues) / self.sum_coefs_ues
class BonusStEtienne(BonusSportAdditif):
"""IUT de Saint-Etienne.
@ -1232,6 +1387,7 @@ class BonusStNazaire(BonusSport):
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
amplitude = 0.01 / 4 # 4pt => 1%
factor_max = 0.1 # 10% max
# Modifié 2022-11-29: calculer chaque bonus
# (de 1 à 3 modules) séparément.
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
@ -1339,6 +1495,44 @@ class BonusIUTvannes(BonusSportAdditif):
classic_use_bonus_ues = False # seulement sur moy gen.
class BonusValenciennes(BonusDirect):
"""Article 7 des RCC de l'IUT de Valenciennes
<p>
Une bonification maximale de 0.25 point (1/4 de point) peut être ajoutée
à la moyenne de chaque Unité d'Enseignement pour :
</p>
<ul>
<li>l'engagement citoyen ;</li>
<li>la participation à un module de sport.</li>
</ul>
<p>
Une bonification accordée par la commission des sports de l'UPHF peut être attribuée
aux sportifs de haut niveau. Cette bonification est appliquée à l'ensemble des
Unités d'Enseignement. Ce bonus est :
</p>
<ul>
<li> 0.5 pour la catégorie <em>or</em> (sportif inscrit sur liste ministérielle
jeunesse et sport) ;
</li>
<li> 0.45 pour la catégorie <em>argent</em> (sportif en club professionnel) ;
</li>
<li> 0.40 pour le <em>bronze</em> (sportif de niveau départemental, régional ou national).
</li>
</ul>
<p>Le cumul de bonifications est possible mais ne peut excéder 0.5 point (un demi-point).
</p>
<p><em>Dans ScoDoc, saisir directement la valeur désirée du bonus
dans une évaluation notée sur 20.</em>
</p>
"""
name = "bonus_valenciennes"
displayed_name = "IUT de Valenciennes"
bonus_max = 0.5
class BonusVilleAvray(BonusSportAdditif):
"""Bonus modules optionnels (sport, culture), règle IUT Ville d'Avray.
@ -1391,6 +1585,63 @@ class BonusIUTV(BonusSportAdditif):
# c'est le bonus par défaut: aucune méthode à surcharger
# Finalement inutile: un bonus direct est mieux adapté à leurs besoins.
# # class BonusMastersUSPNIG(BonusSportAdditif):
# """Calcul bonus modules optionnels (sport, culture), règle Masters de l'Institut Galilée (USPN)
# Les étudiants peuvent suivre des enseignements optionnels
# de l'USPN (sports, musique, deuxième langue, culture, etc) dans une
# UE libre. Les points au-dessus de 10 sur 20 obtenus dans cette UE
# libre sont ajoutés au total des points obtenus pour les UE obligatoires
# du semestre concerné.
# """
# name = "bonus_masters__uspn_ig"
# displayed_name = "Masters de l'Institut Galilée (USPN)"
# proportion_point = 1.0
# seuil_moy_gen = 10.0
# def __init__(
# self,
# formsemestre: "FormSemestre",
# sem_modimpl_moys: np.array,
# ues: list,
# modimpl_inscr_df: pd.DataFrame,
# modimpl_coefs: np.array,
# etud_moy_gen,
# etud_moy_ue,
# ):
# # Pour ce bonus, il nous faut la somme des coefs des modules non bonus
# # du formsemestre (et non auxquels les étudiants sont inscrits !)
# self.sum_coefs = sum(
# [
# m.module.coefficient
# for m in formsemestre.modimpls_sorted
# if (m.module.module_type == ModuleType.STANDARD)
# and (m.module.ue.type == UE_STANDARD)
# ]
# )
# super().__init__(
# formsemestre,
# sem_modimpl_moys,
# ues,
# modimpl_inscr_df,
# modimpl_coefs,
# etud_moy_gen,
# etud_moy_ue,
# )
# # Bonus sur la moyenne générale seulement
# # On a dans bonus_moy_arr le bonus additif classique
# # Sa valeur sera appliquée comme moy_gen += bonus_moy_gen
# # or ici on veut
# # moy_gen = (somme des notes + bonus_moy_arr) / somme des coefs
# # moy_gen += bonus_moy_arr / somme des coefs
# self.bonus_moy_gen = (
# None if self.bonus_moy_gen is None else self.bonus_moy_gen / self.sum_coefs
# )
def get_bonus_class_dict(start=BonusSport, d=None):
"""Dictionnaire des classes de bonus
(liste les sous-classes de BonusSport ayant un nom)

View File

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

View File

@ -7,12 +7,22 @@
"""Stockage des décisions de jury
"""
import pandas as pd
import sqlalchemy as sa
from app import db
from app.models import FormSemestre, ScolarFormSemestreValidation, UniteEns
from app.comp.res_cache import ResultatsCache
from app.models import (
ApcValidationAnnee,
ApcValidationRCUE,
Formation,
FormSemestre,
Identite,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
UniteEns,
)
from app.scodoc import sco_cache
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
class ValidationsSemestre(ResultatsCache):
@ -53,7 +63,7 @@ class ValidationsSemestre(ResultatsCache):
self.comp_decisions_jury()
def comp_decisions_jury(self):
"""Cherche les decisions du jury pour le semestre (pas les UE).
"""Cherche les decisions du jury pour le semestre (pas les RCUE).
Calcule les attributs:
decisions_jury = { etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }}
decision_jury_ues={ etudid :
@ -80,7 +90,7 @@ class ValidationsSemestre(ResultatsCache):
# UEs: { etudid : { ue_id : {"code":, "ects":, "event_date":} }}
decisions_jury_ues = {}
# Parcours les décisions d'UE:
# Parcoure les décisions d'UE:
for decision in (
decisions_jury_q.filter(db.text("ue_id is not NULL"))
.join(UniteEns)
@ -89,7 +99,7 @@ class ValidationsSemestre(ResultatsCache):
if decision.etudid not in decisions_jury_ues:
decisions_jury_ues[decision.etudid] = {}
# Calcul des ECTS associés à cette UE:
if sco_codes_parcours.code_ue_validant(decision.code) and decision.ue:
if codes_cursus.code_ue_validant(decision.code) and decision.ue:
ects = decision.ue.ects or 0.0 # 0 if None
else:
ects = 0.0
@ -102,6 +112,12 @@ class ValidationsSemestre(ResultatsCache):
self.decisions_jury_ues = decisions_jury_ues
def has_decision(self, etud: Identite) -> bool:
"""Vrai si etud a au moins une décision enregistrée depuis
ce semestre (quelle qu'elle soit)
"""
return (etud.id in self.decisions_jury_ues) or (etud.id in self.decisions_jury)
def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame:
"""Liste des UE capitalisées (ADM) utilisables dans ce formsemestre
@ -126,7 +142,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,
@ -138,21 +155,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,
@ -160,5 +178,82 @@ 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
def erase_decisions_annee_formation(
etud: Identite, formation: Formation, annee: int, delete=False
) -> list:
"""Efface toutes les décisions de jury de l'étudiant dans les formations de même code
que celle donnée pour cette année de la formation:
UEs, RCUEs de l'année BUT, année BUT, passage vers l'année suivante.
Ne considère pas l'origine de la décision.
annee: entier, 1, 2, 3, ...
Si delete est faux, renvoie la liste des validations qu'il faudrait effacer, sans y toucher.
"""
sem1, sem2 = annee * 2 - 1, annee * 2
# UEs
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.join(UniteEns)
.filter(db.or_(UniteEns.semestre_idx == sem1, UniteEns.semestre_idx == sem2))
.join(Formation)
.filter_by(formation_code=formation.formation_code)
.order_by(
UniteEns.acronyme, UniteEns.numero
) # acronyme d'abord car 2 semestres
.all()
)
# RCUEs (a priori inutile de matcher sur l'ue2_id)
validations += (
ApcValidationRCUE.query.filter_by(etudid=etud.id)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
.filter_by(semestre_idx=sem1)
.join(Formation)
.filter_by(formation_code=formation.formation_code)
.order_by(UniteEns.acronyme, UniteEns.numero)
.all()
)
# Validation de semestres classiques
validations += (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id, ue_id=None)
.join(
FormSemestre,
FormSemestre.id == ScolarFormSemestreValidation.formsemestre_id,
)
.filter(
db.or_(FormSemestre.semestre_id == sem1, FormSemestre.semestre_id == sem2)
)
.join(Formation)
.filter_by(formation_code=formation.formation_code)
.all()
)
# Année BUT
validations += ApcValidationAnnee.query.filter_by(
etudid=etud.id,
ordre=annee,
referentiel_competence_id=formation.referentiel_competence_id,
).all()
# Autorisations vers les semestres suivants ceux de l'année:
validations += (
ScolarAutorisationInscription.query.filter_by(
etudid=etud.id, formation_code=formation.formation_code
)
.filter(
db.or_(
ScolarAutorisationInscription.semestre_id == sem1 + 1,
ScolarAutorisationInscription.semestre_id == sem2 + 1,
)
)
.all()
)
if delete:
for validation in validations:
db.session.delete(validation)
db.session.commit()
sco_cache.invalidate_formsemestre_etud(etud)
return []
return validations

View File

@ -14,7 +14,7 @@ import pandas as pd
from app.comp import moy_ue
from app.models.formsemestre import FormSemestre
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_utils import ModuleType

View File

@ -38,12 +38,14 @@ from dataclasses import dataclass
import numpy as np
import pandas as pd
import sqlalchemy as sa
import app
from app import db
from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoBugCatcher
from app.scodoc.sco_utils import ModuleType
@ -84,6 +86,8 @@ class ModuleImplResults:
"{ evaluation.id : bool } indique si à prendre en compte ou non."
self.evaluations_etat = {}
"{ evaluation_id: EvaluationEtat }"
self.etudids_attente = set()
"etudids avec au moins une note ATT dans ce module"
self.en_attente = False
"Vrai si au moins une évaluation a une note en attente"
#
@ -130,7 +134,7 @@ class ModuleImplResults:
manque des notes) ssi il y a des étudiants inscrits au semestre et au module
qui ont des notes ATT.
"""
moduleimpl = ModuleImpl.query.get(self.moduleimpl_id)
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
self.etudids = self._etudids()
# --- Calcul nombre d'inscrits pour déterminer les évaluations "completes":
@ -144,7 +148,6 @@ class ModuleImplResults:
evals_notes = pd.DataFrame(index=self.etudids, dtype=float)
self.evaluations_completes = []
self.evaluations_completes_dict = {}
self.en_attente = False
for evaluation in moduleimpl.evaluations:
eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi tous les inscrits (non dem) au semestre ont une note
@ -171,38 +174,48 @@ class ModuleImplResults:
eval_df, how="left", left_index=True, right_index=True
)
# Notes en attente: (ne prend en compte que les inscrits, non démissionnaires)
nb_att = sum(
evals_notes[str(evaluation.id)][list(inscrits_module)]
== scu.NOTES_ATTENTE
eval_notes_inscr = evals_notes[str(evaluation.id)][list(inscrits_module)]
eval_etudids_attente = set(
eval_notes_inscr.iloc[
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
].index
)
self.etudids_attente |= eval_etudids_attente
self.evaluations_etat[evaluation.id] = EvaluationEtat(
evaluation_id=evaluation.id, nb_attente=nb_att, is_complete=is_complete
evaluation_id=evaluation.id,
nb_attente=len(eval_etudids_attente),
is_complete=is_complete,
)
if nb_att > 0:
self.en_attente = True
# au moins une note en ATT dans ce modimpl:
self.en_attente = bool(self.etudids_attente)
# Force columns names to integers (evaluation ids)
evals_notes.columns = pd.Index([int(x) for x in evals_notes.columns], dtype=int)
self.evals_notes = evals_notes
_load_evaluation_notes_q = sa.text(
"""SELECT n.etudid, n.value AS ":evaluation_id"
FROM notes_notes n, notes_moduleimpl_inscription i
WHERE evaluation_id=:evaluation_id
AND n.etudid = i.etudid
AND i.moduleimpl_id = :moduleimpl_id
"""
)
def _load_evaluation_notes(self, evaluation: Evaluation) -> pd.DataFrame:
"""Charge les notes de l'évaluation
Resultat: dataframe, index: etudid ayant une note, valeur: note brute.
"""
eval_df = pd.read_sql_query(
"""SELECT n.etudid, n.value AS "%(evaluation_id)s"
FROM notes_notes n, notes_moduleimpl_inscription i
WHERE evaluation_id=%(evaluation_id)s
AND n.etudid = i.etudid
AND i.moduleimpl_id = %(moduleimpl_id)s
""",
db.engine,
params={
"evaluation_id": evaluation.id,
"moduleimpl_id": evaluation.moduleimpl.id,
},
index_col="etudid",
)
with db.engine.begin() as connection:
eval_df = pd.read_sql_query(
self._load_evaluation_notes_q,
connection,
params={
"evaluation_id": evaluation.id,
"moduleimpl_id": evaluation.moduleimpl.id,
},
index_col="etudid",
)
eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)])
return eval_df
@ -212,8 +225,8 @@ class ModuleImplResults:
"""
return [
inscr.etudid
for inscr in ModuleImpl.query.get(
self.moduleimpl_id
for inscr in db.session.get(
ModuleImpl, self.moduleimpl_id
).formsemestre.inscriptions
]
@ -306,10 +319,16 @@ class ModuleImplResultsAPC(ModuleImplResults):
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
ne donnent pas de coef vers cette UE.
"""
modimpl = ModuleImpl.query.get(self.moduleimpl_id)
modimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
nb_etuds, nb_evals = self.evals_notes.shape
nb_ues = evals_poids_df.shape[1]
assert evals_poids_df.shape[0] == nb_evals # compat notes/poids
if evals_poids_df.shape[0] != nb_evals:
# compat notes/poids: race condition ?
app.critical_error(
f"""compute_module_moy: evals_poids_df.shape[0] != nb_evals ({
evals_poids_df.shape[0]} != {nb_evals})
"""
)
if nb_etuds == 0:
return pd.DataFrame(index=[], columns=evals_poids_df.columns)
if nb_ues == 0:
@ -400,9 +419,9 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
Résultat: (evals_poids, liste de UEs du semestre sauf le sport)
"""
modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
modimpl: ModuleImpl = db.session.get(ModuleImpl, 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)
@ -479,12 +498,13 @@ class ModuleImplResultsClassic(ModuleImplResults):
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
ne donnent pas de coef.
"""
modimpl = ModuleImpl.query.get(self.moduleimpl_id)
modimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
nb_etuds, nb_evals = self.evals_notes.shape
if nb_etuds == 0:
return pd.Series()
evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1)
assert evals_coefs.shape == (nb_evals,)
if evals_coefs.shape != (nb_evals,):
app.critical_error("compute_module_moy: vals_coefs.shape != nb_evals")
evals_notes_20 = self.get_eval_notes_sur_20(modimpl)
# Les coefs des évals pour chaque étudiant: là où il a des notes
# non neutralisées

View File

@ -30,7 +30,10 @@
import numpy as np
import pandas as pd
from flask import flash, g, Markup, url_for
from flask import flash, g, url_for
from markupsafe import Markup
from app import db
from app.models.formations import Formation
@ -78,7 +81,7 @@ def compute_sem_moys_apc_using_ects(
moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1)
except TypeError:
if None in ects:
formation = Formation.query.get(formation_id)
formation = db.session.get(Formation, formation_id)
flash(
Markup(
f"""Calcul moyenne générale impossible: ECTS des UE manquants !<br>
@ -92,7 +95,7 @@ def compute_sem_moys_apc_using_ects(
return moy_gen
def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series):
def comp_ranks_series(notes: pd.Series) -> tuple[pd.Series, pd.Series]:
"""Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur
numérique) en tenant compte des ex-aequos.

View File

@ -30,6 +30,7 @@
import numpy as np
import pandas as pd
import app
from app import db
from app import models
from app.models import (
@ -40,9 +41,9 @@ from app.models import (
UniteEns,
)
from app.comp import moy_mod
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
from app.scodoc import sco_preferences
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_utils import ModuleType
@ -61,7 +62,7 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
"""
ues = (
UniteEns.query.filter_by(formation_id=formation_id)
.filter(UniteEns.type != sco_codes_parcours.UE_SPORT)
.filter(UniteEns.type != codes_cursus.UE_SPORT)
.order_by(UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme)
)
modules = (
@ -69,10 +70,7 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
.filter(
(Module.module_type == ModuleType.RESSOURCE)
| (Module.module_type == ModuleType.SAE)
| (
(Module.ue_id == UniteEns.id)
& (UniteEns.type == sco_codes_parcours.UE_SPORT)
)
| ((Module.ue_id == UniteEns.id) & (UniteEns.type == codes_cursus.UE_SPORT))
)
.order_by(Module.semestre_id, Module.module_type, Module.numero, Module.code)
)
@ -124,7 +122,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
@ -170,8 +168,14 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray:
"""
assert len(modimpls_notes)
modimpls_notes_arr = [df.values for df in modimpls_notes]
modimpls_notes = np.stack(modimpls_notes_arr)
# passe de (mod x etud x ue) à (etud x mod x ue)
try:
modimpls_notes = np.stack(modimpls_notes_arr)
# passe de (mod x etud x ue) à (etud x mod x ue)
except ValueError:
app.critical_error(
f"""notes_sem_assemble_cube: shapes {
", ".join([x.shape for x in modimpls_notes_arr])}"""
)
return modimpls_notes.swapaxes(0, 1)

View File

@ -10,15 +10,18 @@ import time
import numpy as np
import pandas as pd
from app import log
from app import db, log
from app.comp import moy_ue, moy_sem, inscr_mod
from app.comp.res_compat import NotesTableCompat
from app.comp.bonus_spo import BonusSport
from app.models import ScoDocSiteConfig
from app.models import FormSemestreInscription, ScoDocSiteConfig
from app.models.moduleimpls import ModuleImpl
from app.models.but_refcomp import ApcParcours, ApcNiveau
from app.models.ues import DispenseUE, UniteEns
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.scodoc import sco_preferences
from app.scodoc.codes_cursus import BUT_CODES_ORDER, UE_SPORT
from app.scodoc.sco_utils import ModuleType
class ResultatsSemestreBUT(NotesTableCompat):
@ -39,7 +42,10 @@ 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"""
self.validations_annee: dict[int, ApcValidationAnnee] = {}
"""chargé par get_validations_annee: jury annuel BUT"""
if not self.load_cached():
t0 = time.time()
self.compute()
@ -158,6 +164,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
# moyenne sur les UE:
if len(self.sem_cube[etud_idx, mod_idx]):
return np.nanmean(self.sem_cube[etud_idx, mod_idx])
# note: si toutes les valeurs sont nan, on va déclencher ici
# un RuntimeWarning: Mean of empty slice
return np.nan
def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float:
@ -185,9 +193,15 @@ class ResultatsSemestreBUT(NotesTableCompat):
modimpls = [
modimpl
for modimpl in self.formsemestre.modimpls_sorted
if modimpl.module.ue.type != UE_SPORT
and (coefs[modimpl.id][ue.id] != 0)
and self.modimpl_inscr_df[modimpl.id][etudid]
if (
modimpl.module.ue.type != UE_SPORT
and (coefs[modimpl.id][ue.id] != 0)
and self.modimpl_inscr_df[modimpl.id][etudid]
)
or (
modimpl.module.module_type == ModuleType.MALUS
and modimpl.module.ue_id == ue.id
)
]
if not with_bonus:
return [
@ -217,26 +231,30 @@ 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
)
# matrice de NaN: inscrits par défaut à AUCUNE UE:
ues_inscr_parcours_df = pd.DataFrame(
np.nan, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float # XXX
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:
@ -249,6 +267,95 @@ 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 if ue.type != UE_SPORT}
else:
parcour: ApcParcours = db.session.get(ApcParcours, parcour_id)
annee = (self.formsemestre.semestre_id + 1) // 2
niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp)
# Les UEs du formsemestre associées à ces niveaux:
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) -> bool:
"""True s'il y a une décision (quelconque) de jury
émanant de ce formsemestre pour cet étudiant.
prend aussi en compte les autorisations de passage.
Ici sous-classée (BUT) pour les RCUEs et années.
"""
return bool(
super().etud_has_decision(etudid)
or ApcValidationAnnee.query.filter_by(
formsemestre_id=self.formsemestre.id, etudid=etudid
).count()
or ApcValidationRCUE.query.filter_by(
formsemestre_id=self.formsemestre.id, etudid=etudid
).count()
)
def get_validations_annee(self) -> dict[int, ApcValidationAnnee]:
"""Les validations des étudiants de ce semestre
pour l'année BUT d'une formation compatible avec celle de ce semestre.
Attention:
1) la validation ne provient pas nécessairement de ce semestre
(redoublants, pair/impair, extérieurs).
2) l'étudiant a pu démissionner ou défaillir.
3) S'il y a plusieurs validations pour le même étudiant, prend la "meilleure".
Mémorise le résultat (dans l'instance, pas en cache: TODO voir au profiler)
"""
if self.validations_annee:
return self.validations_annee
annee_but = (self.formsemestre.semestre_id + 1) // 2
validations = ApcValidationAnnee.query.filter_by(
ordre=annee_but,
referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id,
).join(
FormSemestreInscription,
db.and_(
FormSemestreInscription.etudid == ApcValidationAnnee.etudid,
FormSemestreInscription.formsemestre_id == self.formsemestre.id,
),
)
validation_by_etud = {}
for validation in validations:
if validation.etudid in validation_by_etud:
# keep the "best"
if BUT_CODES_ORDER.get(validation.code, 0) > BUT_CODES_ORDER.get(
validation_by_etud[validation.etudid].code, 0
):
validation_by_etud[validation.etudid] = validation
else:
validation_by_etud[validation.etudid] = validation
self.validations_annee = validation_by_etud
return self.validations_annee

View File

@ -22,7 +22,7 @@ from app.models import ScoDocSiteConfig
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc.sco_utils import ModuleType
@ -230,7 +230,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
f"""* oups: calcul coef UE impossible\nformsemestre_id='{self.formsemestre.id
}'\netudid='{etudid}'\nue={ue}"""
)
etud: Identite = Identite.query.get(etudid)
etud = Identite.get_etud(etudid)
raise ScoValueError(
f"""<div class="scovalueerror"><p>Coefficient de l'UE capitalisée {ue.acronyme}
impossible à déterminer pour l'étudiant <a href="{

View File

@ -7,15 +7,17 @@
"""Résultats semestre: méthodes communes aux formations classiques et APC
"""
from collections import Counter
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
from flask import g, url_for
from app.auth.models import User
from app import db
from app.comp import res_sem
from app.comp.res_cache import ResultatsCache
from app.comp.jury import ValidationsSemestre
@ -23,14 +25,14 @@ from app.comp.moy_mod import ModuleImplResults
from app.models import FormSemestre, FormSemestreUECoef
from app.models import Identite
from app.models import ModuleImpl, ModuleImplInscription
from app.models import ScolarAutorisationInscription
from app.models.ues import UniteEns
from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM
from app.scodoc import sco_evaluation_db
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_groups
from app.scodoc import sco_utils as scu
# Il faut bien distinguer
# - ce qui est caché de façon persistente (via redis):
# ce sont les attributs listés dans `_cached_attrs`
@ -61,7 +63,7 @@ class ResultatsSemestre(ResultatsCache):
def __init__(self, formsemestre: FormSemestre):
super().__init__(formsemestre, ResultatsSemestreCache)
# BUT ou standard ? (apc == "approche par compétences")
self.is_apc = formsemestre.formation.is_apc()
self.is_apc: bool = formsemestre.formation.is_apc()
# Attributs "virtuels", définis dans les sous-classes
self.bonus: pd.Series = None # virtuel
"Bonus sur moy. gen. Series de float, index etudid"
@ -86,8 +88,10 @@ class ResultatsSemestre(ResultatsCache):
"""Coefs APC: rows = UEs (sans bonus), columns = modimpl, value = coef."""
self.validations = None
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}')>"
@ -125,9 +129,17 @@ 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."""
return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid))
"""Liste des UE auxquelles l'étudiant est inscrit
(sans bonus, en BUT prend en compte le parcours de l'étudiant)."""
return (db.session.get(UniteEns, ue_id) for ue_id in self.etud_ues_ids(etudid))
def etud_ects_tot_sem(self, etudid: int) -> float:
"""Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)"""
@ -154,7 +166,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):
@ -174,13 +186,35 @@ class ResultatsSemestre(ResultatsCache):
if m.module.module_type == scu.ModuleType.SAE
]
def get_etudids_attente(self) -> set[int]:
"""L'ensemble des etudids ayant au moins une note en ATTente"""
return set().union(
*[mr.etudids_attente for mr in self.modimpls_results.values()]
)
# --- JURY...
def load_validations(self) -> ValidationsSemestre:
"""Load validations, set attribute and return value"""
def get_formsemestre_validations(self) -> ValidationsSemestre:
"""Load validations if not already stored, set attribute and return value"""
if not self.validations:
self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
return self.validations
def get_autorisations_inscription(self) -> dict[int : list[int]]:
"""Les autorisations d'inscription venant de ce formsemestre.
Lit en base et cache le résultat.
Resultat: { etudid : [ indices de semestres ]}
Note: les etudids peuvent ne plus être inscrits ici.
Seuls ceux avec des autorisations enregistrées sont présents dans le résultat.
"""
if not self.autorisations_inscription:
autorisations = ScolarAutorisationInscription.query.filter_by(
origin_formsemestre_id=self.formsemestre.id
)
self.autorisations_inscription = defaultdict(list)
for aut in autorisations:
self.autorisations_inscription[aut.etudid].append(aut.semestre_id)
return self.autorisations_inscription
def get_etud_ue_validables(self, etudid: int) -> list[UniteEns]:
"""Liste des UEs du semestre qui doivent être validées
@ -203,7 +237,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]:
@ -238,8 +272,8 @@ class ResultatsSemestre(ResultatsCache):
UE capitalisées.
"""
# Supposant qu'il y a peu d'UE capitalisées,
# on recalcule les moyennes gen des etuds ayant des UE capitalisée.
self.load_validations()
# on recalcule les moyennes gen des etuds ayant des UEs capitalisées.
self.get_formsemestre_validations()
ue_capitalisees = self.validations.ue_capitalisees
for etudid in ue_capitalisees.index:
recompute_mg = False
@ -253,7 +287,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
@ -277,7 +311,7 @@ class ResultatsSemestre(ResultatsCache):
def get_etud_etat(self, etudid: int) -> str:
"Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)"
ins = self.formsemestre.etuds_inscriptions.get(etudid, None)
ins = self.formsemestre.etuds_inscriptions.get(etudid)
if ins is None:
return ""
return ins.etat
@ -319,7 +353,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 = db.session.get(UniteEns, ue_id)
ue_dict = ue.to_dict()
if ue.type == UE_SPORT:
return {
"is_capitalized": False,
@ -329,7 +365,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,
@ -347,7 +383,11 @@ class ResultatsSemestre(ResultatsCache):
was_capitalized = False
if etudid in self.validations.ue_capitalisees.index:
ue_cap = self._get_etud_ue_cap(etudid, ue)
if ue_cap and not np.isnan(ue_cap["moy_ue"]):
if (
ue_cap
and (ue_cap["moy_ue"] is not None)
and not np.isnan(ue_cap["moy_ue"])
):
was_capitalized = True
if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue):
moy_ue = ue_cap["moy_ue"]
@ -363,10 +403,10 @@ class ResultatsSemestre(ResultatsCache):
if (not coef_ue) and is_capitalized: # étudiant non inscrit dans l'UE courante
if self.is_apc:
# Coefs de l'UE capitalisée en formation APC: donné par ses ECTS
ue_capitalized = UniteEns.query.get(ue_cap["ue_id"])
ue_capitalized = db.session.get(UniteEns, ue_cap["ue_id"])
coef_ue = ue_capitalized.ects
if coef_ue is None:
orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"])
orig_sem = FormSemestre.get_formsemestre(ue_cap["formsemestre_id"])
raise ScoValueError(
f"""L'UE capitalisée {ue_capitalized.acronyme}
du semestre {orig_sem.titre_annee()}
@ -398,7 +438,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,
}
@ -431,614 +471,3 @@ class ResultatsSemestre(ResultatsCache):
# ici si l'étudiant est inscrit dans le semestre courant,
# somme des coefs des modules de l'UE auxquels il est inscrit
return self.compute_etud_ue_coef(etudid, ue)
# --- TABLEAU RECAP
def get_table_recap(
self,
convert_values=False,
include_evaluations=False,
mode_jury=False,
allow_html=True,
):
"""Table récap. des résultats.
allow_html: si vrai, peut mettre du HTML dans les valeurs
Result: tuple avec
- rows: liste de dicts { column_id : value }
- titles: { column_id : title }
- columns_ids: (liste des id de colonnes)
Si convert_values, transforme les notes en chaines ("12.34").
Les colonnes générées sont:
etudid
rang : rang indicatif (basé sur moy gen)
moy_gen : moy gen indicative
moy_ue_<ue_id>, ..., les moyennes d'UE
moy_res_<modimpl_id>_<ue_id>, ... les moyennes de ressources dans l'UE
moy_sae_<modimpl_id>_<ue_id>, ... les moyennes de SAE dans l'UE
On ajoute aussi des attributs:
- pour les lignes:
_css_row_class (inutilisé pour le monent)
_<column_id>_class classe css:
- la moyenne générale a la classe col_moy_gen
- les colonnes SAE ont la classe col_sae
- les colonnes Resources ont la classe col_res
- les colonnes d'UE ont la classe col_ue
- les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_<ue_id>
_<column_id>_order : clé de tri
"""
if convert_values:
fmt_note = scu.fmt_note
else:
fmt_note = lambda x: x
parcours = self.formsemestre.formation.get_parcours()
barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE
barre_valid_ue = parcours.NOTES_BARRE_VALID_UE
barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING
NO_NOTE = "-" # contenu des cellules sans notes
rows = []
# column_id : title
titles = {}
# les titres en footer: les mêmes, mais avec des bulles et liens:
titles_bot = {}
dict_nom_res = {} # cache uid : nomcomplet
def add_cell(
row: dict,
col_id: str,
title: str,
content: str,
classes: str = "",
idx: int = 100,
):
"Add a row to our table. classes is a list of css class names"
row[col_id] = content
if classes:
row[f"_{col_id}_class"] = classes + f" c{idx}"
if not col_id in titles:
titles[col_id] = title
titles[f"_{col_id}_col_order"] = idx
if classes:
titles[f"_{col_id}_class"] = classes
return idx + 1
etuds_inscriptions = self.formsemestre.etuds_inscriptions
ues = self.formsemestre.query_ues(with_sport=True) # avec bonus
ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
modimpl_ids = set() # modimpl effectivement présents dans la table
for etudid in etuds_inscriptions:
idx = 0 # index de la colonne
etud = Identite.query.get(etudid)
row = {"etudid": etudid}
# --- Codes (seront cachés, mais exportés en excel)
idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx)
idx = add_cell(
row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx
)
# --- Rang
if not self.formsemestre.block_moyenne_generale:
idx = add_cell(
row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx
)
row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}"
# --- Identité étudiant
idx = add_cell(
row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx
)
idx = add_cell(
row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail", idx
)
row["_nom_disp_order"] = etud.sort_key
idx = add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail", idx)
idx = add_cell(
row, "nom_short", "Nom", etud.nom_short, "identite_court", idx
)
row["_nom_short_order"] = etud.sort_key
row["_nom_short_target"] = url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
formsemestre_id=self.formsemestre.id,
etudid=etudid,
)
row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"'
row["_nom_disp_target"] = row["_nom_short_target"]
row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"]
idx = 30 # début des colonnes de notes
# --- Moyenne générale
if not self.formsemestre.block_moyenne_generale:
moy_gen = self.etud_moy_gen.get(etudid, False)
note_class = ""
if moy_gen is False:
moy_gen = NO_NOTE
elif isinstance(moy_gen, float) and moy_gen < barre_moy:
note_class = " moy_ue_warning" # en rouge
idx = add_cell(
row,
"moy_gen",
"Moy",
fmt_note(moy_gen),
"col_moy_gen" + note_class,
idx,
)
titles_bot["_moy_gen_target_attrs"] = (
'title="moyenne indicative"' if self.is_apc else ""
)
# --- Moyenne d'UE
nb_ues_validables, nb_ues_warning = 0, 0
for ue in ues_sans_bonus:
ue_status = self.get_etud_ue_status(etudid, ue.id)
if ue_status is not None:
col_id = f"moy_ue_{ue.id}"
val = ue_status["moy"]
note_class = ""
if isinstance(val, float):
if val < barre_moy:
note_class = " moy_inf"
elif val >= barre_valid_ue:
note_class = " moy_ue_valid"
nb_ues_validables += 1
if val < barre_warning_ue:
note_class = " moy_ue_warning" # notes très basses
nb_ues_warning += 1
idx = add_cell(
row,
col_id,
ue.acronyme,
fmt_note(val),
"col_ue" + note_class,
idx,
)
titles_bot[
f"_{col_id}_target_attrs"
] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """
if mode_jury:
# pas d'autre colonnes de résultats
continue
# Bonus (sport) dans cette UE ?
# Le bonus sport appliqué sur cette UE
if (self.bonus_ues is not None) and (ue.id in self.bonus_ues):
val = self.bonus_ues[ue.id][etud.id] or ""
val_fmt = val_fmt_html = fmt_note(val)
if val:
val_fmt_html = f'<span class="green-arrow-up"></span><span class="sp2l">{val_fmt}</span>'
idx = add_cell(
row,
f"bonus_ue_{ue.id}",
f"Bonus {ue.acronyme}",
val_fmt_html if allow_html else val_fmt,
"col_ue_bonus",
idx,
)
row[f"_bonus_ue_{ue.id}_xls"] = val_fmt
# Les moyennes des modules (ou ressources et SAÉs) dans cette UE
idx_malus = idx # place pour colonne malus à gauche des modules
idx += 1
for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False):
if ue_status["is_capitalized"]:
val = "-c-"
else:
modimpl_results = self.modimpls_results.get(modimpl.id)
if modimpl_results: # pas bonus
if self.is_apc: # BUT
moys_vers_ue = modimpl_results.etuds_moy_module.get(
ue.id
)
val = (
moys_vers_ue.get(etudid, "?")
if moys_vers_ue is not None
else ""
)
else: # classique: Series indépendante de l'UE
val = modimpl_results.etuds_moy_module.get(
etudid, "?"
)
else:
val = ""
col_id = (
f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
)
val_fmt = val_fmt_html = fmt_note(val)
if convert_values and (
modimpl.module.module_type == scu.ModuleType.MALUS
):
val_fmt_html = (
(scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else ""
)
idx = add_cell(
row,
col_id,
modimpl.module.code,
val_fmt_html,
# class col_res mod_ue_123
f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}",
idx,
)
row[f"_{col_id}_xls"] = val_fmt
if modimpl.module.module_type == scu.ModuleType.MALUS:
titles[f"_{col_id}_col_order"] = idx_malus
titles_bot[f"_{col_id}_target"] = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
nom_resp = dict_nom_res.get(modimpl.responsable_id)
if nom_resp is None:
user = User.query.get(modimpl.responsable_id)
nom_resp = user.get_nomcomplet() if user else ""
dict_nom_res[modimpl.responsable_id] = nom_resp
titles_bot[
f"_{col_id}_target_attrs"
] = f""" title="{modimpl.module.titre} ({nom_resp})" """
modimpl_ids.add(modimpl.id)
nb_ues_etud_parcours = len(self.etud_ues_ids(etudid))
ue_valid_txt = (
ue_valid_txt_html
) = f"{nb_ues_validables}/{nb_ues_etud_parcours}"
if nb_ues_warning:
ue_valid_txt_html += " " + scu.EMO_WARNING
add_cell(
row,
"ues_validables",
"UEs",
ue_valid_txt_html,
"col_ue col_ues_validables",
29, # juste avant moy. gen.
)
row["_ues_validables_xls"] = ue_valid_txt
if nb_ues_warning:
row["_ues_validables_class"] += " moy_ue_warning"
elif nb_ues_validables < len(ues_sans_bonus):
row["_ues_validables_class"] += " moy_inf"
row["_ues_validables_order"] = nb_ues_validables # pour tri
if mode_jury and self.validations:
if self.is_apc:
# formations BUT: pas de code semestre, concatene ceux des UE
dec_ues = self.validations.decisions_jury_ues.get(etudid)
if dec_ues:
jury_code_sem = ",".join(
[dec_ues[ue_id].get("code", "") for ue_id in dec_ues]
)
else:
jury_code_sem = ""
else:
# formations classiques: code semestre
dec_sem = self.validations.decisions_jury.get(etudid)
jury_code_sem = dec_sem["code"] if dec_sem else ""
idx = add_cell(
row,
"jury_code_sem",
"Jury",
jury_code_sem,
"jury_code_sem",
1000,
)
idx = add_cell(
row,
"jury_link",
"",
f"""<a href="{url_for('notes.formsemestre_validation_etud_form',
scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre.id, etudid=etudid
)
}">{"saisir" if not jury_code_sem else "modifier"} décision</a>""",
"col_jury_link",
idx,
)
rows.append(row)
col_idx = self.recap_add_partitions(rows, titles)
self.recap_add_cursus(rows, titles, col_idx=col_idx + 1)
self._recap_add_admissions(rows, titles)
# tri par rang croissant
if not self.formsemestre.block_moyenne_generale:
rows.sort(key=lambda e: e["_rang_order"])
else:
rows.sort(key=lambda e: e["_ues_validables_order"], reverse=True)
# INFOS POUR FOOTER
bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note)
if include_evaluations:
self._recap_add_evaluations(rows, titles, bottom_infos)
# Ajoute style "col_empty" aux colonnes de modules vides
for col_id in titles:
c_class = f"_{col_id}_class"
if "col_empty" in bottom_infos["moy"].get(c_class, ""):
for row in rows:
row[c_class] = row.get(c_class, "") + " col_empty"
titles[c_class] += " col_empty"
for row in bottom_infos.values():
row[c_class] = row.get(c_class, "") + " col_empty"
# --- TABLE FOOTER: ECTS, moyennes, min, max...
footer_rows = []
for (bottom_line, row) in bottom_infos.items():
# Cases vides à styler:
row["moy_gen"] = row.get("moy_gen", "")
row["_moy_gen_class"] = "col_moy_gen"
# titre de la ligne:
row["prenom"] = row["nom_short"] = (
row.get("_title", "") or bottom_line.capitalize()
)
row["_tr_class"] = bottom_line.lower() + (
(" " + row["_tr_class"]) if "_tr_class" in row else ""
)
footer_rows.append(row)
titles_bot.update(titles)
footer_rows.append(titles_bot)
column_ids = [title for title in titles if not title.startswith("_")]
column_ids.sort(
key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000)
)
return (rows, footer_rows, titles, column_ids)
def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict:
"""Les informations à mettre en bas de la table: min, max, moy, ECTS"""
row_min, row_max, row_moy, row_coef, row_ects, row_apo = (
{"_tr_class": "bottom_info", "_title": "Min."},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info", "_title": "Code Apogée"},
)
# --- ECTS
for ue in ues:
colid = f"moy_ue_{ue.id}"
row_ects[colid] = ue.ects
row_ects[f"_{colid}_class"] = "col_ue"
# style cases vides pour borders verticales
row_coef[colid] = ""
row_coef[f"_{colid}_class"] = "col_ue"
# row_apo[colid] = ue.code_apogee or ""
row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT])
row_ects["_moy_gen_class"] = "col_moy_gen"
# --- MIN, MAX, MOY, APO
row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min())
row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max())
row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean())
for ue in ues:
colid = f"moy_ue_{ue.id}"
row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min())
row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max())
row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean())
row_min[f"_{colid}_class"] = "col_ue"
row_max[f"_{colid}_class"] = "col_ue"
row_moy[f"_{colid}_class"] = "col_ue"
row_apo[colid] = ue.code_apogee or ""
for modimpl in self.formsemestre.modimpls_sorted:
if modimpl.id in modimpl_ids:
colid = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
if self.is_apc:
coef = self.modimpl_coefs_df[modimpl.id][ue.id]
else:
coef = modimpl.module.coefficient or 0
row_coef[colid] = fmt_note(coef)
notes = self.modimpl_notes(modimpl.id, ue.id)
if np.isnan(notes).all():
# aucune note valide
row_min[colid] = np.nan
row_max[colid] = np.nan
moy = np.nan
else:
row_min[colid] = fmt_note(np.nanmin(notes))
row_max[colid] = fmt_note(np.nanmax(notes))
moy = np.nanmean(notes)
row_moy[colid] = fmt_note(moy)
if np.isnan(moy):
# aucune note dans ce module
row_moy[f"_{colid}_class"] = "col_empty"
row_apo[colid] = modimpl.module.code_apogee or ""
return { # { key : row } avec key = min, max, moy, coef
"min": row_min,
"max": row_max,
"moy": row_moy,
"coef": row_coef,
"ects": row_ects,
"apo": row_apo,
}
def _recap_etud_groups_infos(
self, etudid: int, row: dict, titles: dict
): # XXX non utilisé
"""Table recap: ajoute à row les colonnes sur les groupes pour cet etud"""
# dec = self.get_etud_decision_sem(etudid)
# if dec:
# codes_nb[dec["code"]] += 1
row_class = ""
etud_etat = self.get_etud_etat(etudid)
if etud_etat == DEM:
gr_name = "Dém."
row_class = "dem"
elif etud_etat == DEF:
gr_name = "Déf."
row_class = "def"
else:
# XXX probablement à revoir pour utiliser données cachées,
# via get_etud_groups_in_partition ou autre
group = sco_groups.get_etud_main_group(etudid, self.formsemestre.id)
gr_name = group["group_name"] or ""
row["group"] = gr_name
row["_group_class"] = "group"
if row_class:
row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class])
titles["group"] = "Gr"
def _recap_add_admissions(self, rows: list[dict], titles: dict):
"""Ajoute les colonnes "admission"
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "admission"
"""
fields = {
"bac": "Bac",
"specialite": "Spécialité",
"type_admission": "Type Adm.",
"classement": "Rg. Adm.",
}
first = True
for i, cid in enumerate(fields):
titles[f"_{cid}_col_order"] = 10000 + i # tout à droite
if first:
titles[f"_{cid}_class"] = "admission admission_first"
first = False
else:
titles[f"_{cid}_class"] = "admission"
titles.update(fields)
for row in rows:
etud = Identite.query.get(row["etudid"])
admission = etud.admission.first()
first = True
for cid in fields:
row[cid] = getattr(admission, cid) or ""
if first:
row[f"_{cid}_class"] = "admission admission_first"
first = False
else:
row[f"_{cid}_class"] = "admission"
def recap_add_cursus(self, rows: list[dict], titles: dict, col_idx: int = None):
"""Ajoute colonne avec code cursus, eg 'S1 S2 S1'"""
cid = "code_cursus"
titles[cid] = "Cursus"
titles[f"_{cid}_col_order"] = col_idx
formation_code = self.formsemestre.formation.formation_code
for row in rows:
etud = Identite.query.get(row["etudid"])
row[cid] = " ".join(
[
f"S{ins.formsemestre.semestre_id}"
for ins in reversed(etud.inscriptions())
if ins.formsemestre.formation.formation_code == formation_code
]
)
def recap_add_partitions(
self, rows: list[dict], titles: dict, col_idx: int = None
) -> int:
"""Ajoute les colonnes indiquant les groupes
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "partition"
Renvoie l'indice de la dernière colonne utilisée
"""
partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups(
self.formsemestre.id
)
first_partition = True
col_order = 10 if col_idx is None else col_idx
for partition in partitions:
cid = f"part_{partition['partition_id']}"
rg_cid = cid + "_rg" # rang dans la partition
titles[cid] = partition["partition_name"]
if first_partition:
klass = "partition"
else:
klass = "partition partition_aux"
titles[f"_{cid}_class"] = klass
titles[f"_{cid}_col_order"] = col_order
titles[f"_{rg_cid}_col_order"] = col_order + 1
col_order += 2
if partition["bul_show_rank"]:
titles[rg_cid] = f"Rg {partition['partition_name']}"
titles[f"_{rg_cid}_class"] = "partition_rangs"
partition_etud_groups = partitions_etud_groups[partition["partition_id"]]
for row in rows:
group = None # group (dict) de l'étudiant dans cette partition
# dans NotesTableCompat, à revoir
etud_etat = self.get_etud_etat(row["etudid"])
if etud_etat == scu.DEMISSION:
gr_name = "Dém."
row["_tr_class"] = "dem"
elif etud_etat == DEF:
gr_name = "Déf."
row["_tr_class"] = "def"
else:
group = partition_etud_groups.get(row["etudid"])
gr_name = group["group_name"] if group else ""
if gr_name:
row[cid] = gr_name
row[f"_{cid}_class"] = klass
# Rangs dans groupe
if (
partition["bul_show_rank"]
and (group is not None)
and (group["id"] in self.moy_gen_rangs_by_group)
):
rang = self.moy_gen_rangs_by_group[group["id"]][0]
row[rg_cid] = rang.get(row["etudid"], "")
first_partition = False
return col_order
def _recap_add_evaluations(
self, rows: list[dict], titles: dict, bottom_infos: dict
):
"""Ajoute les colonnes avec les notes aux évaluations
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "evaluation"
"""
# nouvelle ligne pour description évaluations:
bottom_infos["descr_evaluation"] = {
"_tr_class": "bottom_info",
"_title": "Description évaluation",
}
first_eval = True
index_col = 9000 # à droite
for modimpl in self.formsemestre.modimpls_sorted:
evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl)
eval_index = len(evals) - 1
inscrits = {i.etudid for i in modimpl.inscriptions}
first_eval_of_mod = True
for e in evals:
cid = f"eval_{e.id}"
titles[
cid
] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}'
klass = "evaluation"
if first_eval:
klass += " first"
elif first_eval_of_mod:
klass += " first_of_mod"
titles[f"_{cid}_class"] = klass
first_eval_of_mod = first_eval = False
titles[f"_{cid}_col_order"] = index_col
index_col += 1
eval_index -= 1
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
e.evaluation_id
)
for row in rows:
etudid = row["etudid"]
if etudid in inscrits:
if etudid in notes_db:
val = notes_db[etudid]["value"]
else:
# Note manquante mais prise en compte immédiate: affiche ATT
val = scu.NOTES_ATTENTE
row[cid] = scu.fmt_note(val)
row[f"_{cid}_class"] = klass + {
"ABS": " abs",
"ATT": " att",
"EXC": " exc",
}.get(row[cid], "")
else:
row[cid] = "ni"
row[f"_{cid}_class"] = klass + " non_inscrit"
bottom_infos["coef"][cid] = e.coefficient
bottom_infos["min"][cid] = "0"
bottom_infos["max"][cid] = scu.fmt_note(e.note_max)
bottom_infos["descr_evaluation"][cid] = e.description or ""
bottom_infos["descr_evaluation"][f"_{cid}_target"] = url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.id,
)

View File

@ -7,19 +7,20 @@
"""Classe résultats pour compatibilité avec le code ScoDoc 7
"""
from functools import cached_property
import pandas as pd
from flask import flash, g, Markup, url_for
from flask import flash, g, url_for
from markupsafe import Markup
from app import log
from app import db, log
from app.comp import moy_sem
from app.comp.aux_stats import StatsMoyenne
from app.comp.res_common import ResultatsSemestre
from app.models import FormSemestre
from app.models import Identite
from app.models import ModuleImpl
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF
from app.models import Identite, FormSemestre, ModuleImpl, ScolarAutorisationInscription
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
@ -52,7 +53,7 @@ class NotesTableCompat(ResultatsSemestre):
self.moy_gen_rangs_by_group = {} # { group_id : (Series, Series) }
self.ue_rangs_by_group = {} # { ue_id : {group_id : (Series, Series)}}
self.expr_diagnostics = ""
self.parcours = self.formsemestre.formation.get_parcours()
self.parcours = self.formsemestre.formation.get_cursus()
self._modimpls_dict_by_ue = {} # local cache
def get_inscrits(self, include_demdef=True, order_by=False) -> list[Identite]:
@ -109,7 +110,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()
@ -166,15 +167,24 @@ class NotesTableCompat(ResultatsSemestre):
moy_gen_rangs_by_group[group_id]
ue_rangs_by_group[group_id]
"""
mask_inscr = pd.Series(
[
self.formsemestre.etuds_inscriptions[etudid].etat == scu.INSCRIT
for etudid in self.etud_moy_gen.index
],
dtype=float,
index=self.etud_moy_gen.index,
)
etud_moy_gen_dem_zero = self.etud_moy_gen * mask_inscr
(
self.etud_moy_gen_ranks,
self.etud_moy_gen_ranks_int,
) = moy_sem.comp_ranks_series(self.etud_moy_gen)
ues = self.formsemestre.query_ues()
) = moy_sem.comp_ranks_series(etud_moy_gen_dem_zero)
ues = self.formsemestre.get_ues()
for ue in ues:
moy_ue = self.etud_moy_ue[ue.id]
self.ue_rangs[ue.id] = (
moy_sem.comp_ranks_series(moy_ue)[0], # juste en chaine
moy_sem.comp_ranks_series(moy_ue * mask_inscr)[0], # juste en chaine
int(moy_ue.count()),
)
# .count() -> nb of non NaN values
@ -194,7 +204,7 @@ class NotesTableCompat(ResultatsSemestre):
)
# list() car pandas veut une sequence pour take()
# Rangs / moyenne générale:
group_moys_gen = self.etud_moy_gen[group_members]
group_moys_gen = etud_moy_gen_dem_zero[group_members]
self.moy_gen_rangs_by_group[group.id] = moy_sem.comp_ranks_series(
group_moys_gen
)
@ -203,7 +213,7 @@ class NotesTableCompat(ResultatsSemestre):
group_moys_ue = self.etud_moy_ue[ue.id][group_members]
self.ue_rangs_by_group.setdefault(ue.id, {})[
group.id
] = moy_sem.comp_ranks_series(group_moys_ue)
] = moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
def get_etud_rang(self, etudid: int) -> str:
"""Le rang (classement) de l'étudiant dans le semestre.
@ -252,28 +262,42 @@ 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"""
return self.get_etud_decision_ues(etudid) or self.get_etud_decision_sem(etudid)
def etud_has_decision(self, etudid) -> bool:
"""True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre.
prend aussi en compte les autorisations de passage.
Sous-classée en BUT pour les RCUEs et années.
"""
return bool(
self.get_etud_decisions_ue(etudid)
or self.get_etud_decision_sem(etudid)
or ScolarAutorisationInscription.query.filter_by(
origin_formsemestre_id=self.formsemestre.id, etudid=etudid
).count()
)
def get_etud_decision_ues(self, etudid: int) -> dict:
def get_etud_decisions_ue(self, etudid: int) -> dict:
"""Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu.
Ne tient pas compte des UE capitalisées.
{ ue_id : { 'code' : ADM|CMP|AJ, 'event_date' : "d/m/y", 'ects' : x }
@ -282,16 +306,16 @@ class NotesTableCompat(ResultatsSemestre):
if self.get_etud_etat(etudid) == DEF:
return {}
else:
validations = self.load_validations()
validations = self.get_formsemestre_validations()
return validations.decisions_jury_ues.get(etudid, None)
def get_etud_ects_valides(self, etudid: int, decisions_ues: dict = False) -> 0:
"""Le total des ECTS validés (et enregistrés) par l'étudiant dans ce semestre.
NB: avant jury, rien d'enregistré, donc zéro ECTS.
Optimisation: si decisions_ues est passé, l'utilise, sinon appelle get_etud_decision_ues()
Optimisation: si decisions_ues est passé, l'utilise, sinon appelle get_etud_decisions_ue()
"""
if decisions_ues is False:
decisions_ues = self.get_etud_decision_ues(etudid)
decisions_ues = self.get_etud_decisions_ue(etudid)
if not decisions_ues:
return 0.0
return sum([d.get("ects", 0.0) for d in decisions_ues.values()])
@ -299,7 +323,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 {
@ -309,7 +334,7 @@ class NotesTableCompat(ResultatsSemestre):
"compense_formsemestre_id": None,
}
else:
validations = self.load_validations()
validations = self.get_formsemestre_validations()
return validations.decisions_jury.get(etudid, None)
def get_etud_mat_moy(self, matiere_id: int, etudid: int) -> str:
@ -369,7 +394,7 @@ class NotesTableCompat(ResultatsSemestre):
de ce module.
Évaluation "complete" ssi toutes notes saisies ou en attente.
"""
modimpl = ModuleImpl.query.get(moduleimpl_id)
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
modimpl_results = self.modimpls_results.get(moduleimpl_id)
if not modimpl_results:
return [] # safeguard
@ -460,7 +485,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

@ -16,6 +16,7 @@ import flask_login
import app
from app.auth.models import User
import app.scodoc.sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError
class ZUser(object):
@ -95,7 +96,7 @@ def permission_required(permission):
return decorator
def permission_required_compat_scodoc7(permission):
def permission_required_compat_scodoc7(permission): # XXX TODO A SUPPRIMER
"""Décorateur pour les fonctions utilisées comme API dans ScoDoc 7
Comme @permission_required mais autorise de passer directement
les informations d'auth en paramètres:
@ -117,6 +118,10 @@ def permission_required_compat_scodoc7(permission):
else:
abort(405) # method not allowed
if user_name and user_password:
# Ancienne API: va être supprimée courant mars 2023
current_app.logger.warning(
"using DEPRECATED ScoDoc7 authentication method !"
)
u = User.query.filter_by(user_name=user_name).first()
if u and u.check_password(user_password):
auth_ok = True
@ -180,19 +185,24 @@ def scodoc7func(func):
else:
arg_names = argspec.args
for arg_name in arg_names: # pour chaque arg de la fonction vue
if arg_name == "REQUEST": # ne devrait plus arriver !
# debug check, TODO remove after tests
raise ValueError("invalid REQUEST parameter !")
else:
# peut produire une KeyError s'il manque un argument attendu:
v = req_args[arg_name]
# try to convert all arguments to INTEGERS
# necessary for db ids and boolean values
try:
v = int(v)
except (ValueError, TypeError):
pass
pos_arg_values.append(v)
# peut produire une KeyError s'il manque un argument attendu:
v = req_args[arg_name]
# try to convert all arguments to INTEGERS
# necessary for db ids and boolean values
try:
v = int(v) if v else v
except (ValueError, TypeError) as exc:
if arg_name in {
"etudid",
"formation_id",
"formsemestre_id",
"module_id",
"moduleimpl_id",
"partition_id",
"ue_id",
}:
raise ScoValueError("page introuvable (id invalide)") from exc
pos_arg_values.append(v)
# current_app.logger.info("pos_arg_values=%s" % pos_arg_values)
# current_app.logger.info("req_args=%s" % req_args)
# Add keyword arguments

View File

@ -11,6 +11,8 @@ from flask import current_app, g
from flask_mail import Message
from app import mail
from app.models.departements import Departement
from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_preferences
@ -56,6 +58,7 @@ def send_message(msg: Message):
In mail debug mode, addresses are discarded and all mails are sent to the
specified debugging address.
"""
email_test_mode_address = False
if hasattr(g, "scodoc_dept"):
# on est dans un département, on peut accéder aux préférences
email_test_mode_address = sco_preferences.get_preference(
@ -81,6 +84,35 @@ Adresses d'origine:
+ msg.body
)
current_app.logger.info(
f"""email sent to{' (mode test)' if email_test_mode_address else ''}: {msg.recipients}
from sender {msg.sender}
"""
)
Thread(
target=send_async_email, args=(current_app._get_current_object(), msg)
).start()
def get_from_addr(dept_acronym: str = None):
"""L'adresse "from" à utiliser pour envoyer un mail
Si le departement est spécifié, ou si l'attribut `g.scodoc_dept`existe,
prend le `email_from_addr` des préférences de ce département si ce champ est non vide.
Sinon, utilise le paramètre global `email_from_addr`.
Sinon, la variable de config `SCODOC_MAIL_FROM`.
"""
dept_acronym = dept_acronym or getattr(g, "scodoc_dept", None)
if dept_acronym:
dept = Departement.query.filter_by(acronym=dept_acronym).first()
if dept:
from_addr = (
sco_preferences.get_preference("email_from_addr", dept_id=dept.id) or ""
).strip()
if from_addr:
return from_addr
return (
ScoDocSiteConfig.get("email_from_addr")
or current_app.config["SCODOC_MAIL_FROM"]
or "none"
)

View File

@ -44,8 +44,8 @@ from app.entreprises.models import (
EntrepriseHistorique,
)
from app import email, db
from app.scodoc import sco_preferences
from app.scodoc import sco_excel
from app.scodoc import sco_utils as scu
from app.models import Departement
from app.scodoc.sco_permissions import Permission
@ -216,7 +216,7 @@ def send_email_notifications_entreprise(subject: str, entreprise: Entreprise):
txt = "\n".join(txt)
email.send_email(
subject,
sco_preferences.get_preference("email_from_addr"),
email.get_from_addr(),
[EntreprisePreferences.get_email_notifications],
txt,
)
@ -392,7 +392,8 @@ def check_entreprise_import(entreprise_data):
else:
try:
req = requests.get(
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}"
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}",
timeout=scu.SCO_EXT_TIMEOUT,
)
if req.status_code != 200:
return False

View File

@ -24,9 +24,9 @@
#
##############################################################################
from datetime import datetime
import re
import requests
from datetime import datetime
from flask import url_for
from flask_wtf import FlaskForm
@ -34,18 +34,17 @@ from flask_wtf.file import FileField, FileAllowed, FileRequired
from markupsafe import Markup
from sqlalchemy import text
from wtforms import (
StringField,
IntegerField,
SubmitField,
TextAreaField,
SelectField,
HiddenField,
SelectMultipleField,
DateField,
BooleanField,
DateField,
FieldList,
FormField,
BooleanField,
HiddenField,
IntegerField,
SelectField,
SelectMultipleField,
StringField,
SubmitField,
TextAreaField,
)
from wtforms.validators import (
ValidationError,
@ -56,6 +55,9 @@ from wtforms.validators import (
)
from wtforms.widgets import ListWidget, CheckboxInput
from app import db
from app.auth.models import User
from app.entreprises import SIRET_PROVISOIRE_START
from app.entreprises.models import (
Entreprise,
EntrepriseCorrespondant,
@ -63,10 +65,8 @@ from app.entreprises.models import (
EntrepriseSite,
EntrepriseTaxeApprentissage,
)
from app import db
from app.models import Identite, Departement
from app.auth.models import User
from app.entreprises import SIRET_PROVISOIRE_START
from app.scodoc import sco_utils as scu
CHAMP_REQUIS = "Ce champ est requis"
SUBMIT_MARGE = {"style": "margin-bottom: 10px;"}
@ -122,13 +122,13 @@ class EntrepriseCreationForm(FlaskForm):
origine = _build_string_field("Origine du correspondant", required=False)
notes = _build_string_field("Notes sur le correspondant", required=False)
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate(self):
def validate(self, extra_validators=None):
validate = True
if not FlaskForm.validate(self):
validate = False
if not super().validate(extra_validators):
return False
if EntreprisePreferences.get_check_siret() and self.siret.data != "":
siret_data = self.siret.data.strip().replace(" ", "")
@ -139,7 +139,8 @@ class EntrepriseCreationForm(FlaskForm):
else:
try:
req = requests.get(
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret_data}"
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret_data}",
timeout=scu.SCO_EXT_TIMEOUT,
)
if req.status_code != 200:
self.siret.errors.append("SIRET inexistant")
@ -220,7 +221,8 @@ class EntrepriseModificationForm(FlaskForm):
else:
try:
req = requests.get(
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret_data}"
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret_data}",
timeout=scu.SCO_EXT_TIMEOUT,
)
if req.status_code != 200:
raise ValidationError("SIRET inexistant")
@ -246,13 +248,13 @@ class SiteCreationForm(FlaskForm):
codepostal = _build_string_field("Code postal (*)")
ville = _build_string_field("Ville (*)")
pays = _build_string_field("Pays", required=False)
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate(self):
def validate(self, extra_validators=None):
validate = True
if not FlaskForm.validate(self):
validate = False
if not super().validate(extra_validators):
return False
site = EntrepriseSite.query.filter_by(
entreprise_id=self.hidden_entreprise_id.data, nom=self.nom.data
@ -276,10 +278,10 @@ class SiteModificationForm(FlaskForm):
submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate(self):
def validate(self, extra_validators=None):
validate = True
if not FlaskForm.validate(self):
validate = False
if not super().validate(extra_validators):
return False
site = EntrepriseSite.query.filter(
EntrepriseSite.entreprise_id == self.hidden_entreprise_id.data,
@ -316,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=[
@ -324,7 +326,7 @@ class OffreCreationForm(FlaskForm):
FileAllowed(["pdf", "docx"], "Fichier .pdf ou .docx uniquement"),
],
)
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def __init__(self, *args, **kwargs):
@ -342,10 +344,10 @@ class OffreCreationForm(FlaskForm):
(dept.id, dept.acronym) for dept in Departement.query.all()
]
def validate(self):
def validate(self, extra_validators=None):
validate = True
if not FlaskForm.validate(self):
validate = False
if not super().validate(extra_validators):
return False
if len(self.depts.data) < 1:
self.depts.errors.append("Choisir au moins un département")
@ -371,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)
@ -390,10 +392,10 @@ class OffreModificationForm(FlaskForm):
(dept.id, dept.acronym) for dept in Departement.query.all()
]
def validate(self):
def validate(self, extra_validators=None):
validate = True
if not FlaskForm.validate(self):
validate = False
if not super().validate(extra_validators):
return False
if len(self.depts.data) < 1:
self.depts.errors.append("Choisir au moins un département")
@ -440,10 +442,10 @@ class CorrespondantCreationForm(FlaskForm):
"Notes", required=False, render_kw={"class": "form-control"}
)
def validate(self):
def validate(self, extra_validators=None):
validate = True
if not FlaskForm.validate(self):
validate = False
if not super().validate(extra_validators):
return False
if not self.telephone.data and not self.mail.data:
msg = "Saisir un moyen de contact (mail ou téléphone)"
@ -456,13 +458,13 @@ class CorrespondantCreationForm(FlaskForm):
class CorrespondantsCreationForm(FlaskForm):
hidden_site_id = HiddenField()
correspondants = FieldList(FormField(CorrespondantCreationForm), min_entries=1)
submit = SubmitField("Envoyer")
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler")
def validate(self):
def validate(self, extra_validators=None):
validate = True
if not FlaskForm.validate(self):
validate = False
if not super().validate(extra_validators):
return False
correspondant_list = []
for entry in self.correspondants.entries:
@ -529,10 +531,10 @@ class CorrespondantModificationForm(FlaskForm):
.all()
]
def validate(self):
def validate(self, extra_validators=None):
validate = True
if not FlaskForm.validate(self):
validate = False
if not super().validate(extra_validators):
return False
correspondant = EntrepriseCorrespondant.query.filter(
EntrepriseCorrespondant.id != self.hidden_correspondant_id.data,
@ -564,7 +566,7 @@ class ContactCreationForm(FlaskForm):
render_kw={"placeholder": "Tapez le nom de l'utilisateur"},
)
notes = TextAreaField("Notes (*)", validators=[DataRequired(message=CHAMP_REQUIS)])
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate_utilisateur(self, utilisateur):
@ -611,8 +613,9 @@ class ContactModificationForm(FlaskForm):
class StageApprentissageCreationForm(FlaskForm):
etudiant = _build_string_field(
"Étudiant (*)",
render_kw={"placeholder": "Tapez le nom de l'étudiant"},
render_kw={"placeholder": "Tapez le nom de l'étudiant", "autocomplete": "off"},
)
etudid = HiddenField()
type_offre = SelectField(
"Type de l'offre (*)",
choices=[("Stage"), ("Alternance")],
@ -625,12 +628,12 @@ class StageApprentissageCreationForm(FlaskForm):
"Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
)
notes = TextAreaField("Notes")
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate(self):
def validate(self, extra_validators=None):
validate = True
if not FlaskForm.validate(self):
if not super().validate(extra_validators):
validate = False
if (
@ -644,64 +647,27 @@ class StageApprentissageCreationForm(FlaskForm):
return validate
def validate_etudiant(self, etudiant):
etudiant_data = etudiant.data.upper().strip()
stm = text(
"SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom"
)
etudiant = (
Identite.query.from_statement(stm).params(nom_prenom=etudiant_data).first()
)
def validate_etudid(self, field):
"L'etudid doit avoit été placé par le JS"
etudid = int(field.data) if field.data else None
etudiant = db.session.get(Identite, etudid) if etudid is not None else None
if etudiant is None:
raise ValidationError("Champ incorrect (selectionnez dans la liste)")
raise ValidationError("Étudiant introuvable (sélectionnez dans la liste)")
class StageApprentissageModificationForm(FlaskForm):
etudiant = _build_string_field(
"Étudiant (*)",
render_kw={"placeholder": "Tapez le nom de l'étudiant"},
)
type_offre = SelectField(
"Type de l'offre (*)",
choices=[("Stage"), ("Alternance")],
validators=[DataRequired(message=CHAMP_REQUIS)],
)
date_debut = DateField(
"Date début (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
)
date_fin = DateField(
"Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
)
notes = TextAreaField("Notes")
submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
class FrenchFloatField(StringField):
"A field allowing to enter . or ,"
def validate(self):
validate = True
if not FlaskForm.validate(self):
validate = False
if (
self.date_debut.data
and self.date_fin.data
and self.date_debut.data > self.date_fin.data
):
self.date_debut.errors.append("Les dates sont incompatibles")
self.date_fin.errors.append("Les dates sont incompatibles")
validate = False
return validate
def validate_etudiant(self, etudiant):
etudiant_data = etudiant.data.upper().strip()
stm = text(
"SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom"
)
etudiant = (
Identite.query.from_statement(stm).params(nom_prenom=etudiant_data).first()
)
if etudiant is None:
raise ValidationError("Champ incorrect (selectionnez dans la liste)")
def process_formdata(self, valuelist):
"catch incoming data"
if not valuelist:
return
try:
value = valuelist[0].replace(",", ".")
self.data = float(value)
except ValueError as exc:
self.data = None
raise ValueError(self.gettext("Not a valid decimal value.")) from exc
class TaxeApprentissageForm(FlaskForm):
@ -718,25 +684,26 @@ class TaxeApprentissageForm(FlaskForm):
],
default=int(datetime.now().strftime("%Y")),
)
montant = IntegerField(
montant = FrenchFloatField(
"Montant (*)",
validators=[
DataRequired(message=CHAMP_REQUIS),
NumberRange(
min=1,
message="Le montant doit être supérieur à 0",
),
# NumberRange(
# min=0.1,
# max=1e8,
# message="Le montant doit être supérieur à 0",
# ),
],
default=1,
)
notes = TextAreaField("Notes")
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
def validate(self):
def validate(self, extra_validators=None):
validate = True
if not FlaskForm.validate(self):
validate = False
if not super().validate(extra_validators):
return False
taxe = EntrepriseTaxeApprentissage.query.filter_by(
entreprise_id=self.hidden_entreprise_id.data, annee=self.annee.data
@ -786,12 +753,12 @@ class EnvoiOffreForm(FlaskForm):
submit = SubmitField("Envoyer")
cancel = SubmitField("Annuler")
def validate(self):
def validate(self, extra_validators=None):
validate = True
list_select = True
if not FlaskForm.validate(self):
validate = False
if not super().validate(extra_validators):
return False
for entry in self.responsables.entries:
if entry.data:

View File

@ -164,7 +164,10 @@ class EntrepriseStageApprentissage(db.Model):
entreprise_id = db.Column(
db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade")
)
etudid = db.Column(db.Integer)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
)
type_offre = db.Column(db.Text)
date_debut = db.Column(db.Date)
date_fin = db.Column(db.Date)
@ -180,7 +183,7 @@ class EntrepriseTaxeApprentissage(db.Model):
db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade")
)
annee = db.Column(db.Integer)
montant = db.Column(db.Integer)
montant = db.Column(db.Float)
notes = db.Column(db.Text)

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
@ -27,7 +28,6 @@ from app.entreprises.forms import (
ContactCreationForm,
ContactModificationForm,
StageApprentissageCreationForm,
StageApprentissageModificationForm,
EnvoiOffreForm,
AjoutFichierForm,
TaxeApprentissageForm,
@ -58,8 +58,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"])
@ -89,7 +88,7 @@ def index():
visible=True, association=True, siret_provisoire=True
)
return render_template(
"entreprises/entreprises.html",
"entreprises/entreprises.j2",
title="Entreprises",
entreprises=entreprises,
logs=logs,
@ -109,7 +108,7 @@ def logs():
EntrepriseHistorique.date.desc()
).paginate(page=page, per_page=20)
return render_template(
"entreprises/logs.html",
"entreprises/logs.j2",
title="Logs",
logs=logs,
)
@ -134,7 +133,7 @@ def correspondants():
.all()
)
return render_template(
"entreprises/correspondants.html",
"entreprises/correspondants.j2",
title="Correspondants",
correspondants=correspondants,
logs=logs,
@ -149,7 +148,7 @@ def validation():
"""
entreprises = Entreprise.query.filter_by(visible=False).all()
return render_template(
"entreprises/entreprises_validation.html",
"entreprises/entreprises_validation.j2",
title="Validation entreprises",
entreprises=entreprises,
)
@ -167,7 +166,7 @@ def fiche_entreprise_validation(entreprise_id):
description=f"fiche entreprise (validation) {entreprise_id} inconnue"
)
return render_template(
"entreprises/fiche_entreprise_validation.html",
"entreprises/fiche_entreprise_validation.j2",
title="Validation fiche entreprise",
entreprise=entreprise,
)
@ -205,7 +204,7 @@ def validate_entreprise(entreprise_id):
flash("L'entreprise a été validé et ajouté à la liste.")
return redirect(url_for("entreprises.validation"))
return render_template(
"entreprises/form_validate_confirmation.html",
"entreprises/form_validate_confirmation.j2",
title="Validation entreprise",
form=form,
)
@ -239,10 +238,10 @@ def delete_validation_entreprise(entreprise_id):
text=f"Non validation de la fiche entreprise ({entreprise.nom})",
)
db.session.add(log)
flash("L'entreprise a été supprimé de la liste des entreprise à valider.")
flash("L'entreprise a été supprimée de la liste des entreprises à valider.")
return redirect(url_for("entreprises.validation"))
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supression entreprise",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -282,7 +281,7 @@ def offres_recues():
files.append(file)
offres_recues_with_files.append([envoi_offre, offre, files, correspondant])
return render_template(
"entreprises/offres_recues.html",
"entreprises/offres_recues.j2",
title="Offres reçues",
offres_recues=offres_recues_with_files,
)
@ -321,7 +320,7 @@ def preferences():
form.mail_entreprise.data = EntreprisePreferences.get_email_notifications()
form.check_siret.data = int(EntreprisePreferences.get_check_siret())
return render_template(
"entreprises/preferences.html",
"entreprises/preferences.j2",
title="Préférences",
form=form,
)
@ -357,7 +356,7 @@ def add_entreprise():
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
"entreprises/form_ajout_entreprise.html",
"entreprises/form_ajout_entreprise.j2",
title="Ajout entreprise avec correspondant",
form=form,
)
@ -408,7 +407,7 @@ def add_entreprise():
flash("L'entreprise a été ajouté à la liste pour la validation.")
return redirect(url_for("entreprises.index"))
return render_template(
"entreprises/form_ajout_entreprise.html",
"entreprises/form_ajout_entreprise.j2",
title="Ajout entreprise avec correspondant",
form=form,
)
@ -446,7 +445,7 @@ def fiche_entreprise(entreprise_id):
.all()
)
return render_template(
"entreprises/fiche_entreprise.html",
"entreprises/fiche_entreprise.j2",
title="Fiche entreprise",
entreprise=entreprise,
offres=offres_with_files,
@ -472,7 +471,7 @@ def logs_entreprise(entreprise_id):
.paginate(page=page, per_page=20)
)
return render_template(
"entreprises/logs_entreprise.html",
"entreprises/logs_entreprise.j2",
title="Logs",
logs=logs,
entreprise=entreprise,
@ -490,7 +489,7 @@ def offres_expirees(entreprise_id):
).first_or_404(description=f"fiche entreprise {entreprise_id} inconnue")
offres_with_files = are.get_offres_expirees_with_files(entreprise.offres)
return render_template(
"entreprises/offres_expirees.html",
"entreprises/offres_expirees.j2",
title="Offres expirées",
entreprise=entreprise,
offres_expirees=offres_with_files,
@ -574,7 +573,7 @@ def edit_entreprise(entreprise_id):
form.pays.data = entreprise.pays
form.association.data = entreprise.association
return render_template(
"entreprises/form_modification_entreprise.html",
"entreprises/form_modification_entreprise.j2",
title="Modification entreprise",
form=form,
)
@ -610,7 +609,7 @@ def fiche_entreprise_desactiver(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Désactiver entreprise",
form=form,
info_message="Cliquez sur le bouton Modifier pour confirmer la désactivation",
@ -646,7 +645,7 @@ def fiche_entreprise_activer(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Activer entreprise",
form=form,
info_message="Cliquez sur le bouton Modifier pour confirmer l'activaction",
@ -692,7 +691,7 @@ def add_taxe_apprentissage(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Ajout taxe apprentissage",
form=form,
)
@ -735,7 +734,7 @@ def edit_taxe_apprentissage(entreprise_id, taxe_id):
form.montant.data = taxe.montant
form.notes.data = taxe.notes
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Modification taxe apprentissage",
form=form,
)
@ -770,12 +769,12 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id):
)
db.session.add(log)
db.session.commit()
flash("La taxe d'apprentissage a été supprimé de la liste.")
flash("La taxe d'apprentissage a été supprimée de la liste.")
return redirect(
url_for("entreprises.fiche_entreprise", entreprise_id=taxe.entreprise_id)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supprimer taxe apprentissage",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -845,7 +844,7 @@ def add_offre(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Ajout offre",
form=form,
)
@ -921,7 +920,7 @@ def edit_offre(entreprise_id, offre_id):
form.expiration_date.data = offre.expiration_date
form.depts.data = offre_depts_list
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Modification offre",
form=form,
)
@ -966,12 +965,12 @@ def delete_offre(entreprise_id, offre_id):
)
db.session.add(log)
db.session.commit()
flash("L'offre a été supprimé de la fiche entreprise.")
flash("L'offre a été supprimée de la fiche entreprise.")
return redirect(
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supression offre",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1047,7 +1046,7 @@ def add_site(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Ajout site",
form=form,
)
@ -1098,7 +1097,7 @@ def edit_site(entreprise_id, site_id):
form.ville.data = site.ville
form.pays.data = site.pays
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Modification site",
form=form,
)
@ -1154,7 +1153,7 @@ def add_correspondant(entreprise_id, site_id):
url_for("entreprises.fiche_entreprise", entreprise_id=site.entreprise_id)
)
return render_template(
"entreprises/form_ajout_correspondants.html",
"entreprises/form_ajout_correspondants.j2",
title="Ajout correspondant",
form=form,
)
@ -1234,7 +1233,7 @@ def edit_correspondant(entreprise_id, site_id, correspondant_id):
form.origine.data = correspondant.origine
form.notes.data = correspondant.notes
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Modification correspondant",
form=form,
)
@ -1290,7 +1289,7 @@ def delete_correspondant(entreprise_id, site_id, correspondant_id):
)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supression correspondant",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1308,7 +1307,7 @@ def contacts(entreprise_id):
).first_or_404(description=f"entreprise {entreprise_id} inconnue")
contacts = EntrepriseContact.query.filter_by(entreprise=entreprise.id).all()
return render_template(
"entreprises/contacts.html",
"entreprises/contacts.j2",
title="Liste des contacts",
contacts=contacts,
entreprise=entreprise,
@ -1365,7 +1364,7 @@ def add_contact(entreprise_id):
db.session.commit()
return redirect(url_for("entreprises.contacts", entreprise_id=entreprise.id))
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Ajout contact",
form=form,
)
@ -1421,7 +1420,7 @@ def edit_contact(entreprise_id, contact_id):
)
form.notes.data = contact.notes
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Modification contact",
form=form,
)
@ -1459,7 +1458,7 @@ def delete_contact(entreprise_id, contact_id):
url_for("entreprises.contacts", entreprise_id=contact.entreprise)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supression contact",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1473,7 +1472,8 @@ def delete_contact(entreprise_id, contact_id):
@permission_required(Permission.RelationsEntreprisesChange)
def add_stage_apprentissage(entreprise_id):
"""
Permet d'ajouter un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise
Permet d'ajouter un étudiant ayant réalisé un stage ou alternance
sur la fiche de l'entreprise
"""
entreprise = Entreprise.query.filter_by(
id=entreprise_id, visible=True
@ -1484,15 +1484,8 @@ def add_stage_apprentissage(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id)
)
if form.validate_on_submit():
etudiant_nomcomplet = form.etudiant.data.upper().strip()
stm = text(
"SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom"
)
etudiant = (
Identite.query.from_statement(stm)
.params(nom_prenom=etudiant_nomcomplet)
.first()
)
etudid = form.etudid.data
etudiant = Identite.query.get_or_404(etudid)
formation = etudiant.inscription_courante_date(
form.date_debut.data, form.date_fin.data
)
@ -1525,7 +1518,7 @@ def add_stage_apprentissage(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form_ajout_stage_apprentissage.html",
"entreprises/form_ajout_stage_apprentissage.j2",
title="Ajout stage / apprentissage",
form=form,
)
@ -1538,7 +1531,7 @@ def add_stage_apprentissage(entreprise_id):
@permission_required(Permission.RelationsEntreprisesChange)
def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
"""
Permet de modifier un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise
Permet de modifier un étudiant ayant réalisé un stage ou alternance sur la fiche de l'entreprise
"""
stage_apprentissage = EntrepriseStageApprentissage.query.filter_by(
id=stage_apprentissage_id, entreprise_id=entreprise_id
@ -1548,21 +1541,14 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
etudiant = Identite.query.filter_by(id=stage_apprentissage.etudid).first_or_404(
description=f"etudiant {stage_apprentissage.etudid} inconnue"
)
form = StageApprentissageModificationForm()
form = StageApprentissageCreationForm()
if request.method == "POST" and form.cancel.data:
return redirect(
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id)
)
if form.validate_on_submit():
etudiant_nomcomplet = form.etudiant.data.upper().strip()
stm = text(
"SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom"
)
etudiant = (
Identite.query.from_statement(stm)
.params(nom_prenom=etudiant_nomcomplet)
.first()
)
etudid = form.etudid.data
etudiant = Identite.query.get_or_404(etudid)
formation = etudiant.inscription_courante_date(
form.date_debut.data, form.date_fin.data
)
@ -1577,6 +1563,7 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
formation.formsemestre.formsemestre_id if formation else None,
)
stage_apprentissage.notes = form.notes.data.strip()
db.session.add(stage_apprentissage)
log = EntrepriseHistorique(
authenticated_user=current_user.user_name,
entreprise_id=stage_apprentissage.entreprise_id,
@ -1593,13 +1580,15 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
)
)
elif request.method == "GET":
form.etudiant.data = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}"
form.etudiant.data = f"""{sco_etud.format_nom(etudiant.nom)} {
sco_etud.format_prenom(etudiant.prenom)}"""
form.etudid.data = etudiant.id
form.type_offre.data = stage_apprentissage.type_offre
form.date_debut.data = stage_apprentissage.date_debut
form.date_fin.data = stage_apprentissage.date_fin
form.notes.data = stage_apprentissage.notes
return render_template(
"entreprises/form_ajout_stage_apprentissage.html",
"entreprises/form_ajout_stage_apprentissage.j2",
title="Modification stage / apprentissage",
form=form,
)
@ -1640,7 +1629,7 @@ def delete_stage_apprentissage(entreprise_id, stage_apprentissage_id):
)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supression stage/apprentissage",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1690,7 +1679,7 @@ def envoyer_offre(entreprise_id, offre_id):
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
)
return render_template(
"entreprises/form_envoi_offre.html",
"entreprises/form_envoi_offre.j2",
title="Envoyer une offre",
form=form,
)
@ -1698,6 +1687,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 +1713,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 +1739,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")
@ -1816,7 +1806,7 @@ def import_donnees():
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
"entreprises/import_donnees.html",
"entreprises/import_donnees.j2",
title="Importation données",
form=form,
)
@ -1843,9 +1833,9 @@ 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.html",
"entreprises/import_donnees.j2",
title="Importation données",
form=form,
entreprises_import=entreprises_import,
@ -1853,7 +1843,7 @@ def import_donnees():
correspondants_import=correspondants,
)
return render_template(
"entreprises/import_donnees.html", title="Importation données", form=form
"entreprises/import_donnees.j2", title="Importation données", form=form
)
@ -1927,7 +1917,7 @@ def add_offre_file(entreprise_id, offre_id):
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
)
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Ajout fichier à une offre",
form=form,
)
@ -1969,7 +1959,7 @@ def delete_offre_file(entreprise_id, offre_id, filedir):
)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Suppression fichier d'une offre",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1981,4 +1971,4 @@ def not_found_error_handler(e):
"""
Renvoie une page d'erreur pour l'erreur 404
"""
return render_template("entreprises/error.html", title="Erreur", e=e)
return render_template("entreprises/error.j2", title="Erreur", e=e)

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

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire changement formation
"""
from flask_wtf import FlaskForm
from wtforms import RadioField, SubmitField
from app.models import Formation
class FormSemestreChangeFormationForm(FlaskForm):
"Formulaire changement formation d'un formsemestre"
# construit dynamiquement ci-dessous
def gen_formsemestre_change_formation_form(
formations: list[Formation],
) -> FormSemestreChangeFormationForm:
"Create our dynamical form"
# see https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition
class F(FormSemestreChangeFormationForm):
pass
setattr(
F,
"radio_but",
RadioField(
"Label",
choices=[
(formation.id, formation.get_titre_version())
for formation in formations
],
),
)
setattr(F, "submit", SubmitField("Changer la formation"))
setattr(F, "cancel", SubmitField("Annuler"))
return F()

View File

@ -35,14 +35,14 @@ from wtforms.fields.simple import StringField
from app.models import SHORT_STR_LEN
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
def _build_code_field(code):
return StringField(
label=code,
default=code,
description=sco_codes_parcours.CODES_EXPL[code],
description=codes_cursus.CODES_EXPL[code],
validators=[
validators.regexp(
r"^[A-Z0-9_]*$",
@ -63,7 +63,9 @@ class CodesDecisionsForm(FlaskForm):
ABL = _build_code_field("ABL")
ADC = _build_code_field("ADC")
ADJ = _build_code_field("ADJ")
ADJR = _build_code_field("ADJR")
ADM = _build_code_field("ADM")
ADSUP = _build_code_field("ADSUP")
AJ = _build_code_field("AJ")
ATB = _build_code_field("ATB")
ATJ = _build_code_field("ATJ")
@ -80,7 +82,8 @@ class CodesDecisionsForm(FlaskForm):
NOTES_FMT = StringField(
label="Format notes exportées",
description="""Format des notes. Par défaut <tt style="font-family: monotype;">%3.2f</tt> (deux chiffres après la virgule)""",
description="""Format des notes. Par défaut
<tt style="font-family: monotype;">%3.2f</tt> (deux chiffres après la virgule)""",
validators=[
validators.Length(
max=SHORT_STR_LEN,

View File

@ -0,0 +1,78 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire configuration CAS
"""
from flask_wtf import FlaskForm
from wtforms import BooleanField, SubmitField
from wtforms.fields.simple import FileField, StringField
class ConfigCASForm(FlaskForm):
"Formulaire paramétrage CAS"
cas_enable = BooleanField("Activer le CAS")
cas_force = BooleanField(
"Forcer l'utilisation de CAS (tous les utilisateurs seront redirigés vers le CAS)"
)
cas_server = StringField(
label="URL du serveur CAS",
description="""url complète. Commence en général par <tt>https://</tt>.""",
)
cas_login_route = StringField(
label="Route du login CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas</tt> (si commence par <tt>/</tt>, part de la racine)""",
default="/cas",
)
cas_logout_route = StringField(
label="Route du logout CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas/logout</tt>""",
default="/cas/logout",
)
cas_validate_route = StringField(
label="Route de validation CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas/serviceValidate</tt>""",
default="/cas/serviceValidate",
)
cas_attribute_id = StringField(
label="Attribut CAS utilisé comme id (laissez vide pour prendre l'id par défaut)",
description="""Le champs CAS qui sera considéré comme l'id unique des
comptes utilisateurs.""",
)
cas_ssl_verify = BooleanField("Vérification du certificat SSL")
cas_ssl_certificate_file = FileField(
label="Certificat (PEM)",
description="""Le contenu du certificat PEM
(commence typiquement par <tt>-----BEGIN CERTIFICATE-----</tt>)""",
)
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -148,6 +148,9 @@ class AddLogoForm(FlaskForm):
kwargs["meta"] = {"csrf": False}
super().__init__(*args, **kwargs)
def id(self):
return f"id=add_{self.dept_key.data}"
def validate_name(self, name):
dept_id = dept_key_to_id(self.dept_key.data)
if dept_id == GLOBAL:
@ -171,7 +174,7 @@ class AddLogoForm(FlaskForm):
class LogoForm(FlaskForm):
"""Embed both presentation of a logo (cf. template file configuration.html)
"""Embed both presentation of a logo (cf. template file configuration.j2)
and all its data and UI action (change, delete)"""
dept_key = HiddenField()
@ -227,6 +230,10 @@ class LogoForm(FlaskForm):
self.description = "Se substitue au footer défini au niveau global"
self.titre = "Logo pied de page"
def id(self):
idstring = f"{self.dept_key.data}_{self.logo_id.data}"
return f"id={idstring}"
def select_action(self):
from app.scodoc.sco_config_actions import LogoRename
from app.scodoc.sco_config_actions import LogoUpdate
@ -258,6 +265,9 @@ class DeptForm(FlaskForm):
kwargs["meta"] = {"csrf": False}
super().__init__(*args, **kwargs)
def id(self):
return f"id=DEPT_{self.dept_key.data}"
def is_local(self):
if self.dept_key.data == GLOBAL:
return None
@ -434,7 +444,7 @@ def config_logos():
scu.flash_errors(form)
return render_template(
"config_logos.html",
"config_logos.j2",
scodoc_dept=None,
title="Configuration ScoDoc",
form=form,

View File

@ -31,8 +31,8 @@ Formulaires configuration Exports Apogée (codes)
from flask import flash, url_for, redirect, request, render_template
from flask_wtf import FlaskForm
from wtforms import BooleanField, SelectField, SubmitField
from wtforms import BooleanField, SelectField, StringField, SubmitField
from wtforms.validators import Email, Optional
import app
from app.models import ScoDocSiteConfig
import app.scodoc.sco_utils as scu
@ -70,6 +70,12 @@ class ScoDocConfigurationForm(FlaskForm):
(i, name.capitalize()) for (i, name) in enumerate(scu.MONTH_NAMES, start=1)
],
)
email_from_addr = StringField(
label="Adresse source des mails",
description="""adresse email source (from) des mails émis par ScoDoc.
Attention: si ce champ peut aussi être défini dans chaque département.""",
validators=[Optional(), Email()],
)
submit_scodoc = SubmitField("Valider")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
@ -87,6 +93,7 @@ def configuration():
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
"month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(),
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
"email_from_addr": ScoDocSiteConfig.get("email_from_addr"),
}
)
if request.method == "POST" and (
@ -130,10 +137,12 @@ def configuration():
scu.MONTH_NAMES[ScoDocSiteConfig.get_month_debut_periode2()-1]
}"""
)
if ScoDocSiteConfig.set("email_from_addr", form_scodoc.data["email_from_addr"]):
flash("Adresse email origine enregistrée")
return redirect(url_for("scodoc.index"))
return render_template(
"configuration.html",
"configuration.j2",
form_bonus=form_bonus,
form_scodoc=form_scodoc,
scu=scu,

View File

@ -9,6 +9,7 @@ CODE_STR_LEN = 16 # chaine pour les codes
SHORT_STR_LEN = 32 # courtes chaine, eg acronymes
APO_CODE_STR_LEN = 512 # nb de car max d'un code Apogée (il peut y en avoir plusieurs)
GROUPNAME_STR_LEN = 64
USERNAME_STR_LEN = 64
convention = {
"ix": "ix_%(column_0_label)s",
@ -20,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

@ -24,10 +24,8 @@ class Absence(db.Model):
# moduleimpid concerne (optionnel):
moduleimpl_id = db.Column(
db.Integer,
db.ForeignKey("notes_moduleimpl.id"),
db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"),
)
# XXX TODO: contrainte ajoutée: vérifier suppression du module
# (mettre à NULL sans supprimer)
def to_dict(self):
data = {

View File

@ -6,8 +6,11 @@
"""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 import g
from flask_sqlalchemy.query import Query
from sqlalchemy.orm import class_mapper
import sqlalchemy
@ -53,7 +56,9 @@ class XMLModel:
class ApcReferentielCompetences(db.Model, XMLModel):
"Référentiel de compétence d'une spécialité"
id = db.Column(db.Integer, primary_key=True)
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
dept_id = db.Column(
db.Integer, db.ForeignKey("departement.id", ondelete="CASCADE"), index=True
)
annexe = db.Column(db.Text()) # '1', '22', ...
specialite = db.Column(db.Text()) # 'CJ', 'RT', 'INFO', ...
specialite_long = db.Column(
@ -82,8 +87,18 @@ class ApcReferentielCompetences(db.Model, XMLModel):
backref="referentiel",
lazy="dynamic",
cascade="all, delete-orphan",
order_by="ApcParcours.numero, ApcParcours.code",
)
formations = db.relationship(
"Formation",
backref="referentiel_competence",
order_by="Formation.acronyme, Formation.version",
)
validations_annee = db.relationship(
"ApcValidationAnnee",
backref="referentiel_competence",
lazy="dynamic",
)
formations = db.relationship("Formation", backref="referentiel_competence")
def __repr__(self):
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
@ -94,9 +109,10 @@ class ApcReferentielCompetences(db.Model, XMLModel):
return ""
return self.version_orebut.split()[0]
def to_dict(self):
def to_dict(self, parcours: list["ApcParcours"] = None, with_app_critiques=True):
"""Représentation complète du ref. de comp.
comme un dict.
Si parcours est une liste de parcours, restreint l'export aux parcours listés.
"""
return {
"dept_id": self.dept_id,
@ -111,16 +127,22 @@ class ApcReferentielCompetences(db.Model, XMLModel):
if self.scodoc_date_loaded
else "",
"scodoc_orig_filename": self.scodoc_orig_filename,
"competences": {x.titre: x.to_dict() for x in self.competences},
"parcours": {x.code: x.to_dict() for x in self.parcours},
"competences": {
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
for x in self.competences
},
"parcours": {
x.code: x.to_dict()
for x in (self.parcours if parcours is None else parcours)
},
}
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.
@ -137,10 +159,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
@ -174,12 +194,53 @@ class ApcReferentielCompetences(db.Model, XMLModel):
niveaux_by_parcours_no_tc["TC"] = niveaux_tc
return parcours, niveaux_by_parcours_no_tc
def get_competences_tronc_commun(self) -> list["ApcCompetence"]:
"""Liste des compétences communes à tous les parcours du référentiel."""
parcours = self.parcours.all()
if not parcours:
return []
ids = set.intersection(
*[
{competence.id for competence in parcour.query_competences()}
for parcour in parcours
]
)
return sorted(
[
competence
for competence in parcours[0].query_competences()
if competence.id in ids
],
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"
id = db.Column(db.Integer, primary_key=True)
referentiel_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
db.Integer,
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
nullable=False,
)
# les compétences dans Orébut sont identifiées par leur id unique
# (mais id_orebut n'est pas unique car le même ref. pourra être chargé dans plusieurs depts)
@ -187,7 +248,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
@ -215,7 +276,7 @@ class ApcCompetence(db.Model, XMLModel):
def __repr__(self):
return f"<ApcCompetence {self.id} {self.titre!r}>"
def to_dict(self):
def to_dict(self, with_app_critiques=True):
"repr dict recursive sur situations, composantes, niveaux"
return {
"id_orebut": self.id_orebut,
@ -227,7 +288,10 @@ class ApcCompetence(db.Model, XMLModel):
"composantes_essentielles": [
x.to_dict() for x in self.composantes_essentielles
],
"niveaux": {x.annee: x.to_dict() for x in self.niveaux},
"niveaux": {
x.annee: x.to_dict(with_app_critiques=with_app_critiques)
for x in self.niveaux
},
}
def to_dict_bul(self) -> dict:
@ -245,9 +309,12 @@ class ApcSituationPro(db.Model, XMLModel):
"Situation professionnelle"
id = db.Column(db.Integer, primary_key=True)
competence_id = db.Column(
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
db.Integer,
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
nullable=False,
)
libelle = db.Column(db.Text(), nullable=False)
# aucun attribut (le text devient le libellé)
def to_dict(self):
return {"libelle": self.libelle}
@ -257,7 +324,9 @@ class ApcComposanteEssentielle(db.Model, XMLModel):
"Composante essentielle"
id = db.Column(db.Integer, primary_key=True)
competence_id = db.Column(
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
db.Integer,
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
nullable=False,
)
libelle = db.Column(db.Text(), nullable=False)
@ -275,7 +344,9 @@ class ApcNiveau(db.Model, XMLModel):
id = db.Column(db.Integer, primary_key=True)
competence_id = db.Column(
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
db.Integer,
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
nullable=False,
)
libelle = db.Column(db.Text(), nullable=False)
annee = db.Column(db.Text(), nullable=False) # "BUT1", "BUT2", "BUT3"
@ -293,13 +364,18 @@ class ApcNiveau(db.Model, XMLModel):
return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={
self.annee!r} {self.competence!r}>"""
def to_dict(self):
"as a dict, recursif sur les AC"
def __str__(self):
return f"""{self.competence.titre} niveau {self.ordre}"""
def to_dict(self, with_app_critiques=True):
"as a dict, recursif (ou non) sur les AC"
return {
"libelle": self.libelle,
"annee": self.annee,
"ordre": self.ordre,
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques},
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
if with_app_critiques
else {},
}
def to_dict_bul(self):
@ -311,38 +387,95 @@ 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)
.join(ApcCompetence)
.join(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,
) -> flask_sqlalchemy.BaseQuery:
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.
"""
key = (
parcour.id if parcour else None,
annee,
referentiel_competence.id if referentiel_competence else None,
competence.id if competence else None,
)
_cache = getattr(g, "_niveaux_annee_de_parcours_cache", None)
if _cache:
result = g._niveaux_annee_de_parcours_cache.get(key, False)
if result is not False:
return result
else:
g._niveaux_annee_de_parcours_cache = {}
_cache = g._niveaux_annee_de_parcours_cache
if annee not in {1, 2, 3}:
raise ValueError("annee invalide pour un parcours BUT")
referentiel_competence = (
parcour.referentiel if parcour else referentiel_competence
)
if referentiel_competence is None:
raise ScoNoReferentielCompetences()
annee_formation = f"BUT{annee}"
if parcour is None:
return ApcNiveau.query.filter(
if not parcour:
annee_formation = f"BUT{annee}"
query = ApcNiveau.query.filter(
ApcNiveau.annee == annee_formation,
ApcCompetence.id == ApcNiveau.competence_id,
ApcCompetence.referentiel_id == referentiel_competence.id,
)
if competence is not None:
query = query.filter(ApcCompetence.id == competence.id)
result = query.all()
_cache[key] = result
return result
annee_parcour: ApcAnneeParcours = parcour.annees.filter_by(ordre=annee).first()
if not annee_parcour:
_cache[key] = []
return []
if competence is None:
parcour_niveaux: list[
ApcParcoursNiveauCompetence
] = annee_parcour.niveaux_competences
niveaux: list[ApcNiveau] = [
pn.competence.niveaux.filter_by(ordre=pn.niveau).first()
for pn in parcour_niveaux
]
else:
return ApcNiveau.query.filter(
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
ApcParcours.id == ApcAnneeParcours.parcours_id,
ApcParcours.referentiel == parcour.referentiel,
ApcParcoursNiveauCompetence.competence_id == ApcCompetence.id,
ApcCompetence.id == ApcNiveau.competence_id,
ApcAnneeParcours.parcours == parcour,
ApcNiveau.annee == annee_formation,
)
niveaux: list[ApcNiveau] = competence.niveaux.filter_by(
annee=f"BUT{int(annee)}"
).all()
_cache[key] = niveaux
return niveaux
app_critiques_modules = db.Table(
@ -354,7 +487,7 @@ app_critiques_modules = db.Table(
),
db.Column(
"app_crit_id",
db.ForeignKey("apc_app_critique.id"),
db.ForeignKey("apc_app_critique.id", ondelete="CASCADE"),
primary_key=True,
),
)
@ -363,7 +496,9 @@ app_critiques_modules = db.Table(
class ApcAppCritique(db.Model, XMLModel):
"Apprentissage Critique BUT"
id = db.Column(db.Integer, primary_key=True)
niveau_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"), nullable=False)
niveau_id = db.Column(
db.Integer, db.ForeignKey("apc_niveau.id", ondelete="CASCADE"), nullable=False
)
code = db.Column(db.Text(), nullable=False, index=True)
libelle = db.Column(db.Text())
@ -380,7 +515,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(
@ -412,7 +547,10 @@ class ApcAppCritique(db.Model, XMLModel):
parcours_modules = db.Table(
"parcours_modules",
db.Column(
"parcours_id", db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
"parcours_id",
db.Integer,
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
primary_key=True,
),
db.Column(
"module_id",
@ -426,7 +564,10 @@ parcours_modules = db.Table(
parcours_formsemestre = db.Table(
"parcours_formsemestre",
db.Column(
"parcours_id", db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
"parcours_id",
db.Integer,
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
primary_key=True,
),
db.Column(
"formsemestre_id",
@ -442,9 +583,11 @@ class ApcParcours(db.Model, XMLModel):
"Un parcours BUT"
id = db.Column(db.Integer, primary_key=True)
referentiel_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
db.Integer,
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(
@ -453,7 +596,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}>"
@ -471,17 +613,38 @@ class ApcParcours(db.Model, XMLModel):
d["annees"] = {x.ordre: x.to_dict() for x in self.annees}
return d
def query_competences(self) -> Query:
"Les compétences associées à ce parcours"
return (
ApcCompetence.query.join(ApcParcoursNiveauCompetence)
.join(ApcAnneeParcours)
.filter_by(parcours_id=self.id)
.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)
.join(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)
parcours_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id"), nullable=False
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="CASCADE"), nullable=False
)
ordre = db.Column(db.Integer)
"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

@ -2,9 +2,6 @@
"""Décisions de jury (validations) des RCUE et années du BUT
"""
from typing import Union
import flask_sqlalchemy
from app import db
from app.models import CODE_STR_LEN
@ -13,8 +10,6 @@ from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.scodoc import sco_codes_parcours as sco_codes
from app.scodoc import sco_utils as scu
class ApcValidationRCUE(db.Model):
@ -22,7 +17,7 @@ class ApcValidationRCUE(db.Model):
aka "regroupements cohérents d'UE" dans le jargon BUT.
le formsemestre est celui du semestre PAIR du niveau de compétence
Le formsemestre est l'origine, utilisé pour effacer
"""
__tablename__ = "apc_validation_rcue"
@ -41,12 +36,14 @@ class ApcValidationRCUE(db.Model):
formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
)
"formsemestre pair du RCUE"
"formsemestre origine du RCUE (celui d'où a été émis la validation)"
# Les deux UE associées à ce niveau:
ue1_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
# optionnel, le parcours dans lequel se trouve la compétence:
parcours_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), nullable=True)
parcours_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="set null"), nullable=True
)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
@ -64,18 +61,33 @@ class ApcValidationRCUE(db.Model):
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
def to_html(self) -> str:
def html(self) -> str:
"description en HTML"
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
<b>{self.code}</b>
<em>enregistrée le {self.date.strftime("%d/%m/%Y")}
à {self.date.strftime("%Hh%M")}</em>"""
def annee(self) -> str:
"""l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """
niveau = self.niveau()
return niveau.annee if niveau else None
def niveau(self) -> ApcNiveau:
"""Le niveau de compétence associé à cet RCUE."""
# Par convention, il est donné par la seconde UE
return self.ue2.niveau_competence
def to_dict(self):
"as a dict"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
d["etud"] = self.etud.to_dict_short()
d["ue1"] = self.ue1.to_dict()
d["ue2"] = self.ue2.to_dict()
return d
def to_dict_bul(self) -> dict:
"Export dict pour bulletins: le code et le niveau de compétence"
niveau = self.niveau()
@ -84,197 +96,16 @@ class ApcValidationRCUE(db.Model):
"niveau": None if niveau is None else niveau.to_dict_bul(),
}
# Attention: ce n'est pas un modèle mais une classe ordinaire:
class RegroupementCoherentUE:
"""Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs
de la même année (BUT1,2,3) liées au *même niveau de compétence*.
La moyenne (10/20) au RCUE déclenche la compensation des UE.
"""
def __init__(
self,
etud: Identite,
formsemestre_1: FormSemestre,
dec_ue_1: "DecisionsProposeesUE",
formsemestre_2: FormSemestre,
dec_ue_2: "DecisionsProposeesUE",
inscription_etat: str,
):
ue_1 = dec_ue_1.ue
ue_2 = dec_ue_2.ue
# Ordonne les UE dans le sens croissant (S1,S2) ou (S3,S4)...
if formsemestre_1.semestre_id > formsemestre_2.semestre_id:
(ue_1, formsemestre_1), (ue_2, formsemestre_2) = (
(ue_2, formsemestre_2),
(ue_1, formsemestre_1),
)
assert formsemestre_1.semestre_id % 2 == 1
assert formsemestre_2.semestre_id % 2 == 0
assert abs(formsemestre_1.semestre_id - formsemestre_2.semestre_id) == 1
assert ue_1.niveau_competence_id == ue_2.niveau_competence_id
self.etud = etud
self.formsemestre_1 = formsemestre_1
"semestre impair"
self.ue_1 = ue_1
self.formsemestre_2 = formsemestre_2
"semestre pair"
self.ue_2 = ue_2
# Stocke les moyennes d'UE
if inscription_etat != scu.INSCRIT:
self.moy_rcue = None
self.moy_ue_1 = self.moy_ue_2 = "-"
self.moy_ue_1_val = self.moy_ue_2_val = 0.0
return
self.moy_ue_1 = dec_ue_1.moy_ue_with_cap
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
self.moy_ue_2 = dec_ue_2.moy_ue_with_cap
self.moy_ue_2_val = self.moy_ue_2 if self.moy_ue_2 is not None else 0.0
# Calcul de la moyenne au RCUE (utilise les moy d'UE capitalisées)
if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
# Moyenne RCUE (les pondérations par défaut sont 1.)
self.moy_rcue = (
self.moy_ue_1 * ue_1.coef_rcue + self.moy_ue_2 * ue_2.coef_rcue
) / (ue_1.coef_rcue + ue_2.coef_rcue)
else:
self.moy_rcue = None
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} {
self.ue_1.acronyme}({self.moy_ue_1}) {
self.ue_2.acronyme}({self.moy_ue_2})>"""
def __str__(self) -> str:
return f"""RCUE {
self.ue_1.acronyme}({self.moy_ue_1}) + {
self.ue_2.acronyme}({self.moy_ue_2})"""
def query_validations(
self,
) -> flask_sqlalchemy.BaseQuery: # list[ApcValidationRCUE]
"""Les validations de jury enregistrées pour ce RCUE"""
niveau = self.ue_2.niveau_competence
return (
ApcValidationRCUE.query.filter_by(
etudid=self.etud.id,
)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
.join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id)
.filter(ApcNiveau.id == niveau.id)
)
def other_ue(self, ue: UniteEns) -> UniteEns:
"""L'autre UE du regroupement. Si ue ne fait pas partie du regroupement, ValueError"""
if ue.id == self.ue_1.id:
return self.ue_2
elif ue.id == self.ue_2.id:
return self.ue_1
raise ValueError(f"ue {ue} hors RCUE {self}")
def est_enregistre(self) -> bool:
"""Vrai si ce RCUE, donc le niveau de compétences correspondant
a une décision jury enregistrée
"""
return self.query_validations().count() > 0
def est_compensable(self):
"""Vrai si ce RCUE est validable (uniquement) par compensation
c'est à dire que sa moyenne est > 10 avec une UE < 10.
Note: si ADM, est_compensable est faux.
"""
return (
(self.moy_rcue is not None)
and (self.moy_rcue > sco_codes.BUT_BARRE_RCUE)
and (
(self.moy_ue_1_val < sco_codes.NOTES_BARRE_GEN)
or (self.moy_ue_2_val < sco_codes.NOTES_BARRE_GEN)
)
)
def est_suffisant(self) -> bool:
"""Vrai si ce RCUE est > 8"""
return (self.moy_rcue is not None) and (
self.moy_rcue > sco_codes.BUT_RCUE_SUFFISANT
)
def est_validable(self) -> bool:
"""Vrai si ce RCUE satisfait les conditions pour être validé,
c'est à dire que la moyenne des UE qui le constituent soit > 10
"""
return (self.moy_rcue is not None) and (
self.moy_rcue > sco_codes.BUT_BARRE_RCUE
)
def code_valide(self) -> Union[ApcValidationRCUE, None]:
"Si ce RCUE est ADM, CMP ou ADJ, la validation. Sinon, None"
validation = self.query_validations().first()
if (validation is not None) and (
validation.code in sco_codes.CODES_RCUE_VALIDES
):
return validation
return None
# unused
# def find_rcues(
# formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str
# ) -> list[RegroupementCoherentUE]:
# """Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
# ce semestre pour cette UE.
# Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit.
# En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
# Résultat: la liste peut être vide.
# """
# if (ue.niveau_competence is None) or (ue.semestre_idx is None):
# return []
# if ue.semestre_idx % 2: # S1, S3, S5
# other_semestre_idx = ue.semestre_idx + 1
# else:
# other_semestre_idx = ue.semestre_idx - 1
# cursor = db.session.execute(
# text(
# """SELECT
# ue.id, formsemestre.id
# FROM
# notes_ue ue,
# notes_formsemestre_inscription inscr,
# notes_formsemestre formsemestre
# WHERE
# inscr.etudid = :etudid
# AND inscr.formsemestre_id = formsemestre.id
# AND formsemestre.semestre_id = :other_semestre_idx
# AND ue.formation_id = formsemestre.formation_id
# AND ue.niveau_competence_id = :ue_niveau_competence_id
# AND ue.semestre_idx = :other_semestre_idx
# """
# ),
# {
# "etudid": etud.id,
# "other_semestre_idx": other_semestre_idx,
# "ue_niveau_competence_id": ue.niveau_competence_id,
# },
# )
# rcues = []
# for ue_id, formsemestre_id in cursor:
# other_ue = UniteEns.query.get(ue_id)
# other_formsemestre = FormSemestre.query.get(formsemestre_id)
# rcues.append(
# RegroupementCoherentUE(
# etud, formsemestre, ue, other_formsemestre, other_ue, inscription_etat
# )
# )
# # safety check: 1 seul niveau de comp. concerné:
# assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
# return rcues
def to_dict_codes(self) -> dict:
"Dict avec seulement les ids et la date - pour cache table jury"
return {
"id": self.id,
"code": self.code,
"date": self.date,
"etudid": self.etudid,
"niveau_id": self.niveau().id,
"formsemestre_id": self.formsemestre_id,
}
class ApcValidationAnnee(db.Model):
@ -282,7 +113,9 @@ class ApcValidationAnnee(db.Model):
__tablename__ = "apc_validation_annee"
# Assure unicité de la décision:
__table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire", "ordre"),)
__table_args__ = (
db.UniqueConstraint("etudid", "ordre", "referentiel_competence_id"),
)
id = db.Column(db.Integer, primary_key=True)
etudid = db.Column(
db.Integer,
@ -295,8 +128,11 @@ class ApcValidationAnnee(db.Model):
formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True
)
"le semestre IMPAIR (le 1er) de l'année"
annee_scolaire = db.Column(db.Integer, nullable=False) # 2021
"le semestre origine, normalement l'IMPAIR (le 1er) de l'année"
referentiel_competence_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
)
annee_scolaire = db.Column(db.Integer, nullable=False) # eg 2021
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
@ -314,25 +150,50 @@ class ApcValidationAnnee(db.Model):
"dict pour bulletins"
return {
"annee_scolaire": self.annee_scolaire,
"date": self.date.isoformat(),
"date": self.date.isoformat() if self.date else "",
"code": self.code,
"ordre": self.ordre,
}
def html(self) -> str:
"Affichage html"
date_str = (
f"""le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}"""
if self.date
else "(sans date)"
)
link = (
self.formsemestre.html_link_status(
label=f"{self.formsemestre.titre_formation(with_sem_idx=1)}",
title=self.formsemestre.titre_annee(),
)
if self.formsemestre
else "externe/antérieure"
)
return f"""Validation <b>année BUT{self.ordre}</b> émise par
{link}
: <b>{self.code}</b>
{date_str}
"""
def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
"""
Un dict avec les décisions de jury BUT enregistrées:
- decision_rcue : list[dict]
- decision_annee : dict
- decision_annee : dict (décision issue de ce semestre seulement (à confirmer ?))
Ne reprend pas les décisions d'UE, non spécifiques au BUT.
"""
decisions = {}
# --- RCUEs: seulement sur semestres pairs XXX à améliorer
if formsemestre.semestre_id % 2 == 0:
# validations émises depuis ce formsemestre:
validations_rcues = ApcValidationRCUE.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id
validations_rcues = (
ApcValidationRCUE.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id
)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
.order_by(UniteEns.numero, UniteEns.acronyme)
)
decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues]
titres_rcues = []
@ -354,16 +215,11 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
decisions["descr_decisions_rcue"] = ""
decisions["descr_decisions_niveaux"] = ""
# --- Année: prend la validation pour l'année scolaire de ce semestre
validation = (
ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
)
.join(ApcValidationAnnee.formsemestre)
.join(FormSemestre.formation)
.filter(Formation.formation_code == formsemestre.formation.formation_code)
.first()
)
validation = ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
).first()
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
else:

View File

@ -4,17 +4,18 @@
"""
from flask import flash
from app import db, log
from app import current_app, db, log
from app.comp import bonus_spo
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import (
from app.scodoc.codes_cursus import (
ABAN,
ABL,
ADC,
ADJ,
ADJR,
ADM,
ADSUP,
AJ,
ATB,
ATJ,
@ -37,6 +38,7 @@ CODES_SCODOC_TO_APO = {
ADJ: "ADM",
ADJR: "ADM",
ADM: "ADM",
ADSUP: "ADM",
AJ: "AJ",
ATB: "AJAC",
ATJ: "AJAC",
@ -87,6 +89,13 @@ class ScoDocSiteConfig(db.Model):
"enable_entreprises": bool,
"month_debut_annee_scolaire": int,
"month_debut_periode2": int,
# CAS
"cas_enable": bool,
"cas_server": str,
"cas_login_route": str,
"cas_logout_route": str,
"cas_validate_route": str,
"cas_attribute_id": str,
}
def __init__(self, name, value):
@ -170,7 +179,7 @@ class ScoDocSiteConfig(db.Model):
(starting with empty string to represent "no bonus function").
"""
d = bonus_spo.get_bonus_class_dict()
class_list = [(name, d[name].displayed_name) for name in d.keys()]
class_list = [(name, d[name].displayed_name) for name in d]
class_list.sort(key=lambda x: x[1].replace(" du ", " de "))
return [("", "")] + class_list
@ -204,13 +213,17 @@ class ScoDocSiteConfig(db.Model):
db.session.add(cfg)
db.session.commit()
@classmethod
def is_cas_enabled(cls) -> bool:
"""True si on utilise le CAS"""
cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first()
return cfg is not None and cfg.value
@classmethod
def is_entreprises_enabled(cls) -> bool:
"""True si on doit activer le module entreprise"""
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
if (cfg is None) or not cfg.value:
return False
return True
return cfg is not None and cfg.value
@classmethod
def enable_entreprises(cls, enabled=True) -> bool:
@ -228,6 +241,32 @@ class ScoDocSiteConfig(db.Model):
return True
return False
@classmethod
def get(cls, name: str, default: str = "") -> str:
"Get configuration param; empty string or specified default if unset"
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if cfg is None:
return default
return cfg.value or ""
@classmethod
def set(cls, name: str, value: str) -> bool:
"Set parameter, returns True if change. Commit session."
value_str = str(value or "")
if (cls.get(name) or "") != value_str:
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if cfg is None:
cfg = ScoDocSiteConfig(name=name, value=value_str)
else:
cfg.value = str(value or "")
current_app.logger.info(
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}...'"""
)
db.session.add(cfg)
db.session.commit()
return True
return False
@classmethod
def _get_int_field(cls, name: str, default=None) -> int:
"""Valeur d'un champs integer"""

View File

@ -2,12 +2,15 @@
"""ScoDoc models : departements
"""
import re
from app import db
from app.models import SHORT_STR_LEN
from app.models.preferences import ScoPreference
from app.scodoc.sco_exceptions import ScoValueError
VALID_DEPT_EXP = re.compile(r"^[\w@\\\-\.]+$")
class Departement(db.Model):
"""Un département ScoDoc"""
@ -60,6 +63,15 @@ class Departement(db.Model):
}
return data
@classmethod
def invalid_dept_acronym(cls, dept_acronym: str) -> bool:
"Check that dept_acronym is invalid"
return (
not dept_acronym
or (len(dept_acronym) >= SHORT_STR_LEN)
or not VALID_DEPT_EXP.match(dept_acronym)
)
@classmethod
def from_acronym(cls, acronym):
dept = cls.query.filter_by(acronym=acronym).first_or_404()
@ -70,6 +82,8 @@ def create_dept(acronym: str, visible=True) -> Departement:
"Create new departement"
from app.models import ScoPreference
if Departement.invalid_dept_acronym(acronym):
raise ScoValueError("acronyme departement invalide")
existing = Departement.query.filter_by(acronym=acronym).count()
if existing:
raise ScoValueError(f"acronyme {acronym} déjà existant")

View File

@ -6,6 +6,8 @@
import datetime
from functools import cached_property
from operator import attrgetter
from flask import abort, has_request_context, url_for
from flask import g, request
import sqlalchemy
@ -16,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
@ -27,6 +29,8 @@ class Identite(db.Model):
__table_args__ = (
db.UniqueConstraint("dept_id", "code_nip"),
db.UniqueConstraint("dept_id", "code_ine"),
db.CheckConstraint("civilite IN ('M', 'F', 'X')"),
db.CheckConstraint("civilite_etat_civil IN ('M', 'F', 'X')"),
)
id = db.Column(db.Integer, primary_key=True)
@ -36,9 +40,13 @@ class Identite(db.Model):
nom = db.Column(db.Text())
prenom = db.Column(db.Text())
nom_usuel = db.Column(db.Text())
# optionnel (si present, affiché à la place du nom)
"optionnel (si present, affiché à la place du nom)"
civilite = db.Column(db.String(1), nullable=False)
__table_args__ = (db.CheckConstraint("civilite IN ('M', 'F', 'X')"),)
# données d'état-civil. Si présent remplace les données d'usage dans les documents
# officiels (bulletins, PV): voir nomprenom_etat_civil()
civilite_etat_civil = db.Column(db.String(1), nullable=False, server_default="X")
prenom_etat_civil = db.Column(db.Text(), nullable=False, server_default="")
date_naissance = db.Column(db.Date)
lieu_naissance = db.Column(db.Text())
@ -70,20 +78,35 @@ class Identite(db.Model):
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
)
def html_link_fiche(self) -> str:
"lien vers la fiche"
return f"""<a class="stdlink" href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id)
}">{self.nomprenom}</a>"""
@classmethod
def from_request(cls, etudid=None, code_nip=None):
def from_request(cls, etudid=None, code_nip=None) -> "Identite":
"""Étudiant à partir de l'etudid ou du code_nip, soit
passés en argument soit retrouvés directement dans la requête web.
Erreur 404 si inexistant.
"""
args = make_etud_args(etudid=etudid, code_nip=code_nip)
return Identite.query.filter_by(**args).first_or_404()
return cls.query.filter_by(**args).first_or_404()
@classmethod
def get_etud(cls, etudid: int) -> "Identite":
"""Etudiant ou 404, cherche uniquement dans le département courant"""
if g.scodoc_dept:
return cls.query.filter_by(
id=etudid, dept_id=g.scodoc_dept_id
).first_or_404()
return cls.query.filter_by(id=etudid).first_or_404()
@classmethod
def create_etud(cls, **args):
"Crée un étudiant, avec admission et adresse vides."
etud: Identite = cls(**args)
etud.adresses.append(Adresse())
etud.adresses.append(Adresse(typeadresse="domicile"))
etud.admission.append(Admission())
return etud
@ -94,6 +117,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()}"
@ -140,6 +170,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.'"
@ -156,9 +194,63 @@ class Identite(db.Model):
)
def get_first_email(self, field="email") -> str:
"Le mail associé à la première adrese de l'étudiant, ou None"
"Le mail associé à la première adresse de l'étudiant, ou None"
return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None
def get_formsemestres(self) -> list:
"""Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit,
triée par date_debut
"""
return sorted(
[ins.formsemestre for ins in self.formsemestre_inscriptions],
key=attrgetter("date_debut"),
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) and not isinstance(getattr(cls, key, None), property):
# 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 {
@ -171,6 +263,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:
@ -183,6 +277,10 @@ class Identite(db.Model):
e["etudid"] = self.id
e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"])
e["ne"] = self.e
e["nomprenom"] = self.nomprenom
adresse = self.adresses.first()
if adresse:
e.update(adresse.to_dict())
return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty
def to_dict_bul(self, include_urls=True):
@ -210,6 +308,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
@ -415,14 +515,21 @@ class Identite(db.Model):
return situation
def etat_civil_pv(self, line_sep="\n") -> str:
def etat_civil_pv(self, with_paragraph=True, line_sep="\n") -> str:
"""Présentation, pour PV jury
M. Pierre Dupont
n° 12345678
(e) le 7/06/1974
à Paris
Si with_paragraph (défaut):
M. Pierre Dupont
n° 12345678
(e) le 7/06/1974
à Paris
Sinon:
M. Pierre Dupont
"""
return f"""{self.nomprenom}{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 ""}"""
if with_paragraph:
return f"""{self.etat_civil}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
line_sep}à {self.lieu_naissance or ""}"""
return self.etat_civil
def photo_html(self, title=None, size="small") -> str:
"""HTML img tag for the photo, either in small size (h90)
@ -486,6 +593,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)
@ -579,19 +717,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):
@ -144,6 +145,18 @@ class Evaluation(db.Model):
db.session.add(copy)
return copy
def is_matin(self) -> bool:
"Evaluation ayant lieu le matin (faux si pas de date)"
heure_debut_dt = self.heure_debut or datetime.time(8, 00)
# 8:00 au cas ou pas d'heure (note externe?)
return bool(self.jour) and heure_debut_dt < datetime.time(12, 00)
def is_apresmidi(self) -> bool:
"Evaluation ayant lieu l'après midi (faux si pas de date)"
heure_debut_dt = self.heure_debut or datetime.time(8, 00)
# 8:00 au cas ou pas d'heure (note externe?)
return bool(self.jour) and heure_debut_dt >= datetime.time(12, 00)
def set_default_poids(self) -> bool:
"""Initialize les poids bvers les UE à leurs valeurs par défaut
C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
@ -151,7 +164,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(
@ -177,8 +190,10 @@ class Evaluation(db.Model):
"""
L = []
for ue_id, poids in ue_poids_dict.items():
ue = UniteEns.query.get(ue_id)
L.append(EvaluationUEPoids(evaluation=self, ue=ue, poids=poids))
ue = db.session.get(UniteEns, ue_id)
ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids)
L.append(ue_poids)
db.session.add(ue_poids)
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
self.moduleimpl.invalidate_evaluations_poids() # inval cache
@ -196,7 +211,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")
)
}
@ -325,7 +340,7 @@ def check_evaluation_args(args):
jour = args.get("jour", None)
args["jour"] = jour
if jour:
modimpl = ModuleImpl.query.get(moduleimpl_id)
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
formsemestre = modimpl.formsemestre
y, m, d = [int(x) for x in ndb.DateDMYtoISO(jour).split("-")]
jour = datetime.date(y, m, d)

View File

@ -54,14 +54,17 @@ class ScolarNews(db.Model):
NEWS_APO = "APO" # changements de codes APO
NEWS_FORM = "FORM" # modification formation (object=formation_id)
NEWS_INSCR = "INSCR" # inscription d'étudiants (object=None ou formsemestre_id)
NEWS_JURY = "JURY" # saisie jury
NEWS_MISC = "MISC" # unused
NEWS_NOTE = "NOTES" # saisie note (object=moduleimpl_id)
NEWS_SEM = "SEM" # creation semestre (object=None)
NEWS_MAP = {
NEWS_ABS: "saisie absence",
NEWS_APO: "modif. code Apogée",
NEWS_FORM: "modification formation",
NEWS_INSCR: "inscription d'étudiants",
NEWS_JURY: "saisie jury",
NEWS_MISC: "opération", # unused
NEWS_NOTE: "saisie note",
NEWS_SEM: "création semestre",
@ -130,10 +133,10 @@ class ScolarNews(db.Model):
return query.order_by(cls.date.desc()).limit(n).all()
@classmethod
def add(cls, typ, obj=None, text="", url=None, max_frequency=0):
def add(cls, typ, obj=None, text="", url=None, max_frequency=600):
"""Enregistre une nouvelle
Si max_frequency, ne génère pas 2 nouvelles "identiques"
à moins de max_frequency secondes d'intervalle.
à moins de max_frequency secondes d'intervalle (10 minutes par défaut).
Deux nouvelles sont considérées comme "identiques" si elles ont
même (obj, typ, user).
La nouvelle enregistrée est aussi envoyée par mail.
@ -153,7 +156,10 @@ class ScolarNews(db.Model):
if last_news:
now = datetime.datetime.now(tz=last_news.date.tzinfo)
if (now - last_news.date) < datetime.timedelta(seconds=max_frequency):
# on n'enregistre pas
# pas de nouvel event, mais met à jour l'heure
last_news.date = datetime.datetime.now()
db.session.add(last_news)
db.session.commit()
return
news = ScolarNews(
@ -181,14 +187,14 @@ class ScolarNews(db.Model):
elif self.type == self.NEWS_NOTE:
moduleimpl_id = self.object
if moduleimpl_id:
modimpl = ModuleImpl.query.get(moduleimpl_id)
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
if modimpl is None:
return None # module does not exists anymore
formsemestre_id = modimpl.formsemestre_id
if not formsemestre_id:
return None
formsemestre = FormSemestre.query.get(formsemestre_id)
formsemestre = db.session.get(FormSemestre, formsemestre_id)
return formsemestre
def notify_by_mail(self):
@ -233,8 +239,7 @@ class ScolarNews(db.Model):
txt = re.sub(r'<a.*?href\s*=\s*"(.*?)".*?>(.*?)</a>', r"\2: \1", txt)
subject = "[ScoDoc] " + self.NEWS_MAP.get(self.type, "?")
sender = prefs["email_from_addr"]
sender = email.get_from_addr()
email.send_email(subject, sender, destinations, txt)
@classmethod
@ -260,11 +265,8 @@ class ScolarNews(db.Model):
# Informations générales
H.append(
f"""<div>
Pour être informé des évolutions de ScoDoc,
vous pouvez vous
<a class="stdlink" href="{scu.SCO_ANNONCES_WEBSITE}">
abonner à la liste de diffusion</a>.
f"""<div><a class="discretelink" href="{scu.SCO_ANNONCES_WEBSITE}">
Pour en savoir plus sur ScoDoc voir le site scodoc.org</a>.
</div>
"""
)

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,17 +9,16 @@ 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 sco_codes_parcours
from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_STANDARD
from app.scodoc.codes_cursus import UE_STANDARD
class Formation(db.Model):
@ -36,6 +35,7 @@ class Formation(db.Model):
titre = db.Column(db.Text(), nullable=False)
titre_officiel = db.Column(db.Text(), nullable=False)
version = db.Column(db.Integer, default=1, server_default="1")
commentaire = db.Column(db.Text())
formation_code = db.Column(
db.String(SHORT_STR_LEN),
server_default=db.text("notes_newid_fcod()"),
@ -47,29 +47,37 @@ class Formation(db.Model):
# Optionnel, pour les formations type BUT
referentiel_competence_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id")
db.Integer, db.ForeignKey("apc_referentiel_competences.id", ondelete="SET NULL")
)
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):
return f"<{self.__class__.__name__}(id={self.id}, dept_id={self.dept_id}, acronyme='{self.acronyme!r}')>"
return f"""<{self.__class__.__name__}(id={self.id}, dept_id={
self.dept_id}, acronyme={self.acronyme!r}, version={self.version})>"""
def to_html(self) -> str:
def html(self) -> str:
"titre complet pour affichage"
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
def to_dict(self, with_refcomp_attrs=False):
""" "as a dict.
def to_dict(self, with_refcomp_attrs=False, with_departement=True):
"""As a dict.
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.
"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
if "referentiel_competence" in e:
e.pop("referentiel_competence")
e["departement"] = self.departement.to_dict()
e["code_specialite"] = e["code_specialite"] or ""
e["commentaire"] = e["commentaire"] or ""
if with_departement and self.departement:
e["departement"] = self.departement.to_dict()
else:
e.pop("departement", None)
e["formation_id"] = self.id # ScoDoc7 backward compat
if with_refcomp_attrs and self.referentiel_competence:
e["refcomp_version_orebut"] = self.referentiel_competence.version_orebut
@ -78,12 +86,12 @@ class Formation(db.Model):
return e
def get_parcours(self):
"""get l'instance de TypeParcours de cette formation
(le TypeParcours définit le genre de formation, à ne pas confondre
def get_cursus(self) -> codes_cursus.TypeCursus:
"""get l'instance de TypeCursus de cette formation
(le TypeCursus définit le genre de formation, à ne pas confondre
avec les parcours du BUT).
"""
return sco_codes_parcours.get_parcours_from_code(self.type_parcours)
return codes_cursus.get_cursus_from_code(self.type_parcours)
def get_titre_version(self) -> str:
"""Titre avec version"""
@ -91,7 +99,7 @@ class Formation(db.Model):
def is_apc(self):
"True si formation APC avec SAE (BUT)"
return self.get_parcours().APC_SAE
return self.get_cursus().APC_SAE
def get_module_coefs(self, semestre_idx: int = None):
"""Les coefs des modules vers les UE (accès via cache)"""
@ -110,9 +118,14 @@ class Formation(db.Model):
df_cache.ModuleCoefsCache.set(key, modules_coefficients)
return modules_coefficients
def has_locked_sems(self):
"True if there is a locked formsemestre in this formation"
return len(self.formsemestres.filter_by(etat=False).all()) > 0
def has_locked_sems(self, semestre_idx: int = None):
"""True if there is a locked formsemestre in this formation.
If semestre_idx is specified, check only this index.
"""
query = self.formsemestres.filter_by(etat=False)
if semestre_idx is not None:
query = query.filter_by(semestre_id=semestre_idx)
return len(query.all()) > 0
def invalidate_module_coefs(self, semestre_idx: int = None):
"""Invalide le cache des coefficients de modules.
@ -201,27 +214,38 @@ class Formation(db.Model):
if change:
app.clear_scodoc_cache()
def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery:
"""Les UEs d'un parcours de la formation.
def query_ues_parcour(
self, parcour: ApcParcours, with_sport: bool = False
) -> Query:
"""Les UEs (sans bonus, sauf si with_sport) d'un parcours de la formation
(déclarée comme faisant partie du parcours ou du tronc commun, sans aucun parcours)
Si parcour est None, les UE sans parcours.
Exemple: pour avoir les UE du semestre 3, faire
`formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)`
`formation.query_ues_parcour(parcour).filter(UniteEns.semestre_idx == 3)`
"""
if parcour is None:
return UniteEns.query.filter_by(
formation=self, type=UE_STANDARD, parcour_id=None
)
return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter(
UniteEns.niveau_competence_id == ApcNiveau.id,
(UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None),
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
ApcAnneeParcours.parcours_id == parcour.id,
if with_sport:
query_f = UniteEns.query.filter_by(formation=self)
else:
query_f = UniteEns.query.filter_by(formation=self, type=UE_STANDARD)
# Les UE sans parcours:
query_no_parcours = query_f.outerjoin(UEParcours).filter(
UEParcours.parcours_id == None
)
if parcour is None:
return query_no_parcours.order_by(UniteEns.numero)
# Ajoute les UE du parcours sélectionné:
return query_no_parcours.union(
query_f.join(UEParcours).filter_by(parcours_id=parcour.id)
).order_by(UniteEns.numero)
# return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter(
# UniteEns.niveau_competence_id == ApcNiveau.id,
# (UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None),
# ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
# ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
# ApcAnneeParcours.parcours_id == parcour.id,
# )
def query_competences_parcour(
self, parcour: ApcParcours
) -> flask_sqlalchemy.BaseQuery:
def query_competences_parcour(self, parcour: ApcParcours) -> Query:
"""Les ApcCompetences d'un parcours de la formation.
None si pas de référentiel de compétences.
"""
@ -269,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")
@ -282,6 +306,6 @@ class Matiere(db.Model):
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators
e["ue_id"] = self.id
e["numero"] = e["numero"] if e["numero"] else 0
e["ue_id"] = self.id
return e

View File

@ -12,20 +12,19 @@
"""
import datetime
from functools import cached_property
from operator import attrgetter
import flask_sqlalchemy
from flask import flash, g
from sqlalchemy import and_, or_
from flask_login import current_user
from flask import flash, g, url_for
from sqlalchemy.sql import text
import app.scodoc.sco_utils as scu
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,
)
@ -37,12 +36,14 @@ from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
from app.models.modules import Module
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import sco_codes_parcours, sco_preferences
from app.scodoc import codes_cursus, sco_preferences
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV
from app.scodoc.sco_vdi import ApoEtapeVDI
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
class FormSemestre(db.Model):
"""Mise en oeuvre d'un semestre de formation"""
@ -63,51 +64,55 @@ class FormSemestre(db.Model):
"False si verrouillé"
modalite = db.Column(
db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite")
) # "FI", "FAP", "FC", ...
# gestion compensation sem DUT:
)
"Modalité de formation: 'FI', 'FAP', 'FC', ..."
gestion_compensation = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# ne publie pas le bulletin XML ou JSON:
"gestion compensation sem DUT (inutilisé en APC)"
bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# Bloque le calcul des moyennes (générale et d'UE)
"ne publie pas le bulletin XML ou JSON"
block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# Bloque le calcul de la moyenne générale (utile pour BUT)
"Bloque le calcul des moyennes (générale et d'UE)"
block_moyenne_generale = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
# semestres decales (pour gestion jurys):
gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# couleur fond bulletins HTML:
"Semestres décalés (pour gestion jurys DUT, pas implémenté ou utile en BUT)"
bul_bgcolor = db.Column(
db.String(SHORT_STR_LEN),
default="white",
server_default="white",
nullable=False,
)
# autorise resp. a modifier semestre:
"couleur fond bulletins HTML"
resp_can_edit = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# autorise resp. a modifier slt les enseignants:
"autorise resp. à modifier le formsemestre"
resp_can_change_ens = db.Column(
db.Boolean(), nullable=False, default=True, server_default="true"
)
# autorise les ens a creer des evals:
"autorise resp. a modifier slt les enseignants"
ens_can_edit_eval = db.Column(
db.Boolean(), nullable=False, default=False, server_default="False"
)
# code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'
"autorise les enseignants à créer des évals dans leurs modimpls"
elt_sem_apo = db.Column(db.Text()) # peut être fort long !
# code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'
"code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
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(
@ -147,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):
@ -157,6 +163,28 @@ class FormSemestre(db.Model):
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>"
def html_link_status(self, label=None, title=None) -> str:
"html link to status page"
return f"""<a class="stdlink" href="{
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
formsemestre_id=self.id,)
}" title="{title or ''}">{label or self.titre_mois()}</a>
"""
@classmethod
def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre":
""" "FormSemestre ou 404, cherche uniquement dans le département courant"""
if g.scodoc_dept:
return cls.query.filter_by(
id=formsemestre_id, dept_id=g.scodoc_dept_id
).first_or_404()
return cls.query.filter_by(id=formsemestre_id).first_or_404()
def sort_key(self) -> tuple:
"""clé pour tris par ordre alphabétique
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
return (self.date_debut, self.semestre_id)
def to_dict(self, convert_objects=False) -> dict:
"""dict (compatible ScoDoc7).
If convert_objects, convert all attributes to native types
@ -179,11 +207,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):
@ -250,6 +281,11 @@ class FormSemestre(db.Model):
d["etapes_apo_str"] = self.etapes_apo_str()
return d
def flip_lock(self):
"""Flip etat (lock)"""
self.etat = not self.etat
db.session.add(self)
def get_parcours_apc(self) -> list[ApcParcours]:
"""Liste des parcours proposés par ce semestre.
Si aucun n'est coché et qu'il y a un référentiel, tous ceux du référentiel.
@ -260,60 +296,57 @@ 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_parcours().APC_SAE:
sem_ues = UniteEns.query.filter_by(
formation=self.formation, semestre_idx=self.semestre_id
)
if self.parcours:
# Prend toutes les UE de l'un des parcours du sem., ou déclarées sans parcours
sem_ues = sem_ues.filter(
(UniteEns.parcour == None)
| (UniteEns.parcour_id.in_([p.id for p in self.parcours]))
# per-request caching
key = (self.id, with_sport)
_cache = getattr(g, "_formsemestre_get_ues_cache", None)
if _cache:
result = _cache.get(key, False)
if result is not False:
return result
else:
g._formsemestre_get_ues_cache = {}
_cache = g._formsemestre_get_ues_cache
formation: Formation = self.formation
if formation.is_apc():
# UEs de tronc commun (sans parcours indiqué)
sem_ues = {
ue.id: ue
for ue in formation.query_ues_parcour(
None, with_sport=with_sport
).filter(UniteEns.semestre_idx == self.semestre_id)
}
# Ajoute les UE de parcours
for parcour in self.parcours:
sem_ues.update(
{
ue.id: ue
for ue in formation.query_ues_parcour(
parcour, with_sport=with_sport
).filter(UniteEns.semestre_idx == self.semestre_id)
}
)
# si le sem. ne coche aucun parcours, prend toutes les UE
ues = sorted(sem_ues.values(), key=attrgetter("numero", "acronyme"))
else:
sem_ues = db.session.query(UniteEns).filter(
ModuleImpl.formsemestre_id == self.id,
Module.id == ModuleImpl.module_id,
UniteEns.id == Module.ue_id,
)
if not with_sport:
sem_ues = sem_ues.filter(UniteEns.type != sco_codes_parcours.UE_SPORT)
return sem_ues.order_by(UniteEns.numero)
def query_ues_parcours_etud(self, etudid: int) -> flask_sqlalchemy.BaseQuery:
"""XXX inutilisé à part pour un test unitaire => supprimer ?
UEs que suit l'étudiant dans ce semestre BUT
en fonction du parcours dans lequel il est inscrit.
Si l'étudiant n'est inscrit à aucun parcours,
renvoie uniquement les UEs de tronc commun (sans parcours).
Si voulez les UE d'un parcours, il est plus efficace de passer par
`formation.query_ues_parcour(parcour)`.
"""
return self.query_ues().filter(
FormSemestreInscription.etudid == etudid,
FormSemestreInscription.formsemestre == self,
UniteEns.niveau_competence_id == ApcNiveau.id,
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
or_(
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
and_(
FormSemestreInscription.parcour_id.is_(None),
UniteEns.parcour_id.is_(None),
),
),
)
if not with_sport:
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
ues = sem_ues.order_by(UniteEns.numero).all()
_cache[key] = ues
return ues
@cached_property
def modimpls_sorted(self) -> list[ModuleImpl]:
@ -325,7 +358,7 @@ class FormSemestre(db.Model):
if self.formation.is_apc():
modimpls.sort(
key=lambda m: (
m.module.module_type or 0,
m.module.module_type or 0, # ressources (2) avant SAEs (3)
m.module.numero or 0,
m.module.code or 0,
)
@ -361,7 +394,7 @@ class FormSemestre(db.Model):
),
{"formsemestre_id": self.id, "parcours_id": parcours.id},
)
return [ModuleImpl.query.get(modimpl_id) for modimpl_id in cursor]
return [db.session.get(ModuleImpl, modimpl_id) for modimpl_id in cursor]
def can_be_edited_by(self, user):
"""Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)"""
@ -428,6 +461,12 @@ class FormSemestre(db.Model):
)
)
def est_terminal(self) -> bool:
"Vrai si dernier semestre de son cursus (ou formation mono-semestre)"
return (self.semestre_id < 0) or (
self.semestre_id == self.formation.get_cursus().NB_SEM
)
@classmethod
def comp_periode(
cls,
@ -499,6 +538,11 @@ class FormSemestre(db.Model):
return ""
return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
def add_etape(self, etape_apo: str):
"Ajoute une étape"
etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=etape_apo)
db.session.add(etape)
def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
"""Calcule la liste des regroupements cohérents d'UE impliquant ce
formsemestre.
@ -530,10 +574,43 @@ class FormSemestre(db.Model):
else:
return ", ".join([u.get_nomcomplet() for u in self.responsables])
def est_responsable(self, user):
def est_responsable(self, user: User):
"True si l'user est l'un des responsables du semestre"
return user.id in [u.id for u in self.responsables]
def est_chef_or_diretud(self, user: User = None):
"Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre"
user = user or current_user
return user.has_permission(Permission.ScoImplement) or self.est_responsable(
user
)
def can_change_groups(self, user: User = None) -> bool:
"""Vrai si l'utilisateur (par def. current) peut changer les groupes dans
ce semestre: vérifie permission et verrouillage.
"""
if not self.etat:
return False # semestre verrouillé
user = user or current_user
if user.has_permission(Permission.ScoEtudChangeGroups):
return True # typiquement admin, chef dept
return self.est_responsable(user)
def can_edit_jury(self, user: User = None):
"""Vrai si utilisateur (par def. current) peut saisir decision de jury
dans ce semestre: vérifie permission et verrouillage.
"""
user = user or current_user
return self.etat and self.est_chef_or_diretud(user)
def can_edit_pv(self, user: User = None):
"Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre"
user = user or current_user
# Autorise les secrétariats, repérés via la permission ScoEtudChangeAdr
return self.est_chef_or_diretud(user) or user.has_permission(
Permission.ScoEtudChangeAdr
)
def annee_scolaire(self) -> int:
"""L'année de début de l'année scolaire.
Par exemple, 2022 si le semestre va de septembre 2022 à février 2023."""
@ -569,7 +646,7 @@ class FormSemestre(db.Model):
if not imputation_dept:
imputation_dept = prefs["DeptName"]
imputation_dept = imputation_dept.upper()
parcours_name = self.formation.get_parcours().NAME
cursus_name = self.formation.get_cursus().NAME
modalite = self.modalite
# exception pour code Apprentissage:
modalite = (modalite or "").replace("FAP", "FA").replace("APP", "FA")
@ -582,7 +659,7 @@ class FormSemestre(db.Model):
scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
)
return scu.sanitize_string(
f"{imputation_dept}-{parcours_name}-{modalite}-{semestre_id}-{annee_sco}"
f"{imputation_dept}-{cursus_name}-{modalite}-{semestre_id}-{annee_sco}"
)
def titre_annee(self) -> str:
@ -596,10 +673,12 @@ class FormSemestre(db.Model):
titre_annee += "-" + str(self.date_fin.year)
return titre_annee
def titre_formation(self):
def titre_formation(self, with_sem_idx=False):
"""Titre avec formation, court, pour passerelle: "BUT R&T"
(méthode de formsemestre car on pourrait ajouter le semestre, ou d'autres infos, à voir)
"""
if with_sem_idx and self.semestre_id > 0:
return f"{self.formation.acronyme} S{self.semestre_id}"
return self.formation.acronyme
def titre_mois(self) -> str:
@ -614,9 +693,9 @@ class FormSemestre(db.Model):
def titre_num(self) -> str:
"""Le titre et le semestre, ex ""DUT Informatique semestre 2"" """
if self.semestre_id == sco_codes_parcours.NO_SEMESTRE_ID:
if self.semestre_id == codes_cursus.NO_SEMESTRE_ID:
return self.titre
return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}"
return f"{self.titre} {self.formation.get_cursus().SESSION_NAME} {self.semestre_id}"
def sem_modalite(self) -> str:
"""Le semestre et la modalité, ex "S2 FI" ou "S3 APP" """
@ -739,6 +818,8 @@ class FormSemestre(db.Model):
Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS
et leur nom est le code du parcours (eg "Cyber").
"""
if self.formation.referentiel_competence_id is None:
return # safety net
partition = Partition.query.filter_by(
formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS
).first()
@ -762,7 +843,10 @@ class FormSemestre(db.Model):
query = (
ApcParcours.query.filter_by(code=group.group_name)
.join(ApcReferentielCompetences)
.filter_by(dept_id=g.scodoc_dept_id)
.filter_by(
dept_id=g.scodoc_dept_id,
id=self.formation.referentiel_competence_id,
)
)
if query.count() != 1:
log(
@ -811,15 +895,12 @@ class FormSemestre(db.Model):
.order_by(UniteEns.numero)
.all()
)
vals_annee = (
vals_annee = ( # issues de cette année scolaire seulement
ApcValidationAnnee.query.filter_by(
etudid=etudid,
annee_scolaire=self.annee_scolaire(),
)
.join(ApcValidationAnnee.formsemestre)
.join(FormSemestre.formation)
.filter(Formation.formation_code == self.formation.formation_code)
.all()
referentiel_competence_id=self.formation.referentiel_competence_id,
).all()
)
H = []
for vals in (vals_sem, vals_ues, vals_rcues, vals_annee):
@ -886,7 +967,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)
@ -909,14 +990,14 @@ 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():
"""Create default modalities"""
numero = 0
try:
for (code, titre) in (
for code, titre in (
(FormationModalite.DEFAULT_MODALITE, "Formation Initiale"),
("FAP", "Apprentissage"),
("FC", "Formation Continue"),
@ -1032,7 +1113,9 @@ class FormSemestreInscription(db.Model):
# Etape Apogée d'inscription (ajout 2020)
etape = db.Column(db.String(APO_CODE_STR_LEN))
# Parcours (pour les BUT)
parcour_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), index=True)
parcour_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="SET NULL"), index=True
)
parcour = db.relationship(ApcParcours)
def __repr__(self):
@ -1055,6 +1138,15 @@ class NotesSemSet(db.Model):
sem_id = db.Column(db.Integer, nullable=False, default=0)
"période: 0 (année), 1 (Simpair), 2 (Spair)"
def set_periode(self, periode: int):
"""Modifie la période 0 (année), 1 (Simpair), 2 (Spair)"""
if periode not in {0, 1, 2}:
raise ValueError("periode invalide")
self.sem_id = periode
log(f"semset.set_periode({self.id}, {periode})")
db.session.add(self)
db.session.commit()
# Association: many to many
notes_semset_formsemestre = db.Table(

View File

@ -7,11 +7,14 @@
"""ScoDoc models: Groups & partitions
"""
from operator import attrgetter
from sqlalchemy.exc import IntegrityError
from app import db
from app import db, log
from app.models import SHORT_STR_LEN
from app.models import GROUPNAME_STR_LEN
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
class Partition(db.Model):
@ -29,7 +32,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"
@ -72,7 +75,7 @@ class Partition(db.Model):
"""
if not isinstance(partition_name, str):
return False
if not len(partition_name.strip()) > 0:
if not (0 < len(partition_name.strip()) < SHORT_STR_LEN):
return False
if (not existing) and (
partition_name in [p.partition_name for p in formsemestre.partitions]
@ -84,20 +87,113 @@ class Partition(db.Model):
"Vrai s'il s'agit de la partition de parcours"
return self.partition_name == scu.PARTITION_PARCOURS
def to_dict(self, with_groups=False) -> dict:
"""as a dict, with or without groups"""
def to_dict(self, with_groups=False, str_keys: bool = False) -> dict:
"""as a dict, with or without groups.
If str_keys, convert integer dict keys to strings (useful for JSON)
"""
d = dict(self.__dict__)
d["partition_id"] = self.id
d.pop("_sa_instance_state", None)
d.pop("formsemestre", None)
if with_groups:
groups = sorted(self.groups, key=lambda g: (g.numero or 0, g.group_name))
groups = sorted(self.groups, key=attrgetter("numero", "group_name"))
# un dict et non plus une liste, pour JSON
d["groups"] = {
group.id: group.to_dict(with_partition=False) for group in groups
}
if str_keys:
d["groups"] = {
str(group.id): group.to_dict(with_partition=False)
for group in groups
}
else:
d["groups"] = {
group.id: group.to_dict(with_partition=False) for group in groups
}
return d
def get_etud_group(self, etudid: int) -> "GroupDescr":
"Le groupe de l'étudiant dans cette partition, ou None si pas présent"
return (
GroupDescr.query.filter_by(partition_id=self.id)
.join(group_membership)
.filter_by(etudid=etudid)
.first()
)
def set_etud_group(self, etud: "Identite", group: "GroupDescr") -> bool:
"""Affect etudid to group_id in given partition.
Raises IntegrityError si conflit,
or ValueError si ce group_id n'est pas dans cette partition
ou que l'étudiant n'est pas inscrit au semestre.
Return True si changement, False s'il était déjà dans ce groupe.
"""
if not group.id in (g.id for g in self.groups):
raise ScoValueError(
f"""Le groupe {group.id} n'est pas dans la partition {
self.partition_name or "tous"}"""
)
if etud.id not in (e.id for e in self.formsemestre.etuds):
raise ScoValueError(
f"""étudiant {etud.nomprenom} non inscrit au formsemestre du groupe {
group.group_name}"""
)
try:
existing_row = (
db.session.query(group_membership)
.filter_by(etudid=etud.id)
.join(GroupDescr)
.filter_by(partition_id=self.id)
.first()
)
if existing_row:
existing_group_id = existing_row[1]
if group.id == existing_group_id:
return False
# Fait le changement avec l'ORM sinon risque élevé de blocage
existing_group = db.session.get(GroupDescr, existing_group_id)
db.session.commit()
group.etuds.append(etud)
existing_group.etuds.remove(etud)
db.session.add(etud)
db.session.add(existing_group)
db.session.add(group)
else:
new_row = group_membership.insert().values(
etudid=etud.id, group_id=group.id
)
db.session.execute(new_row)
db.session.commit()
except IntegrityError:
db.session.rollback()
raise
return True
def create_group(self, group_name="", default=False) -> "GroupDescr":
"Crée un groupe dans cette partition"
if not self.formsemestre.can_change_groups():
raise AccessDenied(
"""Vous n'avez pas le droit d'effectuer cette opération,
ou bien le semestre est verrouillé !"""
)
if group_name:
group_name = group_name.strip()
if not group_name and not default:
raise ValueError("invalid group name: ()")
if not GroupDescr.check_name(self, group_name, default=default):
raise ScoValueError(
f"Le groupe {group_name} existe déjà dans cette partition"
)
numeros = [g.numero if g.numero is not None else 0 for g in self.groups]
if len(numeros) > 0:
new_numero = max(numeros) + 1
else:
new_numero = 0
group = GroupDescr(partition=self, group_name=group_name, numero=new_numero)
db.session.add(group)
db.session.commit()
log(f"create_group: created group_id={group.id}")
#
return group
class GroupDescr(db.Model):
"""Description d'un groupe d'une partition"""
@ -111,7 +207,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",
@ -146,7 +242,7 @@ class GroupDescr(db.Model):
"""
if not isinstance(group_name, str):
return False
if not default and not len(group_name.strip()) > 0:
if not default and not (0 < len(group_name.strip()) < GROUPNAME_STR_LEN):
return False
if (not existing) and (group_name in [g.group_name for g in partition.groups]):
return False

View File

@ -2,13 +2,15 @@
"""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
from app.comp import df_cache
from app.models.etudiants import Identite
from app.models.modules import Module
from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
@ -64,7 +66,7 @@ class ModuleImpl(db.Model):
"""true si les poids des évaluations du module permettent de satisfaire
les coefficients du PN.
"""
if not self.module.formation.get_parcours().APC_SAE or (
if not self.module.formation.get_cursus().APC_SAE or (
self.module.module_type != scu.ModuleType.RESSOURCE
and self.module.module_type != scu.ModuleType.SAE
):
@ -99,6 +101,27 @@ class ModuleImpl(db.Model):
d.pop("module", None)
return d
def can_change_ens_by(self, user: User, raise_exc=False) -> bool:
"""Check if user can modify module resp.
If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.
= Admin, et dir des etud. (si option l'y autorise)
"""
if not self.formsemestre.etat:
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")
return False
# -- check access
# admin ou resp. semestre avec flag resp_can_change_resp
if user.has_permission(Permission.ScoImplement):
return True
if (
user.id in [resp.id for resp in self.formsemestre.responsables]
) and self.formsemestre.resp_can_change_ens:
return True
if raise_exc:
raise AccessDenied(f"Modification impossible pour {user}")
return False
# Enseignants (chargés de TD ou TP) d'un moduleimpl
notes_modules_enseignants = db.Table(
@ -140,7 +163,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

@ -1,11 +1,13 @@
"""ScoDoc 9 models : Modules
"""
from flask import current_app
from app import db
from app.models import APO_CODE_STR_LEN
from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import ModuleType
@ -31,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)
@ -53,7 +55,7 @@ class Module(db.Model):
secondary=parcours_modules,
lazy="subquery",
backref=db.backref("modules", lazy=True),
order_by="ApcParcours.numero",
order_by="ApcParcours.numero, ApcParcours.code",
)
app_critiques = db.relationship(
@ -175,6 +177,11 @@ class Module(db.Model):
ue_coef_dict = { ue_id : coef }
Les coefs nuls (zéro) ne sont pas stockés: la relation est supprimée.
"""
if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logguer.info(
f"set_ue_coef_dict: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
changed = False
for ue_id, coef in ue_coef_dict.items():
# Existant ?
@ -191,7 +198,7 @@ class Module(db.Model):
else:
# crée nouveau coef:
if coef != 0.0:
ue = UniteEns.query.get(ue_id)
ue = db.session.get(UniteEns, ue_id)
ue_coef = ModuleUECoef(module=self, ue=ue, coef=coef)
db.session.add(ue_coef)
self.ue_coefs.append(ue_coef)
@ -201,6 +208,11 @@ class Module(db.Model):
def update_ue_coef_dict(self, ue_coef_dict: dict):
"""update coefs vers UE (ajoute aux existants)"""
if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logguer.info(
f"update_ue_coef_dict: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
current = self.get_ue_coef_dict()
current.update(ue_coef_dict)
self.set_ue_coef_dict(current)
@ -209,18 +221,27 @@ class Module(db.Model):
"""returns { ue_id : coef }"""
return {p.ue.id: p.coef for p in self.ue_coefs}
def get_ue_coef_dict_acronyme(self):
"""returns { ue_acronyme : coef }"""
return {p.ue.acronyme: p.coef for p in self.ue_coefs}
def delete_ue_coef(self, ue):
"""delete coef"""
ue_coef = ModuleUECoef.query.get((self.id, ue.id))
if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logguer.info(
"delete_ue_coef: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
ue_coef = db.session.get(ModuleUECoef, (self.id, ue.id))
if ue_coef:
db.session.delete(ue_coef)
self.formation.invalidate_module_coefs()
def get_ue_coefs_sorted(self):
"les coefs d'UE, trié par numéro d'UE"
"les coefs d'UE, trié par numéro et acronyme d'UE"
# je n'ai pas su mettre un order_by sur le backref sans avoir
# à redéfinir les relationships...
return sorted(self.ue_coefs, key=lambda x: x.ue.numero)
return sorted(self.ue_coefs, key=lambda uc: (uc.ue.numero, uc.ue.acronyme))
def ue_coefs_list(
self, include_zeros=True, ues: list["UniteEns"] = None

View File

@ -3,9 +3,8 @@
"""Notes, décisions de jury, évènements scolaires
"""
import sqlalchemy as sa
from app import db
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
@ -53,6 +52,13 @@ class NotesNotes(db.Model):
d.pop("_sa_instance_state", None)
return d
def __repr__(self):
"pour debug"
from app.models.evaluations import Evaluation
return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} v={self.value} {self.date.isoformat()
} {db.session.get(Evaluation, self.evaluation_id) if self.evaluation_id else "X" }>"""
class NotesNotesLog(db.Model):
"""Historique des modifs sur notes (anciennes entrees de notes_notes)"""
@ -81,7 +87,8 @@ def etud_has_notes_attente(etudid, formsemestre_id):
(ne compte que les notes en attente dans des évaluations avec coef. non nul).
"""
cursor = db.session.execute(
"""SELECT COUNT(*)
sa.text(
"""SELECT COUNT(*)
FROM notes_notes n, notes_evaluation e, notes_moduleimpl m,
notes_moduleimpl_inscription i
WHERE n.etudid = :etudid
@ -92,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

@ -1,6 +1,7 @@
"""ScoDoc 9 models : Unités d'Enseignement (UE)
"""
from flask import g
import pandas as pd
from app import db, log
@ -8,7 +9,6 @@ from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN
from app.models.but_refcomp import ApcNiveau, ApcParcours
from app.models.modules import Module
from app.scodoc.sco_exceptions import ScoFormationConflict
from app.scodoc import sco_utils as scu
@ -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))
@ -51,12 +51,18 @@ class UniteEns(db.Model):
color = db.Column(db.Text())
# BUT
niveau_competence_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"))
niveau_competence_id = db.Column(
db.Integer, db.ForeignKey("apc_niveau.id", ondelete="SET NULL")
)
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"), index=True)
parcour = db.relationship("ApcParcours", back_populates="ues")
# 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),
order_by="ApcParcours.numero, ApcParcours.code",
)
# relations
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
@ -97,20 +103,40 @@ 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).
"""
# cache car très utilisé par anciens codes
key = (self.id, convert_objects, with_module_ue_coefs)
_cache = getattr(g, "_ue_to_dict_cache", None)
if _cache:
result = g._ue_to_dict_cache.get(key, False)
if result is not False:
return result
else:
g._ue_to_dict_cache = {}
_cache = g._ue_to_dict_cache
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
e.pop("evaluation_ue_poids", None)
# 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["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"] = [
@ -118,6 +144,7 @@ class UniteEns(db.Model):
]
else:
e.pop("module_ue_coefs", None)
_cache[key] = e
return e
def annee(self) -> int:
@ -158,6 +185,55 @@ 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:
key = (parcour.id, self.id, only_parcours)
ue_ects_cache = getattr(g, "_ue_ects_cache", None)
if ue_ects_cache:
ects = g._ue_ects_cache.get(key, False)
if ects is not False:
return ects
else:
g._ue_ects_cache = {}
ue_ects_cache = g._ue_ects_cache
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:
ue_ects_cache[key] = ue_parcour.ects
return ue_parcour.ects
if only_parcours:
ue_ects_cache[key] = None
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()
@ -179,80 +255,203 @@ class UniteEns(db.Model):
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
def _check_apc_conflict(self, new_niveau_id: int, new_parcour_id: int):
"raises ScoFormationConflict si (niveau, parcours) pas unique dans ce semestre"
# Les UE du même semestre que nous:
ues_sem = self.formation.ues.filter_by(semestre_idx=self.semestre_idx)
if (new_niveau_id, new_parcour_id) in (
(oue.niveau_competence_id, oue.parcour_id)
for oue in ues_sem
if oue.id != self.id
):
log(
f"set_ue_niveau_competence: {self}: ({new_niveau_id}, {new_parcour_id}) déjà associé"
)
raise ScoFormationConflict()
def _parcours_niveaux_ids(self, parcours=list[ApcParcours]) -> set[int]:
"""set des ids de niveaux communs à tous les parcours listés"""
return set.intersection(
*[
{
n.id
for n in self.niveau_competence.niveaux_annee_de_parcours(
parcour, self.annee(), self.formation.referentiel_competence
)
}
for parcour in parcours
]
)
def set_niveau_competence(self, niveau: ApcNiveau):
def check_niveau_unique_dans_parcours(
self, niveau: ApcNiveau, parcours=list[ApcParcours]
) -> tuple[bool, str]:
"""Vérifie que
- le niveau est dans au moins l'un des parcours listés;
- et que l'un des parcours associé à cette UE ne contient pas
déjà une UE associée au niveau donné dans une autre année.
Renvoie: (True, "") si ok, sinon (False, message).
"""
# Le niveau est-il dans l'un des parcours listés ?
if parcours:
if niveau.id not in self._parcours_niveaux_ids(parcours):
log(
f"Le niveau {niveau} ne fait pas partie des parcours de l'UE {self}."
)
return (
False,
f"""Le niveau {
niveau.libelle} ne fait pas partie des parcours de l'UE {self.acronyme}.""",
)
for parcour in parcours or [None]:
if parcour is None:
code_parcour = "TC"
ues_meme_niveau = [
ue
for ue in self.formation.query_ues_parcour(None).filter(
UniteEns.niveau_competence == niveau
)
]
else:
code_parcour = parcour.code
ues_meme_niveau = [
ue
for ue in parcour.ues
if ue.id != self.id
and ue.formation_id == self.formation_id
and ue.niveau_competence_id == niveau.id
]
if ues_meme_niveau:
msg_parc = f"parcours {code_parcour}" if parcour else "tronc commun"
if len(ues_meme_niveau) > 1: # deja 2 UE sur ce niveau
msg = f"""Niveau "{
niveau.libelle}" déjà associé à deux UE du {msg_parc}"""
log(
f"check_niveau_unique_dans_parcours(niveau_id={niveau.id}): "
+ msg
)
return False, msg
# s'il y a déjà une UE associée à ce niveau, elle doit être dans l'autre semestre
# de la même année scolaire
other_semestre_idx = self.semestre_idx + (
2 * (self.semestre_idx % 2) - 1
)
if ues_meme_niveau[0].semestre_idx != other_semestre_idx:
msg = f"""Erreur: niveau "{
niveau.libelle}" déjà associé à une autre UE du semestre S{
ues_meme_niveau[0].semestre_idx} du {msg_parc}"""
log(
f"check_niveau_unique_dans_parcours(niveau_id={niveau.id}): "
+ msg
)
return False, msg
return True, ""
def set_niveau_competence(self, niveau: ApcNiveau) -> tuple[bool, str]:
"""Associe cette UE au niveau de compétence indiqué.
Le niveau doit être dans le parcours de l'UE, s'il y en a un.
Le niveau doit être dans l'un des parcours de l'UE (si elle n'est pas
de tronc commun).
Assure que ce soit la seule dans son parcours.
Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie.
Returns True if (de)association done, False on error.
"""
# Sanity checks
if not self.formation.referentiel_competence:
return (
False,
"La formation n'est pas associée à un référentiel de compétences",
)
if niveau is not None:
self._check_apc_conflict(niveau.id, self.parcour_id)
# Le niveau est-il dans le parcours ? Sinon, erreur
if self.parcour and niveau.id not in (
n.id
for n in niveau.niveaux_annee_de_parcours(
self.parcour, self.annee(), self.formation.referentiel_competence
if self.niveau_competence_id is not None:
return (
False,
f"""{self.acronyme} déjà associée à un niveau de compétences ({
self.id}, {self.niveau_competence_id})""",
)
if (
niveau.competence.referentiel.id
!= self.formation.referentiel_competence.id
):
log(
f"set_niveau_competence: niveau {niveau} hors parcours {self.parcour}"
return (
False,
"Le niveau n'appartient pas au référentiel de la formation",
)
return
if niveau.id == self.niveau_competence_id:
return True, "" # nothing to do
if self.niveau_competence_id is not None:
ok, error_message = self.check_niveau_unique_dans_parcours(
niveau, self.parcours
)
if not ok:
return ok, error_message
elif self.niveau_competence_id is None:
return True, "" # nothing to do
self.niveau_competence = niveau
db.session.add(self)
db.session.commit()
# Invalidation du cache
self.formation.invalidate_cached_sems()
log(f"ue.set_niveau_competence( {self}, {niveau} )")
return True, ""
def set_parcour(self, parcour: ApcParcours):
"""Associe cette UE au parcours indiqué.
Assure que ce soit la seule dans son parcours.
Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie.
def set_parcours(self, parcours: list[ApcParcours]) -> tuple[bool, str]:
"""Associe cette UE aux parcours indiqués.
Si un niveau est déjà associé, vérifie sa cohérence.
Renvoie (True, "") si ok, sinon (False, error_message)
"""
if (parcour is not None) and self.niveau_competence is not None:
self._check_apc_conflict(self.niveau_competence.id, parcour.id)
self.parcour = parcour
# Le niveau est-il dans ce parcours ? Sinon, l'enlève
msg = ""
# Le niveau est-il dans tous ces parcours ? Sinon, l'enlève
prev_niveau = self.niveau_competence
if (
parcour
parcours
and self.niveau_competence
and self.niveau_competence.id
not in (
n.id
for n in self.niveau_competence.niveaux_annee_de_parcours(
parcour, self.annee(), self.formation.referentiel_competence
)
)
and self.niveau_competence.id not in self._parcours_niveaux_ids(parcours)
):
self.niveau_competence = None
msg = " (niveau compétence désassocié !)"
if parcours and self.niveau_competence:
ok, error_message = self.check_niveau_unique_dans_parcours(
self.niveau_competence, parcours
)
if not ok:
self.niveau_competence = prev_niveau # restore
return False, error_message
self.parcours = parcours
db.session.add(self)
db.session.commit()
log(f"ue.set_parcour( {self}, {parcour} )")
# Invalidation du cache
self.formation.invalidate_cached_sems()
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
@ -263,6 +462,7 @@ class DispenseUE(db.Model):
la dispense étant une exception.
"""
__tablename__ = "dispenseUE"
__table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id", "etudid"),)
id = db.Column(db.Integer, primary_key=True)
formsemestre_id = formsemestre_id = db.Column(

View File

@ -8,10 +8,13 @@ from app import log
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN
from app.models.events import Scolog
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import CODES_UE_VALIDES
class ScolarFormSemestreValidation(db.Model):
"""Décisions de jury"""
"""Décisions de jury (sur semestre ou UEs)"""
__tablename__ = "scolar_formsemestre_validation"
# Assure unicité de la décision:
@ -54,18 +57,30 @@ class ScolarFormSemestreValidation(db.Model):
)
ue = db.relationship("UniteEns", lazy="select", uselist=False)
etud = db.relationship("Identite", backref="validations")
formsemestre = db.relationship(
"FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
)
def __repr__(self):
return f"{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
return f"""{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={
self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"""
def __str__(self):
if self.ue_id:
# Note: si l'objet vient d'être créé, ue_id peut exister mais pas ue !
return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id}: {self.code}"""
return f"""décision sur semestre {self.formsemestre.titre_mois()} du {self.event_date.strftime("%d/%m/%Y")}"""
return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id
} ({self.ue_id}): {self.code}"""
return f"""décision sur semestre {self.formsemestre.titre_mois()} du {
self.event_date.strftime("%d/%m/%Y")}"""
def delete(self):
"Efface cette validation"
log(f"{self.__class__.__name__}.delete({self})")
etud = self.etud
db.session.delete(self)
db.session.commit()
sco_cache.invalidate_formsemestre_etud(etud)
def to_dict(self) -> dict:
"as a dict"
@ -73,6 +88,49 @@ class ScolarFormSemestreValidation(db.Model):
d.pop("_sa_instance_state", None)
return d
def html(self, detail=False) -> str:
"Affichage html"
if self.ue_id is not None:
moyenne = (
f", moyenne {scu.fmt_note(self.moy_ue)}/20 "
if self.moy_ue is not None
else ""
)
link = (
self.formsemestre.html_link_status(
label=f"{self.formsemestre.titre_formation(with_sem_idx=1)}",
title=self.formsemestre.titre_annee(),
)
if self.formsemestre
else "externe/antérieure"
)
return f"""Validation
{'<span class="redboldtext">externe</span>' if self.is_external else ""}
de l'UE <b>{self.ue.acronyme}</b>
{('parcours <span class="parcours">'
+ ", ".join([p.code for p in self.ue.parcours]))
+ "</span>"
if self.ue.parcours else ""}
{("émise par " + link)}
: <b>{self.code}</b>{moyenne}
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
"""
else:
return f"""Validation du semestre S{
self.formsemestre.semestre_id if self.formsemestre else "?"}
{self.formsemestre.html_link_status() if self.formsemestre else ""}
: <b>{self.code}</b>
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
"""
def ects(self) -> float:
"Les ECTS acquis par cette validation. (0 si ce n'est pas une validation d'UE)"
return (
self.ue.ects
if (self.ue is not None) and (self.code in CODES_UE_VALIDES)
else 0.0
)
class ScolarAutorisationInscription(db.Model):
"""Autorisation d'inscription dans un semestre"""
@ -93,6 +151,7 @@ class ScolarAutorisationInscription(db.Model):
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
)
origin_formsemestre = db.relationship("FormSemestre", lazy="select", uselist=False)
def __repr__(self) -> str:
return f"""{self.__class__.__name__}(id={self.id}, etudid={
@ -104,6 +163,21 @@ class ScolarAutorisationInscription(db.Model):
d.pop("_sa_instance_state", None)
return d
def html(self) -> str:
"Affichage html"
link = (
self.origin_formsemestre.html_link_status(
label=f"{self.origin_formsemestre.titre_formation(with_sem_idx=1)}",
title=self.origin_formsemestre.titre_annee(),
)
if self.origin_formsemestre
else "externe/antérieure"
)
return f"""Autorisation de passage vers <b>S{self.semestre_id}</b> émise par
{link}
le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}
"""
@classmethod
def autorise_etud(
cls,
@ -112,8 +186,7 @@ class ScolarAutorisationInscription(db.Model):
origin_formsemestre_id: int,
semestre_id: int,
):
"""Enregistre une autorisation, remplace celle émanant du même semestre si elle existe."""
cls.delete_autorisation_etud(etudid, origin_formsemestre_id)
"""Ajoute une autorisation"""
autorisation = cls(
etudid=etudid,
formation_code=formation_code,
@ -132,7 +205,7 @@ class ScolarAutorisationInscription(db.Model):
etudid: int,
origin_formsemestre_id: int,
):
"""Efface les autorisations de cette étudiant venant du sem. origine"""
"""Efface les autorisations de cet étudiant venant du sem. origine"""
autorisations = cls.query.filter_by(
etudid=etudid, origin_formsemestre_id=origin_formsemestre_id
)
@ -160,11 +233,11 @@ class ScolarEvent(db.Model):
event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
db.ForeignKey("notes_formsemestre.id", ondelete="SET NULL"),
)
ue_id = db.Column(
db.Integer,
db.ForeignKey("notes_ue.id"),
db.ForeignKey("notes_ue.id", ondelete="SET NULL"),
)
# 'CREATION', 'INSCRIPTION', 'DEMISSION',
# 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM'

View File

@ -48,11 +48,11 @@ from zipfile import ZipFile
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
from app.models import Formation, FormSemestre
from app.scodoc.gen_tables import GenTable, SeqGenTable
import app.scodoc.sco_utils as scu
from app.scodoc import sco_codes_parcours # sco_codes_parcours.NEXT -> sem suivant
from app.scodoc import codes_cursus # codes_cursus.NEXT -> sem suivant
from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre
from app.pe import pe_tagtable
@ -65,10 +65,8 @@ def comp_nom_semestre_dans_parcours(sem):
"""Le nom a afficher pour titrer un semestre
par exemple: "semestre 2 FI 2015"
"""
from app.scodoc import sco_formations
F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0]
parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"])
formation: Formation = Formation.query.get_or_404(sem["formation_id"])
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
return "%s %s %s %s" % (
parcours.SESSION_NAME, # eg "semestre"
sem["semestre_id"], # eg 2
@ -457,10 +455,9 @@ class JuryPE(object):
reponse = False
etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
(_, parcours) = sco_report.get_codeparcoursetud(etud)
(_, parcours) = sco_report.get_code_cursus_etud(etud)
if (
len(set(sco_codes_parcours.CODES_SEM_REO.keys()) & set(parcours.values()))
> 0
len(codes_cursus.CODES_SEM_REO & set(parcours.values())) > 0
): # Eliminé car NAR apparait dans le parcours
reponse = True
if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2:
@ -529,14 +526,14 @@ class JuryPE(object):
from app.scodoc import sco_report
etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
(code, parcours) = sco_report.get_codeparcoursetud(
(code, parcours) = sco_report.get_code_cursus_etud(
etud
) # description = '1234:A', parcours = {1:ADM, 2:NAR, ...}
sonDernierSemestreValide = max(
[
int(cle)
for (cle, code) in parcours.items()
if code in sco_codes_parcours.CODES_SEM_VALIDES
if code in codes_cursus.CODES_SEM_VALIDES
]
+ [0]
) # n° du dernier semestre valide, 0 sinon
@ -563,9 +560,8 @@ class JuryPE(object):
dec = nt.get_etud_decision_sem(
etudid
) # quelle est la décision du jury ?
if dec and dec["code"] in list(
sco_codes_parcours.CODES_SEM_VALIDES.keys()
): # isinstance( sesMoyennes[i+1], float) and
if dec and (dec["code"] in codes_cursus.CODES_SEM_VALIDES):
# isinstance( sesMoyennes[i+1], float) and
# mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide"
leFid = sem["formsemestre_id"]
else:
@ -1139,7 +1135,7 @@ class JuryPE(object):
# ------------------------------------------------------------------------------------------------------------------
def get_cache_notes_d_un_semestre(self, formsemestre_id: int) -> NotesTableCompat:
"""Charge la table des notes d'un formsemestre"""
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
return res_sem.load_formsemestre_results(formsemestre)
# ------------------------------------------------------------------------------------------------------------------

View File

@ -36,14 +36,14 @@ Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
from app import log
from app import db, log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
from app.models.moduleimpls import ModuleImpl
from app.pe import pe_tagtable
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
from app.scodoc import sco_tag_module
from app.scodoc import sco_utils as scu
@ -116,7 +116,7 @@ class SemestreTag(pe_tagtable.TableTag):
self.modimpls = [
modimpl
for modimpl in self.nt.formsemestre.modimpls_sorted
if modimpl.module.ue.type == sco_codes_parcours.UE_STANDARD
if modimpl.module.ue.type == codes_cursus.UE_STANDARD
] # la liste des modules (objet modimpl)
self.somme_coeffs = sum(
[
@ -256,7 +256,7 @@ class SemestreTag(pe_tagtable.TableTag):
# Si le module ne fait pas partie des UE capitalisées
if modimpl.module.ue.id not in ue_capitalisees_id:
note = self.nt.get_etud_mod_moy(modimpl_id, etudid) # lecture de la note
coeff = modimpl.module.coefficient # le coeff
coeff = modimpl.module.coefficient or 0.0 # le coeff (! non compatible BUT)
coeff_norm = (
coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0
) # le coeff normalisé
@ -277,7 +277,7 @@ class SemestreTag(pe_tagtable.TableTag):
fid_prec = fids_prec[0]
# Lecture des notes de ce semestre
# le tableau de note du semestre considéré:
formsemestre_prec = FormSemestre.query.get_or_404(fid_prec)
formsemestre_prec = FormSemestre.get_formsemestre(fid_prec)
nt_prec: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre_prec
)
@ -299,8 +299,9 @@ class SemestreTag(pe_tagtable.TableTag):
modimpl_id, etudid
) # lecture de la note
coeff = modimpl.module.coefficient # le coeff
# nota: self.somme_coeffs peut être None
coeff_norm = (
coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0
coeff / self.somme_coeffs if self.somme_coeffs else 0
) # le coeff normalisé
else:
semtag_prec = SemestreTag(nt_prec, nt_prec.sem)
@ -329,7 +330,7 @@ class SemestreTag(pe_tagtable.TableTag):
notes = []
coeffs_norm = []
ponderations = []
for (moduleimpl_id, modimpl) in self.tagdict[
for moduleimpl_id, modimpl in self.tagdict[
tag
].items(): # pour chaque module du semestre relatif au tag
(note, coeff_norm) = self.get_noteEtCoeff_modimpl(moduleimpl_id, etudid)
@ -345,7 +346,8 @@ class SemestreTag(pe_tagtable.TableTag):
def str_detail_resultat_d_un_tag(self, tag, etudid=None, delim=";"):
"""Renvoie une chaine de caractère décrivant les résultats d'étudiants à un tag :
rappelle les notes obtenues dans les modules à prendre en compte, les moyennes et les rangs calculés.
Si etudid=None, tous les étudiants inscrits dans le semestre sont pris en compte. Sinon seuls les étudiants indiqués sont affichés."""
Si etudid=None, tous les étudiants inscrits dans le semestre sont pris en compte. Sinon seuls les étudiants indiqués sont affichés.
"""
# Entete
chaine = delim.join(["%15s" % "nom", "etudid"]) + delim
taglist = self.get_all_tags()
@ -440,7 +442,7 @@ class SemestreTag(pe_tagtable.TableTag):
taglist = self.get_all_tags()
for tag in taglist:
chaine += " > " + tag + ": "
for (modid, mod) in self.tagdict[tag].items():
for modid, mod in self.tagdict[tag].items():
chaine += (
mod["module_code"]
+ " ("
@ -459,6 +461,7 @@ class SemestreTag(pe_tagtable.TableTag):
# Fonctions diverses
# ************************************************************************
# *********************************************
def comp_coeff_pond(coeffs, ponderations):
"""
@ -484,7 +487,7 @@ def comp_coeff_pond(coeffs, ponderations):
# -----------------------------------------------------------------------------
def get_moduleimpl(modimpl_id) -> dict:
"""Renvoie l'objet modimpl dont l'id est modimpl_id"""
modimpl = ModuleImpl.query.get(modimpl_id)
modimpl = db.session.get(ModuleImpl, modimpl_id)
if modimpl:
return modimpl
if SemestreTag.DEBUG:

View File

@ -40,7 +40,7 @@ Created on Thu Sep 8 09:36:33 2016
import datetime
import numpy as np
from app.scodoc import notes_table
from app.scodoc import sco_utils as scu
class TableTag(object):
@ -186,7 +186,7 @@ class TableTag(object):
if isinstance(col[0], float)
else 0, # remplace les None et autres chaines par des zéros
) # triées
self.rangs[tag] = notes_table.comp_ranks(lesMoyennesTriees) # les rangs
self.rangs[tag] = scu.comp_ranks(lesMoyennesTriees) # les rangs
# calcul des stats
self.comp_stats_d_un_tag(tag)

View File

@ -10,6 +10,11 @@
"""
import html
import re
import flask_wtf
import wtforms
from app import log
from app.scodoc.sco_exceptions import ScoInvalidCSRF
import app.scodoc.sco_utils as scu
# re validant dd/mm/yyyy
@ -22,7 +27,7 @@ def TrivialFormulator(
form_url,
values,
formdescription=(),
initvalues={},
initvalues=None,
method="post",
enctype=None,
submitlabel="OK",
@ -32,7 +37,7 @@ def TrivialFormulator(
cssclass="",
cancelbutton=None,
submitbutton=True,
submitbuttonattributes=[],
submitbuttonattributes=None,
top_buttons=False, # place buttons at top of form
bottom_buttons=True, # buttons after form
html_foot_markup="",
@ -99,7 +104,7 @@ def TrivialFormulator(
form_url,
values,
formdescription,
initvalues,
initvalues or {},
method,
enctype,
submitlabel,
@ -109,7 +114,7 @@ def TrivialFormulator(
cssclass=cssclass,
cancelbutton=cancelbutton,
submitbutton=submitbutton,
submitbuttonattributes=submitbuttonattributes,
submitbuttonattributes=submitbuttonattributes or [],
top_buttons=top_buttons,
bottom_buttons=bottom_buttons,
html_foot_markup=html_foot_markup,
@ -134,8 +139,8 @@ class TF(object):
self,
form_url,
values,
formdescription=[],
initvalues={},
formdescription=None,
initvalues=None,
method="POST",
enctype=None,
submitlabel="OK",
@ -145,7 +150,7 @@ class TF(object):
cssclass="",
cancelbutton=None,
submitbutton=True,
submitbuttonattributes=[],
submitbuttonattributes=None,
top_buttons=False, # place buttons at top of form
bottom_buttons=True, # buttons after form
html_foot_markup="", # html snippet put at the end, just after the table
@ -157,8 +162,8 @@ class TF(object):
):
self.form_url = form_url
self.values = values.copy()
self.formdescription = list(formdescription)
self.initvalues = initvalues
self.formdescription = list(formdescription or [])
self.initvalues = initvalues or {}
self.method = method
self.enctype = enctype
self.submitlabel = submitlabel
@ -171,7 +176,7 @@ class TF(object):
self.cssclass = cssclass
self.cancelbutton = cancelbutton
self.submitbutton = submitbutton
self.submitbuttonattributes = submitbuttonattributes
self.submitbuttonattributes = submitbuttonattributes or []
self.top_buttons = top_buttons
self.bottom_buttons = bottom_buttons
self.html_foot_markup = html_foot_markup
@ -189,11 +194,26 @@ class TF(object):
"true if form has been submitted"
if self.is_submitted:
return True
return self.values.get("%s_submitted" % self.formid, False)
form_submitted = self.values.get(f"{self.formid}_submitted", False)
if form_submitted:
self.check_csrf()
return form_submitted
def check_csrf(self):
"""check token for POST forms.
Raises ScoInvalidCSRF on failure.
"""
if self.method == "post":
token = self.values.get("csrf_token")
try:
flask_wtf.csrf.validate_csrf(token)
except wtforms.validators.ValidationError as exc:
log(f"Form.check_csrf: invalid CSRF token\n{exc.args}")
raise ScoInvalidCSRF() from exc
def canceled(self):
"true if form has been canceled"
return self.values.get("%s_cancel" % self.formid, False)
return self.values.get(f"{self.formid}_cancel", False)
def getform(self):
"return HTML form"
@ -370,12 +390,23 @@ class TF(object):
self.values[field] = True
else:
self.values[field] = False
# open('/tmp/toto','a').write('checkvalues: val=%s (%s) values[%s] = %s\n' % (val, type(val), field, self.values[field]))
if descr.get("convert_numbers", False):
if typ[:3] == "int":
self.values[field] = int(self.values[field])
try:
self.values[field] = int(self.values[field])
except ValueError:
msg.append(
f"valeur invalide ({self.values[field]}) pour le champs {field}"
)
ok = False
elif typ == "float" or typ == "real":
self.values[field] = float(self.values[field].replace(",", "."))
try:
self.values[field] = float(self.values[field].replace(",", "."))
except ValueError:
msg.append(
f"valeur invalide ({self.values[field]}) pour le champs {field}"
)
ok = False
if ok:
self.result = self.values
else:
@ -436,7 +467,13 @@ class TF(object):
self.form_attrs,
)
)
R.append('<input type="hidden" name="%s_submitted" value="1">' % self.formid)
if self.method == "post":
R.append(
f"""<input type="hidden" name="csrf_token" value="{
flask_wtf.csrf.generate_csrf()
}">"""
)
R.append(f"""<input type="hidden" name="{self.formid}_submitted" value="1">""")
if self.top_buttons:
R.append(buttons_markup + "<p></p>")
R.append(self.before_table.format(title=self.title))
@ -789,7 +826,7 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
elif input_type in ("radio", "menu", "checkbox", "boolcheckbox"):
if input_type == "boolcheckbox":
labels = descr.get(
"labels", descr.get("allowed_values", ["oui", "non"])
"labels", descr.get("allowed_values", ["non", "oui"])
)
_val = self.values[field]
if isinstance(_val, bool):

View File

@ -25,9 +25,10 @@
#
##############################################################################
"""Semestres: Codes gestion parcours (constantes)
"""Semestres: Codes gestion cursus (constantes)
Attention: ne pas confondre avec les "parcours" du BUT.
Renommage des anciens "parcours" -> "cursus" effectué en 9.4.41
"""
import collections
import enum
import numpy as np
@ -36,8 +37,8 @@ from app import log
@enum.unique
class CodesParcours(enum.IntEnum):
"""Codes numériques des parcours, enregistrés en base
class CodesCursus(enum.IntEnum):
"""Codes numériques des cursus (ex parcours), enregistrés en base
dans notes_formations.type_parcours
Ne pas modifier.
"""
@ -79,7 +80,7 @@ UE_STANDARD = 0 # UE "fondamentale"
UE_SPORT = 1 # bonus "sport"
UE_STAGE_LP = 2 # ue "projet tuteuré et stage" dans les Lic. Pro.
UE_STAGE_10 = 3 # ue "stage" avec moyenne requise > 10
UE_ELECTIVE = 4 # UE "élective" dans certains parcours (UCAC?, ISCID)
UE_ELECTIVE = 4 # UE "élective" dans certains cursus (UCAC?, ISCID)
UE_PROFESSIONNELLE = 5 # UE "professionnelle" (ISCID, ...)
UE_OPTIONNELLE = 6 # UE non fondamentales (ILEPS, ...)
@ -121,6 +122,7 @@ ABAN = "ABAN"
ABL = "ABL"
ADM = "ADM" # moyenne gen., barres UE, assiduité: sem. validé
ADC = "ADC" # admis par compensation (eg moy(S1, S2) > 10)
ADSUP = "ADSUP" # BUT: UE ou RCUE validé par niveau supérieur
ADJ = "ADJ" # admis par le jury
ADJR = "ADJR" # UE admise car son RCUE est ADJ
ATT = "ATT" #
@ -161,6 +163,7 @@ CODES_EXPL = {
ADJ: "Validé par le Jury",
ADJR: "UE validée car son RCUE est validé ADJ par le jury",
ADM: "Validé",
ADSUP: "UE ou RCUE validé car le niveau supérieur est validé",
AJ: "Ajourné (ou UE/BC de BUT en attente pour problème de moyenne)",
ATB: "Décision en attente d'un autre semestre (au moins une UE sous la barre)",
ATJ: "Décision en attente d'un autre semestre (assiduité insuffisante)",
@ -187,16 +190,30 @@ CODES_EXPL = {
# Les codes de semestres:
CODES_JURY_SEM = {ADC, ADJ, ADM, AJ, ATB, ATJ, ATT, DEF, NAR, RAT}
CODES_SEM_VALIDES = {ADM: True, ADC: True, ADJ: True} # semestre validé
CODES_SEM_ATTENTES = {ATT: True, ATB: True, ATJ: True} # semestre en attente
CODES_SEM_VALIDES_DE_DROIT = {ADM, ADC}
CODES_SEM_VALIDES = CODES_SEM_VALIDES_DE_DROIT | {ADJ} # semestre validé
CODES_SEM_ATTENTES = {ATT, ATB, ATJ} # semestre en attente
CODES_SEM_REO = {NAR: 1} # reorientation
CODES_SEM_REO = {NAR} # reorientation
# Les codes d'UEs
CODES_JURY_UE = {ADM, CMP, ADJ, ADJR, ADSUP, AJ, ATJ, RAT, DEF, ABAN, DEM, UEBSL}
CODES_UE_VALIDES_DE_DROIT = {ADM, CMP} # validation "de droit"
CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR, ADSUP}
"UE validée"
CODES_UE_CAPITALISANTS = {ADM}
"UE capitalisée"
CODES_JURY_RCUE = {ADM, ADJ, ADSUP, CMP, AJ, ATJ, RAT, DEF, ABAN}
"codes de jury utilisables sur les RCUEs"
CODES_RCUE_VALIDES_DE_DROIT = {ADM, CMP}
CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ, ADSUP}
"Niveau RCUE validé"
CODES_UE_VALIDES = {ADM: True, CMP: True, ADJ: True, ADJR: True} # UE validée
CODES_RCUE_VALIDES = CODES_UE_VALIDES # Niveau RCUE validé
# Pour le BUT:
CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD} # PASD pour enregistrement auto
CODES_ANNEE_BUT_VALIDES = {ADM, ADSUP}
CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
CODES_RCUE = {ADM, AJ, CMP}
BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE
BUT_BARRE_UE = BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE
BUT_RCUE_SUFFISANT = 8.0 - NOTES_TOLERANCE
@ -209,34 +226,52 @@ BUT_CODES_PASSAGE = {
}
# les codes, du plus "défavorable" à l'étudiant au plus favorable:
# (valeur par défaut 0)
BUT_CODES_ORDERED = {
NAR: 0,
BUT_CODES_ORDER = {
ABAN: 0,
ABL: 0,
DEM: 0,
DEF: 0,
EXCLU: 0,
NAR: 0,
UEBSL: 0,
RAT: 5,
RED: 6,
AJ: 10,
ATJ: 20,
CMP: 50,
ADC: 50,
PASD: 50,
PAS1NCI: 60,
PAS1NCI: 50,
PASD: 60,
ADJR: 90,
ADJ: 100,
ADSUP: 90,
ADJ: 90,
ADM: 100,
}
def code_semestre_validant(code: str) -> bool:
"Vrai si ce CODE entraine la validation du semestre"
return CODES_SEM_VALIDES.get(code, False)
return code in CODES_SEM_VALIDES
def code_semestre_attente(code: str) -> bool:
"Vrai si ce CODE est un code d'attente (semestre validable plus tard par jury ou compensation)"
return CODES_SEM_ATTENTES.get(code, False)
return code in CODES_SEM_ATTENTES
def code_ue_validant(code: str) -> bool:
"Vrai si ce code d'UE est validant (ie attribue les ECTS)"
return CODES_UE_VALIDES.get(code, False)
return code in CODES_UE_VALIDES
def code_rcue_validant(code: str) -> bool:
"Vrai si ce code d'RCUE est validant"
return code in CODES_RCUE_VALIDES
def code_annee_validant(code: str) -> bool:
"Vrai si code d'année BUT validant"
return code in CODES_ANNEE_BUT_VALIDES
DEVENIR_EXPL = {
@ -265,7 +300,8 @@ DEVENIRS_NEXT2 = {NEXT_OR_NEXT2: 1, NEXT2: 1}
NO_SEMESTRE_ID = -1 # code semestre si pas de semestres
# Règles gestion parcours
# Règles gestion cursus
class DUTRule(object):
def __init__(self, rule_id, premise, conclusion):
self.rule_id = rule_id
@ -287,12 +323,12 @@ class DUTRule(object):
return True
# Types de parcours
DEFAULT_TYPE_PARCOURS = 100 # pour le menu de creation nouvelle formation
# Types de cursus
DEFAULT_TYPE_CURSUS = 700 # (BUT) pour le menu de creation nouvelle formation
class TypeParcours(object):
TYPE_PARCOURS = None # id, utilisé par notes_formation.type_parcours
class TypeCursus:
TYPE_CURSUS = None # id, utilisé par notes_formation.type_parcours
NAME = None # required
NB_SEM = 1 # Nombre de semestres
COMPENSATION_UE = True # inutilisé
@ -306,9 +342,9 @@ class TypeParcours(object):
SESSION_NAME = "semestre"
SESSION_NAME_A = "du "
SESSION_ABBRV = "S" # S1, S2, ...
UNUSED_CODES = set() # Ensemble des codes jury non autorisés dans ce parcours
UNUSED_CODES = set() # Ensemble des codes jury non autorisés dans ce cursus
UE_IS_MODULE = False # 1 seul module par UE (si plusieurs modules, etudiants censéments inscrits à un seul d'entre eux)
ECTS_ONLY = False # Parcours avec progression basée uniquement sur les ECTS
ECTS_ONLY = False # Cursus avec progression basée uniquement sur les ECTS
ALLOWED_UE_TYPES = list(
UE_TYPE_NAME.keys()
) # par defaut, autorise tous les types d'UE
@ -354,18 +390,18 @@ class TypeParcours(object):
return False, """<b>%d UE sous la barre</b>""" % n
# Parcours définis (instances de sous-classes de TypeParcours):
TYPES_PARCOURS = collections.OrderedDict() # type : Parcours
# Cursus définis (instances de sous-classes de TypeCursus):
SCO_CURSUS: dict[int, TypeCursus] = {} # type : Cursus
def register_parcours(Parcours):
TYPES_PARCOURS[int(Parcours.TYPE_PARCOURS)] = Parcours
def register_cursus(cursus: TypeCursus):
SCO_CURSUS[int(cursus.TYPE_CURSUS)] = cursus
class ParcoursBUT(TypeParcours):
class CursusBUT(TypeCursus):
"""BUT Bachelor Universitaire de Technologie"""
TYPE_PARCOURS = 700
TYPE_CURSUS = 700
NAME = "BUT"
NB_SEM = 6
COMPENSATION_UE = False
@ -374,63 +410,63 @@ class ParcoursBUT(TypeParcours):
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
register_parcours(ParcoursBUT())
register_cursus(CursusBUT())
class ParcoursDUT(TypeParcours):
class CursusDUT(TypeCursus):
"""DUT selon l'arrêté d'août 2005"""
TYPE_PARCOURS = 100
TYPE_CURSUS = 100
NAME = "DUT"
NB_SEM = 4
COMPENSATION_UE = True
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
register_parcours(ParcoursDUT())
register_cursus(CursusDUT())
class ParcoursDUT4(ParcoursDUT):
class CursusDUT4(CursusDUT):
"""DUT (en 4 semestres sans compensations)"""
TYPE_PARCOURS = 110
TYPE_CURSUS = 110
NAME = "DUT4"
COMPENSATION_UE = False
register_parcours(ParcoursDUT4())
register_cursus(CursusDUT4())
class ParcoursDUTMono(TypeParcours):
class CursusDUTMono(TypeCursus):
"""DUT en un an (FC, Années spéciales)"""
TYPE_PARCOURS = 120
TYPE_CURSUS = 120
NAME = "DUT"
NB_SEM = 1
COMPENSATION_UE = False
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursDUTMono())
register_cursus(CursusDUTMono())
class ParcoursDUT2(ParcoursDUT):
class CursusDUT2(CursusDUT):
"""DUT en deux semestres (par ex.: années spéciales semestrialisées)"""
TYPE_PARCOURS = CodesParcours.DUT2
TYPE_CURSUS = CodesCursus.DUT2
NAME = "DUT2"
NB_SEM = 2
register_parcours(ParcoursDUT2())
register_cursus(CursusDUT2())
class ParcoursLP(TypeParcours):
class CursusLP(TypeCursus):
"""Licence Pro (en un "semestre")
(pour anciennes LP. Après 2014, préférer ParcoursLP2014)
(pour anciennes LP. Après 2014, préférer CursusLP2014)
"""
TYPE_PARCOURS = CodesParcours.LP
TYPE_CURSUS = CodesCursus.LP
NAME = "LP"
NB_SEM = 1
COMPENSATION_UE = False
@ -441,35 +477,35 @@ class ParcoursLP(TypeParcours):
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursLP())
register_cursus(CursusLP())
class ParcoursLP2sem(ParcoursLP):
class CursusLP2sem(CursusLP):
"""Licence Pro (en deux "semestres")"""
TYPE_PARCOURS = CodesParcours.LP2sem
TYPE_CURSUS = CodesCursus.LP2sem
NAME = "LP2sem"
NB_SEM = 2
COMPENSATION_UE = True
UNUSED_CODES = set((ADC,)) # autorise les codes ATT et ATB, mais pas ADC.
register_parcours(ParcoursLP2sem())
register_cursus(CursusLP2sem())
class ParcoursLP2semEvry(ParcoursLP):
class CursusLP2semEvry(CursusLP):
"""Licence Pro (en deux "semestres", U. Evry)"""
TYPE_PARCOURS = CodesParcours.LP2semEvry
TYPE_CURSUS = CodesCursus.LP2semEvry
NAME = "LP2semEvry"
NB_SEM = 2
COMPENSATION_UE = True
register_parcours(ParcoursLP2semEvry())
register_cursus(CursusLP2semEvry())
class ParcoursLP2014(TypeParcours):
class CursusLP2014(TypeCursus):
"""Licence Pro (en un "semestre"), selon arrêté du 22/01/2014"""
# Note: texte de référence
@ -486,7 +522,7 @@ class ParcoursLP2014(TypeParcours):
# l'établissement d'un coefficient qui peut varier dans un rapport de 1 à 3. ", etc ne sont _pas_
# vérifiés par ScoDoc)
TYPE_PARCOURS = CodesParcours.LP2014
TYPE_CURSUS = CodesCursus.LP2014
NAME = "LP2014"
NB_SEM = 1
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT, UE_STAGE_LP]
@ -524,74 +560,74 @@ class ParcoursLP2014(TypeParcours):
return True, "" # pas de coef, condition ok
register_parcours(ParcoursLP2014())
register_cursus(CursusLP2014())
class ParcoursLP2sem2014(ParcoursLP):
class CursusLP2sem2014(CursusLP):
"""Licence Pro (en deux "semestres", selon arrêté du 22/01/2014)"""
TYPE_PARCOURS = CodesParcours.LP2sem2014
TYPE_CURSUS = CodesCursus.LP2sem2014
NAME = "LP2014_2sem"
NB_SEM = 2
register_parcours(ParcoursLP2sem2014())
register_cursus(CursusLP2sem2014())
# Masters: M2 en deux semestres
class ParcoursM2(TypeParcours):
class CursusM2(TypeCursus):
"""Master 2 (en deux "semestres")"""
TYPE_PARCOURS = CodesParcours.M2
TYPE_CURSUS = CodesCursus.M2
NAME = "M2sem"
NB_SEM = 2
COMPENSATION_UE = True
UNUSED_CODES = set((ATT, ATB))
register_parcours(ParcoursM2())
register_cursus(CursusM2())
class ParcoursM2noncomp(ParcoursM2):
class CursusM2noncomp(CursusM2):
"""Master 2 (en deux "semestres") sans compensation"""
TYPE_PARCOURS = CodesParcours.M2noncomp
TYPE_CURSUS = CodesCursus.M2noncomp
NAME = "M2noncomp"
COMPENSATION_UE = False
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursM2noncomp())
register_cursus(CursusM2noncomp())
class ParcoursMono(TypeParcours):
class CursusMono(TypeCursus):
"""Formation générique en une session"""
TYPE_PARCOURS = CodesParcours.Mono
TYPE_CURSUS = CodesCursus.Mono
NAME = "Mono"
NB_SEM = 1
COMPENSATION_UE = False
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursMono())
register_cursus(CursusMono())
class ParcoursLegacy(TypeParcours):
class CursusLegacy(TypeCursus):
"""DUT (ancien ScoDoc, ne plus utiliser)"""
TYPE_PARCOURS = CodesParcours.Legacy
TYPE_CURSUS = CodesCursus.Legacy
NAME = "DUT"
NB_SEM = 4
COMPENSATION_UE = None # backward compat: defini dans formsemestre
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
register_parcours(ParcoursLegacy())
register_cursus(CursusLegacy())
class ParcoursISCID(TypeParcours):
"""Superclasse pour les parcours de l'ISCID"""
class CursusISCID(TypeCursus):
"""Superclasse pour les cursus de l'ISCID"""
# SESSION_NAME = "année"
# SESSION_NAME_A = "de l'"
@ -610,32 +646,32 @@ class ParcoursISCID(TypeParcours):
ECTS_PROF_DIPL = 0 # crédits professionnels requis pour obtenir le diplôme
class ParcoursBachelorISCID6(ParcoursISCID):
class CursusBachelorISCID6(CursusISCID):
"""ISCID: Bachelor en 3 ans (6 sem.)"""
NAME = "ParcoursBachelorISCID6"
TYPE_PARCOURS = CodesParcours.ISCID6
NAME = "CursusBachelorISCID6"
TYPE_CURSUS = CodesCursus.ISCID6
NAME = ""
NB_SEM = 6
ECTS_PROF_DIPL = 8 # crédits professionnels requis pour obtenir le diplôme
register_parcours(ParcoursBachelorISCID6())
register_cursus(CursusBachelorISCID6())
class ParcoursMasterISCID4(ParcoursISCID):
class CursusMasterISCID4(CursusISCID):
"ISCID: Master en 2 ans (4 sem.)"
TYPE_PARCOURS = CodesParcours.ISCID4
NAME = "ParcoursMasterISCID4"
TYPE_CURSUS = CodesCursus.ISCID4
NAME = "CursusMasterISCID4"
NB_SEM = 4
ECTS_PROF_DIPL = 15 # crédits professionnels requis pour obtenir le diplôme
register_parcours(ParcoursMasterISCID4())
register_cursus(CursusMasterISCID4())
class ParcoursILEPS(TypeParcours):
"""Superclasse pour les parcours de l'ILEPS"""
class CursusILEPS(TypeCursus):
"""Superclasse pour les cursus de l'ILEPS"""
# SESSION_NAME = "année"
# SESSION_NAME_A = "de l'"
@ -651,18 +687,18 @@ class ParcoursILEPS(TypeParcours):
BARRE_UE_DEFAULT = 0.0 # pas de barre sur les autres UE
class ParcoursLicenceILEPS6(ParcoursILEPS):
class CursusLicenceILEPS6(CursusILEPS):
"""ILEPS: Licence 6 semestres"""
TYPE_PARCOURS = 1010
TYPE_CURSUS = 1010
NAME = "LicenceILEPS6"
NB_SEM = 6
register_parcours(ParcoursLicenceILEPS6())
register_cursus(CursusLicenceILEPS6())
class ParcoursUCAC(TypeParcours):
class CursusUCAC(TypeCursus):
"""Règles de validation UCAC"""
SESSION_NAME = "année"
@ -676,79 +712,79 @@ class ParcoursUCAC(TypeParcours):
)
class ParcoursLicenceUCAC3(ParcoursUCAC):
class CursusLicenceUCAC3(CursusUCAC):
"""UCAC: Licence en 3 sessions d'un an"""
TYPE_PARCOURS = CodesParcours.LicenceUCAC3
TYPE_CURSUS = CodesCursus.LicenceUCAC3
NAME = "Licence UCAC en 3 sessions d'un an"
NB_SEM = 3
register_parcours(ParcoursLicenceUCAC3())
register_cursus(CursusLicenceUCAC3())
class ParcoursMasterUCAC2(ParcoursUCAC):
class CursusMasterUCAC2(CursusUCAC):
"""UCAC: Master en 2 sessions d'un an"""
TYPE_PARCOURS = CodesParcours.MasterUCAC2
TYPE_CURSUS = CodesCursus.MasterUCAC2
NAME = "Master UCAC en 2 sessions d'un an"
NB_SEM = 2
register_parcours(ParcoursMasterUCAC2())
register_cursus(CursusMasterUCAC2())
class ParcoursMonoUCAC(ParcoursUCAC):
class CursusMonoUCAC(CursusUCAC):
"""UCAC: Formation en 1 session de durée variable"""
TYPE_PARCOURS = CodesParcours.MonoUCAC
TYPE_CURSUS = CodesCursus.MonoUCAC
NAME = "Formation UCAC en 1 session de durée variable"
NB_SEM = 1
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursMonoUCAC())
register_cursus(CursusMonoUCAC())
class Parcours6Sem(TypeParcours):
"""Parcours générique en 6 semestres"""
class Cursus6Sem(TypeCursus):
"""Cursus générique en 6 semestres"""
TYPE_PARCOURS = CodesParcours.GEN_6_SEM
TYPE_CURSUS = CodesCursus.GEN_6_SEM
NAME = "Formation en 6 semestres"
NB_SEM = 6
COMPENSATION_UE = True
register_parcours(Parcours6Sem())
register_cursus(Cursus6Sem())
# # En cours d'implémentation:
# class ParcoursLicenceLMD(TypeParcours):
# class CursusLicenceLMD(TypeCursus):
# """Licence standard en 6 semestres dans le LMD"""
# TYPE_PARCOURS = 401
# TYPE_CURSUS = 401
# NAME = "Licence LMD"
# NB_SEM = 6
# COMPENSATION_UE = True
# register_parcours(ParcoursLicenceLMD())
# register_cursus(CursusLicenceLMD())
class ParcoursMasterLMD(TypeParcours):
class CursusMasterLMD(TypeCursus):
"""Master générique en 4 semestres dans le LMD"""
TYPE_PARCOURS = CodesParcours.MasterLMD
TYPE_CURSUS = CodesCursus.MasterLMD
NAME = "Master LMD"
NB_SEM = 4
COMPENSATION_UE = True # variabale inutilisée
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursMasterLMD())
register_cursus(CursusMasterLMD())
class ParcoursMasterIG(ParcoursMasterLMD):
class CursusMasterIG(CursusMasterLMD):
"""Master de l'Institut Galilée (U. Paris 13) en 4 semestres (LMD)"""
TYPE_PARCOURS = CodesParcours.MasterIG
TYPE_CURSUS = CodesCursus.MasterIG
NAME = "Master IG P13"
BARRE_MOY = 10.0
NOTES_BARRE_VALID_UE_TH = 10.0 # seuil pour valider UE
@ -758,7 +794,7 @@ class ParcoursMasterIG(ParcoursMasterLMD):
BARRE_MOY_UE_STAGE = 10.0
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT, UE_STAGE_10]
def check_barre_ues(self, ues_status): # inspire de la fonction de ParcoursLP2014
def check_barre_ues(self, ues_status): # inspire de la fonction de CursusLP2014
"""True si la ou les conditions sur les UE sont valides
moyenne d'UE > 7, ou > 10 si UE de stage
"""
@ -797,10 +833,10 @@ class ParcoursMasterIG(ParcoursMasterLMD):
return True, "" # pas de coef, condition ok
register_parcours(ParcoursMasterIG())
register_cursus(CursusMasterIG())
# Ajouter ici vos parcours, le TYPE_PARCOURS devant être unique au monde
# Ajouter ici vos cursus, le TYPE_CURSUS devant être unique au monde
# (avisez sur la liste de diffusion)
@ -808,16 +844,17 @@ register_parcours(ParcoursMasterIG())
# -------------------------
_tp = list(TYPES_PARCOURS.items())
_tp = list(SCO_CURSUS.items())
_tp.sort(key=lambda x: x[1].__doc__) # sort by intitulé
FORMATION_PARCOURS_DESCRS = [p[1].__doc__ for p in _tp] # intitulés (eg pour menu)
FORMATION_PARCOURS_TYPES = [p[0] for p in _tp] # codes numeriques (TYPE_PARCOURS)
FORMATION_CURSUS_DESCRS = [p[1].__doc__ for p in _tp] # intitulés (eg pour menu)
FORMATION_CURSUS_TYPES = [p[0] for p in _tp] # codes numeriques (TYPE_CURSUS)
def get_parcours_from_code(code_parcours):
parcours = TYPES_PARCOURS.get(code_parcours)
if parcours is None:
log(f"Warning: invalid code_parcours: {code_parcours}")
def get_cursus_from_code(code_cursus: int) -> TypeCursus:
"renvoie le cursus de code indiqué"
cursus = SCO_CURSUS.get(code_cursus)
if cursus is None:
log(f"Warning: invalid code_cursus: {code_cursus}")
# default to legacy
parcours = TYPES_PARCOURS.get(0)
return parcours
cursus = SCO_CURSUS.get(0)
return cursus

View File

@ -4,7 +4,7 @@
#
# Command: ./csv2rules.py misc/parcoursDUT.csv
#
from app.scodoc.sco_codes_parcours import (
from app.scodoc.codes_cursus import (
DUTRule,
ADC,
ADJ,

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]:
@ -89,7 +88,7 @@ class DEFAULT_TABLE_PREFERENCES(object):
return self.values[k]
class GenTable(object):
class GenTable:
"""Simple 2D tables with export to HTML, PDF, Excel, CSV.
Can be sub-classed to generate fancy formats.
"""
@ -198,6 +197,9 @@ class GenTable(object):
def __repr__(self):
return f"<gen_table( nrows={self.get_nb_rows()}, ncols={self.get_nb_cols()} )>"
def __len__(self):
return len(self.rows)
def get_nb_cols(self):
return len(self.columns_ids)
@ -647,7 +649,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,
@ -758,31 +760,31 @@ class SeqGenTable(object):
def excel(self):
"""Export des genTables dans un unique fichier excel avec plusieurs feuilles tagguées"""
book = sco_excel.ScoExcelBook() # pylint: disable=no-member
for (_, gt) in self.genTables.items():
for _, gt in self.genTables.items():
gt.excel(wb=book) # Ecrit dans un fichier excel
return book.generate()
# ----- Exemple d'utilisation minimal.
if __name__ == "__main__":
T = GenTable(
table = GenTable(
rows=[{"nom": "Hélène", "age": 26}, {"nom": "Titi&çà§", "age": 21}],
columns_ids=("nom", "age"),
)
print("--- HTML:")
print(T.gen(format="html"))
print(table.gen(format="html"))
print("\n--- XML:")
print(T.gen(format="xml"))
print(table.gen(format="xml"))
print("\n--- JSON:")
print(T.gen(format="json"))
print(table.gen(format="json"))
# Test pdf:
import io
from reportlab.platypus import KeepInFrame
from app.scodoc import sco_preferences, sco_pdf
preferences = sco_preferences.SemPreferences()
T.preferences = preferences
objects = T.gen(format="pdf")
table.preferences = preferences
objects = table.gen(format="pdf")
objects = [KeepInFrame(0, 0, objects, mode="shrink")]
doc = io.BytesIO()
document = sco_pdf.BaseDocTemplate(doc)
@ -795,6 +797,6 @@ if __name__ == "__main__":
data = doc.getvalue()
with open("/tmp/gen_table.pdf", "wb") as f:
f.write(data)
p = T.make_page(format="pdf")
p = table.make_page(format="pdf")
with open("toto.pdf", "wb") as f:
f.write(p)

View File

@ -140,7 +140,7 @@ def sco_header(
init_google_maps=False, # Google maps
init_datatables=True,
titrebandeau="", # titre dans bandeau superieur
head_message="", # message action (petit cadre jaune en haut)
head_message="", # message action (petit cadre jaune en haut) DEPRECATED
user_check=True, # verifie passwords temporaires
etudid=None,
formsemestre_id=None,
@ -251,7 +251,7 @@ def sco_header(
#gtrcontent {{
margin-left: {params["margin_left"]};
height: 100%%;
margin-bottom: 10px;
margin-bottom: 16px;
}}
</style>
"""
@ -274,21 +274,11 @@ def sco_header(
H.append("""<div id="gtrcontent">""")
# En attendant le replacement complet de cette fonction,
# inclusion ici des messages flask
H.append(render_template("flashed_messages.html"))
H.append(render_template("flashed_messages.j2"))
#
# Barre menu semestre:
H.append(formsemestre_page_title(formsemestre_id))
# Avertissement si mot de passe à changer
if user_check:
if current_user.passwd_temp:
H.append(
f"""<div class="passwd_warn">
Attention !<br>
Vous avez reçu un mot de passe temporaire.<br>
Vous devez le changer: <a href="{scu.UsersURL}/form_change_password?user_name={current_user.user_name}">cliquez ici</a>
</div>"""
)
#
if head_message:
H.append('<div class="head_message">' + html.escape(head_message) + "</div>")

View File

@ -166,6 +166,6 @@ def sidebar(etudid: int = None):
def sidebar_dept():
"""Partie supérieure de la marge de gauche"""
return render_template(
"sidebar_dept.html",
"sidebar_dept.j2",
prefs=sco_preferences.SemPreferences(),
)

File diff suppressed because it is too large Load Diff

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
@ -65,7 +63,7 @@ def table_billets_etud(
etudid: int = None, etat: bool = None, with_links=True
) -> GenTable:
"""Page avec table billets."""
etud = Identite.query.get_or_404(etudid) if etudid is not None else None
etud = Identite.get_etud(etudid) if etudid is not None else None
billets = query_billets_etud(etudid, etat)
return table_billets(billets, etud=etud, with_links=with_links)

View File

@ -32,20 +32,21 @@
Il suffit d'appeler abs_notify() après chaque ajout d'absence.
"""
import datetime
from typing import Optional
from flask import g, url_for
from flask_mail import Message
from app.models.formsemestre import FormSemestre
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
from app import db
from app import email
from app import log
from app.scodoc.scolog import logdb
from app.models.absences import AbsenceNotification
from app.models.events import Scolog
from app.models.formsemestre import FormSemestre
import app.scodoc.notesdb as ndb
from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre
from app.scodoc import sco_preferences
from app.scodoc import sco_users
from app import email
def abs_notify(etudid, date):
@ -106,32 +107,24 @@ def do_abs_notify(formsemestre: FormSemestre, etudid, date, nbabs, nbabsjust):
def abs_notify_send(destinations, etudid, msg, nbabs, nbabsjust, formsemestre_id):
"""Actually send the notification by email, and register it in database"""
cnx = ndb.GetDBConnexion()
log("abs_notify: sending notification to %s" % destinations)
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
log(f"abs_notify: sending notification to {destinations}")
for dest_addr in destinations:
msg.recipients = [dest_addr]
email.send_message(msg)
ndb.SimpleQuery(
"""INSERT into absences_notifications
(etudid, email, nbabs, nbabsjust, formsemestre_id)
VALUES (%(etudid)s, %(email)s, %(nbabs)s, %(nbabsjust)s, %(formsemestre_id)s)
""",
{
"etudid": etudid,
"email": dest_addr,
"nbabs": nbabs,
"nbabsjust": nbabsjust,
"formsemestre_id": formsemestre_id,
},
cursor=cursor,
notification = AbsenceNotification(
etudid=etudid,
email=dest_addr,
nbabs=nbabs,
nbabsjust=nbabsjust,
formsemestre_id=formsemestre_id,
)
db.session.add(notification)
logdb(
cnx=cnx,
Scolog.logdb(
method="abs_notify",
etudid=etudid,
msg="sent to %s (nbabs=%d)" % (destinations, nbabs),
msg=f"sent to {destinations} (nbabs={nbabs})",
commit=True,
)
@ -201,39 +194,32 @@ def abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre_id):
return False
def etud_nbabs_last_notified(etudid, formsemestre_id=None):
def etud_nbabs_last_notified(etudid: int, formsemestre_id: int = None):
"""nbabs lors de la dernière notification envoyée pour cet étudiant dans ce semestre
ou sans semestre (ce dernier cas est nécessaire pour la transition au nouveau code)"""
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(
"""select * from absences_notifications where etudid = %(etudid)s and (formsemestre_id = %(formsemestre_id)s or formsemestre_id is NULL) order by notification_date desc""",
vars(),
ou sans semestre (ce dernier cas est nécessaire pour la transition au nouveau code)
"""
notifications = (
AbsenceNotification.query.filter_by(etudid=etudid)
.filter(
(AbsenceNotification.formsemestre_id == formsemestre_id)
| (AbsenceNotification.formsemestre_id.is_(None))
)
.order_by(AbsenceNotification.notification_date.desc())
)
res = cursor.dictfetchone()
if res:
return res["nbabs"]
else:
return 0
last_notif = notifications.first()
return last_notif.nbabs if last_notif else 0
def user_nbdays_since_last_notif(email_addr, etudid):
def user_nbdays_since_last_notif(email_addr, etudid) -> Optional[int]:
"""nb days since last notification to this email, or None if no previous notification"""
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(
"""SELECT * FROM absences_notifications
WHERE email = %(email_addr)s and etudid=%(etudid)s
ORDER BY notification_date DESC
""",
{"email_addr": email_addr, "etudid": etudid},
)
res = cursor.dictfetchone()
if res:
now = datetime.datetime.now(res["notification_date"].tzinfo)
return (now - res["notification_date"]).days
else:
return None
notifications = AbsenceNotification.query.filter_by(
etudid=etudid, email=email_addr
).order_by(AbsenceNotification.notification_date.desc())
last_notif = notifications.first()
if last_notif:
now = datetime.datetime.now(last_notif.notification_date.tzinfo)
return (now - last_notif.notification_date).days
return None
def abs_notification_message(
@ -264,19 +250,19 @@ def abs_notification_message(
log("abs_notification_message: empty template, not sending message")
return None
subject = """[ScoDoc] Trop d'absences pour %(nomprenom)s""" % etud
msg = Message(subject, sender=prefs["email_from_addr"])
subject = f"""[ScoDoc] Trop d'absences pour {etud["nomprenom"]}"""
msg = Message(subject, sender=email.get_from_addr(formsemestre.departement.acronym))
msg.body = txt
return msg
def retreive_current_formsemestre(etudid: int, cur_date) -> FormSemestre:
def retreive_current_formsemestre(etudid: int, cur_date) -> Optional[FormSemestre]:
"""Get formsemestre dans lequel etudid est (ou était) inscrit a la date indiquée
date est une chaine au format ISO (yyyy-mm-dd)
Result: FormSemestre ou None si pas inscrit à la date indiquée
"""
req = """SELECT i.formsemestre_id
req = """SELECT i.formsemestre_id
FROM notes_formsemestre_inscription i, notes_formsemestre sem
WHERE sem.id = i.formsemestre_id AND i.etudid = %(etudid)s
AND (%(cur_date)s >= sem.date_debut) AND (%(cur_date)s <= sem.date_fin)
@ -286,15 +272,14 @@ def retreive_current_formsemestre(etudid: int, cur_date) -> FormSemestre:
if not r:
return None
# s'il y a plusieurs semestres, prend le premier (rarissime et non significatif):
formsemestre = FormSemestre.query.get(r[0]["formsemestre_id"])
formsemestre = FormSemestre.get_formsemestre(r[0]["formsemestre_id"])
return formsemestre
def mod_with_evals_at_date(date_abs, etudid):
"""Liste des moduleimpls avec des evaluations à la date indiquée"""
req = """SELECT m.id AS moduleimpl_id, m.*
req = """SELECT m.id AS moduleimpl_id, m.*
FROM notes_moduleimpl m, notes_evaluation e, notes_moduleimpl_inscription i
WHERE m.id = e.moduleimpl_id AND e.moduleimpl_id = i.moduleimpl_id
AND i.etudid = %(etudid)s AND e.jour = %(date_abs)s"""
r = ndb.SimpleDictFetch(req, {"etudid": etudid, "date_abs": date_abs})
return r
return ndb.SimpleDictFetch(req, {"etudid": etudid, "date_abs": date_abs})

View File

@ -119,7 +119,7 @@ def doSignaleAbsence(
if moduleimpl_id and moduleimpl_id != "NULL":
mod = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0]
formsemestre_id = mod["formsemestre_id"]
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
ues = nt.get_ues_stat_dict()
for ue in ues:
@ -187,7 +187,7 @@ def SignaleAbsenceEtud(): # etudid implied
menu_module = ""
else:
formsemestre_id = etud["cursem"]["formsemestre_id"]
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
ues = nt.get_ues_stat_dict()
require_module = sco_preferences.get_preference(

View File

@ -43,15 +43,17 @@ Pour chaque étudiant commun:
comparer les résultats
"""
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>
@ -68,15 +70,15 @@ 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:
<input type="file" size="30" name="A_file"/>
<input type="file" size="30" name="file_a"/>
</div>
<div class="apo_compare_csv_form_but">
Fichier Apogée B:
<input type="file" size="30" name="B_file"/>
<input type="file" size="30" name="file_b"/>
</div>
<input type="checkbox" name="autodetect" checked/>autodétecter encodage</input>
<div class="apo_compare_csv_form_submit">
@ -88,17 +90,36 @@ def apo_compare_csv_form():
return "\n".join(H)
def apo_compare_csv(A_file, B_file, autodetect=True):
def apo_compare_csv(file_a, file_b, autodetect=True):
"""Page comparing 2 Apogee CSV files"""
A = _load_apo_data(A_file, autodetect=autodetect)
B = _load_apo_data(B_file, autodetect=autodetect)
try:
apo_data_a = _load_apo_data(file_a, autodetect=autodetect)
apo_data_b = _load_apo_data(file_b, autodetect=autodetect)
except (UnicodeDecodeError, UnicodeEncodeError) as exc:
dest_url = url_for("notes.semset_page", scodoc_dept=g.scodoc_dept)
if autodetect:
raise ScoValueError(
"""
Erreur: l'encodage de l'un des fichiers est mal détecté.
Essayez sans auto-détection, ou vérifiez le codage et le contenu
des fichiers.
""",
dest_url=dest_url,
) from exc
else:
raise ScoValueError(
f"""
Erreur: l'encodage de l'un des fichiers est incorrect.
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(A, B),
_apo_compare_csv(apo_data_a, apo_data_b),
"</div>",
"""<p><a href="apo_compare_csv_form" class="stdlink">Autre comparaison</a></p>""",
html_sco_header.sco_footer(),
@ -110,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("apo_compare_csv: %s" % message)
log(f"apo_compare_csv: {message}")
if not data_b:
raise ScoValueError("apo_compare_csv: no data")
data = data_b.decode(sco_apogee_csv.APO_INPUT_ENCODING)
raise ScoValueError("fichier vide ? (apo_compare_csv: no data)")
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.
"""
@ -128,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
@ -203,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
@ -227,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
@ -270,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,494 @@
##############################################################################
#
# 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 }
try:
for i, field in enumerate(fields):
cols[self.col_ids[i]] = field
except IndexError as exc:
raise
raise ScoFormatError(
f"Fichier Apogee incorrect (colonnes excédentaires ? (<tt>{i}/{field}</tt>))",
filename=self.get_filename(),
) from exc
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

@ -43,11 +43,11 @@
Les maquettes Apogée pour l'export des notes sont dans
<archivedir>/apo_csv/<dept_id>/<annee_scolaire>-<sem_id>/<YYYY-MM-DD-HH-MM-SS>/<code_etape>.csv
Un répertoire d'archive contient des fichiers quelconques, et un fichier texte nommé _description.txt
qui est une description (humaine, format libre) de l'archive.
Un répertoire d'archive contient des fichiers quelconques, et un fichier texte
nommé _description.txt qui est une description (humaine, format libre) de l'archive.
"""
import chardet
from typing import Union
import datetime
import glob
import json
@ -56,31 +56,28 @@ import os
import re
import shutil
import time
from typing import Union
import chardet
import flask
from flask import g, request
from flask_login import current_user
from flask import flash, g, request, url_for
import app.scodoc.sco_utils as scu
from config import Config
from app import log
from app import log, ScoDocJSONEncoder
from app.but import jury_but_pv
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Departement, FormSemestre
from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_exceptions import (
AccessDenied,
)
from app.scodoc.sco_exceptions import ScoException, ScoPermissionDenied
from app.scodoc import html_sco_header
from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_permissions_check
from app.scodoc import sco_pvjury
from app.scodoc import sco_pvpdf
from app.scodoc import sco_pv_forms
from app.scodoc import sco_pv_lettres_inviduelles
from app.scodoc import sco_pv_pdf
from app.scodoc.sco_exceptions import ScoValueError
@ -128,6 +125,12 @@ class BaseArchiver(object):
if not os.path.isdir(obj_dir):
log(f"creating directory {obj_dir}")
os.mkdir(obj_dir)
except FileExistsError as exc:
raise ScoException(
f"""BaseArchiver error: obj_dir={obj_dir} exists={
os.path.exists(obj_dir)
} isdir={os.path.isdir(obj_dir)}"""
) from exc
finally:
scu.GSL.release()
return obj_dir
@ -211,7 +214,7 @@ class BaseArchiver(object):
self.initialize()
filename = os.path.join(archive_id, "_description.txt")
try:
with open(filename) as f:
with open(filename, encoding=scu.SCO_ENCODING) as f:
descr = f.read()
except UnicodeDecodeError:
# some (old) files may have saved under exotic encodings
@ -223,15 +226,18 @@ class BaseArchiver(object):
def create_obj_archive(self, oid: int, description: str):
"""Creates a new archive for this object and returns its id."""
# id suffixé par YYYY-MM-DD-hh-mm-ss
archive_id = (
self.get_obj_dir(oid)
+ os.path.sep
+ "-".join(["%02d" % x for x in time.localtime()[:6]])
+ "-".join([f"{x:02d}" for x in time.localtime()[:6]])
)
log(f"creating archive: {archive_id}")
try:
scu.GSL.acquire()
os.mkdir(archive_id) # if exists, raises FileExistsError
os.mkdir(archive_id)
except FileExistsError: # directory already exists !
pass
finally:
scu.GSL.release()
self.store(archive_id, "_description.txt", description)
@ -295,18 +301,18 @@ PVArchive = SemsArchiver()
def do_formsemestre_archive(
formsemestre_id,
group_ids=[], # si indiqué, ne prend que ces groupes
group_ids: list[int] = None, # si indiqué, ne prend que ces groupes
description="",
date_jury="",
signature=None, # pour lettres indiv
date_commission=None,
numeroArrete=None,
VDICode=None,
showTitle=False,
numero_arrete=None,
code_vdi=None,
show_title=False,
pv_title=None,
with_paragraph_nom=False,
anonymous=False,
bulVersion="long",
bul_version="long",
):
"""Make and store new archive for this formsemestre.
Store:
@ -314,11 +320,11 @@ def do_formsemestre_archive(
"""
from app.scodoc.sco_recapcomplet import (
gen_formsemestre_recapcomplet_excel,
gen_formsemestre_recapcomplet_html,
gen_formsemestre_recapcomplet_html_table,
gen_formsemestre_recapcomplet_json,
)
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
sem_archive_id = formsemestre_id
archive_id = PVArchive.create_obj_archive(sem_archive_id, description)
@ -334,25 +340,24 @@ def do_formsemestre_archive(
etudids = [m["etudid"] for m in groups_infos.members]
# Tableau recap notes en XLS (pour tous les etudiants, n'utilise pas les groupes)
data, _ = gen_formsemestre_recapcomplet_excel(
formsemestre, res, include_evaluations=True, format="xls"
)
data, _ = gen_formsemestre_recapcomplet_excel(res, include_evaluations=True)
if data:
PVArchive.store(archive_id, "Tableau_moyennes" + scu.XLSX_SUFFIX, data)
# Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes)
table_html = gen_formsemestre_recapcomplet_html(
table_html, _, _ = gen_formsemestre_recapcomplet_html_table(
formsemestre, res, include_evaluations=True
)
if table_html:
flash(f"Moyennes archivées le {date}", category="info")
data = "\n".join(
[
html_sco_header.sco_header(
page_title=f"Moyennes archivées le {date}",
head_message=f"Moyennes archivées le {date}",
no_side_bar=True,
),
f'<h2 class="fontorange">Valeurs archivées le {date}</h2>',
'<style type="text/css">table.notes_recapcomplet tr { color: rgb(185,70,0); }</style>',
"""<style type="text/css">table.notes_recapcomplet tr { color: rgb(185,70,0); }
</style>""",
table_html,
html_sco_header.sco_footer(),
]
@ -361,15 +366,15 @@ 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
if formsemestre.formation.is_apc():
response = jury_but_pv.pvjury_table_but(formsemestre_id, format="xls")
response = jury_but_pv.pvjury_page_but(formsemestre_id, fmt="xls")
data = response.get_data()
else: # formations classiques
data = sco_pvjury.formsemestre_pvjury(
data = sco_pv_forms.formsemestre_pvjury(
formsemestre_id, format="xls", publish=False
)
if data:
@ -380,12 +385,12 @@ def do_formsemestre_archive(
)
# Classeur bulletins (PDF)
data, _ = sco_bulletins_pdf.get_formsemestre_bulletins_pdf(
formsemestre_id, version=bulVersion
formsemestre_id, version=bul_version
)
if data:
PVArchive.store(archive_id, "Bulletins.pdf", data)
# Lettres individuelles (PDF):
data = sco_pvpdf.pdf_lettres_individuelles(
data = sco_pv_lettres_inviduelles.pdf_lettres_individuelles(
formsemestre_id,
etudids=etudids,
date_jury=date_jury,
@ -393,35 +398,38 @@ def do_formsemestre_archive(
signature=signature,
)
if data:
PVArchive.store(archive_id, "CourriersDecisions%s.pdf" % groups_filename, data)
PVArchive.store(archive_id, f"CourriersDecisions{groups_filename}.pdf", data)
# PV de jury (PDF): disponible seulement en classique
# en BUT, le PV est sous forme excel (Decisions_Jury.xlsx ci-dessus)
if not formsemestre.formation.is_apc():
dpv = sco_pvjury.dict_pvjury(formsemestre_id, etudids=etudids, with_prev=True)
data = sco_pvpdf.pvjury_pdf(
dpv,
date_commission=date_commission,
date_jury=date_jury,
numeroArrete=numeroArrete,
VDICode=VDICode,
showTitle=showTitle,
pv_title=pv_title,
with_paragraph_nom=with_paragraph_nom,
anonymous=anonymous,
)
if data:
PVArchive.store(archive_id, "PV_Jury%s.pdf" % groups_filename, data)
# PV de jury (PDF):
data = sco_pv_pdf.pvjury_pdf(
formsemestre,
etudids=etudids,
date_commission=date_commission,
date_jury=date_jury,
numero_arrete=numero_arrete,
code_vdi=code_vdi,
show_title=show_title,
pv_title=pv_title,
with_paragraph_nom=with_paragraph_nom,
anonymous=anonymous,
)
if data:
PVArchive.store(archive_id, f"PV_Jury{groups_filename}.pdf", data)
def formsemestre_archive(formsemestre_id, group_ids=[]):
def formsemestre_archive(formsemestre_id, group_ids: list[int] = None):
"""Make and store new archive for this formsemestre.
(all students or only selected groups)
"""
if not sco_permissions_check.can_edit_pv(formsemestre_id):
raise AccessDenied("opération non autorisée pour %s" % str(current_user))
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_pv():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
if not group_ids:
# tous les inscrits du semestre
group_ids = [sco_groups.get_default_group(formsemestre_id)]
@ -446,7 +454,11 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
""",
]
F = [
"""<p><em>Note: les documents sont aussi affectés par les réglages sur la page "<a href="edit_preferences">Paramétrage</a>" (accessible à l'administrateur du département).</em>
f"""<p><em>Note: les documents sont aussi affectés par les réglages sur la page
"<a class="stdlink" href="{
url_for("scolar.edit_preferences", scodoc_dept=g.scodoc_dept)
}">Paramétrage</a>"
(accessible à l'administrateur du département).</em>
</p>""",
html_sco_header.sco_footer(),
]
@ -458,7 +470,7 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
),
("sep", {"input_type": "separator", "title": "Informations sur PV de jury"}),
]
descr += sco_pvjury.descrform_pvjury(sem)
descr += sco_pv_forms.descrform_pvjury(formsemestre)
descr += [
(
"signature",
@ -469,7 +481,7 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
},
),
(
"bulVersion",
"bul_version",
{
"input_type": "menu",
"title": "Version des bulletins archivés",
@ -494,7 +506,6 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
scu.get_request_args(),
descr,
cancelbutton="Annuler",
method="POST",
submitlabel="Générer et archiver les documents",
name="tf",
formid="group_selector",
@ -503,7 +514,7 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
if tf[0] == 0:
return "\n".join(H) + "\n" + tf[1] + "\n".join(F)
elif tf[0] == -1:
msg = "Opération%20annulée"
msg = "Opération annulée"
else:
# submit
sf = tf[2]["signature"]
@ -519,26 +530,29 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
date_jury=tf[2]["date_jury"],
date_commission=tf[2]["date_commission"],
signature=signature,
numeroArrete=tf[2]["numeroArrete"],
VDICode=tf[2]["VDICode"],
numero_arrete=tf[2]["numero_arrete"],
code_vdi=tf[2]["code_vdi"],
pv_title=tf[2]["pv_title"],
showTitle=tf[2]["showTitle"],
show_title=tf[2]["show_title"],
with_paragraph_nom=tf[2]["with_paragraph_nom"],
anonymous=tf[2]["anonymous"],
bulVersion=tf[2]["bulVersion"],
bul_version=tf[2]["bul_version"],
)
msg = "Nouvelle%20archive%20créée"
msg = "Nouvelle archive créée"
# submitted or cancelled:
flash(msg)
return flask.redirect(
"formsemestre_list_archives?formsemestre_id=%s&head_message=%s"
% (formsemestre_id, msg)
url_for(
"notes.formsemestre_list_archives",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
def formsemestre_list_archives(formsemestre_id):
"""Page listing archives"""
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
sem_archive_id = formsemestre_id
L = []
for archive_id in PVArchive.list_obj_archives(sem_archive_id):
@ -581,26 +595,38 @@ def formsemestre_list_archives(formsemestre_id):
def formsemestre_get_archived_file(formsemestre_id, archive_name, filename):
"""Send file to client."""
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
sem_archive_id = formsemestre_id
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
sem_archive_id = formsemestre.id
return PVArchive.get_archived_file(sem_archive_id, archive_name, filename)
def formsemestre_delete_archive(formsemestre_id, archive_name, dialog_confirmed=False):
"""Delete an archive"""
if not sco_permissions_check.can_edit_pv(formsemestre_id):
raise AccessDenied("opération non autorisée pour %s" % str(current_user))
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_pv():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
sem_archive_id = formsemestre_id
archive_id = PVArchive.get_id_from_name(sem_archive_id, archive_name)
dest_url = "formsemestre_list_archives?formsemestre_id=%s" % (formsemestre_id)
dest_url = url_for(
"notes.formsemestre_list_archives",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
if not dialog_confirmed:
return scu.confirm_dialog(
"""<h2>Confirmer la suppression de l'archive du %s ?</h2>
<p>La suppression sera définitive.</p>"""
% PVArchive.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M"),
f"""<h2>Confirmer la suppression de l'archive du {
PVArchive.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M")
} ?</h2>
<p>La suppression sera définitive.</p>
""",
dest_url="",
cancel_url=dest_url,
parameters={
@ -610,4 +636,5 @@ def formsemestre_delete_archive(formsemestre_id, archive_name, dialog_confirmed=
)
PVArchive.delete_archive(archive_id)
return flask.redirect(dest_url + "&head_message=Archive%20supprimée")
flash("Archive supprimée")
return flask.redirect(dest_url)

View File

@ -30,7 +30,7 @@
les dossiers d'admission et autres pièces utiles.
"""
import flask
from flask import url_for, render_template
from flask import flash, render_template, url_for
from flask import g, request
from flask_login import current_user
@ -38,7 +38,6 @@ import app.scodoc.sco_utils as scu
from app.scodoc import sco_import_etuds
from app.scodoc import sco_groups
from app.scodoc import sco_trombino
from app.scodoc import sco_excel
from app.scodoc import sco_archives
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
@ -233,13 +232,9 @@ def etud_delete_archive(etudid, archive_name, dialog_confirmed=False):
)
EtudsArchive.delete_archive(archive_id)
flash("Archive supprimée")
return flask.redirect(
url_for(
"scolar.ficheEtud",
scodoc_dept=g.scodoc_dept,
etudid=etudid,
head_message="Archive%20supprimée",
)
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
@ -373,7 +368,7 @@ def etudarchive_import_files(
filename_title="fichier_a_charger",
)
return render_template(
"scolar/photos_import_files.html",
"scolar/photos_import_files.j2",
page_title="Téléchargement de fichiers associés aux étudiants",
ignored_zipfiles=ignored_zipfiles,
unmatched_files=unmatched_files,

View File

@ -28,21 +28,30 @@
"""Génération des bulletins de notes
"""
import collections
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
from app import db, email
from app import log
from app.scodoc.sco_utils import json_error
from app.but import bulletin_but
from app.comp import res_sem
from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, Identite, ModuleImplInscription
from app.models import (
ApcParcours,
Formation,
FormSemestre,
Identite,
ModuleImplInscription,
)
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc import html_sco_header
@ -53,15 +62,13 @@ from app.scodoc import sco_bulletins_generator
from app.scodoc import sco_bulletins_json
from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_bulletins_xml
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
from app.scodoc import sco_etud
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_formations
from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups
from app.scodoc import sco_permissions_check
from app.scodoc import sco_preferences
from app.scodoc import sco_pvjury
from app.scodoc import sco_pv_dict
from app.scodoc import sco_users
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType, fmt_note
@ -73,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():
bul = bulletin_but.BulletinBUT(formsemestre)
if not etud.id in bul.res.identdict:
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(
bul.bulletin_etud(
return json_response(
data_=bulletins_sem.bulletin_etud(
etud,
formsemestre,
force_publishing=force_publishing,
@ -137,29 +144,24 @@ 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)
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if not nt.get_etud_etat(etudid):
raise ScoValueError("Étudiant non inscrit à ce semestre")
I = scu.DictDefault(defaultvalue="")
I = collections.defaultdict(str)
I["etudid"] = etudid
I["formsemestre_id"] = formsemestre_id
I["sem"] = formsemestre.get_infos_dict()
I["server_name"] = request.url_root
# Formation et parcours
formation_dict = None
if I["sem"]["formation_id"]:
formation_dicts = sco_formations.formation_list(
args={"formation_id": I["sem"]["formation_id"]}
)
if formation_dicts:
formation_dict = formation_dicts[0]
if formation_dict is None: # what's the fuck ?
formation_dict = Formation.query.get_or_404(I["sem"]["formation_id"]).to_dict()
else: # what's the fuck ?
formation_dict = {
"acronyme": "?",
"code_specialite": "",
@ -174,9 +176,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
"version": 0,
}
I["formation"] = formation_dict
I["parcours"] = sco_codes_parcours.get_parcours_from_code(
I["formation"]["type_parcours"]
)
I["parcours"] = codes_cursus.get_cursus_from_code(I["formation"]["type_parcours"])
# Infos sur l'etudiant
I["etud"] = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
I["descr_situation"] = I["etud"]["inscriptionstr"]
@ -202,7 +202,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
# --- Decision Jury
infos, dpv = etud_descr_situation_semestre(
etudid,
formsemestre_id,
formsemestre,
format="html",
show_date_inscr=prefs["bul_show_date_inscr"],
show_decisions=prefs["bul_show_decision"],
@ -222,7 +222,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
I["demission"] = ""
if I["etud_etat"] == scu.DEMISSION:
I["demission"] = "(Démission)"
elif I["etud_etat"] == sco_codes_parcours.DEF:
elif I["etud_etat"] == codes_cursus.DEF:
I["demission"] = "(Défaillant)"
# --- Appreciations
@ -239,9 +239,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
I["mention"] = ""
if dpv:
decision_sem = dpv["decisions"][0]["decision_sem"]
if decision_sem and sco_codes_parcours.code_semestre_validant(
decision_sem["code"]
):
if decision_sem and codes_cursus.code_semestre_validant(decision_sem["code"]):
I["mention"] = scu.get_mention(moy_gen)
if dpv and dpv["decisions"][0]:
@ -271,7 +269,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
# n'affiche pas le rang sur le bulletin s'il y a des
# notes en attente dans ce semestre
rang = scu.RANG_ATTENTE_STR
rang_gr = scu.DictDefault(defaultvalue=scu.RANG_ATTENTE_STR)
rang_gr = collections.defaultdict(lambda: scu.RANG_ATTENTE_STR)
inscriptions_counts = nt.get_inscriptions_counts()
I["rang"] = rang
I["rang_gr"] = rang_gr
@ -307,7 +305,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
continue
u["ue_status"] = ue_status # { 'moy', 'coef_ue', ...}
if ue["type"] != sco_codes_parcours.UE_SPORT:
if ue["type"] != codes_cursus.UE_SPORT:
u["cur_moy_ue_txt"] = scu.fmt_note(ue_status["cur_moy_ue"])
else:
if nt.bonus is not None:
@ -335,7 +333,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
and ue["ue_id"] in dpv["decisions"][0]["decisions_ue"]
):
u["ects"] = dpv["decisions"][0]["decisions_ue"][ue["ue_id"]]["ects"]
if ue["type"] == sco_codes_parcours.UE_ELECTIVE:
if ue["type"] == codes_cursus.UE_ELECTIVE:
u["ects"] = (
"%g+" % u["ects"]
) # ajoute un "+" pour indiquer ECTS d'une UE élective
@ -356,7 +354,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
"modules_capitalized"
] = [] # modules de l'UE capitalisée (liste vide si pas capitalisée)
if ue_status["is_capitalized"] and ue_status["formsemestre_id"] is not None:
sem_origin = FormSemestre.query.get(ue_status["formsemestre_id"])
sem_origin = db.session.get(FormSemestre, ue_status["formsemestre_id"])
u[
"ue_descr_txt"
] = f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}'
@ -371,7 +369,9 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
)
if ue_status["moy"] != "NA":
# détail des modules de l'UE capitalisée
formsemestre_cap = FormSemestre.query.get(ue_status["formsemestre_id"])
formsemestre_cap = db.session.get(
FormSemestre, ue_status["formsemestre_id"]
)
nt_cap: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre_cap
)
@ -388,7 +388,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
_sort_mod_by_matiere(u["modules_capitalized"], nt_cap, etudid)
)
else:
if prefs["bul_show_ue_rangs"] and ue["type"] != sco_codes_parcours.UE_SPORT:
if prefs["bul_show_ue_rangs"] and ue["type"] != codes_cursus.UE_SPORT:
if ue_attente or nt.ue_rangs[ue["ue_id"]][0] is None:
u["ue_descr_txt"] = "%s/%s" % (
scu.RANG_ATTENTE_STR,
@ -696,7 +696,7 @@ def get_etud_rangs_groups(
def etud_descr_situation_semestre(
etudid,
formsemestre_id,
formsemestre: FormSemestre,
ne="",
format="html", # currently unused
show_decisions=True,
@ -721,50 +721,14 @@ def etud_descr_situation_semestre(
decisions_ue : noms (acronymes) des UE validées, séparées par des virgules.
descr_decisions_ue : ' UE acquises: UE1, UE2', ou vide si pas de dec. ou si pas show_uevalid
descr_mention : 'Mention Bien', ou vide si pas de mention ou si pas show_mention
parcours_titre, parcours_code, refcomp_specialite, refcomp_specialite_long
"""
# Fonction utilisée par tous les bulletins (APC ou classiques)
cnx = ndb.GetDBConnexion()
infos = scu.DictDefault(defaultvalue="")
infos = collections.defaultdict(str)
# --- Situation et décisions jury
# démission/inscription ?
events = sco_etud.scolar_events_list(
cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id}
)
date_inscr = None
date_dem = None
date_def = None
for event in events:
event_type = event["event_type"]
if event_type == "INSCRIPTION":
if date_inscr:
# plusieurs inscriptions ???
# date_inscr += ', ' + event['event_date'] + ' (!)'
# il y a eu une erreur qui a laissé un event 'inscription'
# on l'efface:
log(
f"etud_descr_situation_semestre: removing duplicate INSCRIPTION event for etudid={etudid} !"
)
sco_etud.scolar_events_delete(cnx, event["event_id"])
else:
date_inscr = event["event_date"]
elif event_type == "DEMISSION":
# assert date_dem == None, 'plusieurs démissions !'
if date_dem: # cela ne peut pas arriver sauf bug (signale a Evry 2013?)
log(
f"etud_descr_situation_semestre: removing duplicate DEMISSION event for etudid={etudid} !"
)
sco_etud.scolar_events_delete(cnx, event["event_id"])
else:
date_dem = event["event_date"]
elif event_type == "DEFAILLANCE":
if date_def:
log(
f"etud_descr_situation_semestre: removing duplicate DEFAILLANCE event for etudid={etudid} !"
)
sco_etud.scolar_events_delete(cnx, event["event_id"])
else:
date_def = event["event_date"]
date_inscr, date_dem, date_def = _dates_insc_dem_def(etudid, formsemestre.id)
if show_date_inscr:
if not date_inscr:
infos["date_inscription"] = ""
@ -778,6 +742,25 @@ def etud_descr_situation_semestre(
infos["descr_defaillance"] = ""
# Parcours BUT
infos["parcours_titre"] = ""
infos["parcours_code"] = ""
infos["refcomp_specialite"] = ""
infos["refcomp_specialite_long"] = ""
if formsemestre.formation.is_apc():
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
parcour_id = res.etuds_parcour_id[etudid]
parcour: ApcParcours = (
db.session.get(ApcParcours, parcour_id) if parcour_id is not None else None
)
if parcour:
infos["parcours_titre"] = parcour.libelle or ""
infos["parcours_code"] = parcour.code or ""
refcomp = parcour.referentiel
if refcomp:
infos["refcomp_specialite"] = refcomp.specialite
infos["refcomp_specialite_long"] = refcomp.specialite_long
# Décision: valeurs par defaut vides:
infos["decision_jury"] = infos["descr_decision_jury"] = ""
infos["decision_sem"] = ""
@ -798,7 +781,7 @@ def etud_descr_situation_semestre(
infos["date_defaillance"] = date_def
infos["descr_decision_jury"] = f"Défaillant{ne}"
dpv = sco_pvjury.dict_pvjury(formsemestre_id, etudids=[etudid])
dpv = sco_pv_dict.dict_pvjury(formsemestre.id, etudids=[etudid])
if dpv:
infos["decision_sem"] = dpv["decisions"][0]["decision_sem"]
@ -862,6 +845,49 @@ def etud_descr_situation_semestre(
return infos, dpv
def _dates_insc_dem_def(etudid, formsemestre_id) -> tuple:
"Cherche les dates d'inscription, démission et défaillance de l'étudiant"
cnx = ndb.GetDBConnexion()
events = sco_etud.scolar_events_list(
cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id}
)
date_inscr = None
date_dem = None
date_def = None
for event in events:
event_type = event["event_type"]
if event_type == "INSCRIPTION":
if date_inscr:
# plusieurs inscriptions ???
# date_inscr += ', ' + event['event_date'] + ' (!)'
# il y a eu une erreur qui a laissé un event 'inscription'
# on l'efface:
log(
f"etud_descr_situation_semestre: removing duplicate INSCRIPTION event for etudid={etudid} !"
)
sco_etud.scolar_events_delete(cnx, event["event_id"])
else:
date_inscr = event["event_date"]
elif event_type == "DEMISSION":
# assert date_dem == None, 'plusieurs démissions !'
if date_dem: # cela ne peut pas arriver sauf bug (signale a Evry 2013?)
log(
f"etud_descr_situation_semestre: removing duplicate DEMISSION event for etudid={etudid} !"
)
sco_etud.scolar_events_delete(cnx, event["event_id"])
else:
date_dem = event["event_date"]
elif event_type == "DEFAILLANCE":
if date_def:
log(
f"etud_descr_situation_semestre: removing duplicate DEFAILLANCE event for etudid={etudid} !"
)
sco_etud.scolar_events_delete(cnx, event["event_id"])
else:
date_def = event["event_date"]
return date_inscr, date_dem, date_def
def _format_situation_fields(
infos, field_names: list[str], extra_values: list[str]
) -> None:
@ -904,29 +930,31 @@ def formsemestre_bulletinetud(
"""
format = format or "html"
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
if not formsemestre:
raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
bulletin = do_formsemestre_bulletinetud(
formsemestre,
etud.id,
etud,
format=format,
version=version,
xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing,
prefer_mail_perso=prefer_mail_perso,
)[0]
if format not in {"html", "pdfmail"}:
filename = scu.bul_filename(formsemestre, etud, format)
return scu.send_file(bulletin, filename, mime=scu.get_mime_suffix(format)[0])
filename = scu.bul_filename(formsemestre, etud)
mime, suffix = scu.get_mime_suffix(format)
return scu.send_file(bulletin, filename, mime=mime, suffix=suffix)
elif format == "pdfmail":
return ""
H = [
_formsemestre_bulletinetud_header_html(etud, formsemestre, format, version),
bulletin,
render_template(
"bul_foot.html",
"bul_foot.j2",
appreciations=None, # déjà affichées
css_class="bul_classic_foot",
etud=etud,
@ -952,12 +980,13 @@ def can_send_bulletin_by_mail(formsemestre_id):
def do_formsemestre_bulletinetud(
formsemestre: FormSemestre,
etudid: int,
etud: Identite,
version="long", # short, long, selectedevals
format=None,
xml_with_decisions=False, # force décisions dans XML
force_publishing=False, # force publication meme si semestre non publié sur "portail"
prefer_mail_perso=False, # mails envoyés sur adresse perso si non vide
xml_with_decisions: bool = False,
force_publishing: bool = False,
prefer_mail_perso: bool = False,
with_img_signatures_pdf: bool = True,
):
"""Génère le bulletin au format demandé.
Utilisé pour:
@ -965,6 +994,12 @@ def do_formsemestre_bulletinetud(
- le format "oldjson" (les json sont générés à part, voir get_formsemestre_bulletin_etud_json)
- les formats PDF, XML et mail pdf (toutes formations)
Options:
- xml_with_decisions: force décisions dans XML
- force_publishing: force publication meme si semestre non publié sur "portail"
- prefer_mail_perso: mails envoyés sur adresse perso si non vide
- with_img_signatures_pdf: si faux, ne met pas les signatures dans le footer PDF.
Résultat: (bul, filigranne)
bul est str ou bytes au format demandé (html, pdf, pdfmail, pdfpart, xml, json)
et filigranne est un message à placer en "filigranne" (eg "Provisoire").
@ -973,7 +1008,7 @@ def do_formsemestre_bulletinetud(
if format == "xml":
bul = sco_bulletins_xml.make_xml_formsemestre_bulletinetud(
formsemestre.id,
etudid,
etud.id,
xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing,
version=version,
@ -984,7 +1019,7 @@ def do_formsemestre_bulletinetud(
elif format == "json": # utilisé pour classic et "oldjson"
bul = sco_bulletins_json.make_json_formsemestre_bulletinetud(
formsemestre.id,
etudid,
etud.id,
xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing,
version=version,
@ -994,33 +1029,32 @@ def do_formsemestre_bulletinetud(
version = version[:-4] # enlève le "_mat"
if formsemestre.formation.is_apc():
etudiant = Identite.query.get(etudid)
r = bulletin_but.BulletinBUT(formsemestre)
infos = r.bulletin_etud_complet(etudiant, version=version)
bulletins_sem = bulletin_but.BulletinBUT(formsemestre)
bul_dict = bulletins_sem.bulletin_etud_complet(etud, version=version)
else:
infos = formsemestre_bulletinetud_dict(formsemestre.id, etudid)
etud = infos["etud"]
bul_dict = formsemestre_bulletinetud_dict(formsemestre.id, etud.id)
if format == "html":
htm, _ = sco_bulletins_generator.make_formsemestre_bulletinetud(
infos, version=version, format="html"
bul_dict, version=version, format="html"
)
return htm, infos["filigranne"]
return htm, bul_dict["filigranne"]
elif format == "pdf" or format == "pdfpart":
bul, filename = sco_bulletins_generator.make_formsemestre_bulletinetud(
infos,
bul_dict,
version=version,
format="pdf",
stand_alone=(format != "pdfpart"),
with_img_signatures_pdf=with_img_signatures_pdf,
)
if format == "pdf":
return (
scu.sendPDFFile(bul, filename),
infos["filigranne"],
bul_dict["filigranne"],
) # unused ret. value
else:
return bul, infos["filigranne"]
return bul, bul_dict["filigranne"]
elif format == "pdfmail":
# format pdfmail: envoie le pdf par mail a l'etud, et affiche le html
@ -1029,24 +1063,28 @@ def do_formsemestre_bulletinetud(
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
pdfdata, filename = sco_bulletins_generator.make_formsemestre_bulletinetud(
infos, version=version, format="pdf"
bul_dict, version=version, format="pdf"
)
if prefer_mail_perso:
recipient_addr = etud.get("emailperso", "") or etud.get("email", "")
recipient_addr = (
etud.get_first_email("emailperso") or etud.get_first_email()
)
else:
recipient_addr = etud.get("email", "") or etud.get("emailperso", "")
recipient_addr = etud.get_first_email() or etud.get_first_email(
"emailperso"
)
if not recipient_addr:
flash(f"{etud['nomprenom']} n'a pas d'adresse e-mail !")
return False, infos["filigranne"]
flash(f"{etud.nomprenom} n'a pas d'adresse e-mail !")
return False, bul_dict["filigranne"]
else:
mail_bulletin(formsemestre.id, infos, pdfdata, filename, recipient_addr)
mail_bulletin(formsemestre.id, bul_dict, pdfdata, filename, recipient_addr)
flash(f"mail envoyé à {recipient_addr}")
return True, infos["filigranne"]
return True, bul_dict["filigranne"]
raise ValueError("do_formsemestre_bulletinetud: invalid format (%s)" % format)
raise ValueError(f"do_formsemestre_bulletinetud: invalid format ({format})")
def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
@ -1082,9 +1120,9 @@ def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
subject = f"""Relevé de notes de {etud["nomprenom"]}"""
recipients = [recipient_addr]
sender = sco_preferences.get_preference("email_from_addr", formsemestre_id)
sender = email.get_from_addr()
if copy_addr:
bcc = copy_addr.strip()
bcc = copy_addr.strip().split(",")
else:
bcc = ""
@ -1094,7 +1132,7 @@ def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
subject,
sender,
recipients,
bcc=[bcc],
bcc=bcc,
text_body=hea,
attachments=[
{"filename": filename, "mimetype": scu.PDF_MIMETYPE, "data": pdfdata}
@ -1202,13 +1240,13 @@ def make_menu_autres_operations(
"enabled": current_user.has_permission(Permission.ScoImplement),
},
{
"title": "Enregistrer une validation d'UE antérieure",
"title": "Gérer les validations d'UEs antérieures",
"endpoint": "notes.formsemestre_validate_previous_ue",
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": sco_permissions_check.can_validate_sem(formsemestre.id),
"enabled": formsemestre.can_edit_jury(),
},
{
"title": "Enregistrer note d'une UE externe",
@ -1217,7 +1255,7 @@ def make_menu_autres_operations(
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": sco_permissions_check.can_validate_sem(formsemestre.id)
"enabled": formsemestre.can_edit_jury()
and not formsemestre.formation.is_apc(),
},
{
@ -1227,7 +1265,7 @@ def make_menu_autres_operations(
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": sco_permissions_check.can_validate_sem(formsemestre.id),
"enabled": formsemestre.can_edit_jury(),
},
{
"title": "Éditer PV jury",
@ -1259,7 +1297,7 @@ def _formsemestre_bulletinetud_header_html(
cssstyles=["css/radar_bulletin.css"],
),
render_template(
"bul_head.html",
"bul_head.j2",
etud=etud,
format=format,
formsemestre=formsemestre,

View File

@ -83,23 +83,27 @@ class BulletinGenerator:
def __init__(
self,
infos,
bul_dict,
authuser=None,
version="long",
filigranne=None,
server_name=None,
with_img_signatures_pdf: bool = True,
):
from app.scodoc import sco_preferences
if not version in scu.BULLETINS_VERSIONS:
if version not in scu.BULLETINS_VERSIONS:
raise ValueError("invalid version code !")
self.infos = infos
self.authuser = authuser # nécessaire pour version HTML qui contient liens dépendant de l'utilisateur
self.bul_dict = bul_dict
self.infos = bul_dict # legacy code compat
# authuser nécessaire pour version HTML qui contient liens dépendants de l'utilisateur
self.authuser = authuser
self.version = version
self.filigranne = filigranne
self.server_name = server_name
self.with_img_signatures_pdf = with_img_signatures_pdf
# Store preferences for convenience:
formsemestre_id = self.infos["formsemestre_id"]
formsemestre_id = self.bul_dict["formsemestre_id"]
self.preferences = sco_preferences.SemPreferences(formsemestre_id)
self.diagnostic = None # error message if any problem
# Common PDF styles:
@ -125,13 +129,13 @@ class BulletinGenerator:
def get_filename(self):
"""Build a filename to be proposed to the web client"""
sem = sco_formsemestre.get_formsemestre(self.infos["formsemestre_id"])
return scu.bul_filename_old(sem, self.infos["etud"], "pdf")
sem = sco_formsemestre.get_formsemestre(self.bul_dict["formsemestre_id"])
return scu.bul_filename_old(sem, self.bul_dict["etud"], "pdf")
def generate(self, format="", stand_alone=True):
"""Return bulletin in specified format"""
if not format in self.supported_formats:
raise ValueError("unsupported bulletin format (%s)" % format)
raise ValueError(f"unsupported bulletin format ({format})")
try:
PDFLOCK.acquire() # this lock is necessary since reportlab is not re-entrant
if format == "html":
@ -139,7 +143,7 @@ class BulletinGenerator:
elif format == "pdf":
return self.generate_pdf(stand_alone=stand_alone)
else:
raise ValueError("invalid bulletin format (%s)" % format)
raise ValueError(f"invalid bulletin format ({format})")
finally:
PDFLOCK.release()
@ -161,11 +165,13 @@ class BulletinGenerator:
"""
from app.scodoc import sco_preferences
formsemestre_id = self.infos["formsemestre_id"]
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(
self.infos["etud"]["nomprenom"],
filigranne=self.infos["filigranne"],
footer_content=f"""ScoDoc - Bulletin de {self.infos["etud"]["nomprenom"]} - {time.strftime("%d/%m/%Y %H:%M")}""",
self.bul_dict["etat_civil"],
filigranne=self.bul_dict["filigranne"],
footer_content=f"""ScoDoc - Bulletin de {nomprenom} - {time.strftime("%d/%m/%Y %H:%M")}""",
)
story = []
# partie haute du bulletin
@ -206,8 +212,7 @@ class BulletinGenerator:
document,
author="%s %s (E. Viennet) [%s]"
% (sco_version.SCONAME, sco_version.SCOVERSION, self.description),
title="Bulletin %s de %s"
% (sem["titremois"], self.infos["etud"]["nomprenom"]),
title=f"""Bulletin {sem["titremois"]} de {etat_civil}""",
subject="Bulletin de note",
margins=self.margins,
server_name=self.server_name,
@ -245,10 +250,11 @@ class BulletinGenerator:
# ---------------------------------------------------------------------------
def make_formsemestre_bulletinetud(
infos,
bul_dict,
version=None, # short, long, selectedevals
format="pdf", # html, pdf
stand_alone=True,
with_img_signatures_pdf: bool = True,
):
"""Bulletin de notes
@ -259,10 +265,10 @@ def make_formsemestre_bulletinetud(
from app.scodoc import sco_preferences
version = version or "long"
if not version in scu.BULLETINS_VERSIONS:
if version not in scu.BULLETINS_VERSIONS:
raise ValueError("invalid version code !")
formsemestre_id = infos["formsemestre_id"]
formsemestre_id = bul_dict["formsemestre_id"]
bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id)
gen_class = None
@ -271,24 +277,23 @@ def make_formsemestre_bulletinetud(
# si pas trouvé (modifs locales bizarres ,), ré-essaye avec la valeur par défaut
bulletin_default_class_name(),
):
if infos.get("type") == "BUT" and format.startswith("pdf"):
if bul_dict.get("type") == "BUT" and format.startswith("pdf"):
gen_class = bulletin_get_class(bul_class_name + "BUT")
if gen_class is None:
gen_class = bulletin_get_class(bul_class_name)
if gen_class is None:
raise ValueError(
"Type de bulletin PDF invalide (paramètre: %s)" % bul_class_name
)
raise ValueError(f"Type de bulletin PDF invalide (paramètre: {bul_class_name})")
try:
PDFLOCK.acquire()
bul_generator = gen_class(
infos,
bul_dict,
authuser=current_user,
version=version,
filigranne=infos["filigranne"],
filigranne=bul_dict["filigranne"],
server_name=request.url_root,
with_img_signatures_pdf=with_img_signatures_pdf,
)
if format not in bul_generator.supported_formats:
# use standard generator
@ -299,23 +304,22 @@ def make_formsemestre_bulletinetud(
bul_class_name = bulletin_default_class_name()
gen_class = bulletin_get_class(bul_class_name)
bul_generator = gen_class(
infos,
bul_dict,
authuser=current_user,
version=version,
filigranne=infos["filigranne"],
filigranne=bul_dict["filigranne"],
server_name=request.url_root,
with_img_signatures_pdf=with_img_signatures_pdf,
)
data = bul_generator.generate(format=format, stand_alone=stand_alone)
finally:
PDFLOCK.release()
if bul_generator.diagnostic:
log("bul_error: %s" % bul_generator.diagnostic)
log(f"bul_error: {bul_generator.diagnostic}")
raise NoteProcessError(bul_generator.diagnostic)
filename = bul_generator.get_filename()
return data, filename

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