Amélioration affichage ( Ticket #562 ) #563
138
app/__init__.py
138
app/__init__.py
|
@ -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}
|
||||
"""
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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", "")
|
||||
)
|
||||
|
|
|
@ -8,16 +8,23 @@
|
|||
ScoDoc 9 API : accès aux formations
|
||||
"""
|
||||
|
||||
from flask import g, jsonify
|
||||
from flask import flash, g, request
|
||||
from flask_json import as_json
|
||||
from flask_login import login_required
|
||||
|
||||
import app
|
||||
from app import db, log
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.models.formations import Formation
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.models.moduleimpls import ModuleImpl
|
||||
from app.models import (
|
||||
ApcNiveau,
|
||||
ApcParcours,
|
||||
Formation,
|
||||
FormSemestre,
|
||||
ModuleImpl,
|
||||
UniteEns,
|
||||
)
|
||||
from app.scodoc import sco_formations
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
|
||||
|
@ -27,6 +34,7 @@ from app.scodoc.sco_permissions import Permission
|
|||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def formations():
|
||||
"""
|
||||
Retourne la liste de toutes les formations (tous départements)
|
||||
|
@ -35,7 +43,7 @@ def formations():
|
|||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
|
||||
return jsonify([d.to_dict() for d in query])
|
||||
return [d.to_dict() for d in query]
|
||||
|
||||
|
||||
@bp.route("/formations_ids")
|
||||
|
@ -43,6 +51,7 @@ def formations():
|
|||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def formations_ids():
|
||||
"""
|
||||
Retourne la liste de toutes les id de formations (tous départements)
|
||||
|
@ -52,7 +61,7 @@ def formations_ids():
|
|||
query = Formation.query
|
||||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
return jsonify([d.id for d in query])
|
||||
return [d.id for d in query]
|
||||
|
||||
|
||||
@bp.route("/formation/<int:formation_id>")
|
||||
|
@ -60,6 +69,7 @@ def formations_ids():
|
|||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def formation_by_id(formation_id: int):
|
||||
"""
|
||||
La formation d'id donné
|
||||
|
@ -84,7 +94,7 @@ def formation_by_id(formation_id: int):
|
|||
query = Formation.query.filter_by(id=formation_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
return jsonify(query.first_or_404().to_dict())
|
||||
return query.first_or_404().to_dict()
|
||||
|
||||
|
||||
@bp.route(
|
||||
|
@ -106,6 +116,7 @@ def formation_by_id(formation_id: int):
|
|||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def formation_export_by_formation_id(formation_id: int, export_ids=False):
|
||||
"""
|
||||
Retourne la formation, avec UE, matières, modules
|
||||
|
@ -174,7 +185,7 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
|
|||
]
|
||||
},
|
||||
{
|
||||
"titre": "Se sensibiliser \u00e0 l'hygi\u00e8ne informatique et \u00e0 la cybers\u00e9curit\u00e9",
|
||||
"titre": "Se sensibiliser \u00e0 l'hygi\u00e8ne informatique...",
|
||||
"abbrev": "Hygi\u00e8ne informatique",
|
||||
"code": "SAE11",
|
||||
"heures_cours": 0.0,
|
||||
|
@ -212,7 +223,7 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
|
|||
except ValueError:
|
||||
return json_error(500, message="Erreur inconnue")
|
||||
|
||||
return jsonify(data)
|
||||
return data
|
||||
|
||||
|
||||
@bp.route("/formation/<int:formation_id>/referentiel_competences")
|
||||
|
@ -220,6 +231,7 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
|
|||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def referentiel_competences(formation_id: int):
|
||||
"""
|
||||
Retourne le référentiel de compétences
|
||||
|
@ -233,8 +245,8 @@ def referentiel_competences(formation_id: int):
|
|||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
formation = query.first_or_404(formation_id)
|
||||
if formation.referentiel_competence is None:
|
||||
return jsonify(None)
|
||||
return jsonify(formation.referentiel_competence.to_dict())
|
||||
return None
|
||||
return formation.referentiel_competence.to_dict()
|
||||
|
||||
|
||||
@bp.route("/moduleimpl/<int:moduleimpl_id>")
|
||||
|
@ -242,6 +254,7 @@ def referentiel_competences(formation_id: int):
|
|||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def moduleimpl(moduleimpl_id: int):
|
||||
"""
|
||||
Retourne un moduleimpl en fonction de son id
|
||||
|
@ -281,4 +294,92 @@ def moduleimpl(moduleimpl_id: int):
|
|||
if g.scodoc_dept:
|
||||
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
modimpl: ModuleImpl = query.first_or_404()
|
||||
return jsonify(modimpl.to_dict(convert_objects=True))
|
||||
return modimpl.to_dict(convert_objects=True)
|
||||
|
||||
|
||||
@bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
|
||||
@api_web_bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoChangeFormation)
|
||||
@as_json
|
||||
def set_ue_parcours(ue_id: int):
|
||||
"""Associe UE et parcours BUT.
|
||||
La liste des ids de parcours est passée en argument JSON.
|
||||
JSON arg: [parcour_id1, parcour_id2, ...]
|
||||
"""
|
||||
query = UniteEns.query.filter_by(id=ue_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
|
||||
ue: UniteEns = query.first_or_404()
|
||||
parcours_ids = request.get_json(force=True) or [] # may raise 400 Bad Request
|
||||
if parcours_ids == [""]:
|
||||
parcours = []
|
||||
else:
|
||||
parcours = [
|
||||
ApcParcours.query.get_or_404(int(parcour_id)) for parcour_id in parcours_ids
|
||||
]
|
||||
log(f"set_ue_parcours: ue_id={ue.id} parcours_ids={parcours_ids}")
|
||||
ok, error_message = ue.set_parcours(parcours)
|
||||
if not ok:
|
||||
return json_error(404, error_message)
|
||||
return {"status": ok, "message": error_message}
|
||||
|
||||
|
||||
@bp.route(
|
||||
"/assoc_ue_niveau/<int:ue_id>/<int:niveau_id>",
|
||||
methods=["POST"],
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/assoc_ue_niveau/<int:ue_id>/<int:niveau_id>",
|
||||
methods=["POST"],
|
||||
)
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoChangeFormation)
|
||||
@as_json
|
||||
def assoc_ue_niveau(ue_id: int, niveau_id: int):
|
||||
"""Associe l'UE au niveau de compétence"""
|
||||
query = UniteEns.query.filter_by(id=ue_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
|
||||
ue: UniteEns = query.first_or_404()
|
||||
niveau: ApcNiveau = ApcNiveau.query.get_or_404(niveau_id)
|
||||
ok, error_message = ue.set_niveau_competence(niveau)
|
||||
if not ok:
|
||||
if g.scodoc_dept: # "usage web"
|
||||
flash(error_message, "error")
|
||||
return json_error(404, error_message)
|
||||
if g.scodoc_dept: # "usage web"
|
||||
flash(f"""{ue.acronyme} associée au niveau "{niveau.libelle}" """)
|
||||
return {"status": 0}
|
||||
|
||||
|
||||
@bp.route(
|
||||
"/desassoc_ue_niveau/<int:ue_id>",
|
||||
methods=["POST"],
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/desassoc_ue_niveau/<int:ue_id>",
|
||||
methods=["POST"],
|
||||
)
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoChangeFormation)
|
||||
@as_json
|
||||
def desassoc_ue_niveau(ue_id: int):
|
||||
"""Désassocie cette UE de son niveau de compétence
|
||||
(si elle n'est pas associée, ne fait rien)
|
||||
"""
|
||||
query = UniteEns.query.filter_by(id=ue_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
|
||||
ue: UniteEns = query.first_or_404()
|
||||
ue.niveau_competence = None
|
||||
db.session.add(ue)
|
||||
db.session.commit()
|
||||
log(f"desassoc_ue_niveau: {ue}")
|
||||
if g.scodoc_dept:
|
||||
# "usage web"
|
||||
flash(f"UE {ue.acronyme} dé-associée")
|
||||
return {"status": 0}
|
||||
|
|
|
@ -7,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()
|
||||
|
|
332
app/api/jury.py
332
app/api/jury.py
|
@ -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"
|
||||
|
|
|
@ -30,11 +30,10 @@ Contrib @jmp
|
|||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from flask import jsonify, g, send_file
|
||||
from flask_login import login_required
|
||||
from flask import Response, send_file
|
||||
from flask_json import as_json
|
||||
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.api import requested_format
|
||||
from app.api import api_bp as bp
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.models import Departement
|
||||
from app.scodoc.sco_logos import list_logos, find_logo
|
||||
|
@ -47,10 +46,11 @@ from app.scodoc.sco_permissions import Permission
|
|||
@bp.route("/logos")
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoSuperAdmin)
|
||||
@as_json
|
||||
def api_get_glob_logos():
|
||||
"""Liste tous les logos"""
|
||||
logos = list_logos()[None]
|
||||
return jsonify(list(logos.keys()))
|
||||
return list(logos.keys())
|
||||
|
||||
|
||||
@bp.route("/logo/<string:logoname>")
|
||||
|
@ -68,27 +68,29 @@ def api_get_glob_logo(logoname):
|
|||
)
|
||||
|
||||
|
||||
def core_get_logos(dept_id):
|
||||
def _core_get_logos(dept_id) -> list:
|
||||
logos = list_logos().get(dept_id, dict())
|
||||
return jsonify(list(logos.keys()))
|
||||
return list(logos.keys())
|
||||
|
||||
|
||||
@bp.route("/departement/<string:departement>/logos")
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoSuperAdmin)
|
||||
@as_json
|
||||
def api_get_local_logos_by_acronym(departement):
|
||||
dept_id = Departement.from_acronym(departement).id
|
||||
return core_get_logos(dept_id)
|
||||
return _core_get_logos(dept_id)
|
||||
|
||||
|
||||
@bp.route("/departement/id/<int:dept_id>/logos")
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoSuperAdmin)
|
||||
@as_json
|
||||
def api_get_local_logos_by_id(dept_id):
|
||||
return core_get_logos(dept_id)
|
||||
return _core_get_logos(dept_id)
|
||||
|
||||
|
||||
def core_get_logo(dept_id, logoname):
|
||||
def _core_get_logo(dept_id, logoname) -> Response:
|
||||
logo = find_logo(logoname=logoname, dept_id=dept_id)
|
||||
if logo is None:
|
||||
return json_error(404, message="logo not found")
|
||||
|
@ -105,11 +107,11 @@ def core_get_logo(dept_id, logoname):
|
|||
@permission_required(Permission.ScoSuperAdmin)
|
||||
def api_get_local_logo_dept_by_acronym(departement, logoname):
|
||||
dept_id = Departement.from_acronym(departement).id
|
||||
return core_get_logo(dept_id, logoname)
|
||||
return _core_get_logo(dept_id, logoname)
|
||||
|
||||
|
||||
@bp.route("/departement/id/<int:dept_id>/logo/<string:logoname>")
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoSuperAdmin)
|
||||
def api_get_local_logo_dept_by_id(dept_id, logoname):
|
||||
return core_get_logo(dept_id, logoname)
|
||||
return _core_get_logo(dept_id, logoname)
|
||||
|
|
|
@ -7,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}
|
||||
|
|
|
@ -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})
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -6,3 +6,4 @@ from flask import Blueprint
|
|||
bp = Blueprint("auth", __name__)
|
||||
|
||||
from app.auth import routes
|
||||
from app.auth import cas
|
||||
|
|
|
@ -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
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 :</b>
|
||||
<select class="select_parcour"
|
||||
onchange="set_ue_parcour(this);"
|
||||
data-ue_id="{ue.id}"
|
||||
data-setter="{
|
||||
url_for( "notes.set_ue_parcours", scodoc_dept=g.scodoc_dept)
|
||||
}">
|
||||
<option value="" {
|
||||
'selected' if ue.parcour is None else ''
|
||||
}>Tous</option>
|
||||
{newline.join(parcours_options)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<b>Niveau de compétence :</b>
|
||||
<select class="select_niveau_ue"
|
||||
onchange="set_ue_niveau_competence(this);"
|
||||
data-ue_id="{ue.id}"
|
||||
data-setter="{
|
||||
url_for( "notes.set_ue_niveau_competence", scodoc_dept=g.scodoc_dept)
|
||||
}">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
H = [
|
||||
"""
|
||||
<div class="ue_advanced">
|
||||
<h3>Parcours du BUT</h3>
|
||||
"""
|
||||
]
|
||||
# Choix des parcours
|
||||
ue_pids = [p.id for p in ue.parcours]
|
||||
H.append("""<form id="choix_parcours">""")
|
||||
|
||||
ects_differents = {
|
||||
ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours
|
||||
} != {None}
|
||||
for parcour in ref_comp.parcours:
|
||||
ects_parcour = ue.get_ects(parcour)
|
||||
ects_parcour_txt = (
|
||||
f" ({ue.get_ects(parcour):.3g} ects)" if ects_differents else ""
|
||||
)
|
||||
H.append(
|
||||
f"""<label><input type="checkbox" name="{parcour.id}" value="{parcour.id}"
|
||||
{'checked' if parcour.id in ue_pids else ""}
|
||||
onclick="set_ue_parcour(this);"
|
||||
data-setter="{url_for("apiweb.set_ue_parcours",
|
||||
scodoc_dept=g.scodoc_dept, ue_id=ue.id)}"
|
||||
>{parcour.code}{ects_parcour_txt}</label>"""
|
||||
)
|
||||
H.append("""</form>""")
|
||||
#
|
||||
H.append(
|
||||
f"""
|
||||
<ul>
|
||||
<li>
|
||||
<a class="stdlink" href="{
|
||||
url_for("notes.ue_parcours_ects",
|
||||
scodoc_dept=g.scodoc_dept, ue_id=ue.id)
|
||||
}">définir des ECTS différents dans chaque parcours</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
return "\n".join(H)
|
||||
|
||||
|
||||
def get_ue_niveaux_options_html(ue: UniteEns) -> str:
|
||||
|
@ -85,9 +86,7 @@ def get_ue_niveaux_options_html(ue: UniteEns) -> str:
|
|||
return ""
|
||||
# Les niveaux:
|
||||
annee = ue.annee() # 1, 2, 3
|
||||
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(
|
||||
annee, parcour=ue.parcour
|
||||
)
|
||||
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee, ue.parcours)
|
||||
|
||||
# Les niveaux déjà associés à d'autres UE du même semestre
|
||||
autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx)
|
||||
|
@ -101,7 +100,7 @@ def get_ue_niveaux_options_html(ue: UniteEns) -> str:
|
|||
options.append(
|
||||
f"""<option value="{n.id}" {
|
||||
'selected' if ue.niveau_competence == n else ''}
|
||||
>{n.annee} {n.competence.titre_long}
|
||||
>{n.annee} {n.competence.titre} / {n.competence.titre_long}
|
||||
niveau {n.ordre}</option>"""
|
||||
)
|
||||
options.append("""</optgroup>""")
|
||||
|
@ -116,7 +115,7 @@ def get_ue_niveaux_options_html(ue: UniteEns) -> str:
|
|||
options.append(
|
||||
f"""<option value="{n.id}" {'selected'
|
||||
if ue.niveau_competence == n else ''}
|
||||
{disabled}>{n.annee} {n.competence.titre_long}
|
||||
{disabled}>{n.annee} {n.competence.titre} / {n.competence.titre_long}
|
||||
niveau {n.ordre}</option>"""
|
||||
)
|
||||
options.append("""</optgroup>""")
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
où 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
|
||||
|
|
|
@ -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(
|
||||
|
|
1345
app/but/jury_but.py
1345
app/but/jury_but.py
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
|
|
|
@ -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
|
||||
où 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
|
|
@ -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
|
|
@ -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 ré-é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
|
||||
|
|
|
@ -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 :</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(
|
||||
|
|
|
@ -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()}",
|
||||
)
|
|
@ -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
|
||||
# }
|
|
@ -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
|
||||
}
|
|
@ -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 d’ajout 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 d’association dès lors qu’une 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 qu’un é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>
|
||||
Lorsqu’un é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 l’activité 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 d’UE du semestre. Exemple : 16 en
|
||||
sport ajoute 6*0,03 = 0,18 points à toutes les moyennes d’UE 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 d’UE 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, l’IUT d’Orlé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
|
||||
d’anné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 l’IUT,
|
||||
de l’Université ou à l’ensemble 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
|
||||
|
||||
L’IUT de Sceaux (Université de Paris-Saclay) propose aux étudiants un seul enseignement
|
||||
non rattaché aux UE : l’option Sport.
|
||||
<p>
|
||||
Cette option donne à l’étudiant qui la suit une bonification qui s’applique uniquement
|
||||
si sa note est supérieure à 10.
|
||||
</p>
|
||||
<p>
|
||||
Cette bonification s’applique sur l’ensemble des UE d’un 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 à l’option 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)
|
||||
|
|
|
@ -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
|
||||
|
|
119
app/comp/jury.py
119
app/comp/jury.py
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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="{
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
32
app/email.py
32
app/email.py
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
# empty but required for pylint
|
|
@ -0,0 +1,35 @@
|
|||
from flask import g, url_for
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import FieldList, Form, DecimalField, validators
|
||||
|
||||
from app.models import ApcParcours, ApcReferentielCompetences, UniteEns
|
||||
|
||||
|
||||
class _UEParcoursECTSForm(FlaskForm):
|
||||
"Formulaire association ECTS par parcours à une UE"
|
||||
# construit dynamiquement ci-dessous
|
||||
|
||||
|
||||
def UEParcoursECTSForm(ue: UniteEns) -> FlaskForm:
|
||||
"Génère formulaire association ECTS par parcours à une UE"
|
||||
|
||||
class F(_UEParcoursECTSForm):
|
||||
pass
|
||||
|
||||
parcours: list[ApcParcours] = ue.formation.referentiel_competence.parcours
|
||||
# Initialise un champs de saisie par parcours
|
||||
for parcour in parcours:
|
||||
ects = ue.get_ects(parcour, only_parcours=True)
|
||||
setattr(
|
||||
F,
|
||||
f"ects_parcour_{parcour.id}",
|
||||
DecimalField(
|
||||
f"Parcours {parcour.code}",
|
||||
validators=[
|
||||
validators.Optional(),
|
||||
validators.NumberRange(min=0, max=30),
|
||||
],
|
||||
default=ects,
|
||||
),
|
||||
)
|
||||
return F()
|
|
@ -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()
|
|
@ -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,
|
||||
|
|
|
@ -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})
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
né(e) le 7/06/1974
|
||||
à Paris
|
||||
Si with_paragraph (défaut):
|
||||
M. Pierre Dupont
|
||||
n° 12345678
|
||||
né(e) le 7/06/1974
|
||||
à Paris
|
||||
Sinon:
|
||||
M. Pierre Dupont
|
||||
"""
|
||||
return f"""{self.nomprenom}{line_sep}n°{self.code_nip or ""}{line_sep}né{self.e} le {self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{line_sep}à {self.lieu_naissance or ""}"""
|
||||
if with_paragraph:
|
||||
return f"""{self.etat_civil}{line_sep}n°{self.code_nip or ""}{line_sep}né{self.e} le {
|
||||
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
|
||||
line_sep}à {self.lieu_naissance or ""}"""
|
||||
return self.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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
"""
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
|
@ -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(
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
||||
# ------------------------------------------------------------------------------------------------------------------
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>")
|
||||
|
|
|
@ -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
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
où 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,
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue