forked from ScoDoc/ScoDoc
Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
ab0466b1b5 |
170
app/__init__.py
170
app/__init__.py
@ -3,7 +3,6 @@
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
@ -13,42 +12,29 @@ 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
|
||||
from flask import abort, flash, has_request_context, jsonify
|
||||
from flask import render_template
|
||||
|
||||
# from flask.json import JSONEncoder
|
||||
from flask.json import JSONEncoder
|
||||
from flask.logging import default_handler
|
||||
|
||||
from flask_bootstrap import Bootstrap
|
||||
from flask_caching import Cache
|
||||
from flask_json import FlaskJSON, json_response
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager, current_user
|
||||
from flask_mail import Mail
|
||||
from flask_migrate import Migrate
|
||||
from flask_bootstrap import Bootstrap
|
||||
from flask_moment import Moment
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
from jinja2 import select_autoescape
|
||||
import sqlalchemy as sa
|
||||
|
||||
from flask_cas import CAS
|
||||
import werkzeug.debug
|
||||
from flask_caching import Cache
|
||||
import sqlalchemy
|
||||
|
||||
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
|
||||
|
||||
@ -74,20 +60,11 @@ cache = Cache(
|
||||
|
||||
|
||||
def handle_sco_value_error(exc):
|
||||
return render_template("sco_value_error.j2", exc=exc), 404
|
||||
return render_template("sco_value_error.html", exc=exc), 404
|
||||
|
||||
|
||||
def handle_access_denied(exc):
|
||||
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
|
||||
return render_template("error_access_denied.html", exc=exc), 403
|
||||
|
||||
|
||||
def internal_server_error(exc):
|
||||
@ -95,13 +72,9 @@ 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.j2",
|
||||
"error_500.html",
|
||||
SCOVERSION=sco_version.SCOVERSION,
|
||||
date=datetime.datetime.now().isoformat(),
|
||||
exc=exc,
|
||||
@ -119,12 +92,9 @@ def handle_sco_bug(exc):
|
||||
"""Un bug, en général rare, sur lequel les dev cherchent des
|
||||
informations pour le corriger.
|
||||
"""
|
||||
if current_app.config["TESTING"] or current_app.config["DEBUG"]:
|
||||
raise ScoException # for development servers only
|
||||
else:
|
||||
Thread(
|
||||
target=_async_dump, args=(current_app._get_current_object(), request.url)
|
||||
).start()
|
||||
Thread(
|
||||
target=_async_dump, args=(current_app._get_current_object(), request.url)
|
||||
).start()
|
||||
|
||||
return internal_server_error(exc)
|
||||
|
||||
@ -141,27 +111,23 @@ def _async_dump(app, request_url: str):
|
||||
|
||||
|
||||
def handle_invalid_usage(error):
|
||||
response = json_response(data_=error.to_dict())
|
||||
response = jsonify(error.to_dict())
|
||||
response.status_code = error.status_code
|
||||
return response
|
||||
|
||||
|
||||
# JSON ENCODING
|
||||
# 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)):
|
||||
class ScoDocJSONEncoder(JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, (datetime.datetime, datetime.date)):
|
||||
return o.isoformat()
|
||||
elif isinstance(o, ApoEtapeVDI):
|
||||
return str(o)
|
||||
else:
|
||||
return json.JSONEncoder.default(self, o)
|
||||
|
||||
return super().default(o)
|
||||
|
||||
|
||||
def render_raw_html(template_filename: str, **args) -> str:
|
||||
"""Load and render an HTML file _without_ using Flask
|
||||
Necessary for 503 error message, when DB is down and Flask may be broken.
|
||||
Necessary for 503 error mesage, when DB is down and Flask may be broken.
|
||||
"""
|
||||
template_path = os.path.join(
|
||||
current_app.config["SCODOC_DIR"],
|
||||
@ -176,7 +142,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.j2", SCOVERSION=sco_version.SCOVERSION), 503
|
||||
return render_raw_html("error_503.html", SCOVERSION=sco_version.SCOVERSION), 503
|
||||
|
||||
|
||||
class LogRequestFormatter(logging.Formatter):
|
||||
@ -255,33 +221,15 @@ 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)
|
||||
FlaskJSON(app)
|
||||
|
||||
# Pour conserver l'ordre des objets dans les JSON:
|
||||
# e.g. l'ordre des UE dans les bulletins
|
||||
app.json.sort_keys = False
|
||||
app.json_encoder = ScoDocJSONEncoder
|
||||
app.logger.setLevel(logging.INFO)
|
||||
|
||||
# Evite de logguer toutes les requetes dans notre log
|
||||
logging.getLogger("werkzeug").disabled = True
|
||||
app.logger.setLevel(app.config["LOG_LEVEL"])
|
||||
if app.config["TESTING"] or app.config["DEBUG"]:
|
||||
# S'arrête sur tous les warnings, sauf
|
||||
# flask_sqlalchemy/query (pb deprecation du model.get())
|
||||
warnings.filterwarnings("error", module="flask_sqlalchemy/query")
|
||||
# warnings.filterwarnings("ignore", module="json/provider.py") xxx sans effet en test
|
||||
if app.config["DEBUG"]:
|
||||
# comme on a désactivé ci-dessus les logs de werkzeug,
|
||||
# on affiche nous même le PIN en mode debug:
|
||||
print(
|
||||
f""" * Debugger is active!
|
||||
* Debugger PIN: {werkzeug.debug.get_pin_and_cookie_name(app)[0]}
|
||||
"""
|
||||
)
|
||||
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# 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):
|
||||
@ -292,7 +240,6 @@ 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)
|
||||
@ -303,7 +250,6 @@ 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)
|
||||
@ -325,9 +271,6 @@ 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/...
|
||||
@ -427,15 +370,6 @@ 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
|
||||
|
||||
|
||||
@ -444,7 +378,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 sa.exc.OperationalError:
|
||||
except sqlalchemy.exc.OperationalError:
|
||||
abort(503)
|
||||
if not dept:
|
||||
raise ScoValueError(f"Invalid dept: {scodoc_dept}")
|
||||
@ -522,33 +456,11 @@ def truncate_database():
|
||||
"""
|
||||
# use a stored SQL function, see createtables.sql
|
||||
try:
|
||||
db.session.execute(sa.text("SELECT truncate_tables('scodoc');"))
|
||||
db.session.execute("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
|
||||
SELECT sequence_name
|
||||
FROM information_schema.sequences
|
||||
ORDER BY sequence_name ;
|
||||
BEGIN
|
||||
FOR stmt IN statements LOOP
|
||||
EXECUTE 'ALTER SEQUENCE ' || quote_ident(stmt.sequence_name) || ' RESTART;';
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
SELECT reset_sequences('scodoc');
|
||||
"""
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def clear_scodoc_cache():
|
||||
@ -566,10 +478,12 @@ def clear_scodoc_cache():
|
||||
|
||||
|
||||
# --------- Logging
|
||||
def log(msg: str):
|
||||
def log(msg: str, silent_test=True):
|
||||
"""log a message.
|
||||
If Flask app, use configured logger, else stderr.
|
||||
"""
|
||||
if silent_test and current_app and current_app.config["TESTING"]:
|
||||
return
|
||||
try:
|
||||
dept = getattr(g, "scodoc_dept", "")
|
||||
msg = f" ({dept}) {msg}"
|
||||
@ -594,9 +508,10 @@ def log_call_stack():
|
||||
|
||||
# Alarms by email:
|
||||
def send_scodoc_alarm(subject, txt):
|
||||
from app.scodoc import sco_preferences
|
||||
from app import email
|
||||
|
||||
sender = email.get_from_addr()
|
||||
sender = sco_preferences.get_preference("email_from_addr")
|
||||
email.send_email(subject, sender, ["exception@scodoc.org"], txt)
|
||||
|
||||
|
||||
@ -613,22 +528,3 @@ 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,9 +9,6 @@ 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)
|
||||
@ -46,6 +43,5 @@ from app.api import (
|
||||
jury,
|
||||
logos,
|
||||
partitions,
|
||||
semset,
|
||||
users,
|
||||
)
|
||||
|
@ -1,15 +1,14 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 API : Absences
|
||||
"""
|
||||
|
||||
from flask_json import as_json
|
||||
from flask import jsonify
|
||||
|
||||
from app import db
|
||||
from app.api import api_bp as bp, API_CLIENT_ERROR
|
||||
from app.api import api_bp as bp
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.models import Identite
|
||||
@ -20,12 +19,10 @@ 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
|
||||
@ -52,7 +49,7 @@ def absences(etudid: int = None):
|
||||
}
|
||||
]
|
||||
"""
|
||||
etud = db.session.get(Identite, etudid)
|
||||
etud = Identite.query.get(etudid)
|
||||
if etud is None:
|
||||
return json_error(404, message="etudiant inexistant")
|
||||
# Absences de l'étudiant
|
||||
@ -60,13 +57,12 @@ 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 abs_list
|
||||
return jsonify(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é
|
||||
@ -97,7 +93,7 @@ def absences_just(etudid: int = None):
|
||||
}
|
||||
]
|
||||
"""
|
||||
etud = db.session.get(Identite, etudid)
|
||||
etud = Identite.query.get(etudid)
|
||||
if etud is None:
|
||||
return json_error(404, message="etudiant inexistant")
|
||||
|
||||
@ -107,7 +103,7 @@ def absences_just(etudid: int = None):
|
||||
]
|
||||
for absence in abs_just:
|
||||
absence["jour"] = absence["jour"].isoformat()
|
||||
return abs_just
|
||||
return jsonify(abs_just)
|
||||
|
||||
|
||||
@bp.route(
|
||||
@ -120,7 +116,6 @@ 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)
|
||||
@ -172,7 +167,7 @@ def abs_groupe_etat(group_id: int, date_debut=None, date_fin=None):
|
||||
}
|
||||
data.append(absence)
|
||||
|
||||
return data
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
# XXX TODO EV: A REVOIR (data json dans le POST + modifier les routes)
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -8,10 +8,10 @@
|
||||
API : billets d'absences
|
||||
"""
|
||||
|
||||
from flask import g, request
|
||||
from flask_json import as_json
|
||||
from flask import g, jsonify, request
|
||||
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,11 +27,10 @@ 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 [billet.to_dict() for billet in billets]
|
||||
return jsonify([billet.to_dict() for billet in billets])
|
||||
|
||||
|
||||
@bp.route("/billets_absence/create", methods=["POST"])
|
||||
@ -39,7 +38,6 @@ 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
|
||||
@ -50,9 +48,12 @@ 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_begin, abs_end requis"
|
||||
404, message="Paramètre manquant: etudid, abs_bein, abs_end requis"
|
||||
)
|
||||
etud = Identite.get_etud(etudid)
|
||||
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()
|
||||
billet = BilletAbsence(
|
||||
etudid=etud.id,
|
||||
abs_begin=abs_begin,
|
||||
@ -63,7 +64,7 @@ def billets_absence_create():
|
||||
)
|
||||
db.session.add(billet)
|
||||
db.session.commit()
|
||||
return billet.to_dict()
|
||||
return jsonify(billet.to_dict())
|
||||
|
||||
|
||||
@bp.route("/billets_absence/<int:billet_id>/delete", methods=["POST"])
|
||||
@ -71,7 +72,6 @@ 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 {"OK": True}
|
||||
return jsonify({"OK": True})
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -12,13 +12,12 @@
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from flask import request
|
||||
from flask_json import as_json
|
||||
from flask import jsonify, request
|
||||
from flask_login import login_required
|
||||
|
||||
import app
|
||||
from app import db
|
||||
from app.api import api_bp as bp, API_CLIENT_ERROR
|
||||
from app.api import api_bp as bp
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.models import Departement, FormSemestre
|
||||
@ -42,27 +41,24 @@ def get_departement(dept_ident: str) -> Departement:
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def departements_list():
|
||||
"""Liste les départements"""
|
||||
return [dept.to_dict(with_dept_name=True) for dept in Departement.query]
|
||||
return jsonify([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 [dept.id for dept in Departement.query]
|
||||
return jsonify([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.
|
||||
@ -78,27 +74,25 @@ def departement(acronym: str):
|
||||
}
|
||||
"""
|
||||
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
|
||||
return dept.to_dict(with_dept_name=True)
|
||||
return jsonify(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 dept.to_dict()
|
||||
return jsonify(dept.to_dict())
|
||||
|
||||
|
||||
@bp.route("/departement/create", methods=["POST"])
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoSuperAdmin)
|
||||
@as_json
|
||||
def departement_create():
|
||||
"""
|
||||
Création d'un département.
|
||||
@ -111,20 +105,19 @@ 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(API_CLIENT_ERROR, "missing acronym")
|
||||
return json_error(404, "missing acronym")
|
||||
visible = bool(data.get("visible", True))
|
||||
try:
|
||||
dept = departements.create_dept(acronym, visible=visible)
|
||||
except ScoValueError as exc:
|
||||
return json_error(500, exc.args[0] if exc.args else "")
|
||||
return dept.to_dict()
|
||||
return json_error(404, exc.args[0] if exc.args else "")
|
||||
return jsonify(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é
|
||||
@ -137,12 +130,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(API_CLIENT_ERROR, "missing argument: visible")
|
||||
return json_error(404, "missing argument: visible")
|
||||
visible = bool(visible)
|
||||
dept.visible = visible
|
||||
db.session.add(dept)
|
||||
db.session.commit()
|
||||
return dept.to_dict()
|
||||
return jsonify(dept.to_dict())
|
||||
|
||||
|
||||
@bp.route("/departement/<string:acronym>/delete", methods=["POST"])
|
||||
@ -156,14 +149,13 @@ def departement_delete(acronym):
|
||||
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
|
||||
db.session.delete(dept)
|
||||
db.session.commit()
|
||||
return {"OK": True}
|
||||
return jsonify({"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
|
||||
@ -187,49 +179,45 @@ def dept_etudiants(acronym: str):
|
||||
]
|
||||
"""
|
||||
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
|
||||
return [etud.to_dict_short() for etud in dept.etudiants]
|
||||
return jsonify([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 [etud.to_dict_short() for etud in dept.etudiants]
|
||||
return jsonify([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 [formsemestre.id for formsemestre in dept.formsemestres]
|
||||
return jsonify([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 [formsemestre.id for formsemestre in dept.formsemestres]
|
||||
return jsonify([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é
|
||||
@ -281,14 +269,13 @@ def dept_formsemestres_courants(acronym: str):
|
||||
FormSemestre.date_debut <= test_date,
|
||||
FormSemestre.date_fin >= test_date,
|
||||
)
|
||||
return [d.to_dict_api() for d in formsemestres]
|
||||
return jsonify([d.to_dict(convert_objects=True) 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é
|
||||
@ -307,4 +294,4 @@ def dept_formsemestres_courants_by_id(dept_id: int):
|
||||
FormSemestre.date_fin >= test_date,
|
||||
)
|
||||
|
||||
return [d.to_dict_api() for d in formsemestres]
|
||||
return jsonify([d.to_dict(convert_objects=True) for d in formsemestres])
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -8,17 +8,15 @@
|
||||
API : accès aux étudiants
|
||||
"""
|
||||
from datetime import datetime
|
||||
from operator import attrgetter
|
||||
|
||||
from flask import g, request
|
||||
from flask_json import as_json
|
||||
from flask import abort, g, jsonify, request
|
||||
from flask_login import current_user
|
||||
from flask_login import login_required
|
||||
from sqlalchemy import desc, func, or_
|
||||
from sqlalchemy.dialects.postgresql import VARCHAR
|
||||
from sqlalchemy import desc, or_
|
||||
|
||||
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 (
|
||||
@ -32,8 +30,6 @@ 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>")
|
||||
@ -41,11 +37,11 @@ from app.scodoc.sco_utils import json_error, suppress_accents
|
||||
# @login_required
|
||||
# @scodoc
|
||||
# @permission_required(Permission.ScoView)
|
||||
# @as_json
|
||||
# def api_function(arg: int):
|
||||
# """Une fonction quelconque de l'API"""
|
||||
# return {"current_user": current_user.to_dict(), "arg": arg, "dept": g.scodoc_dept}
|
||||
#
|
||||
# return jsonify(
|
||||
# {"current_user": current_user.to_dict(), "arg": arg, "dept": g.scodoc_dept}
|
||||
# )
|
||||
|
||||
|
||||
@bp.route("/etudiants/courants", defaults={"long": False})
|
||||
@ -55,7 +51,6 @@ from app.scodoc.sco_utils import json_error, suppress_accents
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def etudiants_courants(long=False):
|
||||
"""
|
||||
La liste des étudiants des semestres "courants" (tous départements)
|
||||
@ -101,7 +96,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 data
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@bp.route("/etudiant/etudid/<int:etudid>")
|
||||
@ -113,7 +108,6 @@ 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é.
|
||||
@ -133,7 +127,7 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
|
||||
message="étudiant inconnu",
|
||||
)
|
||||
|
||||
return etud.to_dict_api()
|
||||
return jsonify(etud.to_dict_api())
|
||||
|
||||
|
||||
@bp.route("/etudiants/etudid/<int:etudid>", methods=["GET"])
|
||||
@ -144,7 +138,6 @@ 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
|
||||
@ -167,37 +160,10 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
|
||||
)
|
||||
if not None in allowed_depts:
|
||||
# restreint aux départements autorisés:
|
||||
query = query.join(Departement).filter(
|
||||
etuds = etuds.join(Departement).filter(
|
||||
or_(Departement.acronym == acronym for acronym in allowed_depts)
|
||||
)
|
||||
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"))]
|
||||
return jsonify([etud.to_dict_api() for etud in query])
|
||||
|
||||
|
||||
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
|
||||
@ -208,7 +174,6 @@ def etudiants_by_name(start: str = "", min_len=3, limit=32):
|
||||
@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.
|
||||
@ -241,7 +206,7 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None)
|
||||
|
||||
formsemestres = query.order_by(FormSemestre.date_debut)
|
||||
|
||||
return [formsemestre.to_dict_api() for formsemestre in formsemestres]
|
||||
return jsonify([formsemestre.to_dict_api() for formsemestre in formsemestres])
|
||||
|
||||
|
||||
@bp.route(
|
||||
@ -254,10 +219,6 @@ 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",
|
||||
)
|
||||
@ -268,10 +229,6 @@ 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(
|
||||
@ -280,7 +237,6 @@ 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é
|
||||
@ -300,7 +256,7 @@ def bulletin(
|
||||
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
|
||||
dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
|
||||
if g.scodoc_dept and dept.acronym != g.scodoc_dept:
|
||||
return json_error(404, "formsemestre inexistant", as_response=True)
|
||||
return json_error(404, "formsemestre non trouve")
|
||||
app.set_sco_dept(dept.acronym)
|
||||
|
||||
if code_type == "nip":
|
||||
@ -320,11 +276,7 @@ def bulletin(
|
||||
return json_error(404, message="etudiant inexistant")
|
||||
if pdf:
|
||||
pdf_response, _ = do_formsemestre_bulletinetud(
|
||||
formsemestre,
|
||||
etud,
|
||||
version=version,
|
||||
format="pdf",
|
||||
with_img_signatures_pdf=with_img_signatures_pdf,
|
||||
formsemestre, etud.id, version=version, format="pdf"
|
||||
)
|
||||
return pdf_response
|
||||
return sco_bulletins.get_formsemestre_bulletin_etud_json(
|
||||
@ -338,7 +290,6 @@ 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é
|
||||
@ -388,4 +339,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 data
|
||||
return jsonify(data)
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -8,16 +8,16 @@
|
||||
ScoDoc 9 API : accès aux évaluations
|
||||
"""
|
||||
|
||||
from flask import g, request
|
||||
from flask_json import as_json
|
||||
from flask import g, jsonify
|
||||
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, sco_saisie_notes
|
||||
from app.scodoc import sco_evaluation_db
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
@ -27,7 +27,6 @@ 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.
|
||||
|
||||
@ -58,7 +57,7 @@ def evaluation(evaluation_id: int):
|
||||
.filter_by(dept_id=g.scodoc_dept_id)
|
||||
)
|
||||
e = query.first_or_404()
|
||||
return e.to_dict_api()
|
||||
return jsonify(e.to_dict_api())
|
||||
|
||||
|
||||
@bp.route("/moduleimpl/<int:moduleimpl_id>/evaluations")
|
||||
@ -66,7 +65,6 @@ 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
|
||||
@ -82,7 +80,7 @@ def evaluations(moduleimpl_id: int):
|
||||
.join(FormSemestre)
|
||||
.filter_by(dept_id=g.scodoc_dept_id)
|
||||
)
|
||||
return [e.to_dict_api() for e in query]
|
||||
return jsonify([e.to_dict_api() for e in query])
|
||||
|
||||
|
||||
@bp.route("/evaluation/<int:evaluation_id>/notes")
|
||||
@ -90,25 +88,26 @@ 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 de l'évaluation
|
||||
Retourne la liste des notes à partir de l'id d'une évaluation donnée
|
||||
|
||||
evaluation_id : l'id de l'évaluation
|
||||
evaluation_id : l'id d'une évaluation
|
||||
|
||||
Exemple de résultat :
|
||||
{
|
||||
"11": {
|
||||
"etudid": 11,
|
||||
"1": {
|
||||
"id": 1,
|
||||
"etudid": 10,
|
||||
"evaluation_id": 1,
|
||||
"value": 15.0,
|
||||
"comment": "",
|
||||
"date": "Wed, 20 Apr 2022 06:49:05 GMT",
|
||||
"uid": 2
|
||||
},
|
||||
"12": {
|
||||
"etudid": 12,
|
||||
"2": {
|
||||
"id": 2,
|
||||
"etudid": 1,
|
||||
"evaluation_id": 1,
|
||||
"value": 12.0,
|
||||
"comment": "",
|
||||
@ -138,46 +137,4 @@ def evaluation_notes(evaluation_id: int):
|
||||
note["note_max"] = evaluation.note_max
|
||||
del note["id"]
|
||||
|
||||
# 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", "")
|
||||
)
|
||||
return jsonify(notes)
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -8,23 +8,16 @@
|
||||
ScoDoc 9 API : accès aux formations
|
||||
"""
|
||||
|
||||
from flask import flash, g, request
|
||||
from flask_json import as_json
|
||||
from flask import g, jsonify
|
||||
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 import (
|
||||
ApcNiveau,
|
||||
ApcParcours,
|
||||
Formation,
|
||||
FormSemestre,
|
||||
ModuleImpl,
|
||||
UniteEns,
|
||||
)
|
||||
from app.models.formations import Formation
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.models.moduleimpls import ModuleImpl
|
||||
from app.scodoc import sco_formations
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
|
||||
@ -34,7 +27,6 @@ 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)
|
||||
@ -43,7 +35,7 @@ def formations():
|
||||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
|
||||
return [d.to_dict() for d in query]
|
||||
return jsonify([d.to_dict() for d in query])
|
||||
|
||||
|
||||
@bp.route("/formations_ids")
|
||||
@ -51,7 +43,6 @@ 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)
|
||||
@ -61,7 +52,7 @@ def formations_ids():
|
||||
query = Formation.query
|
||||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
return [d.id for d in query]
|
||||
return jsonify([d.id for d in query])
|
||||
|
||||
|
||||
@bp.route("/formation/<int:formation_id>")
|
||||
@ -69,7 +60,6 @@ def formations_ids():
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def formation_by_id(formation_id: int):
|
||||
"""
|
||||
La formation d'id donné
|
||||
@ -94,7 +84,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 query.first_or_404().to_dict()
|
||||
return jsonify(query.first_or_404().to_dict())
|
||||
|
||||
|
||||
@bp.route(
|
||||
@ -116,7 +106,6 @@ 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
|
||||
@ -185,7 +174,7 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
|
||||
]
|
||||
},
|
||||
{
|
||||
"titre": "Se sensibiliser \u00e0 l'hygi\u00e8ne informatique...",
|
||||
"titre": "Se sensibiliser \u00e0 l'hygi\u00e8ne informatique et \u00e0 la cybers\u00e9curit\u00e9",
|
||||
"abbrev": "Hygi\u00e8ne informatique",
|
||||
"code": "SAE11",
|
||||
"heures_cours": 0.0,
|
||||
@ -223,7 +212,7 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
|
||||
except ValueError:
|
||||
return json_error(500, message="Erreur inconnue")
|
||||
|
||||
return data
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@bp.route("/formation/<int:formation_id>/referentiel_competences")
|
||||
@ -231,7 +220,6 @@ 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
|
||||
@ -245,8 +233,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 None
|
||||
return formation.referentiel_competence.to_dict()
|
||||
return jsonify(None)
|
||||
return jsonify(formation.referentiel_competence.to_dict())
|
||||
|
||||
|
||||
@bp.route("/moduleimpl/<int:moduleimpl_id>")
|
||||
@ -254,7 +242,6 @@ 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
|
||||
@ -294,92 +281,4 @@ 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 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}
|
||||
return jsonify(modimpl.to_dict(convert_objects=True))
|
||||
|
@ -1,21 +1,17 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
ScoDoc 9 API : accès aux formsemestres
|
||||
"""
|
||||
from operator import attrgetter, itemgetter
|
||||
|
||||
from flask import g, make_response, request
|
||||
from flask_json import as_json
|
||||
from flask import g, jsonify, request
|
||||
from flask_login import login_required
|
||||
|
||||
import app
|
||||
from app import db
|
||||
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
|
||||
from app.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.comp import res_sem
|
||||
@ -31,13 +27,11 @@ 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>")
|
||||
@ -45,7 +39,6 @@ from app.tables.recap import TableRecap
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def formsemestre_infos(formsemestre_id: int):
|
||||
"""
|
||||
Information sur le formsemestre indiqué.
|
||||
@ -87,7 +80,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 formsemestre.to_dict_api()
|
||||
return jsonify(formsemestre.to_dict_api())
|
||||
|
||||
|
||||
@bp.route("/formsemestres/query")
|
||||
@ -95,7 +88,6 @@ def formsemestre_infos(formsemestre_id: int):
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def formsemestres_query():
|
||||
"""
|
||||
Retourne les formsemestres filtrés par
|
||||
@ -120,7 +112,7 @@ def formsemestres_query():
|
||||
try:
|
||||
annee_scolaire_int = int(annee_scolaire)
|
||||
except ValueError:
|
||||
return json_error(API_CLIENT_ERROR, "invalid annee_scolaire: not int")
|
||||
return json_error(404, "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(
|
||||
@ -132,7 +124,7 @@ def formsemestres_query():
|
||||
try:
|
||||
dept_id = int(dept_id)
|
||||
except ValueError:
|
||||
return json_error(404, "invalid dept_id: integer expected")
|
||||
return json_error(404, "invalid dept_id: not int")
|
||||
formsemestres = formsemestres.filter_by(dept_id=dept_id)
|
||||
if etape_apo is not None:
|
||||
formsemestres = formsemestres.join(FormSemestreEtape).filter(
|
||||
@ -151,7 +143,7 @@ def formsemestres_query():
|
||||
formsemestres = formsemestres.join(FormSemestreInscription).join(Identite)
|
||||
formsemestres = formsemestres.filter_by(code_ine=ine)
|
||||
|
||||
return [formsemestre.to_dict_api() for formsemestre in formsemestres]
|
||||
return jsonify([formsemestre.to_dict_api() for formsemestre in formsemestres])
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
|
||||
@ -161,7 +153,6 @@ 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é
|
||||
@ -185,7 +176,7 @@ def bulletins(formsemestre_id: int, version: str = "long"):
|
||||
)
|
||||
data.append(bul_etu.json)
|
||||
|
||||
return data
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/programme")
|
||||
@ -193,7 +184,6 @@ 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
|
||||
@ -263,7 +253,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.get_ues()
|
||||
ues = formsemestre.query_ues()
|
||||
m_list = {
|
||||
ModuleType.RESSOURCE: [],
|
||||
ModuleType.SAE: [],
|
||||
@ -273,13 +263,15 @@ 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 {
|
||||
"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 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],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route(
|
||||
@ -317,7 +309,6 @@ 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
|
||||
):
|
||||
@ -353,7 +344,7 @@ def formsemestre_etudiants(
|
||||
etud["id"], formsemestre_id, exclude_default=True
|
||||
)
|
||||
|
||||
return sorted(etuds, key=itemgetter("sort_key"))
|
||||
return jsonify(sorted(etuds, key=lambda e: e["sort_key"]))
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/etat_evals")
|
||||
@ -361,7 +352,6 @@ 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.
|
||||
@ -441,7 +431,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=attrgetter("date"))
|
||||
notes_sorted = sorted(notes, key=lambda note: note.date)
|
||||
|
||||
date_debut = notes_sorted[0].date
|
||||
date_fin = notes_sorted[-1].date
|
||||
@ -463,7 +453,7 @@ def etat_evals(formsemestre_id: int):
|
||||
|
||||
modimpl_dict["evaluations"] = list_eval
|
||||
result.append(modimpl_dict)
|
||||
return result
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/resultats")
|
||||
@ -471,14 +461,13 @@ 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(API_CLIENT_ERROR, "invalid format specification")
|
||||
return json_error(404, "invalid format specification")
|
||||
convert_values = format_spec != "raw"
|
||||
|
||||
query = FormSemestre.query.filter_by(id=formsemestre_id)
|
||||
@ -487,55 +476,16 @@ 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)
|
||||
table = TableRecap(
|
||||
res, convert_values=convert_values, include_evaluations=False, mode_jury=False
|
||||
rows, footer_rows, titles, column_ids = res.get_table_recap(
|
||||
convert_values=convert_values,
|
||||
include_evaluations=False,
|
||||
mode_jury=False,
|
||||
allow_html=False,
|
||||
)
|
||||
# Supprime les champs inutiles (mise en forme)
|
||||
rows = table.to_list()
|
||||
# Ajoute le groupe de chaque partition:
|
||||
table = [{k: row[k] for k in row if not k[0] == "_"} for row in rows]
|
||||
# Ajoute les groupes
|
||||
etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id)
|
||||
for row in rows:
|
||||
for row in table:
|
||||
row["partitions"] = etud_groups.get(row["etudid"], {})
|
||||
|
||||
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()
|
||||
return jsonify(table)
|
||||
|
334
app/api/jury.py
334
app/api/jury.py
@ -1,42 +1,25 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions
|
||||
ScoDoc 9 API : jury
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from flask import flash, g, request, url_for
|
||||
from flask_json import as_json
|
||||
from flask_login import current_user, login_required
|
||||
from flask import g, jsonify, request
|
||||
from flask_login import login_required
|
||||
|
||||
import app
|
||||
from app import db, log
|
||||
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR, tools
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.scodoc.sco_exceptions import ScoException
|
||||
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
|
||||
from app.but import jury_but_recap
|
||||
from app.models import FormSemestre, FormSemestreInscription, Identite
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/decisions_jury")
|
||||
@ -44,308 +27,13 @@ from app.scodoc.sco_utils import json_error
|
||||
@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 = db.session.get(FormSemestre, formsemestre_id)
|
||||
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
|
||||
if formsemestre.formation.is_apc():
|
||||
app.set_sco_dept(formsemestre.departement.acronym)
|
||||
rows = jury_but_results.get_jury_but_results(formsemestre)
|
||||
return rows
|
||||
rows = jury_but_recap.get_jury_but_results(formsemestre)
|
||||
return jsonify(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"
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -30,10 +30,11 @@ Contrib @jmp
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from flask import Response, send_file
|
||||
from flask_json import as_json
|
||||
from flask import jsonify, g, send_file
|
||||
from flask_login import login_required
|
||||
|
||||
from app.api import api_bp as bp
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.api import requested_format
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.models import Departement
|
||||
from app.scodoc.sco_logos import list_logos, find_logo
|
||||
@ -46,11 +47,10 @@ 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 list(logos.keys())
|
||||
return jsonify(list(logos.keys()))
|
||||
|
||||
|
||||
@bp.route("/logo/<string:logoname>")
|
||||
@ -68,29 +68,27 @@ def api_get_glob_logo(logoname):
|
||||
)
|
||||
|
||||
|
||||
def _core_get_logos(dept_id) -> list:
|
||||
def core_get_logos(dept_id):
|
||||
logos = list_logos().get(dept_id, dict())
|
||||
return list(logos.keys())
|
||||
return jsonify(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) -> Response:
|
||||
def core_get_logo(dept_id, logoname):
|
||||
logo = find_logo(logoname=logoname, dept_id=dept_id)
|
||||
if logo is None:
|
||||
return json_error(404, message="logo not found")
|
||||
@ -107,11 +105,11 @@ def _core_get_logo(dept_id, logoname) -> Response:
|
||||
@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)
|
||||
|
@ -1,31 +1,24 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
ScoDoc 9 API : partitions
|
||||
"""
|
||||
from operator import attrgetter
|
||||
|
||||
from flask import g, request
|
||||
from flask_json import as_json
|
||||
from flask import g, jsonify, request
|
||||
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, API_CLIENT_ERROR
|
||||
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 FormSemestre, FormSemestreInscription, Identite
|
||||
from app.models import GroupDescr, Partition, Scolog
|
||||
from app.models import GroupDescr, Partition
|
||||
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
|
||||
|
||||
@ -35,7 +28,6 @@ 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.
|
||||
|
||||
@ -60,7 +52,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 partition.to_dict(with_groups=True)
|
||||
return jsonify(partition.to_dict(with_groups=True))
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/partitions")
|
||||
@ -68,7 +60,6 @@ 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
|
||||
|
||||
@ -93,12 +84,14 @@ 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=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
|
||||
}
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/group/<int:group_id>/etudiants")
|
||||
@ -106,7 +99,6 @@ 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
|
||||
@ -133,7 +125,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 [etud.to_dict_short() for etud in group.etuds]
|
||||
return jsonify([etud.to_dict_short() for etud in group.etuds])
|
||||
|
||||
|
||||
@bp.route("/group/<int:group_id>/etudiants/query")
|
||||
@ -141,12 +133,11 @@ 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(API_CLIENT_ERROR, "etat: valeur invalide")
|
||||
return json_error(404, "etat: valeur invalide")
|
||||
query = GroupDescr.query.filter_by(id=group_id)
|
||||
if g.scodoc_dept:
|
||||
query = (
|
||||
@ -162,7 +153,7 @@ def etud_in_group_query(group_id: int):
|
||||
|
||||
query = query.join(group_membership).filter_by(group_id=group_id)
|
||||
|
||||
return [etud.to_dict_short() for etud in query]
|
||||
return jsonify([etud.to_dict_short() for etud in query])
|
||||
|
||||
|
||||
@bp.route("/group/<int:group_id>/set_etudiant/<int:etudid>", methods=["POST"])
|
||||
@ -170,7 +161,6 @@ def etud_in_group_query(group_id: int):
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoEtudChangeGroups)
|
||||
@as_json
|
||||
def set_etud_group(etudid: int, group_id: int):
|
||||
"""Affecte l'étudiant au groupe indiqué"""
|
||||
etud = Identite.query.get_or_404(etudid)
|
||||
@ -180,18 +170,25 @@ 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")
|
||||
|
||||
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}
|
||||
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})
|
||||
|
||||
|
||||
@bp.route("/group/<int:group_id>/remove_etudiant/<int:etudid>", methods=["POST"])
|
||||
@ -201,7 +198,6 @@ 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)
|
||||
@ -211,21 +207,11 @@ 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 {"group_id": group_id, "etudid": etudid}
|
||||
return jsonify({"group_id": group_id, "etudid": etudid})
|
||||
|
||||
|
||||
@bp.route(
|
||||
@ -237,7 +223,6 @@ 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)
|
||||
@ -247,33 +232,17 @@ 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()
|
||||
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,
|
||||
groups = (
|
||||
GroupDescr.query.filter_by(partition_id=partition_id)
|
||||
.join(group_membership)
|
||||
.filter_by(etudid=etudid)
|
||||
)
|
||||
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 {"partition_id": partition_id, "etudid": etudid}
|
||||
return jsonify({"partition_id": partition_id, "etudid": etudid})
|
||||
|
||||
|
||||
@bp.route("/partition/<int:partition_id>/group/create", methods=["POST"])
|
||||
@ -281,8 +250,7 @@ def partition_remove_etud(partition_id: int, etudid: int):
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoEtudChangeGroups)
|
||||
@as_json
|
||||
def group_create(partition_id: int): # partition-group-create
|
||||
def group_create(partition_id: int):
|
||||
"""Création d'un groupe dans une partition
|
||||
|
||||
The request content type should be "application/json":
|
||||
@ -294,16 +262,14 @@ def group_create(partition_id: int): # partition-group-create
|
||||
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(403, "partition non editable")
|
||||
return json_error(404, "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(API_CLIENT_ERROR, "missing group name or invalid data format")
|
||||
return json_error(404, "missing group name or invalid data format")
|
||||
if not GroupDescr.check_name(partition, group_name):
|
||||
return json_error(API_CLIENT_ERROR, "invalid group_name")
|
||||
return json_error(404, "invalid group_name")
|
||||
group_name = group_name.strip()
|
||||
|
||||
group = GroupDescr(group_name=group_name, partition_id=partition_id)
|
||||
@ -312,7 +278,7 @@ def group_create(partition_id: int): # partition-group-create
|
||||
log(f"created group {group}")
|
||||
app.set_sco_dept(partition.formsemestre.departement.acronym)
|
||||
sco_cache.invalidate_formsemestre(partition.formsemestre_id)
|
||||
return group.to_dict(with_partition=True)
|
||||
return jsonify(group.to_dict(with_partition=True))
|
||||
|
||||
|
||||
@bp.route("/group/<int:group_id>/delete", methods=["POST"])
|
||||
@ -320,7 +286,6 @@ def group_create(partition_id: int): # partition-group-create
|
||||
@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)
|
||||
@ -329,17 +294,15 @@ 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(403, "partition non editable")
|
||||
return json_error(404, "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 {"OK": True}
|
||||
return jsonify({"OK": True})
|
||||
|
||||
|
||||
@bp.route("/group/<int:group_id>/edit", methods=["POST"])
|
||||
@ -347,7 +310,6 @@ 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)
|
||||
@ -356,23 +318,21 @@ 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(403, "partition non editable")
|
||||
return json_error(404, "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(API_CLIENT_ERROR, "invalid group_name")
|
||||
return json_error(404, "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 group.to_dict(with_partition=True)
|
||||
return jsonify(group.to_dict(with_partition=True))
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"])
|
||||
@ -382,7 +342,6 @@ 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
|
||||
|
||||
@ -399,23 +358,17 @@ 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(
|
||||
API_CLIENT_ERROR, "missing partition_name or invalid data format"
|
||||
)
|
||||
return json_error(404, "missing partition_name or invalid data format")
|
||||
if partition_name == scu.PARTITION_PARCOURS:
|
||||
return json_error(
|
||||
API_CLIENT_ERROR, f"invalid partition_name {scu.PARTITION_PARCOURS}"
|
||||
)
|
||||
return json_error(404, f"invalid partition_name {scu.PARTITION_PARCOURS}")
|
||||
if not Partition.check_name(formsemestre, partition_name):
|
||||
return json_error(API_CLIENT_ERROR, "invalid partition_name")
|
||||
return json_error(404, "invalid partition_name")
|
||||
numero = data.get("numero", 0)
|
||||
if not isinstance(numero, int):
|
||||
return json_error(API_CLIENT_ERROR, "invalid type for numero")
|
||||
return json_error(404, "invalid type for numero")
|
||||
args = {
|
||||
"formsemestre_id": formsemestre_id,
|
||||
"partition_name": partition_name.strip(),
|
||||
@ -426,7 +379,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(API_CLIENT_ERROR, f"invalid type for {boolean_field}")
|
||||
return json_error(404, f"invalid type for {boolean_field}")
|
||||
args[boolean_field] = value
|
||||
|
||||
partition = Partition(**args)
|
||||
@ -435,7 +388,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 partition.to_dict(with_groups=True)
|
||||
return jsonify(partition.to_dict(with_groups=True))
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/partitions/order", methods=["POST"])
|
||||
@ -445,7 +398,6 @@ 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, ...]
|
||||
@ -454,28 +406,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(
|
||||
API_CLIENT_ERROR,
|
||||
404,
|
||||
message="paramètre liste des partitions invalide",
|
||||
)
|
||||
for p_id, numero in zip(partition_ids, range(len(partition_ids))):
|
||||
partition = Partition.query.get_or_404(p_id)
|
||||
partition.numero = numero
|
||||
db.session.add(partition)
|
||||
p = Partition.query.get_or_404(p_id)
|
||||
p.numero = numero
|
||||
db.session.add(p)
|
||||
db.session.commit()
|
||||
app.set_sco_dept(formsemestre.departement.acronym)
|
||||
sco_cache.invalidate_formsemestre(formsemestre_id)
|
||||
return [
|
||||
partition.to_dict()
|
||||
for partition in formsemestre.partitions.order_by(Partition.numero)
|
||||
if partition.partition_name is not None
|
||||
]
|
||||
return jsonify(
|
||||
[
|
||||
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"])
|
||||
@ -483,7 +435,6 @@ 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, ...]
|
||||
@ -492,14 +443,12 @@ 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(
|
||||
API_CLIENT_ERROR,
|
||||
404,
|
||||
message="paramètre liste de groupe invalide",
|
||||
)
|
||||
for group_id, numero in zip(group_ids, range(len(group_ids))):
|
||||
@ -510,7 +459,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 partition.to_dict(with_groups=True)
|
||||
return jsonify(partition.to_dict(with_groups=True))
|
||||
|
||||
|
||||
@bp.route("/partition/<int:partition_id>/edit", methods=["POST"])
|
||||
@ -518,7 +467,6 @@ 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
|
||||
|
||||
@ -536,28 +484,24 @@ 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(
|
||||
API_CLIENT_ERROR, f"can't rename {scu.PARTITION_PARCOURS}"
|
||||
)
|
||||
return json_error(404, f"can't rename {scu.PARTITION_PARCOURS}")
|
||||
if not Partition.check_name(
|
||||
partition.formsemestre, partition_name, existing=True
|
||||
):
|
||||
return json_error(API_CLIENT_ERROR, "invalid partition_name")
|
||||
return json_error(404, "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(API_CLIENT_ERROR, "invalid type for numero")
|
||||
return json_error(404, "invalid type for numero")
|
||||
partition.numero = numero
|
||||
modified = True
|
||||
|
||||
@ -565,11 +509,9 @@ 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(API_CLIENT_ERROR, f"invalid type for {boolean_field}")
|
||||
return json_error(404, f"invalid type for {boolean_field}")
|
||||
if boolean_field == "groups_editable" and partition.is_parcours():
|
||||
return json_error(
|
||||
API_CLIENT_ERROR, f"can't change {scu.PARTITION_PARCOURS}"
|
||||
)
|
||||
return json_error(404, f"can't change {scu.PARTITION_PARCOURS}")
|
||||
setattr(partition, boolean_field, value)
|
||||
modified = True
|
||||
|
||||
@ -580,7 +522,7 @@ def partition_edit(partition_id: int):
|
||||
app.set_sco_dept(partition.formsemestre.departement.acronym)
|
||||
sco_cache.invalidate_formsemestre(partition.formsemestre_id)
|
||||
|
||||
return partition.to_dict(with_groups=True)
|
||||
return jsonify(partition.to_dict(with_groups=True))
|
||||
|
||||
|
||||
@bp.route("/partition/<int:partition_id>/delete", methods=["POST"])
|
||||
@ -588,7 +530,6 @@ 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).
|
||||
|
||||
@ -601,12 +542,8 @@ 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(
|
||||
API_CLIENT_ERROR, "ne peut pas supprimer la partition par défaut"
|
||||
)
|
||||
return json_error(404, "ne peut pas supprimer la partition par défaut")
|
||||
is_parcours = partition.is_parcours()
|
||||
formsemestre: FormSemestre = partition.formsemestre
|
||||
log(f"deleting partition {partition}")
|
||||
@ -616,4 +553,4 @@ def partition_delete(partition_id: int):
|
||||
sco_cache.invalidate_formsemestre(formsemestre.id)
|
||||
if is_parcours:
|
||||
formsemestre.update_inscriptions_parcours_from_groups()
|
||||
return {"OK": True}
|
||||
return jsonify({"OK": True})
|
||||
|
@ -1,40 +0,0 @@
|
||||
##############################################################################
|
||||
# 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_json import as_json
|
||||
from flask import jsonify
|
||||
from app import db, log
|
||||
from app.api import api_bp as bp
|
||||
from app.auth.logic import basic_auth, token_auth
|
||||
@ -6,21 +6,18 @@ 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 {"token": token}
|
||||
return jsonify({"token": token})
|
||||
|
||||
|
||||
@bp.route("/tokens", methods=["DELETE"])
|
||||
@token_auth.login_required
|
||||
def revoke_token():
|
||||
"révoque le jeton de l'utilisateur courant"
|
||||
user = token_auth.current_user()
|
||||
user.revoke_token()
|
||||
token_auth.current_user().revoke_token()
|
||||
db.session.commit()
|
||||
log(f"API: revoking token for {user}")
|
||||
return "", 204
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 API : outils
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -9,12 +9,11 @@
|
||||
"""
|
||||
|
||||
|
||||
from flask import g, request
|
||||
from flask_json import as_json
|
||||
from flask import g, jsonify, request
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app import db
|
||||
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
|
||||
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.auth.models import User, Role, UserRole
|
||||
from app.auth.models import is_valid_password
|
||||
@ -30,12 +29,11 @@ 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 = db.session.get(User, uid)
|
||||
user: User = User.query.get(uid)
|
||||
if user is None:
|
||||
return json_error(404, "user not found")
|
||||
if g.scodoc_dept:
|
||||
@ -43,7 +41,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 user.to_dict()
|
||||
return jsonify(user.to_dict())
|
||||
|
||||
|
||||
@bp.route("/users/query")
|
||||
@ -51,7 +49,6 @@ 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>
|
||||
@ -82,7 +79,7 @@ def users_info_query():
|
||||
)
|
||||
|
||||
query = query.order_by(User.user_name)
|
||||
return [user.to_dict() for user in query]
|
||||
return jsonify([user.to_dict() for user in query])
|
||||
|
||||
|
||||
@bp.route("/user/create", methods=["POST"])
|
||||
@ -90,7 +87,6 @@ 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":
|
||||
@ -125,7 +121,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 user.to_dict()
|
||||
return jsonify(user.to_dict())
|
||||
|
||||
|
||||
@bp.route("/user/<int:uid>/edit", methods=["POST"])
|
||||
@ -133,7 +129,6 @@ def user_create():
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoUsersAdmin)
|
||||
@as_json
|
||||
def user_edit(uid: int):
|
||||
"""Modification d'un utilisateur
|
||||
Champs modifiables:
|
||||
@ -170,7 +165,7 @@ def user_edit(uid: int):
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user.to_dict()
|
||||
return jsonify(user.to_dict())
|
||||
|
||||
|
||||
@bp.route("/user/<int:uid>/password", methods=["POST"])
|
||||
@ -178,7 +173,6 @@ 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:
|
||||
@ -193,14 +187,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(API_CLIENT_ERROR, "user_password: invalid password")
|
||||
return json_error(400, "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 user.to_dict()
|
||||
return jsonify(user.to_dict())
|
||||
|
||||
|
||||
@bp.route("/user/<int:uid>/role/<string:role_name>/add", methods=["POST"])
|
||||
@ -216,7 +210,6 @@ 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)
|
||||
@ -229,7 +222,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 user.to_dict()
|
||||
return jsonify(user.to_dict())
|
||||
|
||||
|
||||
@bp.route("/user/<int:uid>/role/<string:role_name>/remove", methods=["POST"])
|
||||
@ -245,7 +238,6 @@ 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)
|
||||
@ -264,7 +256,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 user.to_dict()
|
||||
return jsonify(user.to_dict())
|
||||
|
||||
|
||||
@bp.route("/permissions")
|
||||
@ -272,10 +264,9 @@ 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 list(Permission.permission_by_name.keys())
|
||||
return jsonify(list(Permission.permission_by_name.keys()))
|
||||
|
||||
|
||||
@bp.route("/role/<string:role_name>")
|
||||
@ -283,10 +274,9 @@ def list_permissions():
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoUsersView)
|
||||
@as_json
|
||||
def list_role(role_name: str):
|
||||
"""Un rôle"""
|
||||
return Role.query.filter_by(name=role_name).first_or_404().to_dict()
|
||||
return jsonify(Role.query.filter_by(name=role_name).first_or_404().to_dict())
|
||||
|
||||
|
||||
@bp.route("/roles")
|
||||
@ -294,10 +284,9 @@ 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 [role.to_dict() for role in Role.query]
|
||||
return jsonify([role.to_dict() for role in Role.query])
|
||||
|
||||
|
||||
@bp.route(
|
||||
@ -311,7 +300,6 @@ 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()
|
||||
@ -321,7 +309,7 @@ def role_permission_add(role_name: str, perm_name: str):
|
||||
role.add_permission(permission)
|
||||
db.session.add(role)
|
||||
db.session.commit()
|
||||
return role.to_dict()
|
||||
return jsonify(role.to_dict())
|
||||
|
||||
|
||||
@bp.route(
|
||||
@ -335,7 +323,6 @@ 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()
|
||||
@ -345,7 +332,7 @@ def role_permission_remove(role_name: str, perm_name: str):
|
||||
role.remove_permission(permission)
|
||||
db.session.add(role)
|
||||
db.session.commit()
|
||||
return role.to_dict()
|
||||
return jsonify(role.to_dict())
|
||||
|
||||
|
||||
@bp.route("/role/create/<string:role_name>", methods=["POST"])
|
||||
@ -353,7 +340,6 @@ 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.
|
||||
{
|
||||
@ -373,7 +359,7 @@ def role_create(role_name: str):
|
||||
return json_error(404, "role_create: invalid permissions")
|
||||
db.session.add(role)
|
||||
db.session.commit()
|
||||
return role.to_dict()
|
||||
return jsonify(role.to_dict())
|
||||
|
||||
|
||||
@bp.route("/role/<string:role_name>/edit", methods=["POST"])
|
||||
@ -381,7 +367,6 @@ 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.
|
||||
{
|
||||
@ -405,7 +390,7 @@ def role_edit(role_name: str):
|
||||
role.name = role_name
|
||||
db.session.add(role)
|
||||
db.session.commit()
|
||||
return role.to_dict()
|
||||
return jsonify(role.to_dict())
|
||||
|
||||
|
||||
@bp.route("/role/<string:role_name>/delete", methods=["POST"])
|
||||
@ -413,10 +398,9 @@ 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 {"OK": True}
|
||||
return jsonify({"OK": True})
|
||||
|
@ -6,4 +6,3 @@ from flask import Blueprint
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
from app.auth import routes
|
||||
from app.auth import cas
|
||||
|
251
app/auth/cas.py
251
app/auth/cas.py
@ -1,251 +0,0 @@
|
||||
# -*- 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,20 +1,15 @@
|
||||
# -*- coding: UTF-8 -*
|
||||
|
||||
from flask import render_template
|
||||
from app.auth.models import User
|
||||
from app.email import get_from_addr, send_email
|
||||
from flask import render_template, current_app
|
||||
from flask_babel import _
|
||||
from app.email import send_email
|
||||
|
||||
|
||||
def send_password_reset_email(user: User):
|
||||
"""Send message allowing to reset password"""
|
||||
recipients = user.get_emails()
|
||||
if not recipients:
|
||||
return
|
||||
def send_password_reset_email(user):
|
||||
token = user.get_reset_password_token()
|
||||
send_email(
|
||||
"[ScoDoc] Réinitialisation de votre mot de passe",
|
||||
sender=get_from_addr(),
|
||||
recipients=recipients,
|
||||
sender=current_app.config["SCODOC_MAIL_FROM"],
|
||||
recipients=[user.email],
|
||||
text_body=render_template("email/reset_password.txt", user=user, token=token),
|
||||
html_body=render_template("email/reset_password.j2", user=user, token=token),
|
||||
html_body=render_template("email/reset_password.html", user=user, token=token),
|
||||
)
|
||||
|
@ -1,12 +1,13 @@
|
||||
# -*- 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
|
||||
|
||||
@ -97,12 +98,3 @@ 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,14 +5,12 @@
|
||||
import http
|
||||
|
||||
import flask
|
||||
from flask import current_app, g, redirect, request, url_for
|
||||
from flask import g, redirect, request, url_for
|
||||
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
|
||||
import flask_login
|
||||
|
||||
from app import db, login
|
||||
from app.auth.models import User
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app import login
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.auth.models import User
|
||||
|
||||
basic_auth = HTTPBasicAuth()
|
||||
token_auth = HTTPTokenAuth()
|
||||
@ -39,7 +37,7 @@ def basic_auth_error(status):
|
||||
@login.user_loader
|
||||
def load_user(uid: str) -> User:
|
||||
"flask-login: accès à un utilisateur"
|
||||
return db.session.get(User, int(uid))
|
||||
return User.query.get(int(uid))
|
||||
|
||||
|
||||
@token_auth.verify_token
|
||||
@ -85,15 +83,3 @@ 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,10 +19,9 @@ from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
import jwt
|
||||
|
||||
from app import db, email, log, login
|
||||
from app import db, log, login
|
||||
from app.models import Departement
|
||||
from app.models import SHORT_STR_LEN, USERNAME_STR_LEN
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app.models import SHORT_STR_LEN
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
|
||||
@ -32,7 +31,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) -> bool:
|
||||
def is_valid_password(cleartxt):
|
||||
"""Check password.
|
||||
returns True if OK.
|
||||
"""
|
||||
@ -49,45 +48,17 @@ def is_valid_password(cleartxt) -> bool:
|
||||
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(USERNAME_STR_LEN), index=True, unique=True)
|
||||
"le login"
|
||||
user_name = db.Column(db.String(64), index=True, unique=True)
|
||||
email = db.Column(db.String(120))
|
||||
"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))
|
||||
|
||||
nom = db.Column(db.String(64))
|
||||
prenom = db.Column(db.String(64))
|
||||
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))
|
||||
@ -96,8 +67,6 @@ 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)
|
||||
|
||||
@ -117,7 +86,7 @@ class User(UserMixin, db.Model):
|
||||
self.roles = []
|
||||
self.user_roles = []
|
||||
# check login:
|
||||
if kwargs.get("user_name") and invalid_user_name(kwargs["user_name"]):
|
||||
if kwargs.get("user_name") and not VALID_LOGIN_EXP.match(kwargs["user_name"]):
|
||||
raise ValueError(f"invalid user_name: {kwargs['user_name']}")
|
||||
super(User, self).__init__(**kwargs)
|
||||
# Ajoute roles:
|
||||
@ -134,8 +103,7 @@ 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
|
||||
@ -147,56 +115,30 @@ 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: str) -> bool:
|
||||
def check_password(self, password):
|
||||
"""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 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)
|
||||
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
|
||||
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(
|
||||
@ -213,7 +155,7 @@ class User(UserMixin, db.Model):
|
||||
token, current_app.config["SECRET_KEY"], algorithms=["HS256"]
|
||||
)
|
||||
except jwt.exceptions.ExpiredSignatureError:
|
||||
log("verify_reset_password_token: token expired")
|
||||
log(f"verify_reset_password_token: token expired")
|
||||
except:
|
||||
return None
|
||||
try:
|
||||
@ -225,7 +167,7 @@ class User(UserMixin, db.Model):
|
||||
return None
|
||||
except (TypeError, KeyError):
|
||||
return None
|
||||
return db.session.get(User, user_id)
|
||||
return User.query.get(user_id)
|
||||
|
||||
def to_dict(self, include_email=True):
|
||||
"""l'utilisateur comme un dict, avec des champs supplémentaires"""
|
||||
@ -242,12 +184,6 @@ 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
|
||||
@ -264,39 +200,22 @@ 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: dict, new_user=False):
|
||||
def from_dict(self, data, 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",
|
||||
"email_institutionnel",
|
||||
"date_expiration",
|
||||
"cas_id",
|
||||
]:
|
||||
for field in ["nom", "prenom", "dept", "active", "email", "date_expiration"]:
|
||||
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 invalid_user_name(self.user_name):
|
||||
if not VALID_LOGIN_EXP.match(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:
|
||||
@ -322,7 +241,7 @@ class User(UserMixin, db.Model):
|
||||
|
||||
@staticmethod
|
||||
def check_token(token):
|
||||
"""Retreive user for given token, check token's validity
|
||||
"""Retreive user for given token, chek token's validity
|
||||
and returns the user object.
|
||||
"""
|
||||
user = User.query.filter_by(token=token).first()
|
||||
@ -336,15 +255,6 @@ 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`.
|
||||
@ -376,9 +286,7 @@ class User(UserMixin, db.Model):
|
||||
"""
|
||||
if not isinstance(role, Role):
|
||||
raise ScoValueError("add_role: rôle invalide")
|
||||
user_role = UserRole(user=self, role=role, dept=dept)
|
||||
db.session.add(user_role)
|
||||
self.user_roles.append(user_role)
|
||||
self.user_roles.append(UserRole(user=self, role=role, dept=dept))
|
||||
|
||||
def add_roles(self, roles: "list[Role]", dept: str):
|
||||
"""Add roles to this user.
|
||||
@ -402,7 +310,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
|
||||
@ -431,17 +339,24 @@ class User(UserMixin, db.Model):
|
||||
"""nomplogin est le nom en majuscules suivi du prénom et du login
|
||||
e.g. Dupont Pierre (dupont)
|
||||
"""
|
||||
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})"
|
||||
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,
|
||||
)
|
||||
|
||||
@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
|
||||
"""
|
||||
match = re.match(r".*\((.*)\)", nomplogin.strip())
|
||||
if match:
|
||||
user_name = match.group(1)
|
||||
m = re.match(r".*\((.*)\)", nomplogin.strip())
|
||||
if m:
|
||||
user_name = m.group(1)
|
||||
u = User.query.filter_by(user_name=user_name).first()
|
||||
if u:
|
||||
return u.id
|
||||
@ -478,8 +393,6 @@ class User(UserMixin, db.Model):
|
||||
|
||||
|
||||
class AnonymousUser(AnonymousUserMixin):
|
||||
"Notre utilisateur anonyme"
|
||||
|
||||
def has_permission(self, perm, dept=None):
|
||||
return False
|
||||
|
||||
@ -596,7 +509,7 @@ class UserRole(db.Model):
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRole u={self.user} r={self.role} dept={self.dept}>"
|
||||
return "<UserRole u={} r={} dept={}>".format(self.user, self.role, self.dept)
|
||||
|
||||
@staticmethod
|
||||
def role_dept_from_string(role_dept: str):
|
||||
@ -604,21 +517,18 @@ 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.strip().split("_", 1)
|
||||
# maxsplit=1, le dept peut contenir un "_"
|
||||
fields = role_dept.split("_", 1) # maxsplit=1, le dept peut contenir un "_"
|
||||
if len(fields) != 2:
|
||||
current_app.logger.warning(
|
||||
f"auth: role_dept_from_string: Invalid role_dept '{role_dept}'"
|
||||
f"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(f"role {role_name} does not exists")
|
||||
raise ScoValueError("role %s does not exists" % role_name)
|
||||
return (role, dept)
|
||||
|
||||
|
||||
@ -635,22 +545,3 @@ 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,88 +3,54 @@
|
||||
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, current_user
|
||||
from flask_login import login_user, logout_user, current_user
|
||||
from sqlalchemy import func
|
||||
|
||||
from app import db
|
||||
from app.auth import bp, cas, logic
|
||||
from app.auth import bp
|
||||
from app.auth.forms import (
|
||||
CASUsersImportConfigForm,
|
||||
LoginForm,
|
||||
ResetPasswordForm,
|
||||
ResetPasswordRequestForm,
|
||||
UserCreationForm,
|
||||
ResetPasswordRequestForm,
|
||||
ResetPasswordForm,
|
||||
)
|
||||
from app.auth.models import Role, User, invalid_user_name
|
||||
from app.auth.models import Role
|
||||
from app.auth.models import User
|
||||
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 = _
|
||||
|
||||
|
||||
def _login_form():
|
||||
"""le formulaire de login, avec un lien CAS s'il est configuré."""
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"ScoDoc Login form"
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("scodoc.index"))
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
# 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()
|
||||
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.j2",
|
||||
title=_("Sign In"),
|
||||
form=form,
|
||||
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
|
||||
"auth/login.html", title=_("Sign In"), form=form, message=message
|
||||
)
|
||||
|
||||
|
||||
@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() -> flask.Response:
|
||||
"Logout a scodoc user. If CAS session, logout from CAS. Redirect."
|
||||
return logic.logout()
|
||||
def logout():
|
||||
"Logout current user and redirect to home page"
|
||||
logout_user()
|
||||
return redirect(url_for("scodoc.index"))
|
||||
|
||||
|
||||
@bp.route("/create_user", methods=["GET", "POST"])
|
||||
@ -99,7 +65,9 @@ def create_user():
|
||||
db.session.commit()
|
||||
flash(f"Utilisateur {user.user_name} créé")
|
||||
return redirect(url_for("scodoc.index"))
|
||||
return render_template("auth/register.j2", title="Création utilisateur", form=form)
|
||||
return render_template(
|
||||
"auth/register.html", title="Création utilisateur", form=form
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/reset_password_request", methods=["GET", "POST"])
|
||||
@ -130,10 +98,7 @@ def reset_password_request():
|
||||
)
|
||||
return redirect(url_for("auth.login"))
|
||||
return render_template(
|
||||
"auth/reset_password_request.j2",
|
||||
title=_("Reset Password"),
|
||||
form=form,
|
||||
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
|
||||
"auth/reset_password_request.html", title=_("Reset Password"), form=form
|
||||
)
|
||||
|
||||
|
||||
@ -151,7 +116,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.j2", form=form, user=user)
|
||||
return render_template("auth/reset_password.html", form=form, user=user)
|
||||
|
||||
|
||||
@bp.route("/reset_standard_roles_permissions", methods=["GET", "POST"])
|
||||
@ -161,34 +126,3 @@ 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
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -8,69 +8,68 @@
|
||||
Edition associations UE <-> Ref. Compétence
|
||||
"""
|
||||
from flask import g, url_for
|
||||
|
||||
from app.models import ApcReferentielCompetences, UniteEns
|
||||
from app.scodoc import codes_cursus
|
||||
from app.models import ApcReferentielCompetences, Formation, UniteEns
|
||||
from app.scodoc import sco_codes_parcours
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
"""
|
||||
if ue.type != codes_cursus.UE_STANDARD:
|
||||
if ue.type != sco_codes_parcours.UE_STANDARD:
|
||||
return ""
|
||||
ref_comp = ue.formation.referentiel_competence
|
||||
if ref_comp is None:
|
||||
return f"""<div class="ue_advanced">
|
||||
return f"""<div class="ue_choix_niveau">
|
||||
<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>"""
|
||||
|
||||
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}
|
||||
# Les parcours:
|
||||
parcours_options = []
|
||||
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 ""
|
||||
parcours_options.append(
|
||||
f"""<option value="{parcour.id}" {
|
||||
'selected' if ue.parcour == parcour else ''}
|
||||
>{parcour.libelle} ({parcour.code})
|
||||
</option>"""
|
||||
)
|
||||
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>
|
||||
|
||||
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>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
return "\n".join(H)
|
||||
|
||||
|
||||
def get_ue_niveaux_options_html(ue: UniteEns) -> str:
|
||||
@ -86,7 +85,9 @@ 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, ue.parcours)
|
||||
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(
|
||||
annee, parcour=ue.parcour
|
||||
)
|
||||
|
||||
# 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)
|
||||
@ -100,7 +101,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} / {n.competence.titre_long}
|
||||
>{n.annee} {n.competence.titre_long}
|
||||
niveau {n.ordre}</option>"""
|
||||
)
|
||||
options.append("""</optgroup>""")
|
||||
@ -115,7 +116,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} / {n.competence.titre_long}
|
||||
{disabled}>{n.annee} {n.competence.titre_long}
|
||||
niveau {n.ordre}</option>"""
|
||||
)
|
||||
options.append("""</optgroup>""")
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -12,7 +12,6 @@ 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
|
||||
@ -20,10 +19,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 codes_cursus
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.scodoc import sco_groups
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc.codes_cursus import UE_SPORT, DEF
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF
|
||||
from app.scodoc.sco_utils import fmt_note
|
||||
|
||||
|
||||
@ -158,8 +157,8 @@ class BulletinBUT:
|
||||
for _, ue_capitalisee in self.res.validations.ue_capitalisees.loc[
|
||||
[etud.id]
|
||||
].iterrows():
|
||||
if codes_cursus.code_ue_validant(ue_capitalisee.code):
|
||||
ue = db.session.get(UniteEns, ue_capitalisee.ue_id) # XXX cacher ?
|
||||
if sco_codes_parcours.code_ue_validant(ue_capitalisee.code):
|
||||
ue = UniteEns.query.get(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
|
||||
@ -188,8 +187,6 @@ 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"] = {
|
||||
@ -286,9 +283,9 @@ class BulletinBUT:
|
||||
eval_notes[etud.id],
|
||||
note_max=e.note_max,
|
||||
),
|
||||
"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),
|
||||
"min": fmt_note(notes_ok.min()),
|
||||
"max": fmt_note(notes_ok.max()),
|
||||
"moy": fmt_note(notes_ok.mean()),
|
||||
},
|
||||
"poids": poids,
|
||||
"url": url_for(
|
||||
@ -364,7 +361,7 @@ class BulletinBUT:
|
||||
"formsemestre_id": formsemestre.id,
|
||||
"etat_inscription": etat_inscription,
|
||||
"options": sco_preferences.bulletin_option_affichage(
|
||||
formsemestre, self.prefs
|
||||
formsemestre.id, self.prefs
|
||||
),
|
||||
}
|
||||
if not published:
|
||||
@ -388,7 +385,7 @@ class BulletinBUT:
|
||||
"injustifie": nbabs - nbabsjust,
|
||||
"total": nbabs,
|
||||
}
|
||||
decisions_ues = self.res.get_etud_decisions_ue(etud.id) or {}
|
||||
decisions_ues = self.res.get_etud_decision_ues(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)
|
||||
@ -468,7 +465,6 @@ class BulletinBUT:
|
||||
"ressources": {},
|
||||
"saes": {},
|
||||
"ues": {},
|
||||
"ues_capitalisees": {},
|
||||
}
|
||||
)
|
||||
|
||||
@ -476,7 +472,6 @@ 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(
|
||||
@ -485,7 +480,6 @@ 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(
|
||||
@ -506,7 +500,7 @@ class BulletinBUT:
|
||||
# --- Decision Jury
|
||||
infos, dpv = sco_bulletins.etud_descr_situation_semestre(
|
||||
etud.id,
|
||||
self.res.formsemestre,
|
||||
self.res.formsemestre.id,
|
||||
format="html",
|
||||
show_date_inscr=self.prefs["bul_show_date_inscr"],
|
||||
show_decisions=self.prefs["bul_show_decision"],
|
||||
|
@ -1,24 +1,10 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""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
|
||||
@ -26,7 +12,7 @@ from reportlab.platypus import Paragraph, Spacer
|
||||
|
||||
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
|
||||
from app.scodoc import gen_tables
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
@ -79,9 +65,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
||||
|
||||
return objects
|
||||
|
||||
def but_table_synthese_ues(
|
||||
self, title_bg=(182, 235, 255), title_ue_cap_bg=(150, 207, 147)
|
||||
):
|
||||
def but_table_synthese_ues(self, title_bg=(182, 235, 255)):
|
||||
"""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
|
||||
@ -90,7 +74,6 @@ 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,
|
||||
@ -112,7 +95,6 @@ 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
|
||||
@ -159,17 +141,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
||||
blue,
|
||||
),
|
||||
]
|
||||
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
|
||||
)
|
||||
|
||||
for ue_acronym, ue in self.infos["ues"].items():
|
||||
self.ue_rows(rows, ue_acronym, ue, title_bg)
|
||||
|
||||
# Global pdf style commands:
|
||||
pdf_style = [
|
||||
@ -178,19 +152,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", "-")
|
||||
if isinstance(moy_ue, dict):
|
||||
moy_ue = moy_ue.get("value", "-") if moy_ue is not None else "-"
|
||||
moy_ue = ue.get("moyenne")
|
||||
t = {
|
||||
"titre": f"{ue_acronym} - {ue['titre']}",
|
||||
"moyenne": Paragraph(
|
||||
f"""<para align=right><b>{moy_ue or "-"}</b></para>"""
|
||||
f"""<para align=right><b>{moy_ue.get("value", "-")
|
||||
if moy_ue is not None else "-"
|
||||
}</b></para>"""
|
||||
),
|
||||
"_css_row_class": "note_bold",
|
||||
"_pdf_row_markup": ["b"],
|
||||
@ -222,40 +196,25 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
||||
# case Bonus/Malus/Rang "bmr"
|
||||
fields_bmr = []
|
||||
try:
|
||||
value = float(ue.get("bonus", 0.0))
|
||||
value = float(ue["bonus"])
|
||||
if value != 0:
|
||||
fields_bmr.append(f"Bonus: {ue['bonus']}")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
value = float(ue.get("malus", 0.0))
|
||||
value = float(ue["malus"])
|
||||
if value != 0:
|
||||
fields_bmr.append(f"Malus: {ue['malus']}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
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"],
|
||||
if self.preferences["bul_show_ue_rangs"]:
|
||||
fields_bmr.append(
|
||||
f"Rang: {ue['moyenne']['rang']} / {ue['moyenne']['total']}"
|
||||
)
|
||||
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=right>{ects_txt}</para>"""),
|
||||
"_coef_pdf": Paragraph(f"""<para align=left>{ects_txt}</para>"""),
|
||||
"_coef_colspan": 2,
|
||||
"_pdf_style": [
|
||||
("BACKGROUND", (0, 0), (-1, 0), title_bg),
|
||||
@ -263,9 +222,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_min,
|
||||
"max": ue_max,
|
||||
"moy": ue_moy,
|
||||
"min": ue["moyenne"]["min"],
|
||||
"max": ue["moyenne"]["max"],
|
||||
"moy": ue["moyenne"]["moy"],
|
||||
}
|
||||
rows.append(t)
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -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 codes_cursus
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.scodoc import sco_edit_ue
|
||||
from app.scodoc import sco_etud
|
||||
from app.scodoc import sco_photos
|
||||
@ -65,10 +65,11 @@ def bulletin_but_xml_compat(
|
||||
from app.scodoc import sco_bulletins
|
||||
|
||||
log(
|
||||
f"bulletin_but_xml_compat( formsemestre_id={formsemestre_id}, etudid={etudid} )"
|
||||
"bulletin_but_xml_compat( formsemestre_id=%s, etudid=%s )"
|
||||
% (formsemestre_id, etudid)
|
||||
)
|
||||
etud = Identite.get_etud(etudid)
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
|
||||
etud: Identite = Identite.query.get_or_404(etudid)
|
||||
results = bulletin_but.ResultatsSemestreBUT(formsemestre)
|
||||
nb_inscrits = results.get_inscriptions_counts()[scu.INSCRIT]
|
||||
# etat_inscription = etud.inscription_etat(formsemestre.id)
|
||||
@ -158,7 +159,7 @@ def bulletin_but_xml_compat(
|
||||
code_apogee=quote_xml_attr(ue.code_apogee or ""),
|
||||
)
|
||||
doc.append(x_ue)
|
||||
if ue.type != codes_cursus.UE_SPORT:
|
||||
if ue.type != sco_codes_parcours.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()
|
||||
@ -252,7 +253,7 @@ def bulletin_but_xml_compat(
|
||||
):
|
||||
infos, dpv = sco_bulletins.etud_descr_situation_semestre(
|
||||
etudid,
|
||||
formsemestre,
|
||||
formsemestre_id,
|
||||
format="xml",
|
||||
show_uevalid=sco_preferences.get_preference(
|
||||
"bul_show_uevalid", formsemestre_id
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -13,45 +13,46 @@ Classe raccordant avec ScoDoc 7:
|
||||
avec la même interface.
|
||||
|
||||
"""
|
||||
import collections
|
||||
from operator import attrgetter
|
||||
|
||||
from typing import Union
|
||||
|
||||
from flask import g, url_for
|
||||
|
||||
from app import db, log
|
||||
from app import db
|
||||
from app import 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 codes_cursus as sco_codes
|
||||
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD
|
||||
|
||||
from app.scodoc import sco_codes_parcours as sco_codes
|
||||
from app.scodoc.sco_codes_parcours import RED, UE_STANDARD
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
|
||||
from app.scodoc.sco_exceptions import ScoException, 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
|
||||
@ -64,538 +65,3 @@ 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
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -38,8 +38,8 @@ class RefCompLoadForm(FlaskForm):
|
||||
submit = SubmitField("Valider")
|
||||
cancel = SubmitField("Annuler")
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
if not super().validate(extra_validators):
|
||||
def validate(self):
|
||||
if not super().validate():
|
||||
return False
|
||||
if (self.referentiel_standard.data == "0") == (not self.upload.data):
|
||||
self.referentiel_standard.errors.append(
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
from xml.etree import ElementTree
|
||||
|
1504
app/but/jury_but.py
1504
app/but/jury_but.py
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -12,7 +12,6 @@ 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
|
||||
@ -27,34 +26,78 @@ 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)
|
||||
inscriptions = sorted(
|
||||
indices = sorted(
|
||||
[
|
||||
ins
|
||||
ins.formsemestre.semestre_id
|
||||
if ins.formsemestre.semestre_id is not None
|
||||
else -1
|
||||
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_page_but(formsemestre_id: int, fmt="html"):
|
||||
def pvjury_table_but(formsemestre_id: int, format="html"):
|
||||
"""Page récapitulant les décisions de jury BUT
|
||||
formsemestre peut être pair ou impair
|
||||
"""
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
|
||||
assert formsemestre.formation.is_apc()
|
||||
title = "Procès-verbal de jury BUT"
|
||||
if fmt == "html":
|
||||
title = "Procès-verbal de jury BUT annuel"
|
||||
|
||||
if format == "html":
|
||||
line_sep = "<br>"
|
||||
else:
|
||||
line_sep = "\n"
|
||||
rows, titles = pvjury_table_but(formsemestre, line_sep=line_sep)
|
||||
# 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"])
|
||||
|
||||
# Style excel... passages à la ligne sur \n
|
||||
xls_style_base = sco_excel.excel_make_style()
|
||||
@ -66,11 +109,10 @@ def pvjury_page_but(formsemestre_id: int, fmt="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_page_but",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, fmt="xlsx")}"
|
||||
<a href="{url_for("notes.pvjury_table_but",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, format="xlsx")}"
|
||||
class="stdlink">version excel</a></span></div>
|
||||
|
||||
""",
|
||||
@ -94,83 +136,4 @@ def pvjury_page_but(formsemestre_id: int, fmt="html"):
|
||||
},
|
||||
xls_style_base=xls_style_base,
|
||||
)
|
||||
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
|
||||
return tab.make_page(format=format, javascripts=["js/etud_info.js"], init_qtip=True)
|
||||
|
525
app/but/jury_but_recap.py
Normal file
525
app/but/jury_but_recap.py
Normal file
@ -0,0 +1,525 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Jury BUT: table recap annuelle et liens saisie
|
||||
"""
|
||||
|
||||
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 ScoValueError
|
||||
|
||||
|
||||
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 ScoValueError(
|
||||
"""
|
||||
<p>Pas de référentiel de compétences associé à la formation !</p>
|
||||
<p>Pour associer un référentiel, passer par le menu <b>Semestre /
|
||||
Voir la formation... </b> et suivre le lien <em>"associer à un référentiel
|
||||
de compétences"</em>
|
||||
"""
|
||||
)
|
||||
|
||||
rows, titles, column_ids = 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>
|
||||
|
||||
{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]]:
|
||||
"""Construit la table des résultats annuels pour le jury BUT"""
|
||||
res2: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre2)
|
||||
titles = {} # column_id : title
|
||||
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",
|
||||
)
|
||||
# --- 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:
|
||||
res2.recap_add_partitions(rows_dict, titles, col_idx=row.last_etud_cell_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
|
||||
|
||||
|
||||
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
|
@ -1,94 +0,0 @@
|
||||
##############################################################################
|
||||
# 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
|
@ -1,52 +1,34 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""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 import Identite, FormSemestre, ScolarNews
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.scodoc import sco_cache
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
|
||||
|
||||
def formsemestre_validation_auto_but(
|
||||
formsemestre: FormSemestre, only_adm: bool = True
|
||||
) -> int:
|
||||
"""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 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)
|
||||
|
||||
Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code.
|
||||
def formsemestre_validation_auto_but(formsemestre: FormSemestre) -> int:
|
||||
"""Calcul automatique des décisions de jury sur une année BUT.
|
||||
Returns: nombre d'étudiants "admis"
|
||||
"""
|
||||
if not formsemestre.formation.is_apc():
|
||||
raise ScoValueError("fonction réservée aux formations BUT")
|
||||
nb_etud_modif = 0
|
||||
nb_admis = 0
|
||||
with sco_cache.DeferredSemCacheManager():
|
||||
for etudid in formsemestre.etuds_inscriptions:
|
||||
etud = Identite.get_etud(etudid)
|
||||
etud: Identite = Identite.query.get(etudid)
|
||||
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
|
||||
nb_etud_modif += deca.record_all(only_validantes=only_adm)
|
||||
if deca.admis: # année réussie
|
||||
deca.record_all()
|
||||
nb_admis += 1
|
||||
|
||||
db.session.commit()
|
||||
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
|
||||
return nb_admis
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -8,36 +8,25 @@
|
||||
"""
|
||||
|
||||
import re
|
||||
import numpy as np
|
||||
|
||||
import flask
|
||||
from flask import flash, render_template, url_for
|
||||
from flask import flash, url_for
|
||||
from flask import g, request
|
||||
|
||||
from app import db
|
||||
from app.but import jury_but
|
||||
from app.but.jury_but import (
|
||||
DecisionsProposeesAnnee,
|
||||
DecisionsProposeesRCUE,
|
||||
DecisionsProposeesUE,
|
||||
)
|
||||
from app.but.jury_but import DecisionsProposeesAnnee, DecisionsProposeesUE
|
||||
from app.comp import res_sem
|
||||
from app.comp.res_but import ResultatsSemestreBUT
|
||||
from app.models import (
|
||||
ApcNiveau,
|
||||
FormSemestre,
|
||||
FormSemestreInscription,
|
||||
Identite,
|
||||
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
|
||||
|
||||
|
||||
@ -46,95 +35,96 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
|
||||
Si pas read_only, menus sélection codes jury.
|
||||
"""
|
||||
H = []
|
||||
if deca.code_valide and not read_only:
|
||||
erase_span = f"""<a href="{
|
||||
url_for("notes.formsemestre_jury_but_erase",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=deca.formsemestre_id,
|
||||
etudid=deca.etud.id)}" class="stdlink">effacer décisions</a>"""
|
||||
else:
|
||||
erase_span = ""
|
||||
|
||||
H.append("""<div class="but_section_annee">""")
|
||||
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>
|
||||
<span>({'non ' if deca.code_valide is None else ''}enregistrée)</span>
|
||||
<span>{erase_span}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="but_explanation">{deca.explanation}</div>
|
||||
"""
|
||||
)
|
||||
else:
|
||||
H.append("""<div><em>Pas de décision annuelle (sem. impair)</em></div>""")
|
||||
H.append("""</div>""")
|
||||
|
||||
if deca.formsemestre_pair is not None:
|
||||
annee_sco_pair = deca.formsemestre_pair.annee_scolaire()
|
||||
avertissement_redoublement = (
|
||||
f"année {annee_sco_pair}-{annee_sco_pair+1}"
|
||||
if annee_sco_pair != deca.annee_scolaire()
|
||||
else ""
|
||||
)
|
||||
else:
|
||||
avertissement_redoublement = ""
|
||||
|
||||
formsemestre_1 = deca.formsemestre_impair
|
||||
formsemestre_2 = deca.formsemestre_pair
|
||||
# Ordonne selon les dates des 2 semestres considérés (pour les redoublants à cheval):
|
||||
reverse_semestre = (
|
||||
deca.formsemestre_pair
|
||||
and deca.formsemestre_impair
|
||||
and deca.formsemestre_pair.date_debut < deca.formsemestre_impair.date_debut
|
||||
)
|
||||
if reverse_semestre:
|
||||
formsemestre_1, formsemestre_2 = formsemestre_2, formsemestre_1
|
||||
H.append(
|
||||
f"""
|
||||
<div class="titre_niveaux">
|
||||
<b>Niveaux de compétences et unités d'enseignement du BUT{deca.annee_but}</b>
|
||||
</div>
|
||||
<div class="but_explanation">{deca.explanation}</div>
|
||||
<div class="titre_niveaux"><b>Niveaux de compétences et unités d'enseignement</b></div>
|
||||
<div class="but_annee">
|
||||
<div class="titre"></div>
|
||||
<div class="titre">{"S" +str(formsemestre_1.semestre_id)
|
||||
if formsemestre_1 else "-"}
|
||||
<span class="avertissement_redoublement">{formsemestre_1.annee_scolaire_str()
|
||||
if formsemestre_1 else ""}</span>
|
||||
</div>
|
||||
<div class="titre">{"S"+str(formsemestre_2.semestre_id)
|
||||
if formsemestre_2 else "-"}
|
||||
<span class="avertissement_redoublement">{formsemestre_2.annee_scolaire_str()
|
||||
if formsemestre_2 else ""}</span>
|
||||
</div>
|
||||
<div class="titre">S{deca.formsemestre_impair.semestre_id
|
||||
if deca.formsemestre_impair else "-"}</div>
|
||||
<div class="titre">S{deca.formsemestre_pair.semestre_id
|
||||
if deca.formsemestre_pair else "-"}
|
||||
<span class="avertissement_redoublement">{avertissement_redoublement}</span></div>
|
||||
<div class="titre">RCUE</div>
|
||||
"""
|
||||
)
|
||||
for dec_rcue in deca.get_decisions_rcues_annee():
|
||||
rcue = dec_rcue.rcue
|
||||
niveau = rcue.niveau
|
||||
for niveau in deca.niveaux_competences:
|
||||
H.append(
|
||||
f"""<div class="but_niveau_titre">
|
||||
<div title="{niveau.competence.titre_long}">{niveau.competence.titre}</div>
|
||||
</div>"""
|
||||
)
|
||||
ue_impair, ue_pair = rcue.ue_1, rcue.ue_2
|
||||
# Les UEs à afficher,
|
||||
# qui
|
||||
ues_ro = [
|
||||
(
|
||||
ue_impair,
|
||||
rcue.ue_cur_impair is None,
|
||||
),
|
||||
(
|
||||
ue_pair,
|
||||
rcue.ue_cur_pair is None,
|
||||
),
|
||||
]
|
||||
# Ordonne selon les dates des 2 semestres considérés:
|
||||
if reverse_semestre:
|
||||
ues_ro[0], ues_ro[1] = ues_ro[1], ues_ro[0]
|
||||
# Colonnes d'UE:
|
||||
for ue, ue_read_only in ues_ro:
|
||||
if ue:
|
||||
H.append(
|
||||
_gen_but_niveau_ue(
|
||||
ue,
|
||||
deca.decisions_ues[ue.id],
|
||||
disabled=read_only or ue_read_only,
|
||||
annee_prec=ue_read_only,
|
||||
niveau_id=ue.niveau_competence.id,
|
||||
)
|
||||
dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id)
|
||||
if dec_rcue is None:
|
||||
break
|
||||
# Semestre impair
|
||||
H.append(
|
||||
_gen_but_niveau_ue(
|
||||
dec_rcue.rcue.ue_1,
|
||||
deca.decisions_ues[dec_rcue.rcue.ue_1.id],
|
||||
disabled=read_only,
|
||||
)
|
||||
)
|
||||
# Semestre pair
|
||||
H.append(
|
||||
_gen_but_niveau_ue(
|
||||
dec_rcue.rcue.ue_2,
|
||||
deca.decisions_ues[dec_rcue.rcue.ue_2.id],
|
||||
disabled=read_only,
|
||||
)
|
||||
)
|
||||
# RCUE
|
||||
H.append(
|
||||
f"""<div class="but_niveau_rcue
|
||||
{'recorded' if dec_rcue.code_valide is not None else ''}
|
||||
">
|
||||
<div class="but_note">{scu.fmt_note(dec_rcue.rcue.moy_rcue)}</div>
|
||||
<div class="but_code">{
|
||||
_gen_but_select("code_rcue_"+str(niveau.id),
|
||||
dec_rcue.codes,
|
||||
dec_rcue.code_valide,
|
||||
disabled=True, klass="manual"
|
||||
)
|
||||
else:
|
||||
H.append("""<div class="niveau_vide"></div>""")
|
||||
|
||||
# Colonne RCUE
|
||||
H.append(_gen_but_rcue(dec_rcue, niveau))
|
||||
|
||||
}</div>
|
||||
</div>"""
|
||||
)
|
||||
H.append("</div>") # but_annee
|
||||
return "\n".join(H)
|
||||
|
||||
@ -145,109 +135,46 @@ def _gen_but_select(
|
||||
code_valide: str,
|
||||
disabled: bool = False,
|
||||
klass: str = "",
|
||||
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(
|
||||
h = "\n".join(
|
||||
[
|
||||
f"""<option value="{code}"
|
||||
f"""<option value="{code}"
|
||||
{'selected' if code == code_valide else ''}
|
||||
class="{'recorded' if code == code_valide else ''}"
|
||||
>{code
|
||||
if ((code != code_valide) or not code_valide_label)
|
||||
else code_valide_label
|
||||
}</option>"""
|
||||
>{code}</option>"""
|
||||
for code in codes
|
||||
]
|
||||
)
|
||||
return f"""<select required name="{name}"
|
||||
class="but_code {klass}"
|
||||
data-orig_code="{code_valide or (codes[0] if codes else '')}"
|
||||
data-orig_recorded="{code_valide or ''}"
|
||||
onchange="change_menu_code(this);"
|
||||
{"disabled" if disabled else ""}
|
||||
{" ".join( f'data-{k}="{v}"' for (k,v) in data.items() )}
|
||||
>{options_htm}</select>
|
||||
>{h}</select>
|
||||
"""
|
||||
|
||||
|
||||
def _gen_but_niveau_ue(
|
||||
ue: UniteEns,
|
||||
dec_ue: DecisionsProposeesUE,
|
||||
disabled: bool = False,
|
||||
annee_prec: bool = False,
|
||||
niveau_id: int = None,
|
||||
) -> str:
|
||||
def _gen_but_niveau_ue(ue: UniteEns, dec_ue: DecisionsProposeesUE, disabled=False):
|
||||
if dec_ue.ue_status and dec_ue.ue_status["is_capitalized"]:
|
||||
moy_ue_str = f"""<span class="ue_cap">{
|
||||
scu.fmt_note(dec_ue.moy_ue_with_cap)}</span>"""
|
||||
scoplement = f"""<div class="scoplement">
|
||||
<div>
|
||||
<b>UE {ue.acronyme} capitalisée </b>
|
||||
<span>le {dec_ue.ue_status["event_date"].strftime("%d/%m/%Y")}
|
||||
</span>
|
||||
<b>UE {ue.acronyme} capitalisée le
|
||||
{dec_ue.ue_status["event_date"].strftime("%d/%m/%Y")}
|
||||
</b>
|
||||
</div>
|
||||
<div>UE en cours
|
||||
{ "sans notes" if np.isnan(dec_ue.moy_ue)
|
||||
else
|
||||
("avec moyenne <b>" + scu.fmt_note(dec_ue.moy_ue) + "</b>")
|
||||
}
|
||||
<div>UE en cours avec moyenne
|
||||
{scu.fmt_note(dec_ue.moy_ue)}
|
||||
</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:
|
||||
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 = ""
|
||||
scoplement = ""
|
||||
|
||||
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 ''}
|
||||
return f"""<div class="but_niveau_ue {
|
||||
'recorded' if dec_ue.code_valide is not None else ''}
|
||||
">
|
||||
<div title="{ue.titre}">{ue.acronyme}</div>
|
||||
<div class="but_note with_scoplement">
|
||||
@ -256,96 +183,38 @@ def _gen_but_niveau_ue(
|
||||
</div>
|
||||
<div class="but_code">{
|
||||
_gen_but_select("code_ue_"+str(ue.id),
|
||||
dec_ue.codes,
|
||||
dec_ue.code_valide,
|
||||
disabled=disabled,
|
||||
klass=f"code_ue ue_rcue_{niveau_id}" if not disabled else ""
|
||||
dec_ue.codes,
|
||||
dec_ue.code_valide, disabled=disabled
|
||||
)
|
||||
}</div>
|
||||
|
||||
</div>"""
|
||||
|
||||
|
||||
def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
|
||||
if dec_rcue is None or not dec_rcue.rcue.complete:
|
||||
return """
|
||||
<div class="but_niveau_rcue niveau_vide with_scoplement">
|
||||
<div></div>
|
||||
<div class="scoplement">Pas de RCUE (UE non capitalisée ?)</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
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 = ""
|
||||
if dec_rcue.code_valide is not None and dec_rcue.codes:
|
||||
if dec_rcue.code_valide == dec_rcue.codes[0]:
|
||||
niveau_rcue_class = "recorded"
|
||||
else:
|
||||
niveau_rcue_class = "recorded_different"
|
||||
|
||||
return f"""
|
||||
<div class="but_niveau_rcue {niveau_rcue_class}
|
||||
">
|
||||
<div class="but_note with_scoplement">
|
||||
<div>{scu.fmt_note(dec_rcue.rcue.moy_rcue)}</div>
|
||||
{scoplement}
|
||||
</div>
|
||||
<div class="but_code">
|
||||
{_gen_but_select("code_rcue_"+str(niveau.id),
|
||||
dec_rcue.codes,
|
||||
code_propose_menu,
|
||||
disabled=True,
|
||||
klass="manual code_rcue",
|
||||
data = { "niveau_id" : str(niveau.id)},
|
||||
code_valide_label = code_valide_label,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def jury_but_semestriel(
|
||||
formsemestre: FormSemestre,
|
||||
etud: Identite,
|
||||
read_only: bool,
|
||||
navigation_div: str = "",
|
||||
) -> str:
|
||||
"""Page: formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)."""
|
||||
"""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_cursus().NB_SEM
|
||||
formsemestre.semestre_id >= formsemestre.formation.get_parcours().NB_SEM
|
||||
)
|
||||
autorisations_passage = ScolarAutorisationInscription.query.filter_by(
|
||||
etudid=etud.id,
|
||||
origin_formsemestre_id=formsemestre.id,
|
||||
).all()
|
||||
# Par défaut: autorisé à passer dans le semestre suivant si sem. impair,
|
||||
# ou si décision déjà enregistrée:
|
||||
est_autorise_a_passer = (formsemestre.semestre_id % 2) or (
|
||||
formsemestre.semestre_id + 1
|
||||
) in (a.semestre_id for a in autorisations_passage)
|
||||
) in (
|
||||
a.semestre_id
|
||||
for a in ScolarAutorisationInscription.query.filter_by(
|
||||
etudid=etud.id,
|
||||
origin_formsemestre_id=formsemestre.id,
|
||||
)
|
||||
)
|
||||
decisions_ues = {
|
||||
ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat)
|
||||
for ue in ues
|
||||
@ -358,9 +227,9 @@ def jury_but_semestriel(
|
||||
for key in request.form:
|
||||
code = request.form[key]
|
||||
# Codes d'UE
|
||||
code_match = re.match(r"^code_ue_(\d+)$", key)
|
||||
if code_match:
|
||||
ue_id = int(code_match.group(1))
|
||||
m = re.match(r"^code_ue_(\d+)$", key)
|
||||
if m:
|
||||
ue_id = int(m.group(1))
|
||||
dec_ue = decisions_ues.get(ue_id)
|
||||
if not dec_ue:
|
||||
raise ScoValueError(f"UE invalide ue_id={ue_id}")
|
||||
@ -369,12 +238,7 @@ def jury_but_semestriel(
|
||||
flash("codes enregistrés")
|
||||
if not semestre_terminal:
|
||||
if request.form.get("autorisation_passage"):
|
||||
if not formsemestre.semestre_id + 1 in (
|
||||
a.semestre_id for a in autorisations_passage
|
||||
):
|
||||
ScolarAutorisationInscription.delete_autorisation_etud(
|
||||
etud.id, formsemestre.id
|
||||
)
|
||||
if not est_autorise_a_passer:
|
||||
ScolarAutorisationInscription.autorise_etud(
|
||||
etud.id,
|
||||
formsemestre.formation.formation_code,
|
||||
@ -383,8 +247,7 @@ def jury_but_semestriel(
|
||||
)
|
||||
db.session.commit()
|
||||
flash(
|
||||
f"""autorisation de passage en S{formsemestre.semestre_id + 1
|
||||
} enregistrée"""
|
||||
f"autorisation de passage en S{formsemestre.semestre_id + 1} enregistrée"
|
||||
)
|
||||
else:
|
||||
if est_autorise_a_passer:
|
||||
@ -395,16 +258,6 @@ 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",
|
||||
@ -423,7 +276,7 @@ def jury_but_semestriel(
|
||||
warning = ""
|
||||
H = [
|
||||
html_sco_header.sco_header(
|
||||
page_title=f"Validation BUT S{formsemestre.semestre_id}",
|
||||
page_title="Validation BUT",
|
||||
formsemestre_id=formsemestre.id,
|
||||
etudid=etud.id,
|
||||
cssstyles=("css/jury_but.css",),
|
||||
@ -432,47 +285,37 @@ 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>
|
||||
<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 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>
|
||||
<h3>Jury sur un semestre BUT isolé (ne concerne que les UEs)</h3>
|
||||
{warning}
|
||||
<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é</h3>
|
||||
{warning}
|
||||
</div>
|
||||
|
||||
<form method="post" class="jury_but_box" id="jury_but">
|
||||
<form method="POST">
|
||||
""",
|
||||
]
|
||||
|
||||
erase_span = ""
|
||||
if not read_only:
|
||||
# Requête toutes les validations (pas seulement celles du deca courant),
|
||||
# au cas où: changement d'architecture, saisie en mode classique, ...
|
||||
validations = ScolarFormSemestreValidation.query.filter_by(
|
||||
etudid=etud.id, formsemestre_id=formsemestre.id
|
||||
).all()
|
||||
if validations:
|
||||
erase_span = f"""<a href="{
|
||||
url_for("notes.formsemestre_jury_but_erase",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id,
|
||||
etudid=etud.id, only_one_sem=1)
|
||||
}" class="stdlink">effacer les décisions enregistrées</a>"""
|
||||
else:
|
||||
erase_span = (
|
||||
"Cet étudiant n'a aucune décision enregistrée pour ce semestre."
|
||||
)
|
||||
if (not read_only) and any([dec.code_valide for dec in decisions_ues.values()]):
|
||||
erase_span = f"""<a href="{
|
||||
url_for("notes.formsemestre_jury_but_erase",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id,
|
||||
etudid=etud.id, only_one_sem=1)}" class="stdlink">effacer décisions</a>"""
|
||||
else:
|
||||
erase_span = "aucune décision enregistrée pour ce semestre"
|
||||
|
||||
H.append(
|
||||
f"""
|
||||
<div class="but_section_annee">
|
||||
<span>{erase_span}</span>
|
||||
</div>
|
||||
<div><b>Unités d'enseignement de S{formsemestre.semestre_id}:</b></div>
|
||||
"""
|
||||
@ -508,63 +351,34 @@ 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(
|
||||
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>
|
||||
"""
|
||||
"""<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>"""
|
||||
)
|
||||
else:
|
||||
if formsemestre.semestre_id < formsemestre.formation.get_cursus().NB_SEM:
|
||||
if formsemestre.semestre_id < formsemestre.formation.get_parcours().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>
|
||||
</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>
|
||||
"""
|
||||
)
|
||||
else:
|
||||
H.append("""<div class="help">dernier semestre de la formation.</div>""")
|
||||
H.append(
|
||||
f"""
|
||||
"""
|
||||
<div class="but_buttons">
|
||||
<span><input type="submit" value="Enregistrer ces décisions"></span>
|
||||
<span>{erase_span}</span>
|
||||
<input type="submit" value="Enregistrer ces décisions">
|
||||
</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(),
|
||||
)
|
||||
)
|
||||
|
||||
H.append(navigation_div)
|
||||
return "\n".join(H)
|
||||
|
||||
|
||||
@ -573,7 +387,7 @@ def infos_fiche_etud_html(etudid: int) -> str:
|
||||
"""Section html pour fiche etudiant
|
||||
provisoire pour BUT 2022
|
||||
"""
|
||||
etud = Identite.get_etud(etudid)
|
||||
etud: Identite = Identite.query.get_or_404(etudid)
|
||||
inscriptions = (
|
||||
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
|
||||
.filter(
|
||||
@ -590,10 +404,11 @@ def infos_fiche_etud_html(etudid: int) -> str:
|
||||
# temporaire quick & dirty: affiche le dernier
|
||||
try:
|
||||
deca = DecisionsProposeesAnnee(etud, formsemestres_but[-1])
|
||||
return f"""<div class="infos_but">
|
||||
if True: # len(deca.rcues_annee) > 0:
|
||||
return f"""<div class="infos_but">
|
||||
{show_etud(deca, read_only=True)}
|
||||
</div>
|
||||
"""
|
||||
"""
|
||||
except ScoValueError:
|
||||
pass
|
||||
|
||||
|
@ -1,67 +0,0 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Jury édition manuelle des décisions (correction d'erreurs, parcours hors normes)
|
||||
|
||||
Non spécifique au BUT.
|
||||
"""
|
||||
|
||||
from flask import render_template
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.models import (
|
||||
ApcValidationAnnee,
|
||||
ApcValidationRCUE,
|
||||
Identite,
|
||||
UniteEns,
|
||||
ScolarAutorisationInscription,
|
||||
ScolarFormSemestreValidation,
|
||||
)
|
||||
from app.views import ScoData
|
||||
|
||||
|
||||
def jury_delete_manual(etud: Identite):
|
||||
"""Vue (réservée au chef de dept.)
|
||||
présentant *toutes* les décisions de jury concernant cet étudiant
|
||||
et permettant de les supprimer une à une.
|
||||
"""
|
||||
sem_vals = ScolarFormSemestreValidation.query.filter_by(
|
||||
etudid=etud.id, ue_id=None
|
||||
).order_by(ScolarFormSemestreValidation.event_date)
|
||||
ue_vals = (
|
||||
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
|
||||
.join(UniteEns)
|
||||
.order_by(
|
||||
sa.extract("year", ScolarFormSemestreValidation.event_date),
|
||||
UniteEns.semestre_idx,
|
||||
UniteEns.numero,
|
||||
UniteEns.acronyme,
|
||||
)
|
||||
)
|
||||
autorisations = ScolarAutorisationInscription.query.filter_by(
|
||||
etudid=etud.id
|
||||
).order_by(
|
||||
ScolarAutorisationInscription.semestre_id, ScolarAutorisationInscription.date
|
||||
)
|
||||
rcue_vals = (
|
||||
ApcValidationRCUE.query.filter_by(etudid=etud.id)
|
||||
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
|
||||
.order_by(UniteEns.semestre_idx, UniteEns.numero, ApcValidationRCUE.date)
|
||||
)
|
||||
annee_but_vals = ApcValidationAnnee.query.filter_by(etudid=etud.id).order_by(
|
||||
ApcValidationAnnee.ordre, ApcValidationAnnee.date
|
||||
)
|
||||
return render_template(
|
||||
"jury/jury_delete_manual.j2",
|
||||
etud=etud,
|
||||
sem_vals=sem_vals,
|
||||
ue_vals=ue_vals,
|
||||
autorisations=autorisations,
|
||||
rcue_vals=rcue_vals,
|
||||
annee_but_vals=annee_but_vals,
|
||||
sco=ScoData(),
|
||||
title=f"Toutes les décisions de jury enregistrées pour {etud.html_link_fiche()}",
|
||||
)
|
253
app/but/rcue.py
253
app/but/rcue.py
@ -1,253 +0,0 @@
|
||||
##############################################################################
|
||||
# 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
|
||||
# }
|
@ -1,117 +0,0 @@
|
||||
##############################################################################
|
||||
# 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
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -18,8 +18,8 @@ import pandas as pd
|
||||
|
||||
from flask import g
|
||||
|
||||
from app.scodoc.codes_cursus import UE_SPORT, UE_STANDARD
|
||||
from app.scodoc.codes_cursus import CursusDUT, CursusDUTMono
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc.sco_codes_parcours import ParcoursDUT, ParcoursDUTMono
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
@ -106,8 +106,6 @@ 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
|
||||
@ -194,7 +192,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
|
||||
bonus_max = 20.0 # le bonus ne peut dépasser 20 points
|
||||
bonux_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):
|
||||
@ -230,14 +228,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.get_ues(with_sport=False)]
|
||||
ues_idx = [ue.id for ue in self.formsemestre.query_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.get_ues(with_sport=False)]
|
||||
ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)]
|
||||
self.bonus_ues = pd.DataFrame(
|
||||
np.stack([bonus_moy_arr] * len(ues_idx)).T,
|
||||
index=self.etuds_idx,
|
||||
@ -422,7 +420,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.get_ues(with_sport=False)]
|
||||
# ues_idx = [ue.id for ue in self.formsemestre.query_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),
|
||||
@ -432,25 +430,6 @@ 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.
|
||||
@ -602,7 +581,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.get_ues(with_sport=False)
|
||||
ues = self.formsemestre.query_ues(with_sport=False).all()
|
||||
ues_idx = [ue.id for ue in ues]
|
||||
|
||||
if self.formsemestre.formation.is_apc(): # --- BUT
|
||||
@ -668,10 +647,7 @@ 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>
|
||||
<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)
|
||||
</ul>
|
||||
"""
|
||||
|
||||
@ -682,17 +658,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_cursus()
|
||||
parcours = self.formsemestre.formation.get_parcours()
|
||||
# Variantes de DUT ?
|
||||
if (
|
||||
isinstance(parcours, CursusDUT)
|
||||
or parcours.TYPE_CURSUS == CursusDUTMono.TYPE_CURSUS
|
||||
isinstance(parcours, ParcoursDUT)
|
||||
or parcours.TYPE_PARCOURS == ParcoursDUTMono.TYPE_PARCOURS
|
||||
): # 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.get_ues(with_sport=False)
|
||||
ues = self.formsemestre.query_ues(with_sport=False).all()
|
||||
ues_sans_bs = [
|
||||
ue for ue in ues if ue.acronyme[-2:].upper() != "BS"
|
||||
] # les 2 derniers cars forcés en majus
|
||||
@ -743,7 +719,6 @@ 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
|
||||
@ -786,7 +761,6 @@ 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"""
|
||||
@ -795,7 +769,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 = len(self.formsemestre.get_ues(with_sport=False))
|
||||
nb_ues = self.formsemestre.query_ues(with_sport=False).count()
|
||||
|
||||
bonus_moy_arr = np.where(
|
||||
note_bonus_max > self.seuil_moy_gen,
|
||||
@ -827,39 +801,23 @@ class BonusStMalo(BonusIUTRennes1):
|
||||
class BonusLaRocheSurYon(BonusSportAdditif):
|
||||
"""Bonus IUT de La Roche-sur-Yon
|
||||
|
||||
<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>
|
||||
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.
|
||||
"""
|
||||
|
||||
name = "bonus_larochesuryon"
|
||||
displayed_name = "IUT de La Roche-sur-Yon"
|
||||
seuil_moy_gen = 0.0
|
||||
seuil_comptage = 0.0
|
||||
|
||||
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)
|
||||
proportion_point = 1e10 # le moindre point sature le bonus
|
||||
bonus_max = 0.2 # à 0.2
|
||||
|
||||
|
||||
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 :
|
||||
@ -919,15 +877,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>
|
||||
@ -936,7 +894,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>
|
||||
"""
|
||||
|
||||
@ -1076,36 +1034,6 @@ 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.
|
||||
|
||||
@ -1178,13 +1106,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 :
|
||||
@ -1250,89 +1178,6 @@ 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.
|
||||
|
||||
@ -1387,7 +1232,6 @@ 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):
|
||||
@ -1495,44 +1339,6 @@ 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.
|
||||
|
||||
@ -1585,63 +1391,6 @@ 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)
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
|
@ -4,7 +4,6 @@
|
||||
"""Matrices d'inscription aux modules d'un semestre
|
||||
"""
|
||||
import pandas as pd
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app import db
|
||||
|
||||
@ -13,13 +12,6 @@ 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)
|
||||
@ -30,16 +22,17 @@ def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame:
|
||||
moduleimpl_ids = [m.id for m in formsemestre.modimpls_sorted]
|
||||
etudids = [inscr.etudid for inscr in formsemestre.inscriptions]
|
||||
df = pd.DataFrame(index=etudids, dtype=int)
|
||||
with db.engine.begin() as connection:
|
||||
for moduleimpl_id in moduleimpl_ids:
|
||||
ins_df = pd.read_sql_query(
|
||||
_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)
|
||||
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)
|
||||
# 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
|
||||
|
125
app/comp/jury.py
125
app/comp/jury.py
@ -1,28 +1,18 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""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 codes_cursus
|
||||
from app.scodoc import sco_codes_parcours
|
||||
|
||||
|
||||
class ValidationsSemestre(ResultatsCache):
|
||||
@ -63,7 +53,7 @@ class ValidationsSemestre(ResultatsCache):
|
||||
self.comp_decisions_jury()
|
||||
|
||||
def comp_decisions_jury(self):
|
||||
"""Cherche les decisions du jury pour le semestre (pas les RCUE).
|
||||
"""Cherche les decisions du jury pour le semestre (pas les UE).
|
||||
Calcule les attributs:
|
||||
decisions_jury = { etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }}
|
||||
decision_jury_ues={ etudid :
|
||||
@ -90,7 +80,7 @@ class ValidationsSemestre(ResultatsCache):
|
||||
|
||||
# UEs: { etudid : { ue_id : {"code":, "ects":, "event_date":} }}
|
||||
decisions_jury_ues = {}
|
||||
# Parcoure les décisions d'UE:
|
||||
# Parcours les décisions d'UE:
|
||||
for decision in (
|
||||
decisions_jury_q.filter(db.text("ue_id is not NULL"))
|
||||
.join(UniteEns)
|
||||
@ -99,7 +89,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 codes_cursus.code_ue_validant(decision.code) and decision.ue:
|
||||
if sco_codes_parcours.code_ue_validant(decision.code) and decision.ue:
|
||||
ects = decision.ue.ects or 0.0 # 0 if None
|
||||
else:
|
||||
ects = 0.0
|
||||
@ -112,12 +102,6 @@ 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
|
||||
@ -138,12 +122,7 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
|
||||
event_date :
|
||||
} ]
|
||||
"""
|
||||
|
||||
# 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 = sa.text(
|
||||
"""
|
||||
query = """
|
||||
SELECT DISTINCT SFV.*, ue.ue_code
|
||||
FROM
|
||||
notes_ue ue,
|
||||
@ -155,22 +134,21 @@ 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
|
||||
and nf2.id=%(formation_id)s
|
||||
and ins.etudid = SFV.etudid
|
||||
and ins.formsemestre_id = :formsemestre_id
|
||||
and ins.formsemestre_id = %(formsemestre_id)s
|
||||
|
||||
and SFV.ue_id = ue.id
|
||||
and SFV.code = 'ADM'
|
||||
|
||||
and ( (sem.id = SFV.formsemestre_id
|
||||
and sem.date_debut < :date_debut
|
||||
and sem.semestre_id = :semestre_id )
|
||||
and sem.date_debut < %(date_debut)s
|
||||
and sem.semestre_id = %(semestre_id)s )
|
||||
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)
|
||||
AND (SFV.semestre_id is NULL OR SFV.semestre_id=%(semestre_id)s)
|
||||
) )
|
||||
"""
|
||||
)
|
||||
params = {
|
||||
"formation_id": formsemestre.formation.id,
|
||||
"formsemestre_id": formsemestre.id,
|
||||
@ -178,82 +156,5 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
|
||||
"date_debut": formsemestre.date_debut,
|
||||
}
|
||||
|
||||
with db.engine.begin() as connection:
|
||||
df = pd.read_sql_query(query, connection, params=params, index_col="etudid")
|
||||
df = pd.read_sql_query(query, db.engine, 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
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -14,7 +14,7 @@ import pandas as pd
|
||||
from app.comp import moy_ue
|
||||
from app.models.formsemestre import FormSemestre
|
||||
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -38,14 +38,12 @@ 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.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc.sco_exceptions import ScoBugCatcher
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
@ -86,8 +84,6 @@ 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"
|
||||
#
|
||||
@ -134,7 +130,7 @@ class ModuleImplResults:
|
||||
manque des notes) ssi il y a des étudiants inscrits au semestre et au module
|
||||
qui ont des notes ATT.
|
||||
"""
|
||||
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
|
||||
moduleimpl = ModuleImpl.query.get(self.moduleimpl_id)
|
||||
self.etudids = self._etudids()
|
||||
|
||||
# --- Calcul nombre d'inscrits pour déterminer les évaluations "completes":
|
||||
@ -148,6 +144,7 @@ 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
|
||||
@ -174,48 +171,38 @@ 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)
|
||||
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
|
||||
nb_att = sum(
|
||||
evals_notes[str(evaluation.id)][list(inscrits_module)]
|
||||
== scu.NOTES_ATTENTE
|
||||
)
|
||||
self.etudids_attente |= eval_etudids_attente
|
||||
self.evaluations_etat[evaluation.id] = EvaluationEtat(
|
||||
evaluation_id=evaluation.id,
|
||||
nb_attente=len(eval_etudids_attente),
|
||||
is_complete=is_complete,
|
||||
evaluation_id=evaluation.id, nb_attente=nb_att, is_complete=is_complete
|
||||
)
|
||||
# au moins une note en ATT dans ce modimpl:
|
||||
self.en_attente = bool(self.etudids_attente)
|
||||
if nb_att > 0:
|
||||
self.en_attente = True
|
||||
|
||||
# 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.
|
||||
"""
|
||||
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 = 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",
|
||||
)
|
||||
eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)])
|
||||
return eval_df
|
||||
|
||||
@ -225,8 +212,8 @@ class ModuleImplResults:
|
||||
"""
|
||||
return [
|
||||
inscr.etudid
|
||||
for inscr in db.session.get(
|
||||
ModuleImpl, self.moduleimpl_id
|
||||
for inscr in ModuleImpl.query.get(
|
||||
self.moduleimpl_id
|
||||
).formsemestre.inscriptions
|
||||
]
|
||||
|
||||
@ -319,16 +306,10 @@ class ModuleImplResultsAPC(ModuleImplResults):
|
||||
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
|
||||
ne donnent pas de coef vers cette UE.
|
||||
"""
|
||||
modimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
|
||||
modimpl = ModuleImpl.query.get(self.moduleimpl_id)
|
||||
nb_etuds, nb_evals = self.evals_notes.shape
|
||||
nb_ues = evals_poids_df.shape[1]
|
||||
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})
|
||||
"""
|
||||
)
|
||||
assert evals_poids_df.shape[0] == nb_evals # compat notes/poids
|
||||
if nb_etuds == 0:
|
||||
return pd.DataFrame(index=[], columns=evals_poids_df.columns)
|
||||
if nb_ues == 0:
|
||||
@ -419,9 +400,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 = db.session.get(ModuleImpl, moduleimpl_id)
|
||||
modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
|
||||
evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all()
|
||||
ues = modimpl.formsemestre.get_ues(with_sport=False)
|
||||
ues = modimpl.formsemestre.query_ues(with_sport=False).all()
|
||||
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)
|
||||
@ -456,7 +437,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
|
||||
|
||||
|
||||
def moduleimpl_is_conforme(
|
||||
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
|
||||
moduleimpl, evals_poids: pd.DataFrame, modules_coefficients: pd.DataFrame
|
||||
) -> bool:
|
||||
"""Vérifie que les évaluations de ce moduleimpl sont bien conformes
|
||||
au PN.
|
||||
@ -465,7 +446,7 @@ def moduleimpl_is_conforme(
|
||||
|
||||
Arguments:
|
||||
evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs
|
||||
modimpl_coefs_df: DataFrame, cols: modimpl_id, lignes: UEs du formsemestre
|
||||
modules_coefficients: DataFrame, cols module_id, lignes UEs
|
||||
NB: les UEs dans evals_poids sont sans le bonus sport
|
||||
"""
|
||||
nb_evals, nb_ues = evals_poids.shape
|
||||
@ -473,18 +454,18 @@ def moduleimpl_is_conforme(
|
||||
return True # modules vides conformes
|
||||
if nb_ues == 0:
|
||||
return False # situation absurde (pas d'UE)
|
||||
if len(modimpl_coefs_df) != nb_ues:
|
||||
if len(modules_coefficients) != nb_ues:
|
||||
# il arrive (#bug) que le cache ne soit pas à jour...
|
||||
sco_cache.invalidate_formsemestre()
|
||||
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent")
|
||||
|
||||
if moduleimpl.id not in modimpl_coefs_df:
|
||||
if moduleimpl.module_id not in modules_coefficients:
|
||||
# soupçon de bug cache coef ?
|
||||
sco_cache.invalidate_formsemestre()
|
||||
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
|
||||
|
||||
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
|
||||
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
|
||||
return all((modules_coefficients[moduleimpl.module_id] != 0).eq(module_evals_poids))
|
||||
|
||||
|
||||
class ModuleImplResultsClassic(ModuleImplResults):
|
||||
@ -498,13 +479,12 @@ class ModuleImplResultsClassic(ModuleImplResults):
|
||||
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
|
||||
ne donnent pas de coef.
|
||||
"""
|
||||
modimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
|
||||
modimpl = ModuleImpl.query.get(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)
|
||||
if evals_coefs.shape != (nb_evals,):
|
||||
app.critical_error("compute_module_moy: vals_coefs.shape != nb_evals")
|
||||
assert evals_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
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -30,10 +30,7 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from flask import flash, g, url_for
|
||||
from markupsafe import Markup
|
||||
|
||||
from app import db
|
||||
from flask import flash, g, Markup, url_for
|
||||
from app.models.formations import Formation
|
||||
|
||||
|
||||
@ -81,7 +78,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 = db.session.get(Formation, formation_id)
|
||||
formation = Formation.query.get(formation_id)
|
||||
flash(
|
||||
Markup(
|
||||
f"""Calcul moyenne générale impossible: ECTS des UE manquants !<br>
|
||||
@ -95,7 +92,7 @@ def compute_sem_moys_apc_using_ects(
|
||||
return moy_gen
|
||||
|
||||
|
||||
def comp_ranks_series(notes: pd.Series) -> tuple[pd.Series, pd.Series]:
|
||||
def comp_ranks_series(notes: pd.Series) -> (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.
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -30,20 +30,22 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import app
|
||||
from app import db
|
||||
from app import models
|
||||
from app.models import (
|
||||
DispenseUE,
|
||||
FormSemestre,
|
||||
FormSemestreInscription,
|
||||
Identite,
|
||||
Module,
|
||||
ModuleImpl,
|
||||
ModuleUECoef,
|
||||
UniteEns,
|
||||
)
|
||||
from app.comp import moy_mod
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
@ -62,7 +64,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 != codes_cursus.UE_SPORT)
|
||||
.filter(UniteEns.type != sco_codes_parcours.UE_SPORT)
|
||||
.order_by(UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme)
|
||||
)
|
||||
modules = (
|
||||
@ -70,9 +72,14 @@ 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 == codes_cursus.UE_SPORT))
|
||||
| (
|
||||
(Module.ue_id == UniteEns.id)
|
||||
& (UniteEns.type == sco_codes_parcours.UE_SPORT)
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
Module.semestre_id, Module.module_type.desc(), Module.numero, Module.code
|
||||
)
|
||||
.order_by(Module.semestre_id, Module.module_type, Module.numero, Module.code)
|
||||
)
|
||||
if semestre_idx is not None:
|
||||
ues = ues.filter_by(semestre_idx=semestre_idx)
|
||||
@ -122,7 +129,7 @@ def df_load_modimpl_coefs(
|
||||
DataFrame rows = UEs (sans bonus), columns = modimpl, value = coef.
|
||||
"""
|
||||
if ues is None:
|
||||
ues = formsemestre.get_ues()
|
||||
ues = formsemestre.query_ues().all()
|
||||
ue_ids = [x.id for x in ues]
|
||||
if modimpls is None:
|
||||
modimpls = formsemestre.modimpls_sorted
|
||||
@ -168,14 +175,8 @@ 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]
|
||||
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])}"""
|
||||
)
|
||||
modimpls_notes = np.stack(modimpls_notes_arr)
|
||||
# passe de (mod x etud x ue) à (etud x mod x ue)
|
||||
return modimpls_notes.swapaxes(0, 1)
|
||||
|
||||
|
||||
@ -219,6 +220,31 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
|
||||
)
|
||||
|
||||
|
||||
def load_dispense_ues(
|
||||
formsemestre: FormSemestre, etudids: pd.Index, ues: list[UniteEns]
|
||||
) -> set[tuple[int, int]]:
|
||||
"""Construit l'ensemble des
|
||||
etudids = modimpl_inscr_df.index, # les etudids
|
||||
ue_ids : modimpl_coefs_df.index, # les UE du formsemestre sans les UE bonus sport
|
||||
|
||||
Résultat: set de (etudid, ue_id).
|
||||
"""
|
||||
dispense_ues = set()
|
||||
ue_sem_by_code = {ue.ue_code: ue for ue in ues}
|
||||
# Prend toutes les dispenses obtenues par des étudiants de ce formsemestre,
|
||||
# puis filtre sur inscrits et code d'UE UE
|
||||
for dispense_ue in DispenseUE.query.join(
|
||||
Identite, FormSemestreInscription
|
||||
).filter_by(formsemestre_id=formsemestre.id):
|
||||
if dispense_ue.etudid in etudids:
|
||||
# UE dans le semestre avec même code ?
|
||||
ue = ue_sem_by_code.get(dispense_ue.ue.ue_code)
|
||||
if ue is not None:
|
||||
dispense_ues.add((dispense_ue.etudid, ue.id))
|
||||
|
||||
return dispense_ues
|
||||
|
||||
|
||||
def compute_ue_moys_apc(
|
||||
sem_cube: np.array,
|
||||
etuds: list,
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -10,18 +10,15 @@ import time
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from app import db, log
|
||||
from app import 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 FormSemestreInscription, ScoDocSiteConfig
|
||||
from app.models import ScoDocSiteConfig
|
||||
from app.models.moduleimpls import ModuleImpl
|
||||
from app.models.but_refcomp import ApcParcours, ApcNiveau
|
||||
from app.models.ues import DispenseUE, UniteEns
|
||||
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
|
||||
from app.models.ues import UniteEns
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
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):
|
||||
@ -42,10 +39,7 @@ 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()
|
||||
@ -78,7 +72,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
|
||||
modimpl.module.ue.type != UE_SPORT
|
||||
for modimpl in self.formsemestre.modimpls_sorted
|
||||
]
|
||||
self.dispense_ues = DispenseUE.load_formsemestre_dispense_ues_set(
|
||||
self.dispense_ues = moy_ue.load_dispense_ues(
|
||||
self.formsemestre, self.modimpl_inscr_df.index, self.ues
|
||||
)
|
||||
self.etud_moy_ue = moy_ue.compute_ue_moys_apc(
|
||||
@ -164,8 +158,6 @@ 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:
|
||||
@ -193,15 +185,9 @@ 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]
|
||||
)
|
||||
or (
|
||||
modimpl.module.module_type == ModuleType.MALUS
|
||||
and modimpl.module.ue_id == ue.id
|
||||
)
|
||||
if modimpl.module.ue.type != UE_SPORT
|
||||
and (coefs[modimpl.id][ue.id] != 0)
|
||||
and self.modimpl_inscr_df[modimpl.id][etudid]
|
||||
]
|
||||
if not with_bonus:
|
||||
return [
|
||||
@ -231,35 +217,27 @@ 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:
|
||||
# matrice de NaN, inscrits par défaut à aucune UE:
|
||||
# XXX hotfix / à revoir avec 9.4.14 XXX TODO
|
||||
ues_inscr_parcours_df = pd.DataFrame(
|
||||
np.nan, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
|
||||
1.0, 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 (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] = {
|
||||
for parcour in self.formsemestre.formation.referentiel_competence.parcours:
|
||||
ue_by_parcours[parcour.id] = {
|
||||
ue.id: 1.0
|
||||
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 ue in self.formsemestre.formation.query_ues_parcour(
|
||||
parcour
|
||||
).filter_by(semestre_idx=self.formsemestre.semestre_id)
|
||||
}
|
||||
#
|
||||
for etudid in etuds_parcour_id:
|
||||
parcour_id = etuds_parcour_id[etudid]
|
||||
if parcour_id in ue_by_parcours:
|
||||
if parcour_id is not None and parcour_id in ue_by_parcours:
|
||||
if ue_by_parcours[parcour_id]:
|
||||
ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[parcour_id]
|
||||
return ues_inscr_parcours_df
|
||||
@ -267,95 +245,6 @@ 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
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -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.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_codes_parcours 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.get_etud(etudid)
|
||||
etud: Identite = Identite.query.get(etudid)
|
||||
raise ScoValueError(
|
||||
f"""<div class="scovalueerror"><p>Coefficient de l'UE capitalisée {ue.acronyme}
|
||||
impossible à déterminer pour l'étudiant <a href="{
|
||||
|
@ -1,23 +1,21 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Résultats semestre: méthodes communes aux formations classiques et APC
|
||||
"""
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from collections import Counter
|
||||
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 import db
|
||||
from app.auth.models import User
|
||||
from app.comp import res_sem
|
||||
from app.comp.res_cache import ResultatsCache
|
||||
from app.comp.jury import ValidationsSemestre
|
||||
@ -25,14 +23,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.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM
|
||||
from app.scodoc import sco_evaluation_db
|
||||
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`
|
||||
@ -63,7 +61,7 @@ class ResultatsSemestre(ResultatsCache):
|
||||
def __init__(self, formsemestre: FormSemestre):
|
||||
super().__init__(formsemestre, ResultatsSemestreCache)
|
||||
# BUT ou standard ? (apc == "approche par compétences")
|
||||
self.is_apc: bool = formsemestre.formation.is_apc()
|
||||
self.is_apc = 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"
|
||||
@ -88,10 +86,8 @@ 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}')>"
|
||||
@ -129,17 +125,9 @@ class ResultatsSemestre(ResultatsCache):
|
||||
# car tous les étudiants sont inscrits à toutes les UE
|
||||
return [ue.id for ue in self.ues if ue.type != UE_SPORT]
|
||||
|
||||
def etud_parcours_ues_ids(self, etudid: int) -> set[int]:
|
||||
"""Ensemble des UEs que l'étudiant "doit" valider.
|
||||
En formations classiques, c'est la même chose (en set) que etud_ues_ids.
|
||||
Surchargée en BUT pour donner les UEs du parcours de l'étudiant.
|
||||
"""
|
||||
return {ue.id for ue in self.ues if ue.type != UE_SPORT}
|
||||
|
||||
def etud_ues(self, etudid: int) -> Generator[UniteEns]:
|
||||
"""Liste des UE auxquelles l'étudiant est inscrit
|
||||
(sans bonus, en BUT prend en compte le parcours de l'étudiant)."""
|
||||
return (db.session.get(UniteEns, ue_id) for ue_id in self.etud_ues_ids(etudid))
|
||||
"""Liste des UE auxquelles l'étudiant est inscrit."""
|
||||
return (UniteEns.query.get(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)"""
|
||||
@ -166,7 +154,7 @@ class ResultatsSemestre(ResultatsCache):
|
||||
(indices des DataFrames).
|
||||
Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs.
|
||||
"""
|
||||
return self.formsemestre.get_ues(with_sport=True)
|
||||
return self.formsemestre.query_ues(with_sport=True).all()
|
||||
|
||||
@cached_property
|
||||
def ressources(self):
|
||||
@ -186,35 +174,13 @@ 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 get_formsemestre_validations(self) -> ValidationsSemestre:
|
||||
"""Load validations if not already stored, set attribute and return value"""
|
||||
def load_validations(self) -> ValidationsSemestre:
|
||||
"""Load validations, 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
|
||||
|
||||
@ -237,7 +203,7 @@ class ResultatsSemestre(ResultatsCache):
|
||||
for modimpl in self.formsemestre.modimpls_sorted
|
||||
if self.modimpl_inscr_df[modimpl.id][etudid]
|
||||
}
|
||||
ues = sorted(list(ues), key=attrgetter("numero"))
|
||||
ues = sorted(list(ues), key=lambda x: x.numero or 0)
|
||||
return ues
|
||||
|
||||
def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]:
|
||||
@ -272,8 +238,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 UEs capitalisées.
|
||||
self.get_formsemestre_validations()
|
||||
# on recalcule les moyennes gen des etuds ayant des UE capitalisée.
|
||||
self.load_validations()
|
||||
ue_capitalisees = self.validations.ue_capitalisees
|
||||
for etudid in ue_capitalisees.index:
|
||||
recompute_mg = False
|
||||
@ -287,7 +253,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.get_ues():
|
||||
for ue in self.formsemestre.query_ues():
|
||||
ue_cap = self.get_etud_ue_status(etudid, ue.id)
|
||||
if ue_cap is None:
|
||||
continue
|
||||
@ -311,7 +277,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)
|
||||
ins = self.formsemestre.etuds_inscriptions.get(etudid, None)
|
||||
if ins is None:
|
||||
return ""
|
||||
return ins.etat
|
||||
@ -353,9 +319,7 @@ 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 = db.session.get(UniteEns, ue_id)
|
||||
ue_dict = ue.to_dict()
|
||||
|
||||
ue = UniteEns.query.get(ue_id)
|
||||
if ue.type == UE_SPORT:
|
||||
return {
|
||||
"is_capitalized": False,
|
||||
@ -365,7 +329,7 @@ class ResultatsSemestre(ResultatsCache):
|
||||
"cur_moy_ue": 0.0,
|
||||
"moy": 0.0,
|
||||
"event_date": None,
|
||||
"ue": ue_dict,
|
||||
"ue": ue.to_dict(),
|
||||
"formsemestre_id": None,
|
||||
"capitalized_ue_id": None,
|
||||
"ects_pot": 0.0,
|
||||
@ -383,11 +347,7 @@ 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 (ue_cap["moy_ue"] is not None)
|
||||
and not np.isnan(ue_cap["moy_ue"])
|
||||
):
|
||||
if ue_cap 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"]
|
||||
@ -403,10 +363,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 = db.session.get(UniteEns, ue_cap["ue_id"])
|
||||
ue_capitalized = UniteEns.query.get(ue_cap["ue_id"])
|
||||
coef_ue = ue_capitalized.ects
|
||||
if coef_ue is None:
|
||||
orig_sem = FormSemestre.get_formsemestre(ue_cap["formsemestre_id"])
|
||||
orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"])
|
||||
raise ScoValueError(
|
||||
f"""L'UE capitalisée {ue_capitalized.acronyme}
|
||||
du semestre {orig_sem.titre_annee()}
|
||||
@ -438,7 +398,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_dict,
|
||||
"ue": ue.to_dict(),
|
||||
"formsemestre_id": ue_cap["formsemestre_id"] if is_capitalized else None,
|
||||
"capitalized_ue_id": ue_cap["ue_id"] if is_capitalized else None,
|
||||
}
|
||||
@ -471,3 +431,588 @@ 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
|
||||
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
|
||||
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 classiqes: 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)
|
||||
|
||||
self.recap_add_partitions(rows, titles)
|
||||
self._recap_add_admissions(rows, titles)
|
||||
|
||||
# tri par rang croissant
|
||||
rows.sort(key=lambda e: e["_rang_order"])
|
||||
|
||||
# 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_partitions(self, rows: list[dict], titles: dict, col_idx: int = None):
|
||||
"""Ajoute les colonnes indiquant les groupes
|
||||
rows est une liste de dict avec une clé "etudid"
|
||||
Les colonnes ont la classe css "partition"
|
||||
"""
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
|
@ -1,26 +1,25 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Classe résultats pour compatibilité avec le code ScoDoc 7
|
||||
"""
|
||||
from functools import cached_property
|
||||
import pandas as pd
|
||||
|
||||
from flask import flash, g, url_for
|
||||
from markupsafe import Markup
|
||||
from flask import flash, g, Markup, url_for
|
||||
|
||||
from app import db, log
|
||||
from app import 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 Identite, FormSemestre, ModuleImpl, ScolarAutorisationInscription
|
||||
from app.scodoc.codes_cursus import UE_SPORT, DEF
|
||||
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.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
# Pour raccorder le code des anciens codes qui attendent une NoteTable
|
||||
class NotesTableCompat(ResultatsSemestre):
|
||||
"""Implementation partielle de NotesTable
|
||||
@ -53,7 +52,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_cursus()
|
||||
self.parcours = self.formsemestre.formation.get_parcours()
|
||||
self._modimpls_dict_by_ue = {} # local cache
|
||||
|
||||
def get_inscrits(self, include_demdef=True, order_by=False) -> list[Identite]:
|
||||
@ -110,7 +109,7 @@ class NotesTableCompat(ResultatsSemestre):
|
||||
Si filter_sport, retire les UE de type SPORT.
|
||||
Résultat: liste de dicts { champs UE U stats moyenne UE }
|
||||
"""
|
||||
ues = self.formsemestre.get_ues(with_sport=not filter_sport)
|
||||
ues = self.formsemestre.query_ues(with_sport=not filter_sport)
|
||||
ues_dict = []
|
||||
for ue in ues:
|
||||
d = ue.to_dict()
|
||||
@ -167,24 +166,15 @@ 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(etud_moy_gen_dem_zero)
|
||||
ues = self.formsemestre.get_ues()
|
||||
) = moy_sem.comp_ranks_series(self.etud_moy_gen)
|
||||
ues = self.formsemestre.query_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 * mask_inscr)[0], # juste en chaine
|
||||
moy_sem.comp_ranks_series(moy_ue)[0], # juste en chaine
|
||||
int(moy_ue.count()),
|
||||
)
|
||||
# .count() -> nb of non NaN values
|
||||
@ -204,7 +194,7 @@ class NotesTableCompat(ResultatsSemestre):
|
||||
)
|
||||
# list() car pandas veut une sequence pour take()
|
||||
# Rangs / moyenne générale:
|
||||
group_moys_gen = etud_moy_gen_dem_zero[group_members]
|
||||
group_moys_gen = self.etud_moy_gen[group_members]
|
||||
self.moy_gen_rangs_by_group[group.id] = moy_sem.comp_ranks_series(
|
||||
group_moys_gen
|
||||
)
|
||||
@ -213,7 +203,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 * mask_inscr)
|
||||
] = moy_sem.comp_ranks_series(group_moys_ue)
|
||||
|
||||
def get_etud_rang(self, etudid: int) -> str:
|
||||
"""Le rang (classement) de l'étudiant dans le semestre.
|
||||
@ -262,42 +252,28 @@ class NotesTableCompat(ResultatsSemestre):
|
||||
Return: True|False, message explicatif
|
||||
"""
|
||||
ue_status_list = []
|
||||
for ue in self.formsemestre.get_ues():
|
||||
for ue in self.formsemestre.query_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 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
|
||||
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.
|
||||
"""
|
||||
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
|
||||
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
|
||||
|
||||
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 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 get_etud_decisions_ue(self, etudid: int) -> dict:
|
||||
def get_etud_decision_ues(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 }
|
||||
@ -306,16 +282,16 @@ class NotesTableCompat(ResultatsSemestre):
|
||||
if self.get_etud_etat(etudid) == DEF:
|
||||
return {}
|
||||
else:
|
||||
validations = self.get_formsemestre_validations()
|
||||
validations = self.load_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_decisions_ue()
|
||||
Optimisation: si decisions_ues est passé, l'utilise, sinon appelle get_etud_decision_ues()
|
||||
"""
|
||||
if decisions_ues is False:
|
||||
decisions_ues = self.get_etud_decisions_ue(etudid)
|
||||
decisions_ues = self.get_etud_decision_ues(etudid)
|
||||
if not decisions_ues:
|
||||
return 0.0
|
||||
return sum([d.get("ects", 0.0) for d in decisions_ues.values()])
|
||||
@ -323,8 +299,7 @@ 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.
|
||||
Toujours None en BUT.
|
||||
Si état défaillant, force le code a DEF
|
||||
"""
|
||||
if self.get_etud_etat(etudid) == DEF:
|
||||
return {
|
||||
@ -334,7 +309,7 @@ class NotesTableCompat(ResultatsSemestre):
|
||||
"compense_formsemestre_id": None,
|
||||
}
|
||||
else:
|
||||
validations = self.get_formsemestre_validations()
|
||||
validations = self.load_validations()
|
||||
return validations.decisions_jury.get(etudid, None)
|
||||
|
||||
def get_etud_mat_moy(self, matiere_id: int, etudid: int) -> str:
|
||||
@ -394,7 +369,7 @@ class NotesTableCompat(ResultatsSemestre):
|
||||
de ce module.
|
||||
Évaluation "complete" ssi toutes notes saisies ou en attente.
|
||||
"""
|
||||
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
|
||||
modimpl = ModuleImpl.query.get(moduleimpl_id)
|
||||
modimpl_results = self.modimpls_results.get(moduleimpl_id)
|
||||
if not modimpl_results:
|
||||
return [] # safeguard
|
||||
@ -485,7 +460,7 @@ class NotesTableCompat(ResultatsSemestre):
|
||||
"""
|
||||
table_moyennes = []
|
||||
etuds_inscriptions = self.formsemestre.etuds_inscriptions
|
||||
ues = self.formsemestre.get_ues(with_sport=True) # avec bonus
|
||||
ues = self.formsemestre.query_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:
|
||||
|
@ -1,6 +1,6 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -16,7 +16,6 @@ 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):
|
||||
@ -96,7 +95,7 @@ def permission_required(permission):
|
||||
return decorator
|
||||
|
||||
|
||||
def permission_required_compat_scodoc7(permission): # XXX TODO A SUPPRIMER
|
||||
def permission_required_compat_scodoc7(permission):
|
||||
"""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:
|
||||
@ -118,10 +117,6 @@ def permission_required_compat_scodoc7(permission): # XXX TODO A SUPPRIMER
|
||||
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
|
||||
@ -185,24 +180,19 @@ def scodoc7func(func):
|
||||
else:
|
||||
arg_names = argspec.args
|
||||
for arg_name in arg_names: # pour chaque arg de la fonction vue
|
||||
# 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)
|
||||
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)
|
||||
# current_app.logger.info("pos_arg_values=%s" % pos_arg_values)
|
||||
# current_app.logger.info("req_args=%s" % req_args)
|
||||
# Add keyword arguments
|
||||
|
34
app/email.py
34
app/email.py
@ -1,7 +1,7 @@
|
||||
# -*- coding: UTF-8 -*
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -11,8 +11,6 @@ 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
|
||||
|
||||
|
||||
@ -58,7 +56,6 @@ 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(
|
||||
@ -84,35 +81,6 @@ 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"
|
||||
)
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -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,
|
||||
email.get_from_addr(),
|
||||
sco_preferences.get_preference("email_from_addr"),
|
||||
[EntreprisePreferences.get_email_notifications],
|
||||
txt,
|
||||
)
|
||||
@ -392,8 +392,7 @@ def check_entreprise_import(entreprise_data):
|
||||
else:
|
||||
try:
|
||||
req = requests.get(
|
||||
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}",
|
||||
timeout=scu.SCO_EXT_TIMEOUT,
|
||||
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}"
|
||||
)
|
||||
if req.status_code != 200:
|
||||
return False
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -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,17 +34,18 @@ from flask_wtf.file import FileField, FileAllowed, FileRequired
|
||||
from markupsafe import Markup
|
||||
from sqlalchemy import text
|
||||
from wtforms import (
|
||||
BooleanField,
|
||||
DateField,
|
||||
FieldList,
|
||||
FormField,
|
||||
HiddenField,
|
||||
IntegerField,
|
||||
SelectField,
|
||||
SelectMultipleField,
|
||||
StringField,
|
||||
IntegerField,
|
||||
SubmitField,
|
||||
TextAreaField,
|
||||
SelectField,
|
||||
HiddenField,
|
||||
SelectMultipleField,
|
||||
DateField,
|
||||
BooleanField,
|
||||
FieldList,
|
||||
FormField,
|
||||
BooleanField,
|
||||
)
|
||||
from wtforms.validators import (
|
||||
ValidationError,
|
||||
@ -55,9 +56,6 @@ 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,
|
||||
@ -65,8 +63,10 @@ from app.entreprises.models import (
|
||||
EntrepriseSite,
|
||||
EntrepriseTaxeApprentissage,
|
||||
)
|
||||
from app import db
|
||||
from app.models import Identite, Departement
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.auth.models import User
|
||||
from app.entreprises import SIRET_PROVISOIRE_START
|
||||
|
||||
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("Enregistrer", render_kw=SUBMIT_MARGE)
|
||||
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
|
||||
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
def validate(self):
|
||||
validate = True
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if not FlaskForm.validate(self):
|
||||
validate = False
|
||||
|
||||
if EntreprisePreferences.get_check_siret() and self.siret.data != "":
|
||||
siret_data = self.siret.data.strip().replace(" ", "")
|
||||
@ -139,8 +139,7 @@ class EntrepriseCreationForm(FlaskForm):
|
||||
else:
|
||||
try:
|
||||
req = requests.get(
|
||||
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret_data}",
|
||||
timeout=scu.SCO_EXT_TIMEOUT,
|
||||
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret_data}"
|
||||
)
|
||||
if req.status_code != 200:
|
||||
self.siret.errors.append("SIRET inexistant")
|
||||
@ -221,8 +220,7 @@ class EntrepriseModificationForm(FlaskForm):
|
||||
else:
|
||||
try:
|
||||
req = requests.get(
|
||||
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret_data}",
|
||||
timeout=scu.SCO_EXT_TIMEOUT,
|
||||
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret_data}"
|
||||
)
|
||||
if req.status_code != 200:
|
||||
raise ValidationError("SIRET inexistant")
|
||||
@ -248,13 +246,13 @@ class SiteCreationForm(FlaskForm):
|
||||
codepostal = _build_string_field("Code postal (*)")
|
||||
ville = _build_string_field("Ville (*)")
|
||||
pays = _build_string_field("Pays", required=False)
|
||||
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE)
|
||||
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
|
||||
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
def validate(self):
|
||||
validate = True
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if not FlaskForm.validate(self):
|
||||
validate = False
|
||||
|
||||
site = EntrepriseSite.query.filter_by(
|
||||
entreprise_id=self.hidden_entreprise_id.data, nom=self.nom.data
|
||||
@ -278,10 +276,10 @@ class SiteModificationForm(FlaskForm):
|
||||
submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE)
|
||||
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
def validate(self):
|
||||
validate = True
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if not FlaskForm.validate(self):
|
||||
validate = False
|
||||
|
||||
site = EntrepriseSite.query.filter(
|
||||
EntrepriseSite.entreprise_id == self.hidden_entreprise_id.data,
|
||||
@ -318,7 +316,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 à contacter", validators=[Optional()])
|
||||
correspondant = SelectField("Correspondant à contacté", validators=[Optional()])
|
||||
fichier = FileField(
|
||||
"Fichier",
|
||||
validators=[
|
||||
@ -326,7 +324,7 @@ class OffreCreationForm(FlaskForm):
|
||||
FileAllowed(["pdf", "docx"], "Fichier .pdf ou .docx uniquement"),
|
||||
],
|
||||
)
|
||||
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE)
|
||||
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
|
||||
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -344,10 +342,10 @@ class OffreCreationForm(FlaskForm):
|
||||
(dept.id, dept.acronym) for dept in Departement.query.all()
|
||||
]
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
def validate(self):
|
||||
validate = True
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if not FlaskForm.validate(self):
|
||||
validate = False
|
||||
|
||||
if len(self.depts.data) < 1:
|
||||
self.depts.errors.append("Choisir au moins un département")
|
||||
@ -373,7 +371,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 à contacter", validators=[Optional()])
|
||||
correspondant = SelectField("Correspondant à contacté", validators=[Optional()])
|
||||
submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE)
|
||||
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
|
||||
|
||||
@ -392,10 +390,10 @@ class OffreModificationForm(FlaskForm):
|
||||
(dept.id, dept.acronym) for dept in Departement.query.all()
|
||||
]
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
def validate(self):
|
||||
validate = True
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if not FlaskForm.validate(self):
|
||||
validate = False
|
||||
|
||||
if len(self.depts.data) < 1:
|
||||
self.depts.errors.append("Choisir au moins un département")
|
||||
@ -442,10 +440,10 @@ class CorrespondantCreationForm(FlaskForm):
|
||||
"Notes", required=False, render_kw={"class": "form-control"}
|
||||
)
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
def validate(self):
|
||||
validate = True
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if not FlaskForm.validate(self):
|
||||
validate = False
|
||||
|
||||
if not self.telephone.data and not self.mail.data:
|
||||
msg = "Saisir un moyen de contact (mail ou téléphone)"
|
||||
@ -458,13 +456,13 @@ class CorrespondantCreationForm(FlaskForm):
|
||||
class CorrespondantsCreationForm(FlaskForm):
|
||||
hidden_site_id = HiddenField()
|
||||
correspondants = FieldList(FormField(CorrespondantCreationForm), min_entries=1)
|
||||
submit = SubmitField("Enregistrer")
|
||||
submit = SubmitField("Envoyer")
|
||||
cancel = SubmitField("Annuler")
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
def validate(self):
|
||||
validate = True
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if not FlaskForm.validate(self):
|
||||
validate = False
|
||||
|
||||
correspondant_list = []
|
||||
for entry in self.correspondants.entries:
|
||||
@ -531,10 +529,10 @@ class CorrespondantModificationForm(FlaskForm):
|
||||
.all()
|
||||
]
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
def validate(self):
|
||||
validate = True
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if not FlaskForm.validate(self):
|
||||
validate = False
|
||||
|
||||
correspondant = EntrepriseCorrespondant.query.filter(
|
||||
EntrepriseCorrespondant.id != self.hidden_correspondant_id.data,
|
||||
@ -566,7 +564,7 @@ class ContactCreationForm(FlaskForm):
|
||||
render_kw={"placeholder": "Tapez le nom de l'utilisateur"},
|
||||
)
|
||||
notes = TextAreaField("Notes (*)", validators=[DataRequired(message=CHAMP_REQUIS)])
|
||||
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE)
|
||||
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
|
||||
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
|
||||
|
||||
def validate_utilisateur(self, utilisateur):
|
||||
@ -613,9 +611,8 @@ class ContactModificationForm(FlaskForm):
|
||||
class StageApprentissageCreationForm(FlaskForm):
|
||||
etudiant = _build_string_field(
|
||||
"Étudiant (*)",
|
||||
render_kw={"placeholder": "Tapez le nom de l'étudiant", "autocomplete": "off"},
|
||||
render_kw={"placeholder": "Tapez le nom de l'étudiant"},
|
||||
)
|
||||
etudid = HiddenField()
|
||||
type_offre = SelectField(
|
||||
"Type de l'offre (*)",
|
||||
choices=[("Stage"), ("Alternance")],
|
||||
@ -628,12 +625,12 @@ class StageApprentissageCreationForm(FlaskForm):
|
||||
"Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
|
||||
)
|
||||
notes = TextAreaField("Notes")
|
||||
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE)
|
||||
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
|
||||
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
def validate(self):
|
||||
validate = True
|
||||
if not super().validate(extra_validators):
|
||||
if not FlaskForm.validate(self):
|
||||
validate = False
|
||||
|
||||
if (
|
||||
@ -647,27 +644,64 @@ class StageApprentissageCreationForm(FlaskForm):
|
||||
|
||||
return validate
|
||||
|
||||
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
|
||||
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("Étudiant introuvable (sélectionnez dans la liste)")
|
||||
raise ValidationError("Champ incorrect (selectionnez dans la liste)")
|
||||
|
||||
|
||||
class FrenchFloatField(StringField):
|
||||
"A field allowing to enter . or ,"
|
||||
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)
|
||||
|
||||
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
|
||||
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)")
|
||||
|
||||
|
||||
class TaxeApprentissageForm(FlaskForm):
|
||||
@ -684,26 +718,25 @@ class TaxeApprentissageForm(FlaskForm):
|
||||
],
|
||||
default=int(datetime.now().strftime("%Y")),
|
||||
)
|
||||
montant = FrenchFloatField(
|
||||
montant = IntegerField(
|
||||
"Montant (*)",
|
||||
validators=[
|
||||
DataRequired(message=CHAMP_REQUIS),
|
||||
# NumberRange(
|
||||
# min=0.1,
|
||||
# max=1e8,
|
||||
# message="Le montant doit être supérieur à 0",
|
||||
# ),
|
||||
NumberRange(
|
||||
min=1,
|
||||
message="Le montant doit être supérieur à 0",
|
||||
),
|
||||
],
|
||||
default=1,
|
||||
)
|
||||
notes = TextAreaField("Notes")
|
||||
submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE)
|
||||
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
|
||||
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
def validate(self):
|
||||
validate = True
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if not FlaskForm.validate(self):
|
||||
validate = False
|
||||
|
||||
taxe = EntrepriseTaxeApprentissage.query.filter_by(
|
||||
entreprise_id=self.hidden_entreprise_id.data, annee=self.annee.data
|
||||
@ -753,12 +786,12 @@ class EnvoiOffreForm(FlaskForm):
|
||||
submit = SubmitField("Envoyer")
|
||||
cancel = SubmitField("Annuler")
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
def validate(self):
|
||||
validate = True
|
||||
list_select = True
|
||||
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if not FlaskForm.validate(self):
|
||||
validate = False
|
||||
|
||||
for entry in self.responsables.entries:
|
||||
if entry.data:
|
||||
|
@ -164,10 +164,7 @@ class EntrepriseStageApprentissage(db.Model):
|
||||
entreprise_id = db.Column(
|
||||
db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade")
|
||||
)
|
||||
etudid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("identite.id", ondelete="CASCADE"),
|
||||
)
|
||||
etudid = db.Column(db.Integer)
|
||||
type_offre = db.Column(db.Text)
|
||||
date_debut = db.Column(db.Date)
|
||||
date_fin = db.Column(db.Date)
|
||||
@ -183,7 +180,7 @@ class EntrepriseTaxeApprentissage(db.Model):
|
||||
db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade")
|
||||
)
|
||||
annee = db.Column(db.Integer)
|
||||
montant = db.Column(db.Float)
|
||||
montant = db.Column(db.Integer)
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
|
||||
|
@ -1,13 +1,12 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from config import Config
|
||||
from datetime import datetime, date
|
||||
import glob
|
||||
import shutil
|
||||
|
||||
from flask import render_template, redirect, url_for, request, flash, send_file, abort
|
||||
from flask_json import as_json
|
||||
from flask.json import jsonify
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import text, sql
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from app.decorators import permission_required
|
||||
|
||||
@ -28,6 +27,7 @@ from app.entreprises.forms import (
|
||||
ContactCreationForm,
|
||||
ContactModificationForm,
|
||||
StageApprentissageCreationForm,
|
||||
StageApprentissageModificationForm,
|
||||
EnvoiOffreForm,
|
||||
AjoutFichierForm,
|
||||
TaxeApprentissageForm,
|
||||
@ -58,7 +58,8 @@ from app.scodoc import sco_etud, sco_excel
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
from app import db
|
||||
from config import Config
|
||||
from sqlalchemy import text, sql
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
|
||||
@bp.route("/", methods=["GET", "POST"])
|
||||
@ -88,7 +89,7 @@ def index():
|
||||
visible=True, association=True, siret_provisoire=True
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/entreprises.j2",
|
||||
"entreprises/entreprises.html",
|
||||
title="Entreprises",
|
||||
entreprises=entreprises,
|
||||
logs=logs,
|
||||
@ -108,7 +109,7 @@ def logs():
|
||||
EntrepriseHistorique.date.desc()
|
||||
).paginate(page=page, per_page=20)
|
||||
return render_template(
|
||||
"entreprises/logs.j2",
|
||||
"entreprises/logs.html",
|
||||
title="Logs",
|
||||
logs=logs,
|
||||
)
|
||||
@ -133,7 +134,7 @@ def correspondants():
|
||||
.all()
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/correspondants.j2",
|
||||
"entreprises/correspondants.html",
|
||||
title="Correspondants",
|
||||
correspondants=correspondants,
|
||||
logs=logs,
|
||||
@ -148,7 +149,7 @@ def validation():
|
||||
"""
|
||||
entreprises = Entreprise.query.filter_by(visible=False).all()
|
||||
return render_template(
|
||||
"entreprises/entreprises_validation.j2",
|
||||
"entreprises/entreprises_validation.html",
|
||||
title="Validation entreprises",
|
||||
entreprises=entreprises,
|
||||
)
|
||||
@ -166,7 +167,7 @@ def fiche_entreprise_validation(entreprise_id):
|
||||
description=f"fiche entreprise (validation) {entreprise_id} inconnue"
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/fiche_entreprise_validation.j2",
|
||||
"entreprises/fiche_entreprise_validation.html",
|
||||
title="Validation fiche entreprise",
|
||||
entreprise=entreprise,
|
||||
)
|
||||
@ -204,7 +205,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.j2",
|
||||
"entreprises/form_validate_confirmation.html",
|
||||
title="Validation entreprise",
|
||||
form=form,
|
||||
)
|
||||
@ -238,10 +239,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ée de la liste des entreprises à valider.")
|
||||
flash("L'entreprise a été supprimé de la liste des entreprise à valider.")
|
||||
return redirect(url_for("entreprises.validation"))
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.j2",
|
||||
"entreprises/form_confirmation.html",
|
||||
title="Supression entreprise",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
@ -281,7 +282,7 @@ def offres_recues():
|
||||
files.append(file)
|
||||
offres_recues_with_files.append([envoi_offre, offre, files, correspondant])
|
||||
return render_template(
|
||||
"entreprises/offres_recues.j2",
|
||||
"entreprises/offres_recues.html",
|
||||
title="Offres reçues",
|
||||
offres_recues=offres_recues_with_files,
|
||||
)
|
||||
@ -320,7 +321,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.j2",
|
||||
"entreprises/preferences.html",
|
||||
title="Préférences",
|
||||
form=form,
|
||||
)
|
||||
@ -356,7 +357,7 @@ def add_entreprise():
|
||||
db.session.rollback()
|
||||
flash("Une erreur est survenue veuillez réessayer.")
|
||||
return render_template(
|
||||
"entreprises/form_ajout_entreprise.j2",
|
||||
"entreprises/form_ajout_entreprise.html",
|
||||
title="Ajout entreprise avec correspondant",
|
||||
form=form,
|
||||
)
|
||||
@ -407,7 +408,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.j2",
|
||||
"entreprises/form_ajout_entreprise.html",
|
||||
title="Ajout entreprise avec correspondant",
|
||||
form=form,
|
||||
)
|
||||
@ -445,7 +446,7 @@ def fiche_entreprise(entreprise_id):
|
||||
.all()
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/fiche_entreprise.j2",
|
||||
"entreprises/fiche_entreprise.html",
|
||||
title="Fiche entreprise",
|
||||
entreprise=entreprise,
|
||||
offres=offres_with_files,
|
||||
@ -471,7 +472,7 @@ def logs_entreprise(entreprise_id):
|
||||
.paginate(page=page, per_page=20)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/logs_entreprise.j2",
|
||||
"entreprises/logs_entreprise.html",
|
||||
title="Logs",
|
||||
logs=logs,
|
||||
entreprise=entreprise,
|
||||
@ -489,7 +490,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.j2",
|
||||
"entreprises/offres_expirees.html",
|
||||
title="Offres expirées",
|
||||
entreprise=entreprise,
|
||||
offres_expirees=offres_with_files,
|
||||
@ -573,7 +574,7 @@ def edit_entreprise(entreprise_id):
|
||||
form.pays.data = entreprise.pays
|
||||
form.association.data = entreprise.association
|
||||
return render_template(
|
||||
"entreprises/form_modification_entreprise.j2",
|
||||
"entreprises/form_modification_entreprise.html",
|
||||
title="Modification entreprise",
|
||||
form=form,
|
||||
)
|
||||
@ -609,7 +610,7 @@ def fiche_entreprise_desactiver(entreprise_id):
|
||||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.j2",
|
||||
"entreprises/form_confirmation.html",
|
||||
title="Désactiver entreprise",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Modifier pour confirmer la désactivation",
|
||||
@ -645,7 +646,7 @@ def fiche_entreprise_activer(entreprise_id):
|
||||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.j2",
|
||||
"entreprises/form_confirmation.html",
|
||||
title="Activer entreprise",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Modifier pour confirmer l'activaction",
|
||||
@ -691,7 +692,7 @@ def add_taxe_apprentissage(entreprise_id):
|
||||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form.j2",
|
||||
"entreprises/form.html",
|
||||
title="Ajout taxe apprentissage",
|
||||
form=form,
|
||||
)
|
||||
@ -734,7 +735,7 @@ def edit_taxe_apprentissage(entreprise_id, taxe_id):
|
||||
form.montant.data = taxe.montant
|
||||
form.notes.data = taxe.notes
|
||||
return render_template(
|
||||
"entreprises/form.j2",
|
||||
"entreprises/form.html",
|
||||
title="Modification taxe apprentissage",
|
||||
form=form,
|
||||
)
|
||||
@ -769,12 +770,12 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id):
|
||||
)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
flash("La taxe d'apprentissage a été supprimée de la liste.")
|
||||
flash("La taxe d'apprentissage a été supprimé de la liste.")
|
||||
return redirect(
|
||||
url_for("entreprises.fiche_entreprise", entreprise_id=taxe.entreprise_id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.j2",
|
||||
"entreprises/form_confirmation.html",
|
||||
title="Supprimer taxe apprentissage",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
@ -844,7 +845,7 @@ def add_offre(entreprise_id):
|
||||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form.j2",
|
||||
"entreprises/form.html",
|
||||
title="Ajout offre",
|
||||
form=form,
|
||||
)
|
||||
@ -920,7 +921,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.j2",
|
||||
"entreprises/form.html",
|
||||
title="Modification offre",
|
||||
form=form,
|
||||
)
|
||||
@ -965,12 +966,12 @@ def delete_offre(entreprise_id, offre_id):
|
||||
)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
flash("L'offre a été supprimée de la fiche entreprise.")
|
||||
flash("L'offre a été supprimé de la fiche entreprise.")
|
||||
return redirect(
|
||||
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.j2",
|
||||
"entreprises/form_confirmation.html",
|
||||
title="Supression offre",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
@ -1046,7 +1047,7 @@ def add_site(entreprise_id):
|
||||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form.j2",
|
||||
"entreprises/form.html",
|
||||
title="Ajout site",
|
||||
form=form,
|
||||
)
|
||||
@ -1097,7 +1098,7 @@ def edit_site(entreprise_id, site_id):
|
||||
form.ville.data = site.ville
|
||||
form.pays.data = site.pays
|
||||
return render_template(
|
||||
"entreprises/form.j2",
|
||||
"entreprises/form.html",
|
||||
title="Modification site",
|
||||
form=form,
|
||||
)
|
||||
@ -1153,7 +1154,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.j2",
|
||||
"entreprises/form_ajout_correspondants.html",
|
||||
title="Ajout correspondant",
|
||||
form=form,
|
||||
)
|
||||
@ -1233,7 +1234,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.j2",
|
||||
"entreprises/form.html",
|
||||
title="Modification correspondant",
|
||||
form=form,
|
||||
)
|
||||
@ -1289,7 +1290,7 @@ def delete_correspondant(entreprise_id, site_id, correspondant_id):
|
||||
)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.j2",
|
||||
"entreprises/form_confirmation.html",
|
||||
title="Supression correspondant",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
@ -1307,7 +1308,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.j2",
|
||||
"entreprises/contacts.html",
|
||||
title="Liste des contacts",
|
||||
contacts=contacts,
|
||||
entreprise=entreprise,
|
||||
@ -1364,7 +1365,7 @@ def add_contact(entreprise_id):
|
||||
db.session.commit()
|
||||
return redirect(url_for("entreprises.contacts", entreprise_id=entreprise.id))
|
||||
return render_template(
|
||||
"entreprises/form.j2",
|
||||
"entreprises/form.html",
|
||||
title="Ajout contact",
|
||||
form=form,
|
||||
)
|
||||
@ -1420,7 +1421,7 @@ def edit_contact(entreprise_id, contact_id):
|
||||
)
|
||||
form.notes.data = contact.notes
|
||||
return render_template(
|
||||
"entreprises/form.j2",
|
||||
"entreprises/form.html",
|
||||
title="Modification contact",
|
||||
form=form,
|
||||
)
|
||||
@ -1458,7 +1459,7 @@ def delete_contact(entreprise_id, contact_id):
|
||||
url_for("entreprises.contacts", entreprise_id=contact.entreprise)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.j2",
|
||||
"entreprises/form_confirmation.html",
|
||||
title="Supression contact",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
@ -1472,8 +1473,7 @@ 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 alternance
|
||||
sur la fiche de l'entreprise
|
||||
Permet d'ajouter un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise
|
||||
"""
|
||||
entreprise = Entreprise.query.filter_by(
|
||||
id=entreprise_id, visible=True
|
||||
@ -1484,8 +1484,15 @@ def add_stage_apprentissage(entreprise_id):
|
||||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id)
|
||||
)
|
||||
if form.validate_on_submit():
|
||||
etudid = form.etudid.data
|
||||
etudiant = Identite.query.get_or_404(etudid)
|
||||
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()
|
||||
)
|
||||
formation = etudiant.inscription_courante_date(
|
||||
form.date_debut.data, form.date_fin.data
|
||||
)
|
||||
@ -1518,7 +1525,7 @@ def add_stage_apprentissage(entreprise_id):
|
||||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_ajout_stage_apprentissage.j2",
|
||||
"entreprises/form_ajout_stage_apprentissage.html",
|
||||
title="Ajout stage / apprentissage",
|
||||
form=form,
|
||||
)
|
||||
@ -1531,7 +1538,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 alternance sur la fiche de l'entreprise
|
||||
Permet de modifier un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise
|
||||
"""
|
||||
stage_apprentissage = EntrepriseStageApprentissage.query.filter_by(
|
||||
id=stage_apprentissage_id, entreprise_id=entreprise_id
|
||||
@ -1541,14 +1548,21 @@ 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 = StageApprentissageCreationForm()
|
||||
form = StageApprentissageModificationForm()
|
||||
if request.method == "POST" and form.cancel.data:
|
||||
return redirect(
|
||||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id)
|
||||
)
|
||||
if form.validate_on_submit():
|
||||
etudid = form.etudid.data
|
||||
etudiant = Identite.query.get_or_404(etudid)
|
||||
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()
|
||||
)
|
||||
formation = etudiant.inscription_courante_date(
|
||||
form.date_debut.data, form.date_fin.data
|
||||
)
|
||||
@ -1563,7 +1577,6 @@ 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,
|
||||
@ -1580,15 +1593,13 @@ 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.etudid.data = etudiant.id
|
||||
form.etudiant.data = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}"
|
||||
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.j2",
|
||||
"entreprises/form_ajout_stage_apprentissage.html",
|
||||
title="Modification stage / apprentissage",
|
||||
form=form,
|
||||
)
|
||||
@ -1629,7 +1640,7 @@ def delete_stage_apprentissage(entreprise_id, stage_apprentissage_id):
|
||||
)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.j2",
|
||||
"entreprises/form_confirmation.html",
|
||||
title="Supression stage/apprentissage",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
@ -1679,7 +1690,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.j2",
|
||||
"entreprises/form_envoi_offre.html",
|
||||
title="Envoyer une offre",
|
||||
form=form,
|
||||
)
|
||||
@ -1687,7 +1698,6 @@ 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
|
||||
@ -1713,7 +1723,7 @@ def json_etudiants():
|
||||
"info": f"Département {are.get_dept_acronym_by_id(etudiant.dept_id)}",
|
||||
}
|
||||
list.append(content)
|
||||
return list
|
||||
return jsonify(results=list)
|
||||
|
||||
|
||||
@bp.route("/responsables")
|
||||
@ -1739,7 +1749,7 @@ def json_responsables():
|
||||
value = f"{responsable.get_nomplogin()}"
|
||||
content = {"id": f"{responsable.id}", "value": value}
|
||||
list.append(content)
|
||||
return list
|
||||
return jsonify(results=list)
|
||||
|
||||
|
||||
@bp.route("/export_donnees")
|
||||
@ -1806,7 +1816,7 @@ def import_donnees():
|
||||
db.session.rollback()
|
||||
flash("Une erreur est survenue veuillez réessayer.")
|
||||
return render_template(
|
||||
"entreprises/import_donnees.j2",
|
||||
"entreprises/import_donnees.html",
|
||||
title="Importation données",
|
||||
form=form,
|
||||
)
|
||||
@ -1833,9 +1843,9 @@ def import_donnees():
|
||||
db.session.add(correspondant)
|
||||
correspondants.append(correspondant)
|
||||
db.session.commit()
|
||||
flash("Importation réussie")
|
||||
flash(f"Importation réussie")
|
||||
return render_template(
|
||||
"entreprises/import_donnees.j2",
|
||||
"entreprises/import_donnees.html",
|
||||
title="Importation données",
|
||||
form=form,
|
||||
entreprises_import=entreprises_import,
|
||||
@ -1843,7 +1853,7 @@ def import_donnees():
|
||||
correspondants_import=correspondants,
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/import_donnees.j2", title="Importation données", form=form
|
||||
"entreprises/import_donnees.html", title="Importation données", form=form
|
||||
)
|
||||
|
||||
|
||||
@ -1917,7 +1927,7 @@ def add_offre_file(entreprise_id, offre_id):
|
||||
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form.j2",
|
||||
"entreprises/form.html",
|
||||
title="Ajout fichier à une offre",
|
||||
form=form,
|
||||
)
|
||||
@ -1959,7 +1969,7 @@ def delete_offre_file(entreprise_id, offre_id, filedir):
|
||||
)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.j2",
|
||||
"entreprises/form_confirmation.html",
|
||||
title="Suppression fichier d'une offre",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
@ -1971,4 +1981,4 @@ def not_found_error_handler(e):
|
||||
"""
|
||||
Renvoie une page d'erreur pour l'erreur 404
|
||||
"""
|
||||
return render_template("entreprises/error.j2", title="Erreur", e=e)
|
||||
return render_template("entreprises/error.html", title="Erreur", e=e)
|
||||
|
@ -1 +0,0 @@
|
||||
# empty but required for pylint
|
@ -1,35 +0,0 @@
|
||||
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()
|
@ -1,63 +0,0 @@
|
||||
# -*- 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()
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -35,14 +35,14 @@ from wtforms.fields.simple import StringField
|
||||
|
||||
from app.models import SHORT_STR_LEN
|
||||
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_codes_parcours
|
||||
|
||||
|
||||
def _build_code_field(code):
|
||||
return StringField(
|
||||
label=code,
|
||||
default=code,
|
||||
description=codes_cursus.CODES_EXPL[code],
|
||||
description=sco_codes_parcours.CODES_EXPL[code],
|
||||
validators=[
|
||||
validators.regexp(
|
||||
r"^[A-Z0-9_]*$",
|
||||
@ -63,9 +63,7 @@ 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")
|
||||
@ -82,8 +80,7 @@ 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,
|
||||
|
@ -1,78 +0,0 @@
|
||||
# -*- 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})
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -148,9 +148,6 @@ 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:
|
||||
@ -174,7 +171,7 @@ class AddLogoForm(FlaskForm):
|
||||
|
||||
|
||||
class LogoForm(FlaskForm):
|
||||
"""Embed both presentation of a logo (cf. template file configuration.j2)
|
||||
"""Embed both presentation of a logo (cf. template file configuration.html)
|
||||
and all its data and UI action (change, delete)"""
|
||||
|
||||
dept_key = HiddenField()
|
||||
@ -230,10 +227,6 @@ 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
|
||||
@ -265,9 +258,6 @@ 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
|
||||
@ -444,7 +434,7 @@ def config_logos():
|
||||
scu.flash_errors(form)
|
||||
|
||||
return render_template(
|
||||
"config_logos.j2",
|
||||
"config_logos.html",
|
||||
scodoc_dept=None,
|
||||
title="Configuration ScoDoc",
|
||||
form=form,
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -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, StringField, SubmitField
|
||||
from wtforms.validators import Email, Optional
|
||||
from wtforms import BooleanField, SelectField, SubmitField
|
||||
|
||||
import app
|
||||
from app.models import ScoDocSiteConfig
|
||||
import app.scodoc.sco_utils as scu
|
||||
@ -70,12 +70,6 @@ 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})
|
||||
|
||||
@ -93,7 +87,6 @@ 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 (
|
||||
@ -137,12 +130,10 @@ 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.j2",
|
||||
"configuration.html",
|
||||
form_bonus=form_bonus,
|
||||
form_scodoc=form_scodoc,
|
||||
scu=scu,
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
|
@ -9,7 +9,6 @@ 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",
|
||||
@ -21,6 +20,8 @@ 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,8 +24,10 @@ class Absence(db.Model):
|
||||
# moduleimpid concerne (optionnel):
|
||||
moduleimpl_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"),
|
||||
db.ForeignKey("notes_moduleimpl.id"),
|
||||
)
|
||||
# XXX TODO: contrainte ajoutée: vérifier suppression du module
|
||||
# (mettre à NULL sans supprimer)
|
||||
|
||||
def to_dict(self):
|
||||
data = {
|
||||
|
@ -1,23 +1,20 @@
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 models : Référentiel Compétence BUT 2021
|
||||
"""
|
||||
from datetime import datetime
|
||||
import functools
|
||||
from operator import attrgetter
|
||||
|
||||
from flask import g
|
||||
from flask_sqlalchemy.query import Query
|
||||
import flask_sqlalchemy
|
||||
from sqlalchemy.orm import class_mapper
|
||||
import sqlalchemy
|
||||
|
||||
from app import db
|
||||
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
|
||||
|
||||
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
|
||||
@ -56,18 +53,14 @@ 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", ondelete="CASCADE"), index=True
|
||||
)
|
||||
annexe = db.Column(db.Text()) # '1', '22', ...
|
||||
specialite = db.Column(db.Text()) # 'CJ', 'RT', 'INFO', ...
|
||||
specialite_long = db.Column(
|
||||
db.Text()
|
||||
) # 'Carrière Juridique', 'Réseaux et télécommunications', ...
|
||||
type_titre = db.Column(db.Text()) # 'B.U.T.'
|
||||
type_structure = db.Column(db.Text()) # 'type1', 'type2', ...
|
||||
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
|
||||
annexe = db.Column(db.Text())
|
||||
specialite = db.Column(db.Text())
|
||||
specialite_long = db.Column(db.Text())
|
||||
type_titre = db.Column(db.Text())
|
||||
type_structure = db.Column(db.Text())
|
||||
type_departement = db.Column(db.Text()) # "secondaire", "tertiaire"
|
||||
version_orebut = db.Column(db.Text()) # '2021-12-11 00:00:00'
|
||||
version_orebut = db.Column(db.Text())
|
||||
_xml_attribs = { # Orébut xml attrib : attribute
|
||||
"type": "type_titre",
|
||||
"version": "version_orebut",
|
||||
@ -87,18 +80,8 @@ 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}>"
|
||||
@ -109,10 +92,9 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
||||
return ""
|
||||
return self.version_orebut.split()[0]
|
||||
|
||||
def to_dict(self, parcours: list["ApcParcours"] = None, with_app_critiques=True):
|
||||
def to_dict(self):
|
||||
"""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,
|
||||
@ -127,22 +109,16 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
||||
if self.scodoc_date_loaded
|
||||
else "",
|
||||
"scodoc_orig_filename": self.scodoc_orig_filename,
|
||||
"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)
|
||||
},
|
||||
"competences": {x.titre: x.to_dict() for x in self.competences},
|
||||
"parcours": {x.code: x.to_dict() for x in self.parcours},
|
||||
}
|
||||
|
||||
def get_niveaux_by_parcours(
|
||||
self, annee: int, parcours: list["ApcParcours"] = None
|
||||
self, annee, parcour: "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 les parcours donnés.
|
||||
de ce référentiel, ou seulement pour le parcours donné.
|
||||
|
||||
Les niveaux sont groupés par parcours, en isolant les niveaux de tronc commun.
|
||||
|
||||
@ -159,8 +135,10 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
||||
)
|
||||
"""
|
||||
parcours_ref = self.parcours.order_by(ApcParcours.numero).all()
|
||||
if parcours is None:
|
||||
if parcour 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
|
||||
@ -194,53 +172,12 @@ 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", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), 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)
|
||||
@ -248,7 +185,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, nullable=False, default=0) # ordre de présentation
|
||||
numero = db.Column(db.Integer) # ordre de présentation
|
||||
_xml_attribs = { # xml_attrib : attribute
|
||||
"id": "id_orebut",
|
||||
"nom_court": "titre", # was name
|
||||
@ -276,7 +213,7 @@ class ApcCompetence(db.Model, XMLModel):
|
||||
def __repr__(self):
|
||||
return f"<ApcCompetence {self.id} {self.titre!r}>"
|
||||
|
||||
def to_dict(self, with_app_critiques=True):
|
||||
def to_dict(self):
|
||||
"repr dict recursive sur situations, composantes, niveaux"
|
||||
return {
|
||||
"id_orebut": self.id_orebut,
|
||||
@ -288,10 +225,7 @@ class ApcCompetence(db.Model, XMLModel):
|
||||
"composantes_essentielles": [
|
||||
x.to_dict() for x in self.composantes_essentielles
|
||||
],
|
||||
"niveaux": {
|
||||
x.annee: x.to_dict(with_app_critiques=with_app_critiques)
|
||||
for x in self.niveaux
|
||||
},
|
||||
"niveaux": {x.annee: x.to_dict() for x in self.niveaux},
|
||||
}
|
||||
|
||||
def to_dict_bul(self) -> dict:
|
||||
@ -309,12 +243,9 @@ 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", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
|
||||
)
|
||||
libelle = db.Column(db.Text(), nullable=False)
|
||||
|
||||
# aucun attribut (le text devient le libellé)
|
||||
def to_dict(self):
|
||||
return {"libelle": self.libelle}
|
||||
@ -324,9 +255,7 @@ 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", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
|
||||
)
|
||||
libelle = db.Column(db.Text(), nullable=False)
|
||||
|
||||
@ -344,9 +273,7 @@ class ApcNiveau(db.Model, XMLModel):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
competence_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
|
||||
)
|
||||
libelle = db.Column(db.Text(), nullable=False)
|
||||
annee = db.Column(db.Text(), nullable=False) # "BUT1", "BUT2", "BUT3"
|
||||
@ -364,18 +291,13 @@ class ApcNiveau(db.Model, XMLModel):
|
||||
return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={
|
||||
self.annee!r} {self.competence!r}>"""
|
||||
|
||||
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"
|
||||
def to_dict(self):
|
||||
"as a dict, recursif 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}
|
||||
if with_app_critiques
|
||||
else {},
|
||||
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques},
|
||||
}
|
||||
|
||||
def to_dict_bul(self):
|
||||
@ -387,102 +309,39 @@ class ApcNiveau(db.Model, XMLModel):
|
||||
"competence": self.competence.to_dict_bul(),
|
||||
}
|
||||
|
||||
@functools.cached_property
|
||||
def parcours(self) -> list["ApcParcours"]:
|
||||
"""Les parcours passant par ce niveau.
|
||||
Les associations Parcours/Niveaux/compétences ne sont jamais
|
||||
changées par ScoDoc, la valeur est donc cachée.
|
||||
"""
|
||||
annee = int(self.annee[-1])
|
||||
return (
|
||||
ApcParcours.query.join(ApcAnneeParcours)
|
||||
.filter_by(ordre=annee)
|
||||
.join(ApcParcoursNiveauCompetence)
|
||||
.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,
|
||||
competence: ApcCompetence = None,
|
||||
) -> list["ApcNiveau"]:
|
||||
) -> flask_sqlalchemy.BaseQuery:
|
||||
"""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()
|
||||
if not parcour:
|
||||
annee_formation = f"BUT{annee}"
|
||||
query = ApcNiveau.query.filter(
|
||||
raise ScoValueError(
|
||||
"Pas de référentiel de compétences associé à la formation !"
|
||||
)
|
||||
annee_formation = f"BUT{annee}"
|
||||
if parcour is None:
|
||||
return 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:
|
||||
niveaux: list[ApcNiveau] = (
|
||||
ApcNiveau.query.filter_by(annee=f"BUT{int(annee)}")
|
||||
.join(ApcCompetence)
|
||||
.filter_by(id=competence.id)
|
||||
.join(ApcParcoursNiveauCompetence)
|
||||
.filter(ApcParcoursNiveauCompetence.niveau == ApcNiveau.ordre)
|
||||
.join(ApcAnneeParcours)
|
||||
.filter_by(parcours_id=parcour.id)
|
||||
.all()
|
||||
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,
|
||||
)
|
||||
_cache[key] = niveaux
|
||||
return niveaux
|
||||
|
||||
|
||||
app_critiques_modules = db.Table(
|
||||
@ -494,7 +353,7 @@ app_critiques_modules = db.Table(
|
||||
),
|
||||
db.Column(
|
||||
"app_crit_id",
|
||||
db.ForeignKey("apc_app_critique.id", ondelete="CASCADE"),
|
||||
db.ForeignKey("apc_app_critique.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
@ -503,9 +362,7 @@ 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", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
niveau_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"), nullable=False)
|
||||
code = db.Column(db.Text(), nullable=False, index=True)
|
||||
libelle = db.Column(db.Text())
|
||||
|
||||
@ -522,7 +379,7 @@ class ApcAppCritique(db.Model, XMLModel):
|
||||
ref_comp: ApcReferentielCompetences,
|
||||
annee: str,
|
||||
competence: ApcCompetence = None,
|
||||
) -> Query:
|
||||
) -> flask_sqlalchemy.BaseQuery:
|
||||
"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(
|
||||
@ -554,10 +411,7 @@ class ApcAppCritique(db.Model, XMLModel):
|
||||
parcours_modules = db.Table(
|
||||
"parcours_modules",
|
||||
db.Column(
|
||||
"parcours_id",
|
||||
db.Integer,
|
||||
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
"parcours_id", db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
|
||||
),
|
||||
db.Column(
|
||||
"module_id",
|
||||
@ -571,10 +425,7 @@ parcours_modules = db.Table(
|
||||
parcours_formsemestre = db.Table(
|
||||
"parcours_formsemestre",
|
||||
db.Column(
|
||||
"parcours_id",
|
||||
db.Integer,
|
||||
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
"parcours_id", db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
|
||||
),
|
||||
db.Column(
|
||||
"formsemestre_id",
|
||||
@ -590,11 +441,9 @@ 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", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
|
||||
)
|
||||
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
|
||||
numero = db.Column(db.Integer) # ordre de présentation
|
||||
code = db.Column(db.Text(), nullable=False)
|
||||
libelle = db.Column(db.Text(), nullable=False)
|
||||
annees = db.relationship(
|
||||
@ -603,6 +452,7 @@ 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}>"
|
||||
@ -620,38 +470,17 @@ 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", ondelete="CASCADE"), nullable=False
|
||||
db.Integer, db.ForeignKey("apc_parcours.id"), 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 {
|
||||
|
@ -3,13 +3,20 @@
|
||||
"""Décisions de jury (validations) des RCUE et années du BUT
|
||||
"""
|
||||
|
||||
import flask_sqlalchemy
|
||||
from sqlalchemy.sql import text
|
||||
from typing import Union
|
||||
|
||||
from app import db
|
||||
|
||||
from app.models import CODE_STR_LEN
|
||||
from app.models.but_refcomp import ApcNiveau
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.ues import UniteEns
|
||||
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):
|
||||
@ -17,7 +24,7 @@ class ApcValidationRCUE(db.Model):
|
||||
|
||||
aka "regroupements cohérents d'UE" dans le jargon BUT.
|
||||
|
||||
Le formsemestre est l'origine, utilisé pour effacer
|
||||
le formsemestre est celui du semestre PAIR du niveau de compétence
|
||||
"""
|
||||
|
||||
__tablename__ = "apc_validation_rcue"
|
||||
@ -36,14 +43,12 @@ class ApcValidationRCUE(db.Model):
|
||||
formsemestre_id = db.Column(
|
||||
db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
|
||||
)
|
||||
"formsemestre origine du RCUE (celui d'où a été émis la validation)"
|
||||
"formsemestre pair du RCUE"
|
||||
# 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", ondelete="set null"), nullable=True
|
||||
)
|
||||
parcours_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), 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)
|
||||
|
||||
@ -58,36 +63,13 @@ class ApcValidationRCUE(db.Model):
|
||||
self.ue1}/{self.ue2}:{self.code!r}>"""
|
||||
|
||||
def __str__(self):
|
||||
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
|
||||
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
|
||||
|
||||
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
|
||||
return f"""décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {self.code}"""
|
||||
|
||||
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()
|
||||
@ -96,16 +78,195 @@ class ApcValidationRCUE(db.Model):
|
||||
"niveau": None if niveau is None else niveau.to_dict_bul(),
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
# 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,
|
||||
):
|
||||
from app.comp import res_sem
|
||||
from app.comp.res_but import ResultatsSemestreBUT
|
||||
|
||||
# from app.but.jury_but import DecisionsProposeesUE
|
||||
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 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 par compensation
|
||||
c'est à dire que sa moyenne est > 10 avec une UE < 10
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
class ApcValidationAnnee(db.Model):
|
||||
@ -113,9 +274,7 @@ class ApcValidationAnnee(db.Model):
|
||||
|
||||
__tablename__ = "apc_validation_annee"
|
||||
# Assure unicité de la décision:
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("etudid", "ordre", "referentiel_competence_id"),
|
||||
)
|
||||
__table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire"),)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
etudid = db.Column(
|
||||
db.Integer,
|
||||
@ -128,11 +287,8 @@ class ApcValidationAnnee(db.Model):
|
||||
formsemestre_id = db.Column(
|
||||
db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True
|
||||
)
|
||||
"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
|
||||
"le semestre IMPAIR (le 1er) de l'année"
|
||||
annee_scolaire = db.Column(db.Integer, nullable=False) # 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)
|
||||
|
||||
@ -140,8 +296,7 @@ class ApcValidationAnnee(db.Model):
|
||||
formsemestre = db.relationship("FormSemestre", backref="apc_validations_annees")
|
||||
|
||||
def __repr__(self):
|
||||
return f"""<{self.__class__.__name__} {self.id} {self.etud
|
||||
} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>"""
|
||||
return f"<{self.__class__.__name__} {self.id} {self.etud} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>"
|
||||
|
||||
def __str__(self):
|
||||
return f"""décision sur année BUT{self.ordre} {self.annee_scolaire} : {self.code}"""
|
||||
@ -150,50 +305,25 @@ class ApcValidationAnnee(db.Model):
|
||||
"dict pour bulletins"
|
||||
return {
|
||||
"annee_scolaire": self.annee_scolaire,
|
||||
"date": self.date.isoformat() if self.date else "",
|
||||
"date": self.date.isoformat(),
|
||||
"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 (décision issue de ce semestre seulement (à confirmer ?))
|
||||
- decision_annee : dict
|
||||
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
|
||||
)
|
||||
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
|
||||
.order_by(UniteEns.numero, UniteEns.acronyme)
|
||||
validations_rcues = ApcValidationRCUE.query.filter_by(
|
||||
etudid=etud.id, formsemestre_id=formsemestre.id
|
||||
)
|
||||
decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues]
|
||||
titres_rcues = []
|
||||
@ -203,8 +333,7 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
|
||||
titres_rcues.append(f"""pas de compétence: code {dec_rcue["code"]}""")
|
||||
else:
|
||||
titres_rcues.append(
|
||||
f"""{niveau["competence"]["titre"]} {niveau["ordre"]}: {
|
||||
dec_rcue["code"]}"""
|
||||
f"""{niveau["competence"]["titre"]} {niveau["ordre"]}: {dec_rcue["code"]}"""
|
||||
)
|
||||
decisions["descr_decisions_rcue"] = ", ".join(titres_rcues)
|
||||
decisions["descr_decisions_niveaux"] = (
|
||||
@ -215,11 +344,16 @@ 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(),
|
||||
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
|
||||
).first()
|
||||
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()
|
||||
)
|
||||
if validation:
|
||||
decisions["decision_annee"] = validation.to_dict_bul()
|
||||
else:
|
||||
|
@ -4,18 +4,16 @@
|
||||
"""
|
||||
|
||||
from flask import flash
|
||||
from app import current_app, db, log
|
||||
from app import db, log
|
||||
from app.comp import bonus_spo
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
from app.scodoc.codes_cursus import (
|
||||
from app.scodoc.sco_codes_parcours import (
|
||||
ABAN,
|
||||
ABL,
|
||||
ADC,
|
||||
ADJ,
|
||||
ADJR,
|
||||
ADM,
|
||||
ADSUP,
|
||||
AJ,
|
||||
ATB,
|
||||
ATJ,
|
||||
@ -36,9 +34,7 @@ CODES_SCODOC_TO_APO = {
|
||||
ABL: "ABL",
|
||||
ADC: "ADMC",
|
||||
ADJ: "ADM",
|
||||
ADJR: "ADM",
|
||||
ADM: "ADM",
|
||||
ADSUP: "ADM",
|
||||
AJ: "AJ",
|
||||
ATB: "AJAC",
|
||||
ATJ: "AJAC",
|
||||
@ -89,13 +85,6 @@ 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):
|
||||
@ -179,7 +168,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]
|
||||
class_list = [(name, d[name].displayed_name) for name in d.keys()]
|
||||
class_list.sort(key=lambda x: x[1].replace(" du ", " de "))
|
||||
return [("", "")] + class_list
|
||||
|
||||
@ -213,17 +202,13 @@ 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()
|
||||
return cfg is not None and cfg.value
|
||||
if (cfg is None) or not cfg.value:
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def enable_entreprises(cls, enabled=True) -> bool:
|
||||
@ -241,32 +226,6 @@ 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,15 +2,12 @@
|
||||
|
||||
"""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"""
|
||||
@ -63,15 +60,6 @@ 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()
|
||||
@ -82,8 +70,6 @@ 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,8 +6,6 @@
|
||||
|
||||
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
|
||||
@ -18,7 +16,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, ScoValueError
|
||||
from app.scodoc.sco_exceptions import ScoInvalidParamError
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
|
||||
@ -29,8 +27,6 @@ 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)
|
||||
@ -40,13 +36,9 @@ 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)
|
||||
|
||||
# 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="")
|
||||
__table_args__ = (db.CheckConstraint("civilite IN ('M', 'F', 'X')"),)
|
||||
|
||||
date_naissance = db.Column(db.Date)
|
||||
lieu_naissance = db.Column(db.Text())
|
||||
@ -78,35 +70,20 @@ 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) -> "Identite":
|
||||
def from_request(cls, etudid=None, code_nip=None):
|
||||
"""É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 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()
|
||||
return Identite.query.filter_by(**args).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(typeadresse="domicile"))
|
||||
etud.adresses.append(Adresse())
|
||||
etud.admission.append(Admission())
|
||||
return etud
|
||||
|
||||
@ -117,13 +94,6 @@ 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()}"
|
||||
@ -170,14 +140,6 @@ 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.'"
|
||||
@ -194,63 +156,9 @@ class Identite(db.Model):
|
||||
)
|
||||
|
||||
def get_first_email(self, field="email") -> str:
|
||||
"Le mail associé à la première adresse de l'étudiant, ou None"
|
||||
"Le mail associé à la première adrese 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 {
|
||||
@ -263,8 +171,6 @@ 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:
|
||||
@ -277,10 +183,6 @@ 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):
|
||||
@ -308,8 +210,6 @@ 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
|
||||
@ -515,21 +415,14 @@ class Identite(db.Model):
|
||||
|
||||
return situation
|
||||
|
||||
def etat_civil_pv(self, with_paragraph=True, line_sep="\n") -> str:
|
||||
def etat_civil_pv(self, line_sep="\n") -> str:
|
||||
"""Présentation, pour PV jury
|
||||
Si with_paragraph (défaut):
|
||||
M. Pierre Dupont
|
||||
n° 12345678
|
||||
né(e) le 7/06/1974
|
||||
à Paris
|
||||
Sinon:
|
||||
M. Pierre Dupont
|
||||
M. Pierre Dupont
|
||||
n° 12345678
|
||||
né(e) le 7/06/1974
|
||||
à Paris
|
||||
"""
|
||||
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
|
||||
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 ""}"""
|
||||
|
||||
def photo_html(self, title=None, size="small") -> str:
|
||||
"""HTML img tag for the photo, either in small size (h90)
|
||||
@ -593,37 +486,6 @@ 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)
|
||||
@ -717,51 +579,19 @@ class Admission(db.Model):
|
||||
d = dict(self.__dict__)
|
||||
d.pop("_sa_instance_state", None)
|
||||
if no_nulls:
|
||||
for key, value in d.items():
|
||||
if value is None:
|
||||
for k in d.keys():
|
||||
if d[k] is None:
|
||||
col_type = getattr(
|
||||
sqlalchemy.inspect(models.Admission).columns, key
|
||||
sqlalchemy.inspect(models.Admission).columns, "apb_groupe"
|
||||
).expression.type
|
||||
if isinstance(col_type, sqlalchemy.Text):
|
||||
d[key] = ""
|
||||
d[k] = ""
|
||||
elif isinstance(col_type, sqlalchemy.Integer):
|
||||
d[key] = 0
|
||||
d[k] = 0
|
||||
elif isinstance(col_type, sqlalchemy.Boolean):
|
||||
d[key] = False
|
||||
d[k] = 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,7 +3,6 @@
|
||||
"""ScoDoc models: evaluations
|
||||
"""
|
||||
import datetime
|
||||
from operator import attrgetter
|
||||
|
||||
from app import db
|
||||
from app.models.etudiants import Identite
|
||||
@ -14,8 +13,6 @@ from app.models.ues import UniteEns
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
import app.scodoc.notesdb as ndb
|
||||
|
||||
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
|
||||
|
||||
|
||||
class Evaluation(db.Model):
|
||||
"""Evaluation (contrôle, examen, ...)"""
|
||||
@ -45,7 +42,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, nullable=False, default=0)
|
||||
numero = db.Column(db.Integer)
|
||||
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
|
||||
|
||||
def __repr__(self):
|
||||
@ -114,24 +111,12 @@ class Evaluation(db.Model):
|
||||
if self.heure_debut and (
|
||||
not self.heure_fin or self.heure_fin == self.heure_debut
|
||||
):
|
||||
return f"""à {self.heure_debut.strftime("%Hh%M")}"""
|
||||
return f"""à {self.heure_debut.strftime("%H:%M")}"""
|
||||
elif self.heure_debut and self.heure_fin:
|
||||
return f"""de {self.heure_debut.strftime("%Hh%M")} à {self.heure_fin.strftime("%Hh%M")}"""
|
||||
return f"""de {self.heure_debut.strftime("%H:%M")} à {self.heure_fin.strftime("%H:%M")}"""
|
||||
else:
|
||||
return ""
|
||||
|
||||
def descr_duree(self) -> str:
|
||||
"Description de la durée pour affichages"
|
||||
if self.heure_debut is None and self.heure_fin is None:
|
||||
return ""
|
||||
debut = self.heure_debut or DEFAULT_EVALUATION_TIME
|
||||
fin = self.heure_fin or DEFAULT_EVALUATION_TIME
|
||||
d = (fin.hour * 60 + fin.minute) - (debut.hour * 60 + debut.minute)
|
||||
duree = f"{d//60}h"
|
||||
if d % 60:
|
||||
duree += f"{d%60:02d}"
|
||||
return duree
|
||||
|
||||
def clone(self, not_copying=()):
|
||||
"""Clone, not copying the given attrs
|
||||
Attention: la copie n'a pas d'id avant le prochain commit
|
||||
@ -145,18 +130,6 @@ 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.
|
||||
@ -164,7 +137,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.get_ues(with_sport=False)
|
||||
sem_ues = self.moduleimpl.formsemestre.query_ues(with_sport=False).all()
|
||||
modified = False
|
||||
for ue in sem_ues:
|
||||
existing_poids = EvaluationUEPoids.query.filter_by(
|
||||
@ -190,10 +163,8 @@ class Evaluation(db.Model):
|
||||
"""
|
||||
L = []
|
||||
for ue_id, poids in ue_poids_dict.items():
|
||||
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)
|
||||
ue = UniteEns.query.get(ue_id)
|
||||
L.append(EvaluationUEPoids(evaluation=self, ue=ue, poids=poids))
|
||||
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
|
||||
self.moduleimpl.invalidate_evaluations_poids() # inval cache
|
||||
|
||||
@ -211,7 +182,7 @@ class Evaluation(db.Model):
|
||||
return {
|
||||
p.ue.id: p.poids
|
||||
for p in sorted(
|
||||
self.ue_poids, key=attrgetter("ue.numero", "ue.acronyme")
|
||||
self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme)
|
||||
)
|
||||
}
|
||||
|
||||
@ -340,7 +311,7 @@ def check_evaluation_args(args):
|
||||
jour = args.get("jour", None)
|
||||
args["jour"] = jour
|
||||
if jour:
|
||||
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
|
||||
modimpl = ModuleImpl.query.get(moduleimpl_id)
|
||||
formsemestre = modimpl.formsemestre
|
||||
y, m, d = [int(x) for x in ndb.DateDMYtoISO(jour).split("-")]
|
||||
jour = datetime.date(y, m, d)
|
||||
|
@ -54,17 +54,14 @@ 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",
|
||||
@ -133,10 +130,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=600):
|
||||
def add(cls, typ, obj=None, text="", url=None, max_frequency=0):
|
||||
"""Enregistre une nouvelle
|
||||
Si max_frequency, ne génère pas 2 nouvelles "identiques"
|
||||
à moins de max_frequency secondes d'intervalle (10 minutes par défaut).
|
||||
à moins de max_frequency secondes d'intervalle.
|
||||
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.
|
||||
@ -156,10 +153,7 @@ 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):
|
||||
# pas de nouvel event, mais met à jour l'heure
|
||||
last_news.date = datetime.datetime.now()
|
||||
db.session.add(last_news)
|
||||
db.session.commit()
|
||||
# on n'enregistre pas
|
||||
return
|
||||
|
||||
news = ScolarNews(
|
||||
@ -187,14 +181,14 @@ class ScolarNews(db.Model):
|
||||
elif self.type == self.NEWS_NOTE:
|
||||
moduleimpl_id = self.object
|
||||
if moduleimpl_id:
|
||||
modimpl = db.session.get(ModuleImpl, moduleimpl_id)
|
||||
modimpl = ModuleImpl.query.get(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 = db.session.get(FormSemestre, formsemestre_id)
|
||||
formsemestre = FormSemestre.query.get(formsemestre_id)
|
||||
return formsemestre
|
||||
|
||||
def notify_by_mail(self):
|
||||
@ -239,7 +233,8 @@ 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 = email.get_from_addr()
|
||||
sender = prefs["email_from_addr"]
|
||||
|
||||
email.send_email(subject, sender, destinations, txt)
|
||||
|
||||
@classmethod
|
||||
@ -265,8 +260,11 @@ class ScolarNews(db.Model):
|
||||
|
||||
# Informations générales
|
||||
H.append(
|
||||
f"""<div><a class="discretelink" href="{scu.SCO_ANNONCES_WEBSITE}">
|
||||
Pour en savoir plus sur ScoDoc voir le site scodoc.org</a>.
|
||||
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>.
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""ScoDoc 9 models : Formations
|
||||
"""
|
||||
from flask_sqlalchemy.query import Query
|
||||
import flask_sqlalchemy
|
||||
|
||||
import app
|
||||
from app import db
|
||||
@ -9,16 +9,17 @@ 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, UEParcours
|
||||
from app.models.ues import UniteEns
|
||||
from app.scodoc import sco_cache
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.codes_cursus import UE_STANDARD
|
||||
from app.scodoc.sco_codes_parcours import UE_STANDARD
|
||||
|
||||
|
||||
class Formation(db.Model):
|
||||
@ -35,7 +36,6 @@ 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,37 +47,27 @@ class Formation(db.Model):
|
||||
|
||||
# Optionnel, pour les formations type BUT
|
||||
referentiel_competence_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_referentiel_competences.id", ondelete="SET NULL")
|
||||
db.Integer, db.ForeignKey("apc_referentiel_competences.id")
|
||||
)
|
||||
ues = db.relationship("UniteEns", backref="formation", lazy="dynamic")
|
||||
formsemestres = db.relationship("FormSemestre", lazy="dynamic", backref="formation")
|
||||
ues = db.relationship(
|
||||
"UniteEns", lazy="dynamic", backref="formation", order_by="UniteEns.numero"
|
||||
)
|
||||
ues = db.relationship("UniteEns", lazy="dynamic", backref="formation")
|
||||
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}, version={self.version})>"""
|
||||
return f"<{self.__class__.__name__}(id={self.id}, dept_id={self.dept_id}, acronyme='{self.acronyme!r}')>"
|
||||
|
||||
def html(self) -> str:
|
||||
def to_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, with_departement=True):
|
||||
"""As a dict.
|
||||
def to_dict(self, with_refcomp_attrs=False):
|
||||
""" "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["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["departement"] = self.departement.to_dict()
|
||||
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
|
||||
@ -86,12 +76,12 @@ class Formation(db.Model):
|
||||
|
||||
return e
|
||||
|
||||
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
|
||||
def get_parcours(self):
|
||||
"""get l'instance de TypeParcours de cette formation
|
||||
(le TypeParcours définit le genre de formation, à ne pas confondre
|
||||
avec les parcours du BUT).
|
||||
"""
|
||||
return codes_cursus.get_cursus_from_code(self.type_parcours)
|
||||
return sco_codes_parcours.get_parcours_from_code(self.type_parcours)
|
||||
|
||||
def get_titre_version(self) -> str:
|
||||
"""Titre avec version"""
|
||||
@ -99,7 +89,7 @@ class Formation(db.Model):
|
||||
|
||||
def is_apc(self):
|
||||
"True si formation APC avec SAE (BUT)"
|
||||
return self.get_cursus().APC_SAE
|
||||
return self.get_parcours().APC_SAE
|
||||
|
||||
def get_module_coefs(self, semestre_idx: int = None):
|
||||
"""Les coefs des modules vers les UE (accès via cache)"""
|
||||
@ -118,14 +108,9 @@ class Formation(db.Model):
|
||||
df_cache.ModuleCoefsCache.set(key, modules_coefficients)
|
||||
return modules_coefficients
|
||||
|
||||
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 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 invalidate_module_coefs(self, semestre_idx: int = None):
|
||||
"""Invalide le cache des coefficients de modules.
|
||||
@ -214,38 +199,27 @@ class Formation(db.Model):
|
||||
if change:
|
||||
app.clear_scodoc_cache()
|
||||
|
||||
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)
|
||||
def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery:
|
||||
"""Les UEs d'un parcours de la formation.
|
||||
Si parcour est None, les UE sans parcours.
|
||||
Exemple: pour avoir les UE du semestre 3, faire
|
||||
`formation.query_ues_parcour(parcour).filter(UniteEns.semestre_idx == 3)`
|
||||
`formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)`
|
||||
"""
|
||||
if with_sport:
|
||||
query_f = UniteEns.query.filter_by(formation=self)
|
||||
else:
|
||||
query_f = UniteEns.query.filter_by(formation=self, type=UE_STANDARD)
|
||||
# Les UE sans parcours:
|
||||
query_no_parcours = query_f.outerjoin(UEParcours).filter(
|
||||
UEParcours.parcours_id == None
|
||||
)
|
||||
if parcour is None:
|
||||
return 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,
|
||||
# )
|
||||
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,
|
||||
)
|
||||
|
||||
def query_competences_parcour(self, parcour: ApcParcours) -> Query:
|
||||
def query_competences_parcour(
|
||||
self, parcour: ApcParcours
|
||||
) -> flask_sqlalchemy.BaseQuery:
|
||||
"""Les ApcCompetences d'un parcours de la formation.
|
||||
None si pas de référentiel de compétences.
|
||||
"""
|
||||
@ -293,7 +267,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, nullable=False, default=0) # ordre de présentation
|
||||
numero = db.Column(db.Integer) # ordre de présentation
|
||||
|
||||
modules = db.relationship("Module", lazy="dynamic", backref="matiere")
|
||||
|
||||
@ -306,6 +280,6 @@ class Matiere(db.Model):
|
||||
e = dict(self.__dict__)
|
||||
e.pop("_sa_instance_state", None)
|
||||
# ScoDoc7 output_formators
|
||||
e["numero"] = e["numero"] if e["numero"] else 0
|
||||
e["ue_id"] = self.id
|
||||
e["numero"] = e["numero"] if e["numero"] else 0
|
||||
return e
|
||||
|
@ -1,7 +1,7 @@
|
||||
# -*- coding: UTF-8 -*
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
@ -12,19 +12,19 @@
|
||||
"""
|
||||
import datetime
|
||||
from functools import cached_property
|
||||
from operator import attrgetter
|
||||
|
||||
from flask_login import current_user
|
||||
|
||||
from flask import flash, g, url_for
|
||||
import flask_sqlalchemy
|
||||
from flask import flash, g
|
||||
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,
|
||||
)
|
||||
@ -36,14 +36,12 @@ 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 codes_cursus, sco_preferences
|
||||
from app.scodoc import sco_codes_parcours, 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"""
|
||||
@ -57,62 +55,55 @@ class FormSemestre(db.Model):
|
||||
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
|
||||
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
|
||||
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
|
||||
titre = db.Column(db.Text(), nullable=False)
|
||||
date_debut = db.Column(db.Date(), nullable=False)
|
||||
date_fin = db.Column(db.Date(), nullable=False)
|
||||
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
|
||||
"False si verrouillé"
|
||||
titre = db.Column(db.Text())
|
||||
date_debut = db.Column(db.Date())
|
||||
date_fin = db.Column(db.Date())
|
||||
etat = db.Column(
|
||||
db.Boolean(), nullable=False, default=True, server_default="true"
|
||||
) # False si verrouillé
|
||||
modalite = db.Column(
|
||||
db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite")
|
||||
)
|
||||
"Modalité de formation: 'FI', 'FAP', 'FC', ..."
|
||||
) # "FI", "FAP", "FC", ...
|
||||
# gestion compensation sem DUT:
|
||||
gestion_compensation = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
"gestion compensation sem DUT (inutilisé en APC)"
|
||||
# ne publie pas le bulletin XML ou JSON:
|
||||
bul_hide_xml = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
"ne publie pas le bulletin XML ou JSON"
|
||||
# Bloque le calcul des moyennes (générale et d'UE)
|
||||
block_moyennes = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
"Bloque le calcul des moyennes (générale et d'UE)"
|
||||
# Bloque le calcul de la moyenne générale (utile pour BUT)
|
||||
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"
|
||||
)
|
||||
"Semestres décalés (pour gestion jurys DUT, pas implémenté ou utile en BUT)"
|
||||
# couleur fond bulletins HTML:
|
||||
bul_bgcolor = db.Column(
|
||||
db.String(SHORT_STR_LEN),
|
||||
default="white",
|
||||
server_default="white",
|
||||
nullable=False,
|
||||
db.String(SHORT_STR_LEN), default="white", server_default="white"
|
||||
)
|
||||
"couleur fond bulletins HTML"
|
||||
# autorise resp. a modifier semestre:
|
||||
resp_can_edit = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
"autorise resp. à modifier le formsemestre"
|
||||
# autorise resp. a modifier slt les enseignants:
|
||||
resp_can_change_ens = db.Column(
|
||||
db.Boolean(), nullable=False, default=True, server_default="true"
|
||||
)
|
||||
"autorise resp. a modifier slt les enseignants"
|
||||
# autorise les ens a creer des evals:
|
||||
ens_can_edit_eval = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="False"
|
||||
)
|
||||
"autorise les enseignants à créer des évals dans leurs modimpls"
|
||||
# code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'
|
||||
elt_sem_apo = db.Column(db.Text()) # peut être fort long !
|
||||
"code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
|
||||
# code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'
|
||||
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(
|
||||
@ -122,7 +113,6 @@ class FormSemestre(db.Model):
|
||||
"ModuleImpl",
|
||||
backref="formsemestre",
|
||||
lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
etuds = db.relationship(
|
||||
"Identite",
|
||||
@ -152,7 +142,6 @@ 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):
|
||||
@ -163,28 +152,6 @@ 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
|
||||
@ -207,14 +174,11 @@ 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: # pour API
|
||||
d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
|
||||
if convert_objects:
|
||||
d["parcours"] = [p.to_dict() for p in self.parcours]
|
||||
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):
|
||||
@ -238,10 +202,9 @@ class FormSemestre(db.Model):
|
||||
d["etape_apo"] = self.etapes_apo_str()
|
||||
d["formsemestre_id"] = self.id
|
||||
d["formation"] = self.formation.to_dict()
|
||||
d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
|
||||
d["parcours"] = [p.to_dict() for p in self.parcours]
|
||||
d["responsables"] = [u.id for u in self.responsables]
|
||||
d["titre_court"] = self.formation.acronyme
|
||||
d["titre_formation"] = self.titre_formation()
|
||||
d["titre_num"] = self.titre_num()
|
||||
d["session_id"] = self.session_id()
|
||||
return d
|
||||
@ -281,72 +244,42 @@ 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.
|
||||
"""
|
||||
r = self.parcours or (
|
||||
self.formation.referentiel_competence
|
||||
and self.formation.referentiel_competence.parcours
|
||||
)
|
||||
return r or []
|
||||
|
||||
def get_ues(self, with_sport=False) -> list[UniteEns]:
|
||||
def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery:
|
||||
"""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;
|
||||
- et sont associées à l'un des parcours de ce formsemestre
|
||||
(ou à aucun, donc tronc commun).
|
||||
- Formations APC / BUT: les UEs de la formation qui ont
|
||||
le même numéro de semestre que ce formsemestre.
|
||||
"""
|
||||
# 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)
|
||||
}
|
||||
)
|
||||
ues = sorted(sem_ues.values(), key=attrgetter("numero", "acronyme"))
|
||||
if self.formation.get_parcours().APC_SAE:
|
||||
sem_ues = UniteEns.query.filter_by(
|
||||
formation=self.formation, semestre_idx=self.semestre_id
|
||||
)
|
||||
else:
|
||||
sem_ues = db.session.query(UniteEns).filter(
|
||||
ModuleImpl.formsemestre_id == self.id,
|
||||
Module.id == ModuleImpl.module_id,
|
||||
UniteEns.id == Module.ue_id,
|
||||
)
|
||||
if not with_sport:
|
||||
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
|
||||
ues = sem_ues.order_by(UniteEns.numero).all()
|
||||
_cache[key] = ues
|
||||
return ues
|
||||
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:
|
||||
"""UE que suit l'étudiant dans ce semestre BUT
|
||||
en fonction du parcours dans lequel il est inscrit.
|
||||
|
||||
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,
|
||||
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def modimpls_sorted(self) -> list[ModuleImpl]:
|
||||
@ -358,7 +291,7 @@ class FormSemestre(db.Model):
|
||||
if self.formation.is_apc():
|
||||
modimpls.sort(
|
||||
key=lambda m: (
|
||||
m.module.module_type or 0, # ressources (2) avant SAEs (3)
|
||||
m.module.module_type or 0,
|
||||
m.module.numero or 0,
|
||||
m.module.code or 0,
|
||||
)
|
||||
@ -394,10 +327,10 @@ class FormSemestre(db.Model):
|
||||
),
|
||||
{"formsemestre_id": self.id, "parcours_id": parcours.id},
|
||||
)
|
||||
return [db.session.get(ModuleImpl, modimpl_id) for modimpl_id in cursor]
|
||||
return [ModuleImpl.query.get(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)"""
|
||||
"""Vrai si user peut modifier ce semestre"""
|
||||
if not user.has_permission(Permission.ScoImplement): # pas chef
|
||||
if not self.resp_can_edit or user.id not in [
|
||||
resp.id for resp in self.responsables
|
||||
@ -461,12 +394,6 @@ 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,
|
||||
@ -538,11 +465,6 @@ 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.
|
||||
@ -574,43 +496,10 @@ class FormSemestre(db.Model):
|
||||
else:
|
||||
return ", ".join([u.get_nomcomplet() for u in self.responsables])
|
||||
|
||||
def est_responsable(self, user: User):
|
||||
def est_responsable(self, 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."""
|
||||
@ -646,7 +535,7 @@ class FormSemestre(db.Model):
|
||||
if not imputation_dept:
|
||||
imputation_dept = prefs["DeptName"]
|
||||
imputation_dept = imputation_dept.upper()
|
||||
cursus_name = self.formation.get_cursus().NAME
|
||||
parcours_name = self.formation.get_parcours().NAME
|
||||
modalite = self.modalite
|
||||
# exception pour code Apprentissage:
|
||||
modalite = (modalite or "").replace("FAP", "FA").replace("APP", "FA")
|
||||
@ -659,7 +548,7 @@ class FormSemestre(db.Model):
|
||||
scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
|
||||
)
|
||||
return scu.sanitize_string(
|
||||
f"{imputation_dept}-{cursus_name}-{modalite}-{semestre_id}-{annee_sco}"
|
||||
f"{imputation_dept}-{parcours_name}-{modalite}-{semestre_id}-{annee_sco}"
|
||||
)
|
||||
|
||||
def titre_annee(self) -> str:
|
||||
@ -673,12 +562,10 @@ class FormSemestre(db.Model):
|
||||
titre_annee += "-" + str(self.date_fin.year)
|
||||
return titre_annee
|
||||
|
||||
def titre_formation(self, with_sem_idx=False):
|
||||
def titre_formation(self):
|
||||
"""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:
|
||||
@ -693,9 +580,9 @@ class FormSemestre(db.Model):
|
||||
|
||||
def titre_num(self) -> str:
|
||||
"""Le titre et le semestre, ex ""DUT Informatique semestre 2"" """
|
||||
if self.semestre_id == codes_cursus.NO_SEMESTRE_ID:
|
||||
if self.semestre_id == sco_codes_parcours.NO_SEMESTRE_ID:
|
||||
return self.titre
|
||||
return f"{self.titre} {self.formation.get_cursus().SESSION_NAME} {self.semestre_id}"
|
||||
return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}"
|
||||
|
||||
def sem_modalite(self) -> str:
|
||||
"""Le semestre et la modalité, ex "S2 FI" ou "S3 APP" """
|
||||
@ -773,12 +660,8 @@ class FormSemestre(db.Model):
|
||||
db.session.add(partition)
|
||||
db.session.flush() # pour avoir un id
|
||||
flash("Partition Parcours créée.")
|
||||
elif partition.groups_editable:
|
||||
# Il ne faut jamais laisser éditer cette partition de parcours
|
||||
partition.groups_editable = False
|
||||
db.session.add(partition)
|
||||
|
||||
for parcour in self.get_parcours_apc():
|
||||
for parcour in self.parcours:
|
||||
if parcour.code:
|
||||
group = GroupDescr.query.filter_by(
|
||||
partition_id=partition.id, group_name=parcour.code
|
||||
@ -787,28 +670,21 @@ class FormSemestre(db.Model):
|
||||
partition.groups.append(GroupDescr(group_name=parcour.code))
|
||||
db.session.flush()
|
||||
# S'il reste des groupes de parcours qui ne sont plus dans le semestre
|
||||
# - s'ils n'ont pas d'inscrits, supprime-les.
|
||||
# - s'ils ont des inscrits: avertissement
|
||||
# et qui n'ont pas d'inscrits, supprime-les.
|
||||
for group in GroupDescr.query.filter_by(partition_id=partition.id):
|
||||
if group.group_name not in (p.code for p in self.get_parcours_apc()):
|
||||
if (
|
||||
len(
|
||||
[
|
||||
inscr
|
||||
for inscr in self.inscriptions
|
||||
if (inscr.parcour is not None)
|
||||
and inscr.parcour.code == group.group_name
|
||||
]
|
||||
)
|
||||
== 0
|
||||
):
|
||||
flash(f"Suppression du groupe de parcours vide {group.group_name}")
|
||||
db.session.delete(group)
|
||||
else:
|
||||
flash(
|
||||
f"""Attention: groupe de parcours {group.group_name} non vide:
|
||||
réaffectez ses étudiants dans des parcours du semestre"""
|
||||
)
|
||||
if (group.group_name not in (p.code for p in self.parcours)) and (
|
||||
len(
|
||||
[
|
||||
inscr
|
||||
for inscr in self.inscriptions
|
||||
if (inscr.parcour is not None)
|
||||
and inscr.parcour.code == group.group_name
|
||||
]
|
||||
)
|
||||
== 0
|
||||
):
|
||||
flash(f"suppression du groupe de parcours {group.group_name}")
|
||||
db.session.delete(group)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@ -818,8 +694,6 @@ 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()
|
||||
@ -843,10 +717,7 @@ class FormSemestre(db.Model):
|
||||
query = (
|
||||
ApcParcours.query.filter_by(code=group.group_name)
|
||||
.join(ApcReferentielCompetences)
|
||||
.filter_by(
|
||||
dept_id=g.scodoc_dept_id,
|
||||
id=self.formation.referentiel_competence_id,
|
||||
)
|
||||
.filter_by(dept_id=g.scodoc_dept_id)
|
||||
)
|
||||
if query.count() != 1:
|
||||
log(
|
||||
@ -895,12 +766,15 @@ class FormSemestre(db.Model):
|
||||
.order_by(UniteEns.numero)
|
||||
.all()
|
||||
)
|
||||
vals_annee = ( # issues de cette année scolaire seulement
|
||||
vals_annee = (
|
||||
ApcValidationAnnee.query.filter_by(
|
||||
etudid=etudid,
|
||||
annee_scolaire=self.annee_scolaire(),
|
||||
referentiel_competence_id=self.formation.referentiel_competence_id,
|
||||
).all()
|
||||
)
|
||||
.join(ApcValidationAnnee.formsemestre)
|
||||
.join(FormSemestre.formation)
|
||||
.filter(Formation.formation_code == self.formation.formation_code)
|
||||
.all()
|
||||
)
|
||||
H = []
|
||||
for vals in (vals_sem, vals_ues, vals_rcues, vals_annee):
|
||||
@ -967,7 +841,7 @@ class FormSemestreEtape(db.Model):
|
||||
def __repr__(self):
|
||||
return f"<Etape {self.id} apo={self.etape_apo!r}>"
|
||||
|
||||
def as_apovdi(self) -> ApoEtapeVDI:
|
||||
def as_apovdi(self):
|
||||
return ApoEtapeVDI(self.etape_apo)
|
||||
|
||||
|
||||
@ -990,14 +864,14 @@ class FormationModalite(db.Model):
|
||||
) # code
|
||||
titre = db.Column(db.Text()) # texte explicatif
|
||||
# numero = ordre de presentation)
|
||||
numero = db.Column(db.Integer, nullable=False, default=0)
|
||||
numero = db.Column(db.Integer)
|
||||
|
||||
@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"),
|
||||
@ -1113,9 +987,7 @@ 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", ondelete="SET NULL"), index=True
|
||||
)
|
||||
parcour_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), index=True)
|
||||
parcour = db.relationship(ApcParcours)
|
||||
|
||||
def __repr__(self):
|
||||
@ -1138,15 +1010,6 @@ 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(
|
||||
|
@ -1,20 +1,17 @@
|
||||
# -*- coding: UTF-8 -*
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""ScoDoc models: Groups & partitions
|
||||
"""
|
||||
from operator import attrgetter
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app import db, log
|
||||
from app import db
|
||||
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):
|
||||
@ -32,7 +29,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, nullable=False, default=0)
|
||||
numero = db.Column(db.Integer)
|
||||
# Calculer le rang ?
|
||||
bul_show_rank = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
@ -75,7 +72,7 @@ class Partition(db.Model):
|
||||
"""
|
||||
if not isinstance(partition_name, str):
|
||||
return False
|
||||
if not (0 < len(partition_name.strip()) < SHORT_STR_LEN):
|
||||
if not len(partition_name.strip()) > 0:
|
||||
return False
|
||||
if (not existing) and (
|
||||
partition_name in [p.partition_name for p in formsemestre.partitions]
|
||||
@ -87,113 +84,20 @@ 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, str_keys: bool = False) -> dict:
|
||||
"""as a dict, with or without groups.
|
||||
If str_keys, convert integer dict keys to strings (useful for JSON)
|
||||
"""
|
||||
def to_dict(self, with_groups=False) -> dict:
|
||||
"""as a dict, with or without groups"""
|
||||
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=attrgetter("numero", "group_name"))
|
||||
groups = sorted(self.groups, key=lambda g: (g.numero or 0, g.group_name))
|
||||
# un dict et non plus une liste, pour JSON
|
||||
if str_keys:
|
||||
d["groups"] = {
|
||||
str(group.id): group.to_dict(with_partition=False)
|
||||
for group in groups
|
||||
}
|
||||
else:
|
||||
d["groups"] = {
|
||||
group.id: group.to_dict(with_partition=False) for group in groups
|
||||
}
|
||||
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"""
|
||||
@ -207,7 +111,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, nullable=False, default=0)
|
||||
numero = db.Column(db.Integer)
|
||||
|
||||
etuds = db.relationship(
|
||||
"Identite",
|
||||
@ -242,7 +146,7 @@ class GroupDescr(db.Model):
|
||||
"""
|
||||
if not isinstance(group_name, str):
|
||||
return False
|
||||
if not default and not (0 < len(group_name.strip()) < GROUPNAME_STR_LEN):
|
||||
if not default and not len(group_name.strip()) > 0:
|
||||
return False
|
||||
if (not existing) and (group_name in [g.group_name for g in partition.groups]):
|
||||
return False
|
||||
|
@ -2,15 +2,13 @@
|
||||
"""ScoDoc models: moduleimpls
|
||||
"""
|
||||
import pandas as pd
|
||||
from flask_sqlalchemy.query import Query
|
||||
import flask_sqlalchemy
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -22,12 +20,14 @@ class ModuleImpl(db.Model):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
moduleimpl_id = db.synonym("id")
|
||||
module_id = db.Column(db.Integer, db.ForeignKey("notes_modules.id"), nullable=False)
|
||||
module_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("notes_modules.id"),
|
||||
)
|
||||
formsemestre_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("notes_formsemestre.id"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id"))
|
||||
# formule de calcul moyenne:
|
||||
@ -62,11 +62,11 @@ class ModuleImpl(db.Model):
|
||||
"""Invalide poids cachés"""
|
||||
df_cache.EvaluationsPoidsCache.delete(self.id)
|
||||
|
||||
def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool:
|
||||
def check_apc_conformity(self) -> bool:
|
||||
"""true si les poids des évaluations du module permettent de satisfaire
|
||||
les coefficients du PN.
|
||||
"""
|
||||
if not self.module.formation.get_cursus().APC_SAE or (
|
||||
if not self.module.formation.get_parcours().APC_SAE or (
|
||||
self.module.module_type != scu.ModuleType.RESSOURCE
|
||||
and self.module.module_type != scu.ModuleType.SAE
|
||||
):
|
||||
@ -76,7 +76,7 @@ class ModuleImpl(db.Model):
|
||||
return moy_mod.moduleimpl_is_conforme(
|
||||
self,
|
||||
self.get_evaluations_poids(),
|
||||
res.modimpl_coefs_df,
|
||||
self.module.formation.get_module_coefs(self.module.semestre_id),
|
||||
)
|
||||
|
||||
def to_dict(self, convert_objects=False, with_module=True):
|
||||
@ -101,27 +101,6 @@ 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(
|
||||
@ -163,7 +142,7 @@ class ModuleImplInscription(db.Model):
|
||||
@classmethod
|
||||
def etud_modimpls_in_ue(
|
||||
cls, formsemestre_id: int, etudid: int, ue_id: int
|
||||
) -> Query:
|
||||
) -> flask_sqlalchemy.BaseQuery:
|
||||
"""moduleimpls de l'UE auxquels l'étudiant est inscrit.
|
||||
(Attention: inutile en APC, il faut considérer les coefficients)
|
||||
"""
|
||||
|
@ -1,13 +1,11 @@
|
||||
"""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.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
@ -33,15 +31,13 @@ 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, nullable=False, default=0) # ordre de présentation
|
||||
numero = db.Column(db.Integer) # 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)
|
||||
module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0")
|
||||
# Relations:
|
||||
modimpls = db.relationship(
|
||||
"ModuleImpl", backref="module", lazy="dynamic", cascade="all, delete-orphan"
|
||||
)
|
||||
modimpls = db.relationship("ModuleImpl", backref="module", lazy="dynamic")
|
||||
ues_apc = db.relationship("UniteEns", secondary="module_ue_coef", viewonly=True)
|
||||
tags = db.relationship(
|
||||
"NotesTag",
|
||||
@ -55,7 +51,7 @@ class Module(db.Model):
|
||||
secondary=parcours_modules,
|
||||
lazy="subquery",
|
||||
backref=db.backref("modules", lazy=True),
|
||||
order_by="ApcParcours.numero, ApcParcours.code",
|
||||
order_by="ApcParcours.numero",
|
||||
)
|
||||
|
||||
app_critiques = db.relationship(
|
||||
@ -177,11 +173,6 @@ 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 ?
|
||||
@ -198,7 +189,7 @@ class Module(db.Model):
|
||||
else:
|
||||
# crée nouveau coef:
|
||||
if coef != 0.0:
|
||||
ue = db.session.get(UniteEns, ue_id)
|
||||
ue = UniteEns.query.get(ue_id)
|
||||
ue_coef = ModuleUECoef(module=self, ue=ue, coef=coef)
|
||||
db.session.add(ue_coef)
|
||||
self.ue_coefs.append(ue_coef)
|
||||
@ -208,11 +199,6 @@ 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)
|
||||
@ -221,53 +207,38 @@ 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"""
|
||||
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))
|
||||
ue_coef = ModuleUECoef.query.get((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 et acronyme d'UE"
|
||||
"les coefs d'UE, trié par numéro 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 uc: (uc.ue.numero, uc.ue.acronyme))
|
||||
return sorted(self.ue_coefs, key=lambda x: x.ue.numero)
|
||||
|
||||
def ue_coefs_list(
|
||||
self, include_zeros=True, ues: list["UniteEns"] = None
|
||||
) -> list[tuple["UniteEns", float]]:
|
||||
def ue_coefs_list(self, include_zeros=True):
|
||||
"""Liste des coefs vers les UE (pour les modules APC).
|
||||
Si ues est spécifié, restreint aux UE indiquées.
|
||||
Sinon si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre,
|
||||
Si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre,
|
||||
sauf UE bonus sport.
|
||||
Result: List of tuples [ (ue, coef) ]
|
||||
"""
|
||||
if not self.is_apc():
|
||||
return []
|
||||
if include_zeros and ues is None:
|
||||
if include_zeros:
|
||||
# Toutes les UE du même semestre:
|
||||
ues = (
|
||||
ues_semestre = (
|
||||
self.formation.ues.filter_by(semestre_idx=self.ue.semestre_idx)
|
||||
.filter(UniteEns.type != UE_SPORT)
|
||||
.order_by(UniteEns.numero)
|
||||
.all()
|
||||
)
|
||||
if not ues:
|
||||
return []
|
||||
if ues:
|
||||
coefs_dict = self.get_ue_coef_dict()
|
||||
coefs_list = []
|
||||
for ue in ues:
|
||||
for ue in ues_semestre:
|
||||
coefs_list.append((ue, coefs_dict.get(ue.id, 0.0)))
|
||||
return coefs_list
|
||||
# Liste seulement les coefs définis:
|
||||
|
@ -3,8 +3,9 @@
|
||||
"""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
|
||||
|
||||
|
||||
@ -52,13 +53,6 @@ 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)"""
|
||||
@ -87,8 +81,7 @@ def etud_has_notes_attente(etudid, formsemestre_id):
|
||||
(ne compte que les notes en attente dans des évaluations avec coef. non nul).
|
||||
"""
|
||||
cursor = db.session.execute(
|
||||
sa.text(
|
||||
"""SELECT COUNT(*)
|
||||
"""SELECT COUNT(*)
|
||||
FROM notes_notes n, notes_evaluation e, notes_moduleimpl m,
|
||||
notes_moduleimpl_inscription i
|
||||
WHERE n.etudid = :etudid
|
||||
@ -99,8 +92,7 @@ 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,
|
||||
|
57
app/models/raw_sql_init.py
Normal file
57
app/models/raw_sql_init.py
Normal file
@ -0,0 +1,57 @@
|
||||
# -*- 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,14 +1,12 @@
|
||||
"""ScoDoc 9 models : Unités d'Enseignement (UE)
|
||||
"""
|
||||
|
||||
from flask import g
|
||||
import pandas as pd
|
||||
|
||||
from app import db, log
|
||||
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 +19,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, nullable=False, default=0) # ordre de présentation
|
||||
numero = db.Column(db.Integer) # 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 +36,7 @@ class UniteEns(db.Model):
|
||||
server_default=db.text("notes_newid_ucod()"),
|
||||
nullable=False,
|
||||
)
|
||||
ects = db.Column(db.Float) # nombre de credits ECTS (sauf si parcours spécifié)
|
||||
ects = db.Column(db.Float) # nombre de credits ECTS
|
||||
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,18 +49,12 @@ class UniteEns(db.Model):
|
||||
color = db.Column(db.Text())
|
||||
|
||||
# BUT
|
||||
niveau_competence_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_niveau.id", ondelete="SET NULL")
|
||||
)
|
||||
niveau_competence_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"))
|
||||
niveau_competence = db.relationship("ApcNiveau", 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",
|
||||
)
|
||||
# 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")
|
||||
|
||||
# relations
|
||||
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
|
||||
@ -103,40 +95,20 @@ 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.
|
||||
"""as a dict, with the same conversions as in ScoDoc7
|
||||
(except ECTS: keep None)
|
||||
If convert_objects, convert all attributes to native types
|
||||
(suitable for json encoding).
|
||||
(suitable jor 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"] = [
|
||||
@ -144,7 +116,6 @@ class UniteEns(db.Model):
|
||||
]
|
||||
else:
|
||||
e.pop("module_ue_coefs", None)
|
||||
_cache[key] = e
|
||||
return e
|
||||
|
||||
def annee(self) -> int:
|
||||
@ -185,55 +156,6 @@ 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()
|
||||
@ -255,219 +177,84 @@ class UniteEns(db.Model):
|
||||
return {x.strip() for x in self.code_apogee.split(",") if x}
|
||||
return set()
|
||||
|
||||
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 _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 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]:
|
||||
def set_niveau_competence(self, niveau: ApcNiveau):
|
||||
"""Associe cette UE au niveau de compétence indiqué.
|
||||
Le niveau doit être dans l'un des parcours de l'UE (si elle n'est pas
|
||||
de tronc commun).
|
||||
Le niveau doit être dans le parcours de l'UE, s'il y en a un.
|
||||
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:
|
||||
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})""",
|
||||
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 (
|
||||
niveau.competence.referentiel.id
|
||||
!= self.formation.referentiel_competence.id
|
||||
):
|
||||
return (
|
||||
False,
|
||||
"Le niveau n'appartient pas au référentiel de la formation",
|
||||
log(
|
||||
f"set_niveau_competence: niveau {niveau} hors parcours {self.parcour}"
|
||||
)
|
||||
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
|
||||
return
|
||||
|
||||
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_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)
|
||||
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.
|
||||
"""
|
||||
msg = ""
|
||||
# Le niveau est-il dans tous ces parcours ? Sinon, l'enlève
|
||||
prev_niveau = self.niveau_competence
|
||||
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
|
||||
if (
|
||||
parcours
|
||||
parcour
|
||||
and self.niveau_competence
|
||||
and self.niveau_competence.id not in self._parcours_niveaux_ids(parcours)
|
||||
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
|
||||
)
|
||||
)
|
||||
):
|
||||
self.niveau_competence = None
|
||||
msg = " (niveau compétence désassocié !)"
|
||||
|
||||
if parcours and self.niveau_competence:
|
||||
ok, error_message = self.check_niveau_unique_dans_parcours(
|
||||
self.niveau_competence, parcours
|
||||
)
|
||||
if not ok:
|
||||
self.niveau_competence = prev_niveau # restore
|
||||
return False, error_message
|
||||
|
||||
self.parcours = parcours
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
# Invalidation du cache
|
||||
self.formation.invalidate_cached_sems()
|
||||
log(f"ue.set_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})>"
|
||||
log(f"ue.set_parcour( {self}, {parcour} )")
|
||||
|
||||
|
||||
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.
|
||||
- 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
|
||||
- elle ne permet pas la délivrance d'ECTS ou du diplôme.
|
||||
|
||||
On utilise cette dispense et non une "inscription" par souci d'efficacité:
|
||||
en général, la grande majorité des étudiants suivront toutes les UEs de leur parcours,
|
||||
la dispense étant une exception.
|
||||
Utilisé en PCC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée
|
||||
qu'ils ne refont pas.
|
||||
"""
|
||||
|
||||
__tablename__ = "dispenseUE"
|
||||
__table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id", "etudid"),)
|
||||
__table_args__ = (db.UniqueConstraint("ue_id", "etudid"),)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
formsemestre_id = formsemestre_id = db.Column(
|
||||
db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
|
||||
)
|
||||
ue_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey(UniteEns.id, ondelete="CASCADE"),
|
||||
@ -486,25 +273,3 @@ class DispenseUE(db.Model):
|
||||
def __repr__(self) -> str:
|
||||
return f"""<{self.__class__.__name__} {self.id} etud={
|
||||
repr(self.etud)} ue={repr(self.ue)}>"""
|
||||
|
||||
@classmethod
|
||||
def load_formsemestre_dispense_ues_set(
|
||||
cls, formsemestre: "FormSemestre", etudids: pd.Index, ues: list[UniteEns]
|
||||
) -> set[tuple[int, int]]:
|
||||
"""Construit l'ensemble des
|
||||
etudids = modimpl_inscr_df.index, # les etudids
|
||||
ue_ids : modimpl_coefs_df.index, # les UE du formsemestre sans les UE bonus sport
|
||||
|
||||
Résultat: set de (etudid, ue_id).
|
||||
"""
|
||||
# Prend toutes les dispenses obtenues par des étudiants de ce formsemestre,
|
||||
# puis filtre sur inscrits et ues
|
||||
ue_ids = {ue.id for ue in ues}
|
||||
dispense_ues = {
|
||||
(dispense_ue.etudid, dispense_ue.ue_id)
|
||||
for dispense_ue in DispenseUE.query.filter_by(
|
||||
formsemestre_id=formsemestre.id
|
||||
)
|
||||
if dispense_ue.etudid in etudids and dispense_ue.ue_id in ue_ids
|
||||
}
|
||||
return dispense_ues
|
||||
|
@ -4,17 +4,13 @@
|
||||
"""
|
||||
|
||||
from app import db
|
||||
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 (sur semestre ou UEs)"""
|
||||
"""Décisions de jury"""
|
||||
|
||||
__tablename__ = "scolar_formsemestre_validation"
|
||||
# Assure unicité de la décision:
|
||||
@ -57,30 +53,18 @@ 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__}({self.formsemestre_id}, {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.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)
|
||||
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")}"""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"as a dict"
|
||||
@ -88,49 +72,6 @@ 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"""
|
||||
@ -151,11 +92,6 @@ 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={
|
||||
self.etudid}, semestre_id={self.semestre_id})"""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"as a dict"
|
||||
@ -163,21 +99,6 @@ 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,
|
||||
@ -186,7 +107,8 @@ class ScolarAutorisationInscription(db.Model):
|
||||
origin_formsemestre_id: int,
|
||||
semestre_id: int,
|
||||
):
|
||||
"""Ajoute une autorisation"""
|
||||
"""Enregistre une autorisation, remplace celle émanant du même semestre si elle existe."""
|
||||
cls.delete_autorisation_etud(etudid, origin_formsemestre_id)
|
||||
autorisation = cls(
|
||||
etudid=etudid,
|
||||
formation_code=formation_code,
|
||||
@ -194,10 +116,7 @@ class ScolarAutorisationInscription(db.Model):
|
||||
semestre_id=semestre_id,
|
||||
)
|
||||
db.session.add(autorisation)
|
||||
Scolog.logdb(
|
||||
"autorise_etud", etudid=etudid, msg=f"Passage vers S{semestre_id}: autorisé"
|
||||
)
|
||||
log(f"ScolarAutorisationInscription: recording {autorisation}")
|
||||
Scolog.logdb("autorise_etud", etudid=etudid, msg=f"passage vers S{semestre_id}")
|
||||
|
||||
@classmethod
|
||||
def delete_autorisation_etud(
|
||||
@ -205,17 +124,16 @@ class ScolarAutorisationInscription(db.Model):
|
||||
etudid: int,
|
||||
origin_formsemestre_id: int,
|
||||
):
|
||||
"""Efface les autorisations de cet étudiant venant du sem. origine"""
|
||||
"""Efface les autorisations de cette étudiant venant du sem. origine"""
|
||||
autorisations = cls.query.filter_by(
|
||||
etudid=etudid, origin_formsemestre_id=origin_formsemestre_id
|
||||
)
|
||||
for autorisation in autorisations:
|
||||
db.session.delete(autorisation)
|
||||
log(f"ScolarAutorisationInscription: deleting {autorisation}")
|
||||
Scolog.logdb(
|
||||
"autorise_etud",
|
||||
etudid=etudid,
|
||||
msg=f"Passage vers S{autorisation.semestre_id}: effacé",
|
||||
msg=f"annule passage vers S{autorisation.semestre_id}",
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
@ -233,11 +151,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", ondelete="SET NULL"),
|
||||
db.ForeignKey("notes_formsemestre.id"),
|
||||
)
|
||||
ue_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("notes_ue.id", ondelete="SET NULL"),
|
||||
db.ForeignKey("notes_ue.id"),
|
||||
)
|
||||
# 'CREATION', 'INSCRIPTION', 'DEMISSION',
|
||||
# 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM'
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -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 Formation, FormSemestre
|
||||
from app.models import FormSemestre
|
||||
|
||||
from app.scodoc.gen_tables import GenTable, SeqGenTable
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app.scodoc import codes_cursus # codes_cursus.NEXT -> sem suivant
|
||||
from app.scodoc import sco_codes_parcours # sco_codes_parcours.NEXT -> sem suivant
|
||||
from app.scodoc import sco_etud
|
||||
from app.scodoc import sco_formsemestre
|
||||
from app.pe import pe_tagtable
|
||||
@ -65,8 +65,10 @@ def comp_nom_semestre_dans_parcours(sem):
|
||||
"""Le nom a afficher pour titrer un semestre
|
||||
par exemple: "semestre 2 FI 2015"
|
||||
"""
|
||||
formation: Formation = Formation.query.get_or_404(sem["formation_id"])
|
||||
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
|
||||
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"])
|
||||
return "%s %s %s %s" % (
|
||||
parcours.SESSION_NAME, # eg "semestre"
|
||||
sem["semestre_id"], # eg 2
|
||||
@ -455,9 +457,10 @@ class JuryPE(object):
|
||||
|
||||
reponse = False
|
||||
etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
|
||||
(_, parcours) = sco_report.get_code_cursus_etud(etud)
|
||||
(_, parcours) = sco_report.get_codeparcoursetud(etud)
|
||||
if (
|
||||
len(codes_cursus.CODES_SEM_REO & set(parcours.values())) > 0
|
||||
len(set(sco_codes_parcours.CODES_SEM_REO.keys()) & set(parcours.values()))
|
||||
> 0
|
||||
): # Eliminé car NAR apparait dans le parcours
|
||||
reponse = True
|
||||
if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2:
|
||||
@ -526,14 +529,14 @@ class JuryPE(object):
|
||||
from app.scodoc import sco_report
|
||||
|
||||
etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
|
||||
(code, parcours) = sco_report.get_code_cursus_etud(
|
||||
(code, parcours) = sco_report.get_codeparcoursetud(
|
||||
etud
|
||||
) # description = '1234:A', parcours = {1:ADM, 2:NAR, ...}
|
||||
sonDernierSemestreValide = max(
|
||||
[
|
||||
int(cle)
|
||||
for (cle, code) in parcours.items()
|
||||
if code in codes_cursus.CODES_SEM_VALIDES
|
||||
if code in sco_codes_parcours.CODES_SEM_VALIDES
|
||||
]
|
||||
+ [0]
|
||||
) # n° du dernier semestre valide, 0 sinon
|
||||
@ -560,8 +563,9 @@ class JuryPE(object):
|
||||
dec = nt.get_etud_decision_sem(
|
||||
etudid
|
||||
) # quelle est la décision du jury ?
|
||||
if dec and (dec["code"] in codes_cursus.CODES_SEM_VALIDES):
|
||||
# isinstance( sesMoyennes[i+1], float) and
|
||||
if dec and dec["code"] in list(
|
||||
sco_codes_parcours.CODES_SEM_VALIDES.keys()
|
||||
): # isinstance( sesMoyennes[i+1], float) and
|
||||
# mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide"
|
||||
leFid = sem["formsemestre_id"]
|
||||
else:
|
||||
@ -1135,7 +1139,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.get_formsemestre(formsemestre_id)
|
||||
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
|
||||
return res_sem.load_formsemestre_results(formsemestre)
|
||||
|
||||
# ------------------------------------------------------------------------------------------------------------------
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -36,14 +36,14 @@ Created on Fri Sep 9 09:15:05 2016
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
from app import db, log
|
||||
from app import 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 codes_cursus
|
||||
from app.scodoc import sco_codes_parcours
|
||||
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 == codes_cursus.UE_STANDARD
|
||||
if modimpl.module.ue.type == sco_codes_parcours.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 or 0.0 # le coeff (! non compatible BUT)
|
||||
coeff = modimpl.module.coefficient # le coeff
|
||||
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.get_formsemestre(fid_prec)
|
||||
formsemestre_prec = FormSemestre.query.get_or_404(fid_prec)
|
||||
nt_prec: NotesTableCompat = res_sem.load_formsemestre_results(
|
||||
formsemestre_prec
|
||||
)
|
||||
@ -299,9 +299,8 @@ 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 else 0
|
||||
coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0
|
||||
) # le coeff normalisé
|
||||
else:
|
||||
semtag_prec = SemestreTag(nt_prec, nt_prec.sem)
|
||||
@ -330,7 +329,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)
|
||||
@ -346,8 +345,7 @@ 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()
|
||||
@ -442,7 +440,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"]
|
||||
+ " ("
|
||||
@ -461,7 +459,6 @@ class SemestreTag(pe_tagtable.TableTag):
|
||||
# Fonctions diverses
|
||||
# ************************************************************************
|
||||
|
||||
|
||||
# *********************************************
|
||||
def comp_coeff_pond(coeffs, ponderations):
|
||||
"""
|
||||
@ -487,7 +484,7 @@ def comp_coeff_pond(coeffs, ponderations):
|
||||
# -----------------------------------------------------------------------------
|
||||
def get_moduleimpl(modimpl_id) -> dict:
|
||||
"""Renvoie l'objet modimpl dont l'id est modimpl_id"""
|
||||
modimpl = db.session.get(ModuleImpl, modimpl_id)
|
||||
modimpl = ModuleImpl.query.get(modimpl_id)
|
||||
if modimpl:
|
||||
return modimpl
|
||||
if SemestreTag.DEBUG:
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -40,7 +40,7 @@ Created on Thu Sep 8 09:36:33 2016
|
||||
import datetime
|
||||
import numpy as np
|
||||
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc import notes_table
|
||||
|
||||
|
||||
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] = scu.comp_ranks(lesMoyennesTriees) # les rangs
|
||||
self.rangs[tag] = notes_table.comp_ranks(lesMoyennesTriees) # les rangs
|
||||
|
||||
# calcul des stats
|
||||
self.comp_stats_d_un_tag(tag)
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
|
@ -10,11 +10,6 @@
|
||||
"""
|
||||
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
|
||||
@ -27,7 +22,7 @@ def TrivialFormulator(
|
||||
form_url,
|
||||
values,
|
||||
formdescription=(),
|
||||
initvalues=None,
|
||||
initvalues={},
|
||||
method="post",
|
||||
enctype=None,
|
||||
submitlabel="OK",
|
||||
@ -37,7 +32,7 @@ def TrivialFormulator(
|
||||
cssclass="",
|
||||
cancelbutton=None,
|
||||
submitbutton=True,
|
||||
submitbuttonattributes=None,
|
||||
submitbuttonattributes=[],
|
||||
top_buttons=False, # place buttons at top of form
|
||||
bottom_buttons=True, # buttons after form
|
||||
html_foot_markup="",
|
||||
@ -104,7 +99,7 @@ def TrivialFormulator(
|
||||
form_url,
|
||||
values,
|
||||
formdescription,
|
||||
initvalues or {},
|
||||
initvalues,
|
||||
method,
|
||||
enctype,
|
||||
submitlabel,
|
||||
@ -114,7 +109,7 @@ def TrivialFormulator(
|
||||
cssclass=cssclass,
|
||||
cancelbutton=cancelbutton,
|
||||
submitbutton=submitbutton,
|
||||
submitbuttonattributes=submitbuttonattributes or [],
|
||||
submitbuttonattributes=submitbuttonattributes,
|
||||
top_buttons=top_buttons,
|
||||
bottom_buttons=bottom_buttons,
|
||||
html_foot_markup=html_foot_markup,
|
||||
@ -139,8 +134,8 @@ class TF(object):
|
||||
self,
|
||||
form_url,
|
||||
values,
|
||||
formdescription=None,
|
||||
initvalues=None,
|
||||
formdescription=[],
|
||||
initvalues={},
|
||||
method="POST",
|
||||
enctype=None,
|
||||
submitlabel="OK",
|
||||
@ -150,7 +145,7 @@ class TF(object):
|
||||
cssclass="",
|
||||
cancelbutton=None,
|
||||
submitbutton=True,
|
||||
submitbuttonattributes=None,
|
||||
submitbuttonattributes=[],
|
||||
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
|
||||
@ -162,8 +157,8 @@ class TF(object):
|
||||
):
|
||||
self.form_url = form_url
|
||||
self.values = values.copy()
|
||||
self.formdescription = list(formdescription or [])
|
||||
self.initvalues = initvalues or {}
|
||||
self.formdescription = list(formdescription)
|
||||
self.initvalues = initvalues
|
||||
self.method = method
|
||||
self.enctype = enctype
|
||||
self.submitlabel = submitlabel
|
||||
@ -176,7 +171,7 @@ class TF(object):
|
||||
self.cssclass = cssclass
|
||||
self.cancelbutton = cancelbutton
|
||||
self.submitbutton = submitbutton
|
||||
self.submitbuttonattributes = submitbuttonattributes or []
|
||||
self.submitbuttonattributes = submitbuttonattributes
|
||||
self.top_buttons = top_buttons
|
||||
self.bottom_buttons = bottom_buttons
|
||||
self.html_foot_markup = html_foot_markup
|
||||
@ -194,26 +189,11 @@ class TF(object):
|
||||
"true if form has been submitted"
|
||||
if self.is_submitted:
|
||||
return True
|
||||
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
|
||||
return self.values.get("%s_submitted" % self.formid, False)
|
||||
|
||||
def canceled(self):
|
||||
"true if form has been canceled"
|
||||
return self.values.get(f"{self.formid}_cancel", False)
|
||||
return self.values.get("%s_cancel" % self.formid, False)
|
||||
|
||||
def getform(self):
|
||||
"return HTML form"
|
||||
@ -390,23 +370,12 @@ 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":
|
||||
try:
|
||||
self.values[field] = int(self.values[field])
|
||||
except ValueError:
|
||||
msg.append(
|
||||
f"valeur invalide ({self.values[field]}) pour le champs {field}"
|
||||
)
|
||||
ok = False
|
||||
self.values[field] = int(self.values[field])
|
||||
elif typ == "float" or typ == "real":
|
||||
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
|
||||
self.values[field] = float(self.values[field].replace(",", "."))
|
||||
if ok:
|
||||
self.result = self.values
|
||||
else:
|
||||
@ -467,13 +436,7 @@ class TF(object):
|
||||
self.form_attrs,
|
||||
)
|
||||
)
|
||||
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">""")
|
||||
R.append('<input type="hidden" name="%s_submitted" value="1">' % self.formid)
|
||||
if self.top_buttons:
|
||||
R.append(buttons_markup + "<p></p>")
|
||||
R.append(self.before_table.format(title=self.title))
|
||||
@ -826,7 +789,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", ["non", "oui"])
|
||||
"labels", descr.get("allowed_values", ["oui", "non"])
|
||||
)
|
||||
_val = self.values[field]
|
||||
if isinstance(_val, bool):
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
|
@ -4,7 +4,7 @@
|
||||
#
|
||||
# Command: ./csv2rules.py misc/parcoursDUT.csv
|
||||
#
|
||||
from app.scodoc.codes_cursus import (
|
||||
from app.scodoc.sco_codes_parcours import (
|
||||
DUTRule,
|
||||
ADC,
|
||||
ADJ,
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -40,6 +40,7 @@ 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
|
||||
@ -59,7 +60,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, ScoDocJSONEncoder
|
||||
from app import log
|
||||
|
||||
|
||||
def mark_paras(L, tags) -> list[str]:
|
||||
@ -88,7 +89,7 @@ class DEFAULT_TABLE_PREFERENCES(object):
|
||||
return self.values[k]
|
||||
|
||||
|
||||
class GenTable:
|
||||
class GenTable(object):
|
||||
"""Simple 2D tables with export to HTML, PDF, Excel, CSV.
|
||||
Can be sub-classed to generate fancy formats.
|
||||
"""
|
||||
@ -197,9 +198,6 @@ class GenTable:
|
||||
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)
|
||||
|
||||
@ -649,7 +647,7 @@ class GenTable:
|
||||
# v = str(v)
|
||||
r[cid] = v
|
||||
d.append(r)
|
||||
return json.dumps(d, cls=ScoDocJSONEncoder)
|
||||
return json.dumps(d, cls=scu.ScoDocJSONEncoder)
|
||||
|
||||
def make_page(
|
||||
self,
|
||||
@ -760,31 +758,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__":
|
||||
table = GenTable(
|
||||
T = GenTable(
|
||||
rows=[{"nom": "Hélène", "age": 26}, {"nom": "Titi&çà§", "age": 21}],
|
||||
columns_ids=("nom", "age"),
|
||||
)
|
||||
print("--- HTML:")
|
||||
print(table.gen(format="html"))
|
||||
print(T.gen(format="html"))
|
||||
print("\n--- XML:")
|
||||
print(table.gen(format="xml"))
|
||||
print(T.gen(format="xml"))
|
||||
print("\n--- JSON:")
|
||||
print(table.gen(format="json"))
|
||||
print(T.gen(format="json"))
|
||||
# Test pdf:
|
||||
import io
|
||||
from reportlab.platypus import KeepInFrame
|
||||
from app.scodoc import sco_preferences, sco_pdf
|
||||
|
||||
preferences = sco_preferences.SemPreferences()
|
||||
table.preferences = preferences
|
||||
objects = table.gen(format="pdf")
|
||||
T.preferences = preferences
|
||||
objects = T.gen(format="pdf")
|
||||
objects = [KeepInFrame(0, 0, objects, mode="shrink")]
|
||||
doc = io.BytesIO()
|
||||
document = sco_pdf.BaseDocTemplate(doc)
|
||||
@ -797,6 +795,6 @@ if __name__ == "__main__":
|
||||
data = doc.getvalue()
|
||||
with open("/tmp/gen_table.pdf", "wb") as f:
|
||||
f.write(data)
|
||||
p = table.make_page(format="pdf")
|
||||
p = T.make_page(format="pdf")
|
||||
with open("toto.pdf", "wb") as f:
|
||||
f.write(p)
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -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) DEPRECATED
|
||||
head_message="", # message action (petit cadre jaune en haut)
|
||||
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: 16px;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
</style>
|
||||
"""
|
||||
@ -274,11 +274,21 @@ 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.j2"))
|
||||
H.append(render_template("flashed_messages.html"))
|
||||
#
|
||||
# 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>")
|
||||
|
@ -5,7 +5,7 @@
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2022 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
|
||||
@ -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.j2",
|
||||
"sidebar_dept.html",
|
||||
prefs=sco_preferences.SemPreferences(),
|
||||
)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user