1
0
forked from ScoDoc/ScoDoc

Compare commits

..

1 Commits

Author SHA1 Message Date
ab0466b1b5 Hotfix: inscriptions aux UE 2022-12-10 06:30:49 -03:00
639 changed files with 61966 additions and 49100 deletions

View File

@ -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}
"""
)

View File

@ -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,
)

View File

@ -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)

View File

@ -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})

View File

@ -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])

View File

@ -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)

View File

@ -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)

View File

@ -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&apos;hygi\u00e8ne informatique...",
"titre": "Se sensibiliser \u00e0 l&apos;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))

View File

@ -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)

View File

@ -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"

View File

@ -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)

View File

@ -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})

View File

@ -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})

View File

@ -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

View File

@ -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

View File

@ -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})

View File

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

View File

@ -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

View File

@ -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),
)

View File

@ -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})

View File

@ -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"))

View File

@ -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

View File

@ -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

View File

@ -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&nbsp;:</b>
<select class="select_parcour"
onchange="set_ue_parcour(this);"
data-ue_id="{ue.id}"
data-setter="{
url_for( "notes.set_ue_parcours", scodoc_dept=g.scodoc_dept)
}">
<option value="" {
'selected' if ue.parcour is None else ''
}>Tous</option>
{newline.join(parcours_options)}
</select>
</div>
<div>
<b>Niveau de compétence&nbsp;:</b>
<select class="select_niveau_ue"
onchange="set_ue_niveau_competence(this);"
data-ue_id="{ue.id}"
data-setter="{
url_for( "notes.set_ue_niveau_competence", scodoc_dept=g.scodoc_dept)
}">
</select>
</div>
</div>
</form>
</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>""")

View File

@ -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"],

View File

@ -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)

View File

@ -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

View File

@ -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 }
}
}
validation est un petit dict avec niveau_id, etc.
"""
d = {}
for competence in self.competences.values():
d[competence.id] = {}
for annee in ("BUT1", "BUT2", "BUT3"):
validation_rcue: ApcValidationRCUE = (
self.validation_par_competence_et_annee.get(competence.id, {}).get(
annee
)
)
d[competence.id][annee] = (
validation_rcue.to_dict_codes() if validation_rcue else None
)
return d
def competence_annee_has_niveau(self, competence_id: int, annee: str) -> bool:
"vrai si la compétence à un niveau dans cette annee ('BUT1') pour le parcour de cet etud"
# slow, utile pour affichage fiche
return annee in [n.annee for n in self.competences[competence_id].niveaux]
def load_validation_by_niveau(self) -> dict[int, list[ApcValidationRCUE]]:
"""Cherche les validations de jury enregistrées pour chaque niveau
Résultat: { niveau_id : [ ApcValidationRCUE ] }
meilleure validation pour ce niveau
"""
validations_by_niveau = collections.defaultdict(lambda: [])
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=self.etud):
validations_by_niveau[validation_rcue.niveau().id].append(validation_rcue)
validation_by_niveau = {
niveau_id: sorted(
validations, key=lambda v: sco_codes.BUT_CODES_ORDER[v.code]
)[0]
for niveau_id, validations in validations_by_niveau.items()
if validations
}
return validation_by_niveau
class FormSemestreCursusBUT:
"""L'état des étudiants d'un formsemestre dans leur cursus BUT
Permet d'obtenir pour chacun liste des niveaux validés/à valider
"""
def __init__(self, res: ResultatsSemestreBUT):
"""res indique le formsemestre de référence,
qui donne la liste des étudiants et le référentiel de compétence.
"""
self.res = res
self.formsemestre = res.formsemestre
if not res.formsemestre.formation.referentiel_competence:
raise ScoNoReferentielCompetences(formation=res.formsemestre.formation)
# Données cachées pour accélerer les accès:
self.referentiel_competences_id: int = (
self.res.formsemestre.formation.referentiel_competence_id
)
self.ue_ids: set[int] = set()
"set of ue_ids known to belong to our cursus"
self.parcours_by_id: dict[int, ApcParcours] = {}
"cache des parcours"
self.niveaux_by_parcour_by_annee: dict[int, dict[int, list[ApcNiveau]]] = {}
"cache { parcour_id : { annee : [ parcour] } }"
self.niveaux_by_id: dict[int, ApcNiveau] = {}
"cache niveaux"
def get_niveaux_parcours_etud(self, etud: Identite) -> dict[int, list[ApcNiveau]]:
"""Les niveaux compétences que doit valider cet étudiant.
Le parcour considéré est celui de l'inscription dans le semestre courant.
Si on est en début de cursus, on peut être en tronc commun sans avoir choisi
de parcours. Dans ce cas, on n'aura que les compétences de tronc commun.
Il faudra donc, avant de diplômer, s'assurer que les compétences du parcours
du dernier semestre (S6) sont validées (avec parcour non NULL).
"""
parcour_id = self.res.etuds_parcour_id.get(etud.id)
if parcour_id is None:
parcour = None
else:
if parcour_id not in self.parcours_by_id:
self.parcours_by_id[parcour_id] = db.session.get(
ApcParcours, parcour_id
)
parcour = self.parcours_by_id[parcour_id]
return self.get_niveaux_parcours_by_annee(parcour)
def get_niveaux_parcours_by_annee(
self, parcour: ApcParcours
) -> dict[int, list[ApcNiveau]]:
"""La liste des niveaux de compétences du parcours, par année BUT.
{ 1 : [ niveau, ... ] }
Si parcour est None, donne uniquement les niveaux tronc commun
(cas utile par exemple en 1ere année, mais surtout pas pour donner un diplôme!)
"""
parcour_id = None if parcour is None else parcour.id
if parcour_id in self.niveaux_by_parcour_by_annee:
return self.niveaux_by_parcour_by_annee[parcour_id]
ref_comp: ApcReferentielCompetences = (
self.res.formsemestre.formation.referentiel_competence
)
niveaux_by_annee = {}
for annee in (1, 2, 3):
niveaux_d = ref_comp.get_niveaux_by_parcours(
annee, [parcour] if parcour else None
)[1]
# groupe les niveaux de tronc commun et ceux spécifiques au parcour
niveaux_by_annee[annee] = niveaux_d["TC"] + (
niveaux_d[parcour.id] if parcour else []
)
self.niveaux_by_parcour_by_annee[parcour_id] = niveaux_by_annee
self.niveaux_by_id.update(
{niveau.id: niveau for niveau in niveaux_by_annee[annee]}
)
return niveaux_by_annee
def get_etud_validation_par_competence_et_annee(self, etud: Identite):
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
validation_par_competence_et_annee = {}
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# On s'assurer qu'elle concerne notre cursus !
ue = validation_rcue.ue2
if ue.id not in self.ue_ids:
if (
ue.formation.referentiel_competences_id
== self.referentiel_competences_id
):
self.ue_ids = ue.id
else:
continue # skip this validation
niveau = validation_rcue.niveau()
if not niveau.competence.id in validation_par_competence_et_annee:
validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = validation_par_competence_et_annee.get(
niveau.competence.id
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
] = validation_rcue
return validation_par_competence_et_annee
def list_etud_inscriptions(self, etud: Identite):
"Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
self.niveaux_by_annee = {}
"{ annee : liste des niveaux à valider }"
self.niveaux: dict[int, ApcNiveau] = {}
"cache les niveaux"
for annee in (1, 2, 3):
niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
annee, [self.parcour] if self.parcour else None # XXX WIP
)[1]
# groupe les niveaux de tronc commun et ceux spécifiques au parcour
self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
niveaux_d[self.parcour.id] if self.parcour else []
)
self.niveaux.update(
{niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
)
self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau()
if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get(
niveau.competence.id
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
] = validation_rcue
self.competences = {
competence.id: competence
for competence in (
self.parcour.query_competences()
if self.parcour
else self.formation.referentiel_competence.get_competences_tronc_commun()
)
}
"cache { competence_id : competence }"
def but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float:
"""Nombre d'ECTS validés par etud dans le BUT de référentiel indiqué.
Ne prend que les UE associées à des niveaux de compétences,
et ne les compte qu'une fois même en cas de redoublement avec re-validation.
"""
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.filter(ScolarFormSemestreValidation.ue_id != None)
.join(UniteEns)
.join(ApcNiveau)
.join(ApcCompetence)
.filter_by(referentiel_id=referentiel_competence_id)
)
ects_dict = {}
for v in validations:
key = (v.ue.semestre_idx, v.ue.niveau_competence.id)
if v.code in CODES_UE_VALIDES:
ects_dict[key] = v.ue.ects
return sum(ects_dict.values()) if ects_dict else 0.0
def etud_ues_de_but1_non_validees(
etud: Identite, formation: Formation, parcour: ApcParcours
) -> list[UniteEns]:
"""Liste des UEs de S1 et S2 non validées, dans son parcours"""
# Les UEs avec décisions, dans les S1 ou S2 d'une formation de même code:
validations = (
ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
.filter(ScolarFormSemestreValidation.ue_id != None)
.join(UniteEns)
.filter(db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2))
.join(Formation)
.filter_by(formation_code=formation.formation_code)
)
codes_validations_by_ue_code = collections.defaultdict(list)
for v in validations:
codes_validations_by_ue_code[v.ue.ue_code].append(v.code)
# Les UEs du parcours en S1 et S2:
ues = formation.query_ues_parcour(parcour).filter(
db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2)
)
# Liste triée des ues non validées
return sorted(
[
ue
for ue in ues
if not any(
(
code_ue_validant(code)
for code in codes_validations_by_ue_code[ue.ue_code]
)
)
],
key=attrgetter("numero", "acronyme"),
)
def formsemestre_warning_apc_setup(
formsemestre: FormSemestre, res: ResultatsSemestreBUT
) -> str:
"""Vérifie que la formation est OK pour un BUT:
- ref. compétence associé
- tous les niveaux des parcours du semestre associés à des UEs du formsemestre
- pas d'UE non associée à un niveau
Renvoie fragment de HTML.
"""
if not formsemestre.formation.is_apc():
return ""
if formsemestre.formation.referentiel_competence is None:
return f"""<div class="formsemestre_status_warning">
La <a class="stdlink" href="{
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
}">formation n'est pas associée à un référentiel de compétence.</a>
</div>
"""
# Vérifie les niveaux de chaque parcours
H = []
for parcour in formsemestre.parcours or [None]:
annee = (formsemestre.semestre_id + 1) // 2
niveaux_ids = {
niveau.id
for niveau in ApcNiveau.niveaux_annee_de_parcours(
parcour, annee, formsemestre.formation.referentiel_competence
)
}
ues_parcour = formsemestre.formation.query_ues_parcour(parcour).filter(
UniteEns.semestre_idx == formsemestre.semestre_id
)
ues_niveaux_ids = {
ue.niveau_competence.id for ue in ues_parcour if ue.niveau_competence
}
if niveaux_ids != ues_niveaux_ids:
H.append(
f"""Parcours {parcour.code if parcour else "Tronc commun"} :
{len(ues_niveaux_ids)} UE avec niveaux
mais {len(niveaux_ids)} niveaux à valider !
"""
)
if not H:
return ""
return f"""<div class="formsemestre_status_warning">
Problème dans la configuration de la formation:
<ul>
<li>{ '</li><li>'.join(H) }</li>
</ul>
<p class="help">Vérifiez les parcours cochés pour ce semestre,
et les associations entre UE et niveaux <a class="stdlink" href="{
url_for("notes.parcour_formation", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
}">dans la formation.</a>
</p>
</div>
"""
def ue_associee_au_niveau_du_parcours(
ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
) -> UniteEns:
"L'UE associée à ce niveau, ou None"
ues = [ue for ue in ues_possibles if ue.niveau_competence_id == niveau.id]
if len(ues) > 1:
# plusieurs UEs associées à ce niveau: élimine celles sans parcours
ues_pair_avec_parcours = [ue for ue in ues if ue.parcours]
if ues_pair_avec_parcours:
ues = ues_pair_avec_parcours
if len(ues) > 1:
log(f"_niveau_ues: {len(ues)} associées au niveau {niveau} / {sem_name}")
return ues[0] if ues else None
def parcour_formation_competences(parcour: ApcParcours, formation: Formation) -> list:
"""
[
{
'competence' : ApcCompetence,
'niveaux' : {
1 : { ... },
2 : { ... },
3 : {
'niveau' : ApcNiveau,
'ue_impair' : UniteEns, # actuellement associée
'ues_impair' : list[UniteEns], # choix possibles
'ue_pair' : UniteEns,
'ues_pair' : list[UniteEns],
}
}
}
]
"""
refcomp: ApcReferentielCompetences = formation.referentiel_competence
def _niveau_ues(competence: ApcCompetence, annee: int) -> dict:
"""niveau et ues pour cette compétence de cette année du parcours.
Si parcour est None, les niveaux du tronc commun
"""
if parcour is not None:
# L'étudiant est inscrit à un parcours: cherche les niveaux
niveaux = ApcNiveau.niveaux_annee_de_parcours(
parcour, annee, competence=competence
)
else:
# sans parcours, on cherche les niveaux du Tronc Commun de cette année
niveaux = [
niveau
for niveau in refcomp.get_niveaux_by_parcours(annee)[1]["TC"]
if niveau.competence_id == competence.id
]
if len(niveaux) > 0:
if len(niveaux) > 1:
log(
f"""_niveau_ues: plus d'un niveau pour {competence}
annee {annee} {("parcours " + parcour.code) if parcour else ""}"""
)
niveau = niveaux[0]
elif len(niveaux) == 0:
return {
"niveau": None,
"ue_pair": None,
"ue_impair": None,
"ues_pair": [],
"ues_impair": [],
}
# Toutes les UEs de la formation dans ce parcours ou tronc commun
ues = [
ue
for ue in formation.ues
if (
(not ue.parcours)
or (parcour is not None and (parcour.id in (p.id for p in ue.parcours)))
)
and ue.type == UE_STANDARD
]
ues_pair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee)]
ues_impair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee - 1)]
# UE associée au niveau dans ce parcours
ue_pair = ue_associee_au_niveau_du_parcours(
ues_pair_possibles, niveau, f"S{2*annee}"
)
ue_impair = ue_associee_au_niveau_du_parcours(
ues_impair_possibles, niveau, f"S{2*annee-1}"
)
return {
"niveau": niveau,
"ue_pair": ue_pair,
"ues_pair": [
ue
for ue in ues_pair_possibles
if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
],
"ue_impair": ue_impair,
"ues_impair": [
ue
for ue in ues_impair_possibles
if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
],
}
competences = [
{
"competence": competence,
"niveaux": {annee: _niveau_ues(competence, annee) for annee in (1, 2, 3)},
}
for competence in (
parcour.query_competences()
if parcour
else refcomp.competences.order_by(ApcCompetence.numero)
)
]
return competences

View File

@ -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
##############################################################################

View File

@ -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(

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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

View File

@ -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

View File

@ -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

View File

@ -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&nbsp;:</span>
{ ", ".join( ["S" + str(a.semestre_id or '') for a in autorisations_passage ] )}
</div>
"""
if autorisations_passage
else """<div class="but_autorisations_passage but_explanation">pas d'autorisations de passage enregistrées.</div>"""
)
H.append(div_autorisations_passage)
if read_only:
H.append(
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

View File

@ -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()}",
)

View File

@ -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
# }

View File

@ -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
}

View File

@ -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
##############################################################################

View File

@ -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 dajout 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 dassociation dès lors quune 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 quun é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.
Lorsquun é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 lactivité 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 dUE du semestre. Exemple : 16 en
sport ajoute 6*0,03 = 0,18 points à toutes les moyennes dUE du semestre.
</p>
<p>
Les bonifications sport et culture peuvent se cumuler dans la limite de 0,3
points ajoutés aux moyennes des UE. Exemple : 17 en sport et 16 en culture
conduisent au calcul (7 + 6)*0,03 = 0,39 qui dépasse 0,3. La bonification
dans ce cas ne sera que de 0,3 points ajoutés à toutes les moyennes dUE du
semestre.
</p>
<p>
Dans Scodoc on déclarera une UE Sport&Culture dans laquelle on aura un
module pour le Sport et un autre pour la Culture avec pour chaque module la
note sur 20 obtenue en sport ou en culture par létudiant.
</p>
"""
name = "bonus_lyon3"
displayed_name = "IUT de Lyon 3"
proportion_point = 0.03
bonus_max = 0.3
class BonusMantes(BonusSportAdditif):
"""Calcul bonus modules optionnels (investissement, ...), IUT de Mantes en Yvelines.
@ -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, lIUT dOrléans accorde, sous conditions,
une bonification aux étudiants inscrits qui en font la demande en début
d'année universitaire.
danné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 lIUT,
de lUniversité ou à lensemble 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
LIUT de Sceaux (Université de Paris-Saclay) propose aux étudiants un seul enseignement
non rattaché aux UE : loption Sport.
<p>
Cette option donne à létudiant qui la suit une bonification qui sapplique uniquement
si sa note est supérieure à 10.
</p>
<p>
Cette bonification sapplique sur lensemble des UE dun semestre de la façon suivante :
</p>
<p>
<tt>
[ (Note 10) / Nb UE du semestre ] / Total des coefficients de chaque UE
</tt>
</p>
<p>
Exemple : un étudiant qui a obtenu 16/20 à loption Sport en S1
(composé par exemple de 3 UE:UE1.1, UE1.2 et UE1.3)
aurait les bonifications suivantes :
</p>
<ul>
<li>UE1.1 (Total des coefficients : 15) Bonification UE1.1 = <tt>[ (16 10) / 3 ] /15
</tt>
</li>
<li>UE1.2 (Total des coefficients : 14) Bonification UE1.2 = <tt>[ (16 10) / 3 ] /14
</tt>
</li>
<li>UE1.3 (Total des coefficients : 12,5) Bonification UE1.3 = <tt>[ (16 10) / 3 ] /12,5
</tt>
</li>
</ul>
"""
name = "bonus_iut_sceaux"
displayed_name = "IUT de Sceaux"
proportion_point = 1.0
def __init__(
self,
formsemestre: "FormSemestre",
sem_modimpl_moys: np.array,
ues: list,
modimpl_inscr_df: pd.DataFrame,
modimpl_coefs: np.array,
etud_moy_gen,
etud_moy_ue,
):
# Pour ce bonus, il faut conserver:
# - le nombre d'UEs
self.nb_ues = len([ue for ue in ues if ue.type != UE_SPORT])
# - le total des coefs de chaque UE
# modimpl_coefs : DataFrame, lignes modimpl, col UEs (sans sport)
self.sum_coefs_ues = modimpl_coefs.sum() # Series, index ue_id
super().__init__(
formsemestre,
sem_modimpl_moys,
ues,
modimpl_inscr_df,
modimpl_coefs,
etud_moy_gen,
etud_moy_ue,
)
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""Calcul du bonus IUT de Sceaux 2023
sem_modimpl_moys_inscrits: les notes de sport
En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus)
En classic: ndarray (nb_etuds, nb_mod_sport)
Attention: si la somme des coefs de modules dans une UE est nulle, on a un bonus Inf
(moyenne d'UE cappée à 20).
"""
if (0 in sem_modimpl_moys_inscrits.shape) or (self.nb_ues == 0):
# pas d'étudiants ou pas d'UE ou pas de module...
return
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
if self.bonus_ues is not None:
self.bonus_ues = (self.bonus_ues / self.nb_ues) / self.sum_coefs_ues
class BonusStEtienne(BonusSportAdditif):
"""IUT de Saint-Etienne.
@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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,

View File

@ -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

View File

@ -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
##############################################################################

View File

@ -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="{

View File

@ -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,
)

View File

@ -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:

View File

@ -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
##############################################################################

View File

@ -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

View File

@ -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"
)

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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)

View File

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

View File

@ -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()

View File

@ -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()

View File

@ -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,

View File

@ -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})

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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 (

View File

@ -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 = {

View File

@ -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 {

View File

@ -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"]}&nbsp;{niveau["ordre"]}:&nbsp;{
dec_rcue["code"]}"""
f"""{niveau["competence"]["titre"]}&nbsp;{niveau["ordre"]}:&nbsp;{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:

View File

@ -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"""

View File

@ -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")

View File

@ -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
(e) le 7/06/1974
à Paris
Sinon:
M. Pierre Dupont
M. Pierre Dupont
n° 12345678
(e) le 7/06/1974
à Paris
"""
if with_paragraph:
return f"""{self.etat_civil}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
line_sep}à {self.lieu_naissance or ""}"""
return self.etat_civil
return f"""{self.nomprenom}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{line_sep}à {self.lieu_naissance or ""}"""
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):

View File

@ -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)

View File

@ -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>
"""
)

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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)
"""

View File

@ -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:

View File

@ -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,

View 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()

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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)
# ------------------------------------------------------------------------------------------------------------------

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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,

View File

@ -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)

View File

@ -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>")

View File

@ -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