diff --git a/README.md b/README.md
index 209a2a017..65c369b3e 100644
--- a/README.md
+++ b/README.md
@@ -20,10 +20,10 @@ Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes).
### État actuel (26 jan 22)
- - 9.1 (master) reproduit l'ensemble des fonctions de ScoDoc 7 (donc pas de BUT), sauf:
+ - 9.1.5x (master) reproduit l'ensemble des fonctions de ScoDoc 7 (donc pas de BUT), sauf:
- ancien module "Entreprises" (obsolète) et ajoute la gestion du BUT.
- - 9.2 (branche refactor_nt) est la version de développement.
+ - 9.2 (branche dev92) est la version de développement.
### Lignes de commandes
@@ -69,7 +69,12 @@ Puis remplacer `/opt/scodoc` par un clone du git.
cd /opt
git clone https://scodoc.org/git/viennet/ScoDoc.git
# (ou bien utiliser votre clone gitea si vous l'avez déjà créé !)
- mv ScoDoc scodoc # important !
+
+ # Renommer le répertoire:
+ mv ScoDoc scodoc
+
+ # Et donner ce répertoire à l'utilisateur scodoc:
+ chown -R scodoc.scodoc /opt/scodoc
Il faut ensuite installer l'environnement et le fichier de configuration:
diff --git a/app/__init__.py b/app/__init__.py
index 76f9471bb..a1862aaa7 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -10,10 +10,11 @@ import traceback
import logging
from logging.handlers import SMTPHandler, WatchedFileHandler
+from threading import Thread
from flask import current_app, g, request
from flask import Flask
-from flask import abort, has_request_context, jsonify
+from flask import abort, flash, has_request_context, jsonify
from flask import render_template
from flask.logging import default_handler
from flask_sqlalchemy import SQLAlchemy
@@ -27,6 +28,7 @@ import sqlalchemy
from app.scodoc.sco_exceptions import (
AccessDenied,
+ ScoBugCatcher,
ScoGenError,
ScoValueError,
APIInvalidParams,
@@ -43,11 +45,13 @@ mail = Mail()
bootstrap = Bootstrap()
moment = Moment()
-cache = Cache( # XXX TODO: configuration file
+CACHE_TYPE = os.environ.get("CACHE_TYPE")
+cache = Cache(
config={
# see https://flask-caching.readthedocs.io/en/latest/index.html#configuring-flask-caching
- "CACHE_TYPE": "RedisCache",
- "CACHE_DEFAULT_TIMEOUT": 0, # by default, never expire
+ "CACHE_TYPE": CACHE_TYPE or "RedisCache",
+ # by default, never expire:
+ "CACHE_DEFAULT_TIMEOUT": os.environ.get("CACHE_DEFAULT_TIMEOUT") or 0,
}
)
@@ -60,7 +64,7 @@ def handle_access_denied(exc):
return render_template("error_access_denied.html", exc=exc), 403
-def internal_server_error(e):
+def internal_server_error(exc):
"""Bugs scodoc, erreurs 500"""
# note that we set the 500 status explicitly
return (
@@ -68,11 +72,35 @@ def internal_server_error(e):
"error_500.html",
SCOVERSION=sco_version.SCOVERSION,
date=datetime.datetime.now().isoformat(),
+ exc=exc,
+ request_url=request.url,
),
500,
)
+def handle_sco_bug(exc):
+ """Un bug, en général rare, sur lequel les dev cherchent des
+ informations pour le corriger.
+ """
+ Thread(
+ target=_async_dump, args=(current_app._get_current_object(), request.url)
+ ).start()
+
+ return internal_server_error(exc)
+
+
+def _async_dump(app, request_url: str):
+ from app.scodoc.sco_dump_db import sco_dump_and_send_db
+
+ with app.app_context():
+ ndb.open_db_connection()
+ try:
+ sco_dump_and_send_db("ScoBugCatcher", request_url=request_url)
+ except ScoValueError:
+ pass
+
+
def handle_invalid_usage(error):
response = jsonify(error.to_dict())
response.status_code = error.status_code
@@ -187,10 +215,12 @@ def create_app(config_class=DevConfig):
moment.init_app(app)
cache.init_app(app)
sco_cache.CACHE = cache
+ if CACHE_TYPE: # non default
+ app.logger.info(f"CACHE_TYPE={CACHE_TYPE}")
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(AccessDenied, handle_access_denied)
app.register_error_handler(500, internal_server_error)
app.register_error_handler(503, postgresql_server_error)
@@ -201,7 +231,7 @@ def create_app(config_class=DevConfig):
app.register_blueprint(auth_bp, url_prefix="/auth")
from app.entreprises import bp as entreprises_bp
-
+
app.register_blueprint(entreprises_bp, url_prefix="/ScoDoc/entreprises")
from app.views import scodoc_bp
@@ -295,10 +325,12 @@ def create_app(config_class=DevConfig):
from app.scodoc.sco_bulletins_legacy import BulletinGeneratorLegacy
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
+ from app.but.bulletin_but_pdf import BulletinGeneratorStandardBUT
from app.scodoc.sco_bulletins_ucac import BulletinGeneratorUCAC
- # l'ordre est important, le premeir sera le "défaut" pour les nouveaux départements.
+ # l'ordre est important, le premier sera le "défaut" pour les nouveaux départements.
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorStandard)
+ sco_bulletins_generator.register_bulletin_class(BulletinGeneratorStandardBUT)
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorLegacy)
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorUCAC)
if app.testing or app.debug:
@@ -334,7 +366,7 @@ def user_db_init():
current_app.logger.info("Init User's db")
# Create roles:
- Role.insert_roles()
+ Role.reset_standard_roles_permissions()
current_app.logger.info("created initial roles")
# Ensure that admin exists
admin_mail = current_app.config.get("SCODOC_ADMIN_MAIL")
@@ -457,15 +489,12 @@ from app.models import Departement
from app.scodoc import notesdb as ndb, sco_preferences
from app.scodoc import sco_cache
-# admin_role = Role.query.filter_by(name="SuperAdmin").first()
-# if admin_role:
-# admin = (
-# User.query.join(UserRole)
-# .filter((UserRole.user_id == User.id) & (UserRole.role_id == admin_role.id))
-# .first()
-# )
-# else:
-# click.echo(
-# "Warning: user database not initialized !\n (use: flask user-db-init)"
-# )
-# admin = None
+
+def scodoc_flash_status_messages():
+ """Should be called on each page: flash messages indicating specific ScoDoc status"""
+ email_test_mode_address = sco_preferences.get_preference("email_test_mode_address")
+ if email_test_mode_address:
+ flash(
+ f"Mode test: mails redirigés vers {email_test_mode_address}",
+ category="warning",
+ )
diff --git a/app/auth/models.py b/app/auth/models.py
index 32768df03..cfab21a9c 100644
--- a/app/auth/models.py
+++ b/app/auth/models.py
@@ -76,7 +76,9 @@ class User(UserMixin, db.Model):
"Departement",
foreign_keys=[Departement.acronym],
primaryjoin=(dept == Departement.acronym),
- lazy="dynamic",
+ lazy="select",
+ passive_deletes="all",
+ uselist=False,
)
def __init__(self, **kwargs):
@@ -171,7 +173,7 @@ class User(UserMixin, db.Model):
"id": self.id,
"active": self.active,
"status_txt": "actif" if self.active else "fermé",
- "last_seen": self.last_seen.isoformat() + "Z",
+ "last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else "",
"nom": (self.nom or ""), # sco8
"prenom": (self.prenom or ""), # sco8
"roles_string": self.get_roles_string(), # eg "Ens_RT, Ens_Info"
@@ -236,7 +238,7 @@ class User(UserMixin, db.Model):
def get_dept_id(self) -> int:
"returns user's department id, or None"
if self.dept:
- return self._departement.first().id
+ return self._departement.id
return None
# Permissions management:
@@ -268,6 +270,8 @@ class User(UserMixin, db.Model):
"""Add a role to this user.
:param role: Role to add.
"""
+ if not isinstance(role, Role):
+ raise ScoValueError("add_role: rôle invalide")
self.user_roles.append(UserRole(user=self, role=role, dept=dept))
def add_roles(self, roles, dept):
@@ -279,7 +283,9 @@ class User(UserMixin, db.Model):
def set_roles(self, roles, dept):
"set roles in the given dept"
- self.user_roles = [UserRole(user=self, role=r, dept=dept) for r in roles]
+ self.user_roles = [
+ UserRole(user=self, role=r, dept=dept) for r in roles if isinstance(r, Role)
+ ]
def get_roles(self):
"iterator on my roles"
@@ -290,7 +296,11 @@ class User(UserMixin, db.Model):
"""string repr. of user's roles (with depts)
e.g. "Ens_RT, Ens_Info, Secr_CJ"
"""
- return ",".join(f"{r.role.name or ''}_{r.dept or ''}" for r in self.user_roles)
+ return ",".join(
+ f"{r.role.name or ''}_{r.dept or ''}"
+ for r in self.user_roles
+ if r is not None
+ )
def is_administrator(self):
"True if i'm an active SuperAdmin"
@@ -400,20 +410,30 @@ class Role(db.Model):
return self.permissions & perm == perm
@staticmethod
- def insert_roles():
- """Create default roles"""
+ def reset_standard_roles_permissions(reset_permissions=True):
+ """Create default roles if missing, then, if reset_permissions,
+ reset their permissions to default values.
+ """
default_role = "Observateur"
for role_name, permissions in SCO_ROLES_DEFAULTS.items():
role = Role.query.filter_by(name=role_name).first()
if role is None:
role = Role(name=role_name)
- role.reset_permissions()
- for perm in permissions:
- role.add_permission(perm)
- role.default = role.name == default_role
- db.session.add(role)
+ role.default = role.name == default_role
+ db.session.add(role)
+ if reset_permissions:
+ role.reset_permissions()
+ for perm in permissions:
+ role.add_permission(perm)
+ db.session.add(role)
+
db.session.commit()
+ @staticmethod
+ def ensure_standard_roles():
+ """Create default roles if missing"""
+ Role.reset_standard_roles_permissions(reset_permissions=False)
+
@staticmethod
def get_named_role(name):
"""Returns existing role with given name, or None."""
diff --git a/app/auth/routes.py b/app/auth/routes.py
index df3401515..24daa8ca0 100644
--- a/app/auth/routes.py
+++ b/app/auth/routes.py
@@ -19,7 +19,7 @@ from app.auth.forms import (
ResetPasswordForm,
DeactivateUserForm,
)
-from app.auth.models import Permission
+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
@@ -121,3 +121,11 @@ def reset_password(token):
flash(_("Votre mot de passe a été changé."))
return redirect(url_for("auth.login"))
return render_template("auth/reset_password.html", form=form, user=user)
+
+
+@bp.route("/reset_standard_roles_permissions", methods=["GET", "POST"])
+@admin_required
+def reset_standard_roles_permissions():
+ Role.reset_standard_roles_permissions()
+ flash("rôles standard réinitialisés !")
+ return redirect(url_for("scodoc.configuration"))
diff --git a/app/but/__init__.py b/app/but/__init__.py
new file mode 100644
index 000000000..f850a58c3
--- /dev/null
+++ b/app/but/__init__.py
@@ -0,0 +1 @@
+# empty but required for pylint
diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py
index 5b17c814a..689ea9ed9 100644
--- a/app/but/bulletin_but.py
+++ b/app/but/bulletin_but.py
@@ -7,16 +7,20 @@
"""Génération bulletin BUT
"""
+import collections
import datetime
+import numpy as np
from flask import url_for, g
-from app.models.formsemestre import FormSemestre
-from app.scodoc import sco_utils as scu
-from app.scodoc import sco_bulletins_json
-from app.scodoc import sco_preferences
-from app.scodoc.sco_codes_parcours import UE_SPORT
-from app.scodoc.sco_utils import fmt_note
from app.comp.res_but import ResultatsSemestreBUT
+from app.models import FormSemestre, Identite
+from app.models.ues import UniteEns
+from app.scodoc import sco_bulletins, sco_utils as scu
+from app.scodoc import sco_bulletins_json
+from app.scodoc import sco_bulletins_pdf
+from app.scodoc import sco_preferences
+from app.scodoc.sco_codes_parcours import UE_SPORT, DEF
+from app.scodoc.sco_utils import fmt_note
class BulletinBUT:
@@ -28,6 +32,7 @@ class BulletinBUT:
def __init__(self, formsemestre: FormSemestre):
""" """
self.res = ResultatsSemestreBUT(formsemestre)
+ self.prefs = sco_preferences.SemPreferences(formsemestre.id)
def etud_ue_mod_results(self, etud, ue, modimpls) -> dict:
"dict synthèse résultats dans l'UE pour les modules indiqués"
@@ -59,18 +64,15 @@ class BulletinBUT:
# }
return d
- def etud_ue_results(self, etud, ue):
+ def etud_ue_results(self, etud: Identite, ue: UniteEns, decision_ue: dict) -> dict:
"dict synthèse résultats UE"
res = self.res
+
d = {
"id": ue.id,
"titre": ue.titre,
"numero": ue.numero,
"type": ue.type,
- "ECTS": {
- "acquis": 0, # XXX TODO voir jury #sco92
- "total": ue.ects,
- },
"color": ue.color,
"competence": None, # XXX TODO lien avec référentiel
"moyenne": None,
@@ -78,13 +80,18 @@ class BulletinBUT:
"bonus": fmt_note(res.bonus_ues[ue.id][etud.id])
if res.bonus_ues is not None and ue.id in res.bonus_ues
else fmt_note(0.0),
- "malus": res.malus[ue.id][etud.id],
+ "malus": fmt_note(res.malus[ue.id][etud.id]),
"capitalise": None, # "AAAA-MM-JJ" TODO #sco92
"ressources": self.etud_ue_mod_results(etud, ue, res.ressources),
"saes": self.etud_ue_mod_results(etud, ue, res.saes),
}
+ if self.prefs["bul_show_ects"]:
+ d["ECTS"] = {
+ "acquis": decision_ue.get("ects", 0.0),
+ "total": ue.ects or 0.0, # float même si non renseigné
+ }
if ue.type != UE_SPORT:
- if sco_preferences.get_preference("bul_show_ue_rangs", res.formsemestre.id):
+ if self.prefs["bul_show_ue_rangs"]:
rangs, effectif = res.ue_rangs[ue.id]
rang = rangs[etud.id]
else:
@@ -109,9 +116,10 @@ class BulletinBUT:
d["modules"] = self.etud_mods_results(etud, modimpls_spo)
return d
- def etud_mods_results(self, etud, modimpls) -> dict:
+ def etud_mods_results(self, etud, modimpls, version="long") -> dict:
"""dict synthèse résultats des modules indiqués,
- avec évaluations de chacun."""
+ avec évaluations de chacun (sauf si version == "short")
+ """
res = self.res
d = {}
# etud_idx = self.etud_index[etud.id]
@@ -152,14 +160,14 @@ class BulletinBUT:
"evaluations": [
self.etud_eval_results(etud, e)
for e in modimpl.evaluations
- if e.visibulletin
+ if (e.visibulletin or version == "long")
and (
modimpl_results.evaluations_etat[e.id].is_complete
- or sco_preferences.get_preference(
- "bul_show_all_evals", res.formsemestre.id
- )
+ or self.prefs["bul_show_all_evals"]
)
- ],
+ ]
+ if version != "short"
+ else [],
}
return d
@@ -168,14 +176,23 @@ class BulletinBUT:
# eval_notes est une pd.Series avec toutes les notes des étudiants inscrits
eval_notes = self.res.modimpls_results[e.moduleimpl_id].evals_notes[e.id]
notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna()
+ modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id]
+ try:
+ poids = {
+ ue.acronyme: modimpls_evals_poids[ue.id][e.id]
+ for ue in self.res.ues
+ if ue.type != UE_SPORT
+ }
+ except KeyError:
+ poids = collections.defaultdict(lambda: 0.0)
d = {
"id": e.id,
"description": e.description,
"date": e.jour.isoformat() if e.jour else None,
"heure_debut": e.heure_debut.strftime("%H:%M") if e.heure_debut else None,
"heure_fin": e.heure_fin.strftime("%H:%M") if e.heure_debut else None,
- "coef": e.coefficient,
- "poids": {p.ue.acronyme: p.poids for p in e.ue_poids},
+ "coef": fmt_note(e.coefficient),
+ "poids": poids,
"note": {
"value": fmt_note(
eval_notes[etud.id],
@@ -205,7 +222,8 @@ class BulletinBUT:
details = [
f"{fmt_note(bonus_vect[ue.id])} sur {ue.acronyme}"
for ue in res.ues
- if res.modimpls_in_ue(ue.id, etudid)
+ if ue.type != UE_SPORT
+ and res.modimpls_in_ue(ue.id, etudid)
and ue.id in res.bonus_ues
and bonus_vect[ue.id] > 0.0
]
@@ -216,13 +234,23 @@ class BulletinBUT:
else:
return f"Bonus de {fmt_note(bonus_vect.iloc[0])}"
- def bulletin_etud(self, etud, formsemestre, force_publishing=False) -> dict:
- """Le bulletin de l'étudiant dans ce semestre.
- Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai
+ def bulletin_etud(
+ self,
+ etud: Identite,
+ formsemestre: FormSemestre,
+ force_publishing=False,
+ version="long",
+ ) -> dict:
+ """Le bulletin de l'étudiant dans ce semestre: dict pour la version JSON / HTML.
+ - version:
+ "long", "selectedevals": toutes les infos (notes des évaluations)
+ "short" : ne descend pas plus bas que les modules.
+
+ - Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai
(bulletins non publiés).
"""
res = self.res
- etat_inscription = etud.etat_inscription(formsemestre.id)
+ etat_inscription = etud.inscription_etat(formsemestre.id)
nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
published = (not formsemestre.bul_hide_xml) or force_publishing
d = {
@@ -239,7 +267,9 @@ class BulletinBUT:
},
"formsemestre_id": formsemestre.id,
"etat_inscription": etat_inscription,
- "options": sco_preferences.bulletin_option_affichage(formsemestre.id),
+ "options": sco_preferences.bulletin_option_affichage(
+ formsemestre.id, self.prefs
+ ),
}
if not published:
return d
@@ -253,39 +283,55 @@ class BulletinBUT:
"numero": formsemestre.semestre_id,
"inscription": "", # inutilisé mais nécessaire pour le js de Seb.
"groupes": [], # XXX TODO
- "absences": {
- "injustifie": nbabsjust,
- "total": nbabs,
- },
}
+ if self.prefs["bul_show_abs"]:
+ semestre_infos["absences"] = {
+ "injustifie": nbabs - nbabsjust,
+ "total": nbabs,
+ }
+ decisions_ues = self.res.get_etud_decision_ues(etud.id) or {}
+ if self.prefs["bul_show_ects"]:
+ ects_tot = sum([ue.ects or 0 for ue in res.ues]) if res.ues else 0.0
+ ects_acquis = sum([d.get("ects", 0) for d in decisions_ues.values()])
+ semestre_infos["ECTS"] = {"acquis": ects_acquis, "total": ects_tot}
semestre_infos.update(
sco_bulletins_json.dict_decision_jury(etud.id, formsemestre.id)
)
if etat_inscription == scu.INSCRIT:
- semestre_infos.update(
- {
- "notes": { # moyenne des moyennes générales du semestre
- "value": fmt_note(res.etud_moy_gen[etud.id]),
- "min": fmt_note(res.etud_moy_gen.min()),
- "moy": fmt_note(res.etud_moy_gen.mean()),
- "max": fmt_note(res.etud_moy_gen.max()),
- },
- "rang": { # classement wrt moyenne général, indicatif
- "value": res.etud_moy_gen_ranks[etud.id],
- "total": nb_inscrits,
- },
- },
- )
+ # moyenne des moyennes générales du semestre
+ semestre_infos["notes"] = {
+ "value": fmt_note(res.etud_moy_gen[etud.id]),
+ "min": fmt_note(res.etud_moy_gen.min()),
+ "moy": fmt_note(res.etud_moy_gen.mean()),
+ "max": fmt_note(res.etud_moy_gen.max()),
+ }
+ if self.prefs["bul_show_rangs"] and not np.isnan(res.etud_moy_gen[etud.id]):
+ # classement wrt moyenne général, indicatif
+ semestre_infos["rang"] = {
+ "value": res.etud_moy_gen_ranks[etud.id],
+ "total": nb_inscrits,
+ }
+ else:
+ semestre_infos["rang"] = {
+ "value": "-",
+ "total": nb_inscrits,
+ }
d.update(
{
- "ressources": self.etud_mods_results(etud, res.ressources),
- "saes": self.etud_mods_results(etud, res.saes),
+ "ressources": self.etud_mods_results(
+ etud, res.ressources, version=version
+ ),
+ "saes": self.etud_mods_results(etud, res.saes, version=version),
"ues": {
- ue.acronyme: self.etud_ue_results(etud, ue)
+ ue.acronyme: self.etud_ue_results(
+ etud, ue, decision_ue=decisions_ues.get(ue.id, {})
+ )
for ue in res.ues
- if self.res.modimpls_in_ue(
- ue.id, etud.id
- ) # si l'UE comporte des modules auxquels on est inscrit
+ # si l'UE comporte des modules auxquels on est inscrit:
+ if (
+ (ue.type == UE_SPORT)
+ or self.res.modimpls_in_ue(ue.id, etud.id)
+ )
},
"semestre": semestre_infos,
},
@@ -312,3 +358,56 @@ class BulletinBUT:
)
return d
+
+ def bulletin_etud_complet(self, etud: Identite, version="long") -> dict:
+ """Bulletin dict complet avec toutes les infos pour les bulletins BUT pdf
+ Résultat compatible avec celui de sco_bulletins.formsemestre_bulletinetud_dict
+ """
+ d = self.bulletin_etud(
+ etud, self.res.formsemestre, version=version, force_publishing=True
+ )
+ d["etudid"] = etud.id
+ d["etud"] = d["etudiant"]
+ d["etud"]["nomprenom"] = etud.nomprenom
+ d.update(self.res.sem)
+ etud_etat = self.res.get_etud_etat(etud.id)
+ d["filigranne"] = sco_bulletins_pdf.get_filigranne(
+ etud_etat,
+ self.prefs,
+ decision_sem=d["semestre"].get("decision"),
+ )
+ if etud_etat == scu.DEMISSION:
+ d["demission"] = "(Démission)"
+ elif etud_etat == DEF:
+ d["demission"] = "(Défaillant)"
+ else:
+ d["demission"] = ""
+
+ # --- Absences
+ d["nbabs"], d["nbabsjust"] = self.res.formsemestre.get_abs_count(etud.id)
+
+ # --- Decision Jury
+ infos, dpv = sco_bulletins.etud_descr_situation_semestre(
+ etud.id,
+ self.res.formsemestre.id,
+ format="html",
+ show_date_inscr=self.prefs["bul_show_date_inscr"],
+ show_decisions=self.prefs["bul_show_decision"],
+ show_uevalid=self.prefs["bul_show_uevalid"],
+ show_mention=self.prefs["bul_show_mention"],
+ )
+
+ d.update(infos)
+ # --- Rangs
+ d[
+ "rang_nt"
+ ] = f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
+ d["rang_txt"] = "Rang " + d["rang_nt"]
+
+ # --- Appréciations
+ d.update(
+ sco_bulletins.get_appreciations_list(self.res.formsemestre.id, etud.id)
+ )
+ d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"]))
+
+ return d
diff --git a/app/but/bulletin_but_pdf.py b/app/but/bulletin_but_pdf.py
new file mode 100644
index 000000000..36d11d1e4
--- /dev/null
+++ b/app/but/bulletin_but_pdf.py
@@ -0,0 +1,351 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Génération bulletin BUT au format PDF standard
+"""
+from reportlab.lib.colors import blue
+from reportlab.lib.units import cm, mm
+from reportlab.platypus import Paragraph, Spacer
+
+from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
+from app.scodoc import gen_tables
+from app.scodoc.sco_codes_parcours import UE_SPORT
+
+
+class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
+ """Génération du bulletin de BUT au format PDF.
+
+ self.infos est le dict issu de BulletinBUT.bulletin_etud_complet()
+ """
+
+ # spécialisation du BulletinGeneratorStandard, ne pas présenter à l'utilisateur:
+ list_in_menu = False
+ scale_table_in_page = False # pas de mise à l'échelle pleine page auto
+ multi_pages = True # plusieurs pages par bulletins
+ small_fontsize = "8"
+
+ def bul_table(self, format="html"):
+ """Génère la table centrale du bulletin de notes
+ Renvoie:
+ - en HTML: une chaine
+ - en PDF: une liste d'objets PLATYPUS (eg instance de Table).
+ """
+ tables_infos = [
+ # ---- TABLE SYNTHESE UES
+ self.but_table_synthese_ues(),
+ ]
+ if self.version != "short":
+ tables_infos += [
+ # ---- TABLE RESSOURCES
+ self.but_table_ressources(),
+ # ---- TABLE SAE
+ self.but_table_saes(),
+ ]
+ objects = []
+ for i, (col_keys, rows, pdf_style, col_widths) in enumerate(tables_infos):
+ table = gen_tables.GenTable(
+ rows=rows,
+ columns_ids=col_keys,
+ pdf_table_style=pdf_style,
+ pdf_col_widths=[col_widths[k] for k in col_keys],
+ preferences=self.preferences,
+ html_class="notes_bulletin",
+ html_class_ignore_default=True,
+ html_with_td_classes=True,
+ )
+ table_objects = table.gen(format=format)
+ objects += table_objects
+ # objects += [KeepInFrame(0, 0, table_objects, mode="shrink")]
+ if i != 2:
+ objects.append(Spacer(1, 6 * mm))
+
+ return objects
+
+ 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
+ - colkeys: nom des colonnes de la table (clés)
+ - P : table (liste de dicts de chaines de caracteres)
+ - pdf_style : commandes table Platypus
+ - largeurs de colonnes pour PDF
+ """
+ col_widths = {
+ "titre": None,
+ "moyenne": 2 * cm,
+ "coef": 2 * cm,
+ }
+ title_bg = tuple(x / 255.0 for x in title_bg)
+ nota_bene = """La moyenne des ressources et SAÉs dans une UE
+ dépend des poids donnés aux évaluations."""
+ # elems pour générer table avec gen_table (liste de dicts)
+ rows = [
+ # Ligne de titres
+ {
+ "titre": "Unités d'enseignement",
+ "moyenne": Paragraph("
Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'Université de St Quentin non rattachés à une unité d'enseignement. +
++ Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par + l'étudiant (en BUT, s'ajoute à la moyenne de chaque UE). +
+ """ + + name = "bonus_iutstq" + displayed_name = "IUT de Saint-Quentin" + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + if 0 in sem_modimpl_moys_inscrits.shape: + # pas d'étudiants ou pas d'UE ou pas de module... + return + # Calcule moyenne pondérée des notes de sport: + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + bonus_moy_arr = np.sum( + sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + np.nan_to_num(bonus_moy_arr, nan=0.0, copy=False) + + bonus_moy_arr[bonus_moy_arr < 10.0] = 0.0 + bonus_moy_arr[bonus_moy_arr >= 18.1] = 0.5 + bonus_moy_arr[bonus_moy_arr >= 16.1] = 0.4 + bonus_moy_arr[bonus_moy_arr >= 14.1] = 0.3 + bonus_moy_arr[bonus_moy_arr >= 12.1] = 0.2 + bonus_moy_arr[bonus_moy_arr >= 10] = 0.1 + + self.bonus_additif(bonus_moy_arr) + + +class BonusAmiens(BonusSportAdditif): + """Bonus IUT Amiens pour les modules optionnels (sport, culture, ...). + + Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,1 point + sur toutes les moyennes d'UE. + """ + + name = "bonus_amiens" + displayed_name = "IUT d'Amiens" + seuil_moy_gen = 0.0 # tous les points sont comptés + proportion_point = 1e10 + bonus_max = 0.1 + classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP + + +# Finalement ils n'en veulent pas. +# class BonusAnnecy(BonusSport): +# """Calcul bonus modules optionnels (sport), règle IUT d'Annecy. + +# Il peut y avoir plusieurs modules de bonus. +# Prend pour chaque étudiant la meilleure de ses notes bonus et +# ajoute à chaque UE :Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Bordeaux 1 (sport, théâtre) non rattachés à une unité d'enseignement. - +
Chaque point au-dessus de 10 sur 20 obtenus dans cet enseignement correspond à un %
- qui augmente la moyenne de chaque UE et la moyenne générale.
- Formule : le % = points>moyenne / 2
+ qui augmente la moyenne de chaque UE et la moyenne générale.
+ Formule : pourcentage = (points au dessus de 10) / 2
+
Par exemple : sport 13/20 : chaque UE sera multipliée par 1+0,015, ainsi que la moyenne générale. - +
""" name = "bonus_iutBordeaux1" - displayed_name = "IUT de Bordeaux 1" + displayed_name = "IUT de Bordeaux" classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP seuil_moy_gen = 10.0 amplitude = 0.005 +# Exactement le même que Bordeaux: +class BonusBrest(BonusSportMultiplicatif): + """Calcul bonus modules optionnels (sport, culture), règle IUT de Brest, + sur moyenne générale et UEs. ++ Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'Université (sport, théâtre) non rattachés à une unité d'enseignement. +
+ Chaque point au-dessus de 10 sur 20 obtenus dans cet enseignement correspond à un %
+ qui augmente la moyenne de chaque UE et la moyenne générale.
+ Formule : pourcentage = (points au dessus de 10) / 2
+
+ Par exemple : sport 13/20 : chaque UE sera multipliée par 1+0,015, ainsi que la moyenne générale. +
+ """ + + name = "bonus_iut_brest" + displayed_name = "IUT de Brest" + classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP + seuil_moy_gen = 10.0 + amplitude = 0.005 + + +class BonusCachan1(BonusSportAdditif): + """Calcul bonus optionnels (sport, culture), règle IUT de Cachan 1. + +À compter de sept. 2021: La note de sport est sur 20, et on calcule une bonification (en %) qui va s'appliquer à la moyenne de chaque UE du semestre en appliquant la formule : bonification (en %) = (note-10)*0,5. - - Bonification qui ne s'applique que si la note est >10. - - (Une note de 10 donne donc 0% de bonif ; note de 20 : 5% de bonif) - +
+ La bonification ne s'applique que si la note est supérieure à 10. +
+ (Une note de 10 donne donc 0% de bonif, et une note de 20 : 5% de bonif) +
Avant sept. 2021, la note de sport allait de 0 à 5 points (sur 20). Chaque point correspondait à 0.25% d'augmentation de la moyenne générale. Par exemple : note de sport 2/5 : la moyenne générale était augmentée de 0.5%. +
""" name = "bonus_iut1grenoble_2017" @@ -411,45 +660,72 @@ class BonusGrenobleIUT1(BonusSportMultiplicatif): class BonusLaRochelle(BonusSportAdditif): - """Calcul bonus modules optionels (sport, culture), règle IUT de La Rochelle. + """Calcul bonus modules optionnels (sport, culture), règle IUT de La Rochelle. - Si la note de sport est comprise entre 0 et 10 : pas d'ajout de point. - Si la note de sport est comprise entre 10 et 20 : ajout de 1% de cette - note sur la moyenne générale du semestre (ou sur les UE en BUT). ++ 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 + compétences existe ainsi que les activités sportives et culturelles + seront traités au niveau semestriel. +
+ Le maximum de bonification qu’un étudiant peut obtenir sur sa moyenne + est plafonné à 0.5 point. +
+ Lorsqu’un étudiant suit plus de deux matières qui donnent droit à + bonification, l’étudiant choisit les deux notes à retenir. +
+ Les points bonus ne sont acquis que pour une note supérieure à 10/20. +
+ La bonification est calculée de la manière suivante :
+
+ Pour chaque matière (max. 2) donnant lieu à bonification :
+
+ Bonification = (N-10) x 0,05,
+ N étant la note de l’activité sur 20.
+
Les points au-dessus de 10 sur 20 obtenus dans chacune des matières optionnelles sont cumulés. +
+Dans tous les cas, le bonus est dans la limite de 0,5 point.
""" name = "bonus_iutlemans" @@ -471,14 +747,15 @@ class BonusLeMans(BonusSportAdditif): # Bonus simple, mais avec changement de paramètres en 2010 ! class BonusLille(BonusSportAdditif): - """Calcul bonus modules optionels (sport, culture), règle IUT Villeneuve d'Ascq + """Calcul bonus modules optionnels (sport, culture), règle IUT Villeneuve d'Ascq - Les étudiants de l'IUT peuvent suivre des enseignements optionnels +Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Lille (sports, etc) non rattachés à une unité d'enseignement. - +
Les points au-dessus de 10 sur 20 obtenus dans chacune des matières optionnelles sont cumulés et 4% (2% avant août 2010) de ces points cumulés s'ajoutent à la moyenne générale du semestre déjà obtenue par l'étudiant. +
""" name = "bonus_lille" @@ -497,8 +774,25 @@ class BonusLille(BonusSportAdditif): ) +class BonusLimousin(BonusSportAdditif): + """Calcul bonus modules optionnels (sport, culture) à l'IUT du Limousin + + Les points au-dessus de 10 sur 20 obtenus dans chacune des matières optionnelles + sont cumulés. + + La moyenne de chacune des UE du semestre pair est augmentée de 5% du + cumul des points de bonus. + Le maximum de points bonus est de 0,5. + """ + + name = "bonus_limousin" + displayed_name = "IUT du Limousin" + proportion_point = 0.05 + bonus_max = 0.5 + + class BonusLyonProvisoire(BonusSportAdditif): - """Calcul bonus modules optionnels (sport, culture), règle IUT de Lyon (provisoire) + """Calcul bonus modules optionnels (sport, culture) à l'IUT de Lyon (provisoire) Les points au-dessus de 10 sur 20 obtenus dans chacune des matières optionnelles sont cumulés et 1,8% de ces points cumulés @@ -512,8 +806,36 @@ class BonusLyonProvisoire(BonusSportAdditif): bonus_max = 0.5 +class BonusMantes(BonusSportAdditif): + """Calcul bonus modules optionnels (investissement, ...), IUT de Mantes en Yvelines. + ++ Soit N la note attribuée, le bonus (ou malus) correspond à : + (N-10) x 0,05 + appliqué sur chaque UE du semestre sélectionné pour le BUT + ou appliqué sur la moyenne générale du semestre sélectionné pour le DUT. +
+Exemples :
+Nous avons différents types de bonification (sport, culture, engagement citoyen). - +
Nous ajoutons aux moyennes une bonification de 0,2 pour chaque item la bonification totale ne doit pas excéder les 0,5 point. Sur le bulletin nous ne mettons pas une note sur 20 mais directement les bonifications. - - Dans ScoDoc: on a déclarera une UE "sport&culture" dans laquelle on aura des modules - pour chaque activité (Sport, Associations, ...) - avec à chaque fois une note (ScoDoc l'affichera comme une note sur 20, mais en fait ce sera la - valeur de la bonification: entrer 0,1/20 signifiera un bonus de 0,1 point la moyenne générale) +
+ Dans ScoDoc: on a déclarera une UE "sport&culture" dans laquelle on aura + des modules pour chaque activité (Sport, Associations, ...) + avec à chaque fois une note (ScoDoc l'affichera comme une note sur 20, + mais en fait ce sera la valeur de la bonification: entrer 0,1/20 signifiera + un bonus de 0,1 point la moyenne générale). +
""" name = "bonus_nantes" @@ -550,6 +874,19 @@ class BonusNantes(BonusSportAdditif): bonus_max = 0.5 # plafonnement à 0.5 points +class BonusPoitiers(BonusSportAdditif): + """Calcul bonus optionnels (sport, culture), règle IUT de Poitiers. + + Les deux notes d'option supérieure à 10, bonifies les moyennes de chaque UE. + + bonus = (option1 - 10)*5% + (option2 - 10)*5% + """ + + name = "bonus_poitiers" + displayed_name = "IUT de Poitiers" + proportion_point = 0.05 + + class BonusRoanne(BonusSportAdditif): """IUT de Roanne. @@ -561,11 +898,46 @@ class BonusRoanne(BonusSportAdditif): displayed_name = "IUT de Roanne" seuil_moy_gen = 0.0 bonus_max = 0.6 # plafonnement à 0.6 points - apply_bonus_mg_to_ues = True # sur les UE, même en DUT et LP + classic_use_bonus_ues = True # sur les UE, même en DUT et LP + proportion_point = 1 + + +class BonusStBrieuc(BonusSportAdditif): + """IUT de Saint Brieuc + + Ne s'applique qu'aux semestres pairs (S2, S4, S6), et bonifie les moyennes d'UE: +Les notes des UE bonus (ramenées sur 20) sont sommées et 1/40 (2,5%) est ajouté aux moyennes: soit à la moyenne générale, soit pour le BUT à chaque moyenne d'UE. - - Attention: en GEII, facteur 1/40, ailleurs facteur 1. - +
+ Attention: en GEII, facteur 1/40, ailleurs facteur 1. +
Le bonus total est limité à 1 point. +
""" name = "bonus_tours" @@ -611,15 +1042,17 @@ class BonusTours(BonusDirect): class BonusVilleAvray(BonusSport): - """Bonus modules optionels (sport, culture), règle IUT Ville d'Avray. + """Bonus modules optionnels (sport, culture), règle IUT Ville d'Avray. Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Paris 10 (C2I) non rattachés à une unité d'enseignement. - Si la note est >= 10 et < 12, bonus de 0.1 point - Si la note est >= 12 et < 16, bonus de 0.2 point - Si la note est >= 16, bonus de 0.3 point - Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par - l'étudiant. +Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par + l'étudiant.
""" name = "bonus_iutva" @@ -627,25 +1060,25 @@ class BonusVilleAvray(BonusSport): def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): """calcul du bonus""" + if 0 in sem_modimpl_moys_inscrits.shape: + # pas d'étudiants ou pas d'UE ou pas de module... + return # Calcule moyenne pondérée des notes de sport: - bonus_moy_arr = np.sum( - sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 - ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) - bonus_moy_arr[bonus_moy_arr >= 10.0] = 0.1 - bonus_moy_arr[bonus_moy_arr >= 12.0] = 0.2 + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + bonus_moy_arr = np.sum( + sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + np.nan_to_num(bonus_moy_arr, nan=0.0, copy=False) + bonus_moy_arr[bonus_moy_arr < 10.0] = 0.0 bonus_moy_arr[bonus_moy_arr >= 16.0] = 0.3 + bonus_moy_arr[bonus_moy_arr >= 12.0] = 0.2 + bonus_moy_arr[bonus_moy_arr >= 10.0] = 0.1 - # Bonus moyenne générale, et 0 sur les UE - self.bonus_moy_gen = pd.Series(bonus_moy_arr, index=self.etuds_idx, dtype=float) - if self.bonus_max is not None: - # Seuil: bonus (sur moy. gen.) limité à bonus_max points - self.bonus_moy_gen = self.bonus_moy_gen.clip(upper=self.bonus_max) - - # Laisse bonus_ues à None, en APC le bonus moy. gen. sera réparti sur les UEs. + self.bonus_additif(bonus_moy_arr) class BonusIUTV(BonusSportAdditif): - """Calcul bonus modules optionels (sport, culture), règle IUT Villetaneuse + """Calcul bonus modules optionnels (sport, culture), règle IUT Villetaneuse Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Paris 13 (sports, musique, deuxième langue, @@ -657,7 +1090,7 @@ class BonusIUTV(BonusSportAdditif): name = "bonus_iutv" displayed_name = "IUT de Villetaneuse" - pass # oui, c'ets le bonus par défaut + pass # oui, c'est le bonus par défaut def get_bonus_class_dict(start=BonusSport, d=None): diff --git a/app/comp/inscr_mod.py b/app/comp/inscr_mod.py index 667eacbd0..56567f80d 100644 --- a/app/comp/inscr_mod.py +++ b/app/comp/inscr_mod.py @@ -3,11 +3,9 @@ """Matrices d'inscription aux modules d'un semestre """ -import numpy as np import pandas as pd from app import db -from app import models # # Le chargement des inscriptions est long: matrice nb_module x nb_etuds @@ -17,7 +15,7 @@ from app import models 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) - columns: moduleimpl_id (en chaîne) + columns: moduleimpl_id value: bool (0/1 inscrit ou pas) """ # méthode la moins lente: une requete par module, merge les dataframes diff --git a/app/comp/jury.py b/app/comp/jury.py index 6581a2fb0..d9be05b6d 100644 --- a/app/comp/jury.py +++ b/app/comp/jury.py @@ -16,7 +16,7 @@ from app.scodoc import sco_codes_parcours class ValidationsSemestre(ResultatsCache): - """ """ + """Les décisions de jury pour un semestre""" _cached_attrs = ( "decisions_jury", diff --git a/app/comp/moy_mat.py b/app/comp/moy_mat.py new file mode 100644 index 000000000..0a7522637 --- /dev/null +++ b/app/comp/moy_mat.py @@ -0,0 +1,50 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Calcul des moyennes de matières +""" + +# C'est un recalcul (optionnel) effectué _après_ le calcul standard. + +import numpy as np +import pandas as pd +from app.comp import moy_ue +from app.models.formsemestre import FormSemestre + +from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_utils import ModuleType + + +def compute_mat_moys_classic( + formsemestre: FormSemestre, + sem_matrix: np.array, + ues: list, + modimpl_inscr_df: pd.DataFrame, + modimpl_coefs: np.array, +) -> dict: + """Calcul des moyennes par matières. + Result: dict, { matiere_id : Series, index etudid } + """ + modimpls_std = [ + m + for m in formsemestre.modimpls_sorted + if (m.module.module_type == ModuleType.STANDARD) + and (m.module.ue.type != UE_SPORT) + ] + matiere_ids = {m.module.matiere.id for m in modimpls_std} + matiere_moy = {} # { matiere_id : moy pd.Series, index etudid } + for matiere_id in matiere_ids: + modimpl_mask = np.array( + [m.module.matiere.id == matiere_id for m in formsemestre.modimpls_sorted] + ) + etud_moy_mat = moy_ue.compute_mat_moys_classic( + sem_matrix=sem_matrix, + modimpl_inscr_df=modimpl_inscr_df, + modimpl_coefs=modimpl_coefs, + modimpl_mask=modimpl_mask, + ) + matiere_moy[matiere_id] = etud_moy_mat + return matiere_moy diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index f30f04912..eea357e8f 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -335,15 +335,17 @@ class ModuleImplResultsAPC(ModuleImplResults): notes_rat / (eval_rat.note_max / 20.0), np.nan, ) + # "Étend" le rattrapage sur les UE: la note de rattrapage est la même + # pour toutes les UE mais ne remplace que là où elle est supérieure + notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1) # prend le max - etuds_use_rattrapage = notes_rat > etuds_moy_module + etuds_use_rattrapage = notes_rat_ues > etuds_moy_module etuds_moy_module = np.where( - etuds_use_rattrapage[:, np.newaxis], - np.tile(notes_rat[:, np.newaxis], nb_ues), - etuds_moy_module, + etuds_use_rattrapage, notes_rat_ues, etuds_moy_module ) + # Serie indiquant que l'étudiant utilise une note de rattarage sur l'une des UE: self.etuds_use_rattrapage = pd.Series( - etuds_use_rattrapage, index=self.evals_notes.index + etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index ) self.etuds_moy_module = pd.DataFrame( etuds_moy_module, @@ -359,6 +361,10 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: Les valeurs manquantes (évaluations sans coef vers des UE) sont remplies: 1 si le coef de ce module dans l'UE est non nul, zéro sinon (sauf pour module bonus, defaut à 1) + + Si le module n'est pas une ressource ou une SAE, ne charge pas de poids + et renvoie toujours les poids par défaut. + Résultat: (evals_poids, liste de UEs du semestre sauf le sport) """ modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) @@ -367,13 +373,17 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: 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) - for ue_poids in EvaluationUEPoids.query.join( - EvaluationUEPoids.evaluation - ).filter_by(moduleimpl_id=moduleimpl_id): - try: - evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids - except KeyError as exc: - pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre... + if ( + modimpl.module.module_type == ModuleType.RESSOURCE + or modimpl.module.module_type == ModuleType.SAE + ): + for ue_poids in EvaluationUEPoids.query.join( + EvaluationUEPoids.evaluation + ).filter_by(moduleimpl_id=moduleimpl_id): + try: + evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids + except KeyError as exc: + pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre... # Initialise poids non enregistrés: default_poids = ( diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index ae167d4e7..61b5fd15c 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -30,8 +30,11 @@ import numpy as np import pandas as pd +from flask import flash, g, Markup, url_for +from app.models.formations import Formation -def compute_sem_moys_apc( + +def compute_sem_moys_apc_using_coefs( etud_moy_ue_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame ) -> pd.Series: """Calcule les moyennes générales indicatives de tous les étudiants @@ -48,14 +51,57 @@ def compute_sem_moys_apc( return moy_gen -def comp_ranks_series(notes: pd.Series) -> dict[int, str]: +def compute_sem_moys_apc_using_ects( + etud_moy_ue_df: pd.DataFrame, ects: list, formation_id=None, skip_empty_ues=False +) -> pd.Series: + """Calcule les moyennes générales indicatives de tous les étudiants + = moyenne des moyennes d'UE, pondérée par leurs ECTS. + + etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid + ects: liste de floats ou None, 1 par UE + + Si skip_empty_ues: ne compte pas les UE non notées. + Sinon (par défaut), une UE non notée compte comme zéro. + + Result: panda Series, index etudid, valeur float (moyenne générale) + """ + try: + if skip_empty_ues: + # annule les coefs des UE sans notes (NaN) + ects = np.where(etud_moy_ue_df.isna(), 0.0, np.array(ects, dtype=float)) + # ects est devenu nb_etuds x nb_ues + moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1) + else: + moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / sum(ects) + except TypeError: + if None in ects: + formation = Formation.query.get(formation_id) + flash( + Markup( + f"""Calcul moyenne générale impossible: ECTS des UE manquants !Coefficient de l'UE capitalisée %s impossible à déterminer - pour l'étudiant %s
-Il faut saisir le coefficient de cette UE avant de continuer
+ f"""Coefficient de l'UE capitalisée {ue.acronyme} + impossible à déterminer pour l'étudiant {etud.nom_disp()}
+Il faut saisir le coefficient de cette UE avant de continuer
- Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de poursuites d'études.
+ Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de
+ poursuites d'études.
- De nombreux aspects sont paramétrables:
-
+ De nombreux aspects sont paramétrables:
+
voir la documentation.
%s | ' % ( - cls, - modimpl.id, - modimpl.module.titre, - sco_users.user_info(modimpl.responsable_id)["nomcomplet"], - F[0][i], - ) - else: - cells += '%s | ' % (cls, F[0][i]) - if modejury: - cells += 'Décision | ' - ligne_titres = cells + "|
%s | ' % nsn[0] # rang + try: + order = int(nsn[0].split()[0]) + except: + order = 99999 + cells += ( + f'{nsn[0]} | ' # rang + ) cells += '%s | ' % el # nom etud (lien) if not hidebac: cells += '%s | ' % nsn[2] # bac @@ -763,7 +735,8 @@ def make_formsemestre_recapcomplet( cells += "" H.append(cells + "