Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into new_api

This commit is contained in:
Emmanuel Viennet 2022-04-04 08:29:49 +02:00
commit 40921efe28
584 changed files with 174526 additions and 24937 deletions

View File

@ -20,10 +20,10 @@ Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes).
### État actuel (26 jan 22) ### É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. - 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 ### Lignes de commandes
@ -69,7 +69,12 @@ Puis remplacer `/opt/scodoc` par un clone du git.
cd /opt cd /opt
git clone https://scodoc.org/git/viennet/ScoDoc.git git clone https://scodoc.org/git/viennet/ScoDoc.git
# (ou bien utiliser votre clone gitea si vous l'avez déjà créé !) # (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: Il faut ensuite installer l'environnement et le fichier de configuration:

View File

@ -10,10 +10,11 @@ import traceback
import logging import logging
from logging.handlers import SMTPHandler, WatchedFileHandler from logging.handlers import SMTPHandler, WatchedFileHandler
from threading import Thread
from flask import current_app, g, request from flask import current_app, g, request
from flask import Flask 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 import render_template
from flask.logging import default_handler from flask.logging import default_handler
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
@ -27,6 +28,7 @@ import sqlalchemy
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
AccessDenied, AccessDenied,
ScoBugCatcher,
ScoGenError, ScoGenError,
ScoValueError, ScoValueError,
APIInvalidParams, APIInvalidParams,
@ -43,11 +45,13 @@ mail = Mail()
bootstrap = Bootstrap() bootstrap = Bootstrap()
moment = Moment() moment = Moment()
cache = Cache( # XXX TODO: configuration file CACHE_TYPE = os.environ.get("CACHE_TYPE")
cache = Cache(
config={ config={
# see https://flask-caching.readthedocs.io/en/latest/index.html#configuring-flask-caching # see https://flask-caching.readthedocs.io/en/latest/index.html#configuring-flask-caching
"CACHE_TYPE": "RedisCache", "CACHE_TYPE": CACHE_TYPE or "RedisCache",
"CACHE_DEFAULT_TIMEOUT": 0, # by default, never expire # 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 return render_template("error_access_denied.html", exc=exc), 403
def internal_server_error(e): def internal_server_error(exc):
"""Bugs scodoc, erreurs 500""" """Bugs scodoc, erreurs 500"""
# note that we set the 500 status explicitly # note that we set the 500 status explicitly
return ( return (
@ -68,11 +72,35 @@ def internal_server_error(e):
"error_500.html", "error_500.html",
SCOVERSION=sco_version.SCOVERSION, SCOVERSION=sco_version.SCOVERSION,
date=datetime.datetime.now().isoformat(), date=datetime.datetime.now().isoformat(),
exc=exc,
request_url=request.url,
), ),
500, 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): def handle_invalid_usage(error):
response = jsonify(error.to_dict()) response = jsonify(error.to_dict())
response.status_code = error.status_code response.status_code = error.status_code
@ -187,10 +215,12 @@ def create_app(config_class=DevConfig):
moment.init_app(app) moment.init_app(app)
cache.init_app(app) cache.init_app(app)
sco_cache.CACHE = cache 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(ScoGenError, handle_sco_value_error)
app.register_error_handler(ScoValueError, 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(AccessDenied, handle_access_denied)
app.register_error_handler(500, internal_server_error) app.register_error_handler(500, internal_server_error)
app.register_error_handler(503, postgresql_server_error) app.register_error_handler(503, postgresql_server_error)
@ -295,10 +325,12 @@ def create_app(config_class=DevConfig):
from app.scodoc.sco_bulletins_legacy import BulletinGeneratorLegacy from app.scodoc.sco_bulletins_legacy import BulletinGeneratorLegacy
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.but.bulletin_but_pdf import BulletinGeneratorStandardBUT
from app.scodoc.sco_bulletins_ucac import BulletinGeneratorUCAC 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(BulletinGeneratorStandard)
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorStandardBUT)
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorLegacy) sco_bulletins_generator.register_bulletin_class(BulletinGeneratorLegacy)
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorUCAC) sco_bulletins_generator.register_bulletin_class(BulletinGeneratorUCAC)
if app.testing or app.debug: if app.testing or app.debug:
@ -334,7 +366,7 @@ def user_db_init():
current_app.logger.info("Init User's db") current_app.logger.info("Init User's db")
# Create roles: # Create roles:
Role.insert_roles() Role.reset_standard_roles_permissions()
current_app.logger.info("created initial roles") current_app.logger.info("created initial roles")
# Ensure that admin exists # Ensure that admin exists
admin_mail = current_app.config.get("SCODOC_ADMIN_MAIL") 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 notesdb as ndb, sco_preferences
from app.scodoc import sco_cache from app.scodoc import sco_cache
# admin_role = Role.query.filter_by(name="SuperAdmin").first()
# if admin_role: def scodoc_flash_status_messages():
# admin = ( """Should be called on each page: flash messages indicating specific ScoDoc status"""
# User.query.join(UserRole) email_test_mode_address = sco_preferences.get_preference("email_test_mode_address")
# .filter((UserRole.user_id == User.id) & (UserRole.role_id == admin_role.id)) if email_test_mode_address:
# .first() flash(
# ) f"Mode test: mails redirigés vers {email_test_mode_address}",
# else: category="warning",
# click.echo( )
# "Warning: user database not initialized !\n (use: flask user-db-init)"
# )
# admin = None

View File

@ -76,7 +76,9 @@ class User(UserMixin, db.Model):
"Departement", "Departement",
foreign_keys=[Departement.acronym], foreign_keys=[Departement.acronym],
primaryjoin=(dept == Departement.acronym), primaryjoin=(dept == Departement.acronym),
lazy="dynamic", lazy="select",
passive_deletes="all",
uselist=False,
) )
def __init__(self, **kwargs): def __init__(self, **kwargs):
@ -171,7 +173,7 @@ class User(UserMixin, db.Model):
"id": self.id, "id": self.id,
"active": self.active, "active": self.active,
"status_txt": "actif" if self.active else "fermé", "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 "nom": (self.nom or ""), # sco8
"prenom": (self.prenom or ""), # sco8 "prenom": (self.prenom or ""), # sco8
"roles_string": self.get_roles_string(), # eg "Ens_RT, Ens_Info" "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: def get_dept_id(self) -> int:
"returns user's department id, or None" "returns user's department id, or None"
if self.dept: if self.dept:
return self._departement.first().id return self._departement.id
return None return None
# Permissions management: # Permissions management:
@ -268,6 +270,8 @@ class User(UserMixin, db.Model):
"""Add a role to this user. """Add a role to this user.
:param role: Role to add. :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)) self.user_roles.append(UserRole(user=self, role=role, dept=dept))
def add_roles(self, roles, dept): def add_roles(self, roles, dept):
@ -279,7 +283,9 @@ class User(UserMixin, db.Model):
def set_roles(self, roles, dept): def set_roles(self, roles, dept):
"set roles in the given 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): def get_roles(self):
"iterator on my roles" "iterator on my roles"
@ -290,7 +296,11 @@ class User(UserMixin, db.Model):
"""string repr. of user's roles (with depts) """string repr. of user's roles (with depts)
e.g. "Ens_RT, Ens_Info, Secr_CJ" 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): def is_administrator(self):
"True if i'm an active SuperAdmin" "True if i'm an active SuperAdmin"
@ -400,20 +410,30 @@ class Role(db.Model):
return self.permissions & perm == perm return self.permissions & perm == perm
@staticmethod @staticmethod
def insert_roles(): def reset_standard_roles_permissions(reset_permissions=True):
"""Create default roles""" """Create default roles if missing, then, if reset_permissions,
reset their permissions to default values.
"""
default_role = "Observateur" default_role = "Observateur"
for role_name, permissions in SCO_ROLES_DEFAULTS.items(): for role_name, permissions in SCO_ROLES_DEFAULTS.items():
role = Role.query.filter_by(name=role_name).first() role = Role.query.filter_by(name=role_name).first()
if role is None: if role is None:
role = Role(name=role_name) role = Role(name=role_name)
role.default = role.name == default_role
db.session.add(role)
if reset_permissions:
role.reset_permissions() role.reset_permissions()
for perm in permissions: for perm in permissions:
role.add_permission(perm) role.add_permission(perm)
role.default = role.name == default_role
db.session.add(role) db.session.add(role)
db.session.commit() db.session.commit()
@staticmethod
def ensure_standard_roles():
"""Create default roles if missing"""
Role.reset_standard_roles_permissions(reset_permissions=False)
@staticmethod @staticmethod
def get_named_role(name): def get_named_role(name):
"""Returns existing role with given name, or None.""" """Returns existing role with given name, or None."""

View File

@ -19,7 +19,7 @@ from app.auth.forms import (
ResetPasswordForm, ResetPasswordForm,
DeactivateUserForm, DeactivateUserForm,
) )
from app.auth.models import Permission from app.auth.models import Role
from app.auth.models import User from app.auth.models import User
from app.auth.email import send_password_reset_email from app.auth.email import send_password_reset_email
from app.decorators import admin_required from app.decorators import admin_required
@ -121,3 +121,11 @@ def reset_password(token):
flash(_("Votre mot de passe a été changé.")) flash(_("Votre mot de passe a été changé."))
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
return render_template("auth/reset_password.html", form=form, user=user) 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"))

1
app/but/__init__.py Normal file
View File

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

View File

@ -7,16 +7,20 @@
"""Génération bulletin BUT """Génération bulletin BUT
""" """
import collections
import datetime import datetime
import numpy as np
from flask import url_for, g 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.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: class BulletinBUT:
@ -28,6 +32,7 @@ class BulletinBUT:
def __init__(self, formsemestre: FormSemestre): def __init__(self, formsemestre: FormSemestre):
""" """ """ """
self.res = ResultatsSemestreBUT(formsemestre) self.res = ResultatsSemestreBUT(formsemestre)
self.prefs = sco_preferences.SemPreferences(formsemestre.id)
def etud_ue_mod_results(self, etud, ue, modimpls) -> dict: def etud_ue_mod_results(self, etud, ue, modimpls) -> dict:
"dict synthèse résultats dans l'UE pour les modules indiqués" "dict synthèse résultats dans l'UE pour les modules indiqués"
@ -59,18 +64,15 @@ class BulletinBUT:
# } # }
return d 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" "dict synthèse résultats UE"
res = self.res res = self.res
d = { d = {
"id": ue.id, "id": ue.id,
"titre": ue.titre, "titre": ue.titre,
"numero": ue.numero, "numero": ue.numero,
"type": ue.type, "type": ue.type,
"ECTS": {
"acquis": 0, # XXX TODO voir jury #sco92
"total": ue.ects,
},
"color": ue.color, "color": ue.color,
"competence": None, # XXX TODO lien avec référentiel "competence": None, # XXX TODO lien avec référentiel
"moyenne": None, "moyenne": None,
@ -78,13 +80,18 @@ class BulletinBUT:
"bonus": fmt_note(res.bonus_ues[ue.id][etud.id]) "bonus": fmt_note(res.bonus_ues[ue.id][etud.id])
if res.bonus_ues is not None and ue.id in res.bonus_ues if res.bonus_ues is not None and ue.id in res.bonus_ues
else fmt_note(0.0), 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 "capitalise": None, # "AAAA-MM-JJ" TODO #sco92
"ressources": self.etud_ue_mod_results(etud, ue, res.ressources), "ressources": self.etud_ue_mod_results(etud, ue, res.ressources),
"saes": self.etud_ue_mod_results(etud, ue, res.saes), "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 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] rangs, effectif = res.ue_rangs[ue.id]
rang = rangs[etud.id] rang = rangs[etud.id]
else: else:
@ -109,9 +116,10 @@ class BulletinBUT:
d["modules"] = self.etud_mods_results(etud, modimpls_spo) d["modules"] = self.etud_mods_results(etud, modimpls_spo)
return d 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, """dict synthèse résultats des modules indiqués,
avec évaluations de chacun.""" avec évaluations de chacun (sauf si version == "short")
"""
res = self.res res = self.res
d = {} d = {}
# etud_idx = self.etud_index[etud.id] # etud_idx = self.etud_index[etud.id]
@ -152,14 +160,14 @@ class BulletinBUT:
"evaluations": [ "evaluations": [
self.etud_eval_results(etud, e) self.etud_eval_results(etud, e)
for e in modimpl.evaluations for e in modimpl.evaluations
if e.visibulletin if (e.visibulletin or version == "long")
and ( and (
modimpl_results.evaluations_etat[e.id].is_complete modimpl_results.evaluations_etat[e.id].is_complete
or sco_preferences.get_preference( or self.prefs["bul_show_all_evals"]
"bul_show_all_evals", res.formsemestre.id
) )
) ]
], if version != "short"
else [],
} }
return d return d
@ -168,14 +176,23 @@ class BulletinBUT:
# eval_notes est une pd.Series avec toutes les notes des étudiants inscrits # 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] eval_notes = self.res.modimpls_results[e.moduleimpl_id].evals_notes[e.id]
notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna() 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 = { d = {
"id": e.id, "id": e.id,
"description": e.description, "description": e.description,
"date": e.jour.isoformat() if e.jour else None, "date": e.jour.isoformat() if e.jour else None,
"heure_debut": e.heure_debut.strftime("%H:%M") if e.heure_debut 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, "heure_fin": e.heure_fin.strftime("%H:%M") if e.heure_debut else None,
"coef": e.coefficient, "coef": fmt_note(e.coefficient),
"poids": {p.ue.acronyme: p.poids for p in e.ue_poids}, "poids": poids,
"note": { "note": {
"value": fmt_note( "value": fmt_note(
eval_notes[etud.id], eval_notes[etud.id],
@ -205,7 +222,8 @@ class BulletinBUT:
details = [ details = [
f"{fmt_note(bonus_vect[ue.id])} sur {ue.acronyme}" f"{fmt_note(bonus_vect[ue.id])} sur {ue.acronyme}"
for ue in res.ues 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 ue.id in res.bonus_ues
and bonus_vect[ue.id] > 0.0 and bonus_vect[ue.id] > 0.0
] ]
@ -216,13 +234,23 @@ class BulletinBUT:
else: else:
return f"Bonus de {fmt_note(bonus_vect.iloc[0])}" return f"Bonus de {fmt_note(bonus_vect.iloc[0])}"
def bulletin_etud(self, etud, formsemestre, force_publishing=False) -> dict: def bulletin_etud(
"""Le bulletin de l'étudiant dans ce semestre. self,
Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai 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). (bulletins non publiés).
""" """
res = self.res 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] nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
published = (not formsemestre.bul_hide_xml) or force_publishing published = (not formsemestre.bul_hide_xml) or force_publishing
d = { d = {
@ -239,7 +267,9 @@ class BulletinBUT:
}, },
"formsemestre_id": formsemestre.id, "formsemestre_id": formsemestre.id,
"etat_inscription": etat_inscription, "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: if not published:
return d return d
@ -253,39 +283,55 @@ class BulletinBUT:
"numero": formsemestre.semestre_id, "numero": formsemestre.semestre_id,
"inscription": "", # inutilisé mais nécessaire pour le js de Seb. "inscription": "", # inutilisé mais nécessaire pour le js de Seb.
"groupes": [], # XXX TODO "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( semestre_infos.update(
sco_bulletins_json.dict_decision_jury(etud.id, formsemestre.id) sco_bulletins_json.dict_decision_jury(etud.id, formsemestre.id)
) )
if etat_inscription == scu.INSCRIT: if etat_inscription == scu.INSCRIT:
semestre_infos.update( # moyenne des moyennes générales du semestre
{ semestre_infos["notes"] = {
"notes": { # moyenne des moyennes générales du semestre
"value": fmt_note(res.etud_moy_gen[etud.id]), "value": fmt_note(res.etud_moy_gen[etud.id]),
"min": fmt_note(res.etud_moy_gen.min()), "min": fmt_note(res.etud_moy_gen.min()),
"moy": fmt_note(res.etud_moy_gen.mean()), "moy": fmt_note(res.etud_moy_gen.mean()),
"max": fmt_note(res.etud_moy_gen.max()), "max": fmt_note(res.etud_moy_gen.max()),
}, }
"rang": { # classement wrt moyenne général, indicatif 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], "value": res.etud_moy_gen_ranks[etud.id],
"total": nb_inscrits, "total": nb_inscrits,
}, }
}, else:
) semestre_infos["rang"] = {
"value": "-",
"total": nb_inscrits,
}
d.update( d.update(
{ {
"ressources": self.etud_mods_results(etud, res.ressources), "ressources": self.etud_mods_results(
"saes": self.etud_mods_results(etud, res.saes), etud, res.ressources, version=version
),
"saes": self.etud_mods_results(etud, res.saes, version=version),
"ues": { "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 for ue in res.ues
if self.res.modimpls_in_ue( # si l'UE comporte des modules auxquels on est inscrit:
ue.id, etud.id if (
) # si l'UE comporte des modules auxquels on est inscrit (ue.type == UE_SPORT)
or self.res.modimpls_in_ue(ue.id, etud.id)
)
}, },
"semestre": semestre_infos, "semestre": semestre_infos,
}, },
@ -312,3 +358,56 @@ class BulletinBUT:
) )
return d 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

351
app/but/bulletin_but_pdf.py Normal file
View File

@ -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("<para align=right><b>Note/20</b></para>"),
"coef": "Coef.",
"_coef_pdf": Paragraph("<para align=right><b><i>Coef.</i></b></para>"),
"_css_row_class": "note_bold",
"_pdf_row_markup": ["b"],
"_pdf_style": [
("BACKGROUND", (0, 0), (-1, 0), title_bg),
# ("BOTTOMPADDING", (0, 0), (-1, 0), 7),
],
},
{
"titre": nota_bene,
"_titre_pdf": Paragraph(
f"<para fontSize={self.small_fontsize}><i>{nota_bene}</i></para>"
),
"_titre_colspan": 3,
"_pdf_style": [
("BACKGROUND", (0, 0), (-1, 0), title_bg),
("BOTTOMPADDING", (0, 0), (-1, 0), 7),
(
"LINEBELOW",
(0, 0),
(-1, 0),
self.PDF_LINEWIDTH,
blue,
),
],
},
]
col_keys = ["titre", "coef", "moyenne"] # noms des colonnes à afficher
for ue_acronym, ue in self.infos["ues"].items():
self.ue_rows(rows, ue_acronym, ue, title_bg)
# Global pdf style commands:
pdf_style = [
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOX", (0, 0), (-1, -1), 0.4, blue), # ajoute cadre extérieur bleu:
]
return col_keys, rows, pdf_style, col_widths
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"
# 1er ligne titre UE
moy_ue = ue.get("moyenne")
t = {
"titre": f"{ue_acronym} - {ue['titre']}",
"moyenne": Paragraph(
f"""<para align=right><b>{moy_ue.get("value", "-")
if moy_ue is not None else "-"
}</b></para>"""
),
"_css_row_class": "note_bold",
"_pdf_row_markup": ["b"],
"_pdf_style": [
(
"LINEABOVE",
(0, 0),
(-1, 0),
self.PDF_LINEWIDTH,
self.PDF_LINECOLOR,
),
("BACKGROUND", (0, 0), (-1, 0), title_bg),
("BOTTOMPADDING", (0, 0), (-1, 0), 7),
],
}
rows.append(t)
if ue["type"] == UE_SPORT:
self.ue_sport_rows(rows, ue, title_bg)
else:
self.ue_std_rows(rows, ue, title_bg)
def ue_std_rows(self, rows: list, ue: dict, title_bg: tuple):
"Lignes décrivant une UE standard dans la table de synthèse"
# 2eme ligne titre UE (bonus/malus/ects)
if "ECTS" in ue:
ects_txt = f'ECTS: {ue["ECTS"]["acquis"]:.3g} / {ue["ECTS"]["total"]:.3g}'
else:
ects_txt = ""
t = {
"titre": f"""Bonus: {ue['bonus']} - Malus: {
ue["malus"]}""",
"coef": ects_txt,
"_coef_pdf": Paragraph(f"""<para align=left>{ects_txt}</para>"""),
"_coef_colspan": 2,
"_pdf_style": [
("BACKGROUND", (0, 0), (-1, 0), title_bg),
("LINEBELOW", (0, 0), (-1, 0), self.PDF_LINEWIDTH, self.PDF_LINECOLOR),
# cadre autour du bonus/malus, gris clair
("BOX", (0, 0), (0, 0), self.PDF_LINEWIDTH, (0.7, 0.7, 0.7)),
],
}
rows.append(t)
# Liste chaque ressource puis chaque SAE
for mod_type in ("ressources", "saes"):
for mod_code, mod in ue[mod_type].items():
t = {
"titre": f"{mod_code} {self.infos[mod_type][mod_code]['titre']}",
"moyenne": Paragraph(f'<para align=right>{mod["moyenne"]}</para>'),
"coef": mod["coef"],
"_coef_pdf": Paragraph(
f"<para align=right><i>{mod['coef']}</i></para>"
),
"_pdf_style": [
(
"LINEBELOW",
(0, 0),
(-1, 0),
self.PDF_LINEWIDTH,
(0.7, 0.7, 0.7), # gris clair
)
],
}
rows.append(t)
def ue_sport_rows(self, rows: list, ue: dict, title_bg: tuple):
"Lignes décrivant l'UE bonus dans la table de synthèse"
# UE BONUS
for mod_code, mod in ue["modules"].items():
rows.append(
{
"titre": f"{mod_code} {mod['titre']}",
}
)
self.evaluations_rows(rows, mod["evaluations"])
def but_table_ressources(self):
"""La table de synthèse; pour chaque ressources, note et liste d'évaluations
Renvoie: colkeys, P, pdf_style, colWidths
"""
return self.bul_table_modules(
mod_type="ressources", title="Ressources", title_bg=(248, 200, 68)
)
def but_table_saes(self):
"table des SAEs"
return self.bul_table_modules(
mod_type="saes",
title="Situations d'apprentissage et d'évaluation",
title_bg=(198, 255, 171),
)
def bul_table_modules(self, mod_type=None, title="", title_bg=(248, 200, 68)):
"""Table ressources ou SAEs
- 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
"""
# UE à utiliser pour les poids (# colonne/UE)
ue_infos = self.infos["ues"]
ue_acros = list(
[k for k in ue_infos if ue_infos[k]["type"] != UE_SPORT]
) # ['RT1.1', 'RT2.1', 'RT3.1']
# Colonnes à afficher:
col_keys = ["titre"] + ue_acros + ["coef", "moyenne"]
# Largeurs des colonnes:
col_widths = {
"titre": None,
# "poids": None,
"moyenne": 2 * cm,
"coef": 2 * cm,
}
for ue_acro in ue_acros:
col_widths[ue_acro] = 12 * mm # largeur col. poids
title_bg = tuple(x / 255.0 for x in title_bg)
# elems pour générer table avec gen_table (liste de dicts)
# Ligne de titres
t = {
"titre": title,
# "_titre_colspan": 1 + len(ue_acros),
"moyenne": "Note/20",
"coef": "Coef.",
"_coef_pdf": Paragraph("<para align=right><i>Coef.</i></para>"),
"_css_row_class": "note_bold",
"_pdf_row_markup": ["b"],
"_pdf_style": [
("BACKGROUND", (0, 0), (-1, 0), title_bg),
("BOTTOMPADDING", (0, 0), (-1, 0), 7),
(
"LINEBELOW",
(0, 0),
(-1, 0),
self.PDF_LINEWIDTH,
blue,
),
],
}
for ue_acro in ue_acros:
t[ue_acro] = Paragraph(
f"<para align=right fontSize={self.small_fontsize}><i>{ue_acro}</i></para>"
)
rows = [t]
for mod_code, mod in self.infos[mod_type].items():
# 1er ligne titre module
t = {
"titre": f"{mod_code} - {mod['titre']}",
"_titre_colspan": 2 + len(ue_acros),
"_css_row_class": "note_bold",
"_pdf_row_markup": ["b"],
"_pdf_style": [
(
"LINEABOVE",
(0, 0),
(-1, 0),
self.PDF_LINEWIDTH,
self.PDF_LINECOLOR,
),
("BACKGROUND", (0, 0), (-1, 0), title_bg),
("BOTTOMPADDING", (0, 0), (-1, 0), 7),
],
}
rows.append(t)
# Evaluations:
self.evaluations_rows(rows, mod["evaluations"], ue_acros)
# Global pdf style commands:
pdf_style = [
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOX", (0, 0), (-1, -1), 0.4, blue), # ajoute cadre extérieur bleu:
]
return col_keys, rows, pdf_style, col_widths
def evaluations_rows(self, rows, evaluations, ue_acros=()):
"lignes des évaluations"
for e in evaluations:
t = {
"titre": f"{e['description']}",
"moyenne": e["note"]["value"],
"_moyenne_pdf": Paragraph(
f"""<para align=right>{e["note"]["value"]}</para>"""
),
"coef": e["coef"],
"_coef_pdf": Paragraph(
f"<para align=right fontSize={self.small_fontsize}><i>{e['coef']}</i></para>"
),
"_pdf_style": [
(
"LINEBELOW",
(0, 0),
(-1, 0),
self.PDF_LINEWIDTH,
(0.7, 0.7, 0.7), # gris clair
)
],
}
col_idx = 1 # 1ere col. poids
for ue_acro in ue_acros:
t[ue_acro] = Paragraph(
f"""<para align=right fontSize={self.small_fontsize}><i>{
e["poids"].get(ue_acro, "") or ""}</i></para>"""
)
t["_pdf_style"].append(
(
"BOX",
(col_idx, 0),
(col_idx, 0),
self.PDF_LINEWIDTH,
(0.7, 0.7, 0.7), # gris clair
),
)
col_idx += 1
rows.append(t)

View File

@ -72,7 +72,7 @@ def bulletin_but_xml_compat(
etud: Identite = Identite.query.get_or_404(etudid) etud: Identite = Identite.query.get_or_404(etudid)
results = bulletin_but.ResultatsSemestreBUT(formsemestre) results = bulletin_but.ResultatsSemestreBUT(formsemestre)
nb_inscrits = results.get_inscriptions_counts()[scu.INSCRIT] nb_inscrits = results.get_inscriptions_counts()[scu.INSCRIT]
# etat_inscription = etud.etat_inscription(formsemestre.id) # etat_inscription = etud.inscription_etat(formsemestre.id)
etat_inscription = results.formsemestre.etuds_inscriptions[etudid].etat etat_inscription = results.formsemestre.etuds_inscriptions[etudid].etat
if (not formsemestre.bul_hide_xml) or force_publishing: if (not formsemestre.bul_hide_xml) or force_publishing:
published = 1 published = 1
@ -145,7 +145,7 @@ def bulletin_but_xml_compat(
doc.append(Element("note_max", value="20")) # notes toujours sur 20 doc.append(Element("note_max", value="20")) # notes toujours sur 20
doc.append(Element("bonus_sport_culture", value=str(bonus))) doc.append(Element("bonus_sport_culture", value=str(bonus)))
# Liste les UE / modules /evals # Liste les UE / modules /evals
for ue in results.ues: for ue in results.ues: # avec bonus
rang_ue = 0 # XXX TODO rang de l'étudiant dans cette UE rang_ue = 0 # XXX TODO rang de l'étudiant dans cette UE
nb_inscrits_ue = ( nb_inscrits_ue = (
nb_inscrits # approx: compliqué de définir le "nb d'inscrit à une UE" nb_inscrits # approx: compliqué de définir le "nb d'inscrit à une UE"
@ -161,14 +161,17 @@ def bulletin_but_xml_compat(
doc.append(x_ue) doc.append(x_ue)
if ue.type != sco_codes_parcours.UE_SPORT: if ue.type != sco_codes_parcours.UE_SPORT:
v = results.etud_moy_ue[ue.id][etud.id] 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()
else: else:
v = 0 # XXX TODO valeur bonus sport pour cet étudiant v = results.bonus or 0.0
vmin = vmax = 0.0
x_ue.append( x_ue.append(
Element( Element(
"note", "note",
value=scu.fmt_note(v), value=scu.fmt_note(v),
min=scu.fmt_note(results.etud_moy_ue[ue.id].min()), min=scu.fmt_note(vmin),
max=scu.fmt_note(results.etud_moy_ue[ue.id].max()), max=scu.fmt_note(vmax),
) )
) )
x_ue.append(Element("ects", value=str(ue.ects if ue.ects else 0))) x_ue.append(Element("ects", value=str(ue.ects if ue.ects else 0)))
@ -179,7 +182,10 @@ def bulletin_but_xml_compat(
# Liste ici uniquement les modules rattachés à cette UE # Liste ici uniquement les modules rattachés à cette UE
if modimpl.module.ue.id == ue.id: if modimpl.module.ue.id == ue.id:
# mod_moy = scu.fmt_note(results.etud_moy_ue[ue.id][etud.id]) # mod_moy = scu.fmt_note(results.etud_moy_ue[ue.id][etud.id])
try:
coef = results.modimpl_coefs_df[modimpl.id][ue.id] coef = results.modimpl_coefs_df[modimpl.id][ue.id]
except KeyError:
coef = 0.0
x_mod = Element( x_mod = Element(
"module", "module",
id=str(modimpl.id), id=str(modimpl.id),
@ -214,6 +220,7 @@ def bulletin_but_xml_compat(
note_max_origin=str(e.note_max), note_max_origin=str(e.note_max),
) )
x_mod.append(x_eval) x_mod.append(x_eval)
try:
x_eval.append( x_eval.append(
Element( Element(
"note", "note",
@ -225,6 +232,11 @@ def bulletin_but_xml_compat(
), ),
) )
) )
except KeyError:
x_eval.append(
Element("note", value="", note_max="")
)
# XXX TODO: Evaluations incomplètes ou futures: XXX # XXX TODO: Evaluations incomplètes ou futures: XXX
# XXX TODO UE capitalisee (listee seulement si meilleure que l'UE courante) # XXX TODO UE capitalisee (listee seulement si meilleure que l'UE courante)

View File

@ -19,10 +19,12 @@ class FormationRefCompForm(FlaskForm):
class RefCompLoadForm(FlaskForm): class RefCompLoadForm(FlaskForm):
referentiel_standard = SelectField(
"Choisir un référentiel de compétences officiel BUT"
)
upload = FileField( upload = FileField(
label="Sélectionner un fichier XML Orébut", label="Ou bien sélectionner un fichier XML au format Orébut",
validators=[ validators=[
FileRequired(),
FileAllowed( FileAllowed(
[ [
"xml", "xml",
@ -33,3 +35,13 @@ class RefCompLoadForm(FlaskForm):
) )
submit = SubmitField("Valider") submit = SubmitField("Valider")
cancel = SubmitField("Annuler") cancel = SubmitField("Annuler")
def validate(self):
if not super().validate():
return False
if (self.referentiel_standard.data == "0") == (not self.upload.data):
self.referentiel_standard.errors.append(
"Choisir soit un référentiel, soit un fichier xml"
)
return False
return True

View File

@ -6,6 +6,8 @@
from xml.etree import ElementTree from xml.etree import ElementTree
from typing import TextIO from typing import TextIO
import sqlalchemy
from app import db from app import db
from app.models.but_refcomp import ( from app.models.but_refcomp import (
@ -19,7 +21,7 @@ from app.models.but_refcomp import (
ApcAnneeParcours, ApcAnneeParcours,
ApcParcoursNiveauCompetence, ApcParcoursNiveauCompetence,
) )
from app.scodoc.sco_exceptions import ScoFormatError from app.scodoc.sco_exceptions import ScoFormatError, ScoValueError
def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None): def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
@ -27,6 +29,16 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
peut lever TypeError ou ScoFormatError peut lever TypeError ou ScoFormatError
Résultat: instance de ApcReferentielCompetences Résultat: instance de ApcReferentielCompetences
""" """
# Vérifie que le même fichier n'a pas déjà été chargé:
if ApcReferentielCompetences.query.filter_by(
scodoc_orig_filename=orig_filename, dept_id=dept_id
).count():
raise ScoValueError(
f"""Un référentiel a déjà été chargé d'un fichier de même nom.
({orig_filename})
Supprimez-le ou changer le nom du fichier."""
)
try: try:
root = ElementTree.XML(xml_data) root = ElementTree.XML(xml_data)
except ElementTree.ParseError as exc: except ElementTree.ParseError as exc:
@ -42,7 +54,16 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
if not competences: if not competences:
raise ScoFormatError("élément 'competences' manquant") raise ScoFormatError("élément 'competences' manquant")
for competence in competences.findall("competence"): for competence in competences.findall("competence"):
try:
c = ApcCompetence(**ApcCompetence.attr_from_xml(competence.attrib)) c = ApcCompetence(**ApcCompetence.attr_from_xml(competence.attrib))
db.session.flush()
except sqlalchemy.exc.IntegrityError:
# ne devrait plus se produire car pas d'unicité de l'id: donc inutile
db.session.rollback()
raise ScoValueError(
f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({competence.attrib["id"]})
"""
)
ref.competences.append(c) ref.competences.append(c)
# --- SITUATIONS # --- SITUATIONS
situations = competence.find("situations") situations = competence.find("situations")

1
app/comp/__init__.py Normal file
View File

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

View File

@ -20,6 +20,7 @@ from flask import g
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.scodoc.sco_codes_parcours import UE_SPORT 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 from app.scodoc.sco_utils import ModuleType
@ -52,7 +53,7 @@ class BonusSport:
etud_moy_gen et etud_moy_ue ne sont PAS modifiés (mais utilisés par certains bonus non additifs). etud_moy_gen et etud_moy_ue ne sont PAS modifiés (mais utilisés par certains bonus non additifs).
""" """
# En classique, active un bonus sur les UEs: (dans ce cas bonus_moy_gen reste None) # En classique, active un bonus sur les UEs: (dans ce cas bonus_moy_gen est ajusté pour le prendre en compte)
classic_use_bonus_ues = False classic_use_bonus_ues = False
# Attributs virtuels: # Attributs virtuels:
@ -87,7 +88,7 @@ class BonusSport:
for m in formsemestre.modimpls_sorted for m in formsemestre.modimpls_sorted
] ]
) )
if not len(modimpl_mask): if len(modimpl_mask) == 0:
modimpl_mask = np.s_[:] # il n'y a rien, on prend tout donc rien modimpl_mask = np.s_[:] # il n'y a rien, on prend tout donc rien
self.modimpls_spo = [ self.modimpls_spo = [
modimpl modimpl
@ -106,6 +107,8 @@ class BonusSport:
# sem_modimpl_moys_spo est (nb_etuds, nb_mod_sport) # sem_modimpl_moys_spo est (nb_etuds, nb_mod_sport)
# ou (nb_etuds, nb_mod_sport, nb_ues_non_bonus) # ou (nb_etuds, nb_mod_sport, nb_ues_non_bonus)
nb_etuds, nb_mod_sport = sem_modimpl_moys_spo.shape[:2] nb_etuds, nb_mod_sport = sem_modimpl_moys_spo.shape[:2]
if nb_etuds == 0 or nb_mod_sport == 0:
return # no bonus at all
# Enlève les NaN du numérateur: # Enlève les NaN du numérateur:
sem_modimpl_moys_no_nan = np.nan_to_num(sem_modimpl_moys_spo, nan=0.0) sem_modimpl_moys_no_nan = np.nan_to_num(sem_modimpl_moys_spo, nan=0.0)
@ -157,7 +160,8 @@ class BonusSport:
"""Calcul des bonus: méthode virtuelle à écraser. """Calcul des bonus: méthode virtuelle à écraser.
Arguments: Arguments:
- sem_modimpl_moys_inscrits: - sem_modimpl_moys_inscrits:
ndarray (nb_etuds, mod_sport) ou en APC (nb_etuds, mods_sport, nb_ue_non_bonus) ndarray (nb_etuds, mod_sport)
ou en APC (nb_etuds, mods_sport, nb_ue_non_bonus)
les notes aux modules sports auxquel l'étudiant est inscrit, 0 sinon. Pas de nans. les notes aux modules sports auxquel l'étudiant est inscrit, 0 sinon. Pas de nans.
- modimpl_coefs_etuds_no_nan: - modimpl_coefs_etuds_no_nan:
les coefficients: float ndarray les coefficients: float ndarray
@ -194,52 +198,66 @@ class BonusSportAdditif(BonusSport):
à la moyenne générale du semestre déjà obtenue par l'étudiant. à la moyenne générale du semestre déjà obtenue par l'étudiant.
""" """
seuil_moy_gen = 10.0 # seuls les points au dessus du seuil sont comptés seuil_moy_gen = 10.0 # seuls les bonus au dessus du seuil sont pris en compte
# 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 proportion_point = 0.05 # multiplie les points au dessus du seuil
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): def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus """calcul du bonus
sem_modimpl_moys_inscrits: les notes de sport sem_modimpl_moys_inscrits: les notes de sport
En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus) En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus)
modimpl_coefs_etuds_no_nan: En classic: ndarray (nb_etuds, nb_mod_sport)
modimpl_coefs_etuds_no_nan: même shape, les coefs.
""" """
if 0 in sem_modimpl_moys_inscrits.shape: if 0 in sem_modimpl_moys_inscrits.shape:
# pas d'étudiants ou pas d'UE ou pas de module... # pas d'étudiants ou pas d'UE ou pas de module...
return return
seuil_comptage = (
self.seuil_moy_gen if self.seuil_comptage is None else self.seuil_comptage
)
bonus_moy_arr = np.sum( bonus_moy_arr = np.sum(
np.where( np.where(
sem_modimpl_moys_inscrits > self.seuil_moy_gen, (sem_modimpl_moys_inscrits >= self.seuil_moy_gen)
(sem_modimpl_moys_inscrits - self.seuil_moy_gen) & (modimpl_coefs_etuds_no_nan > 0),
* self.proportion_point, (sem_modimpl_moys_inscrits - seuil_comptage) * self.proportion_point,
0.0, 0.0,
), ),
axis=1, axis=1,
) )
if self.bonus_max is not None: # Seuil: bonus dans [min, max] (défaut [0,20])
# Seuil: bonus limité à bonus_max points (et >= 0) bonus_max = self.bonus_max or 0.0
bonus_moy_arr = np.clip( np.clip(bonus_moy_arr, self.bonus_min, bonus_max, out=bonus_moy_arr)
bonus_moy_arr, 0.0, self.bonus_max, out=bonus_moy_arr
)
else: # necessaire pour éviter bonus négatifs !
bonus_moy_arr = np.clip(bonus_moy_arr, 0.0, 20.0, out=bonus_moy_arr)
self.bonus_additif(bonus_moy_arr)
def bonus_additif(self, bonus_moy_arr: np.array):
"Set bonus_ues et bonus_moy_gen"
# en APC, bonus_moy_arr est (nb_etuds, nb_ues_non_bonus) # en APC, bonus_moy_arr est (nb_etuds, nb_ues_non_bonus)
if self.formsemestre.formation.is_apc() or self.classic_use_bonus_ues: if self.formsemestre.formation.is_apc():
# Bonus sur les UE et None sur moyenne générale # Bonus sur les UE et None sur moyenne générale
ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)]
self.bonus_ues = pd.DataFrame( self.bonus_ues = pd.DataFrame(
bonus_moy_arr, index=self.etuds_idx, columns=ues_idx, dtype=float bonus_moy_arr, index=self.etuds_idx, columns=ues_idx, dtype=float
) )
elif self.classic_use_bonus_ues:
# Formations classiques apppliquant le bonus sur les UEs
# ici bonus_moy_arr = ndarray 1d nb_etuds
ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)]
self.bonus_ues = pd.DataFrame(
np.stack([bonus_moy_arr] * len(ues_idx)).T,
index=self.etuds_idx,
columns=ues_idx,
dtype=float,
)
else: else:
# Bonus sur la moyenne générale seulement # Bonus sur la moyenne générale seulement
self.bonus_moy_gen = pd.Series( self.bonus_moy_gen = pd.Series(
bonus_moy_arr, index=self.etuds_idx, dtype=float bonus_moy_arr, index=self.etuds_idx, dtype=float
) )
# if len(bonus_moy_arr.shape) > 1:
# bonus_moy_arr = bonus_moy_arr.sum(axis=1)
# Laisse bonus_moy_gen à None, en APC le bonus moy. gen. sera réparti sur les UEs.
class BonusSportMultiplicatif(BonusSport): class BonusSportMultiplicatif(BonusSport):
"""Bonus sport qui multiplie les moyennes d'UE par un facteur""" """Bonus sport qui multiplie les moyennes d'UE par un facteur"""
@ -284,6 +302,7 @@ class BonusSportMultiplicatif(BonusSport):
class BonusDirect(BonusSportAdditif): class BonusDirect(BonusSportAdditif):
"""Bonus direct: les points sont directement ajoutés à la moyenne générale. """Bonus direct: les points sont directement ajoutés à la moyenne générale.
Les coefficients sont ignorés: tous les points de bonus sont sommés. Les coefficients sont ignorés: tous les points de bonus sont sommés.
(rappel: la note est ramenée sur 20 avant application). (rappel: la note est ramenée sur 20 avant application).
""" """
@ -294,8 +313,112 @@ class BonusDirect(BonusSportAdditif):
proportion_point = 1.0 proportion_point = 1.0
class BonusAisneStQuentin(BonusSportAdditif):
"""Calcul bonus modules optionels (sport, culture), règle IUT Aisne St Quentin
<p>Les étudiants de l'IUT peuvent suivre des enseignements optionnels
de l'Université de St Quentin non rattachés à une unité d'enseignement.
</p>
<ul>
<li>Si la note est >= 10 et < 12.1, bonus de 0.1 point</li>
<li>Si la note est >= 12.1 et < 14.1, bonus de 0.2 point</li>
<li>Si la note est >= 14.1 et < 16.1, bonus de 0.3 point</li>
<li>Si la note est >= 16.1 et < 18.1, bonus de 0.4 point</li>
<li>Si la note est >= 18.1, bonus de 0.5 point</li>
</ul>
<p>
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).
</p>
"""
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 :<br>
# 0.05 point si >=10,<br>
# 0.1 point si >=12,<br>
# 0.15 point si >=14,<br>
# 0.2 point si >=16,<br>
# 0.25 point si >=18.
# """
# name = "bonus_iut_annecy"
# displayed_name = "IUT d'Annecy"
# def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
# """calcul du bonus"""
# # if math.prod(sem_modimpl_moys_inscrits.shape) == 0:
# # return # no etuds or no mod sport
# # Prend la note de chaque modimpl, sans considération d'UE
# if len(sem_modimpl_moys_inscrits.shape) > 2: # apc
# 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
# bonus = np.zeros(note_bonus_max.shape)
# bonus[note_bonus_max >= 10.0] = 0.05
# bonus[note_bonus_max >= 12.0] = 0.10
# bonus[note_bonus_max >= 14.0] = 0.15
# bonus[note_bonus_max >= 16.0] = 0.20
# bonus[note_bonus_max >= 18.0] = 0.25
# # Bonus moyenne générale et sur les UE
# self.bonus_moy_gen = pd.Series(bonus, index=self.etuds_idx, dtype=float)
# ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)]
# nb_ues_no_bonus = len(ues_idx)
# self.bonus_ues = pd.DataFrame(
# np.stack([bonus] * nb_ues_no_bonus, axis=1),
# columns=ues_idx,
# index=self.etuds_idx,
# dtype=float,
# )
class BonusBethune(BonusSportMultiplicatif): class BonusBethune(BonusSportMultiplicatif):
"""Calcul bonus modules optionels (sport), règle IUT de Béthune. """Calcul bonus modules optionnels (sport), règle IUT de Béthune.
Les points au dessus de la moyenne de 10 apportent un bonus pour le semestre. Les points au dessus de la moyenne de 10 apportent un bonus pour le semestre.
Ce bonus est égal au nombre de points divisé par 200 et multiplié par la Ce bonus est égal au nombre de points divisé par 200 et multiplié par la
@ -309,7 +432,7 @@ class BonusBethune(BonusSportMultiplicatif):
class BonusBezier(BonusSportAdditif): class BonusBezier(BonusSportAdditif):
"""Calcul bonus modules optionels (sport, culture), règle IUT de Bézier. """Calcul bonus modules optionnels (sport, culture), règle IUT de Bézier.
Les étudiants de l'IUT peuvent suivre des enseignements optionnels Les étudiants de l'IUT peuvent suivre des enseignements optionnels
sport , etc) non rattachés à une unité d'enseignement. Les points sport , etc) non rattachés à une unité d'enseignement. Les points
@ -330,28 +453,152 @@ class BonusBezier(BonusSportAdditif):
class BonusBordeaux1(BonusSportMultiplicatif): class BonusBordeaux1(BonusSportMultiplicatif):
"""Calcul bonus modules optionels (sport, culture), règle IUT Bordeaux 1, sur moyenne générale """Calcul bonus modules optionnels (sport, culture), règle IUT Bordeaux 1,
et UE. sur moyenne générale et UEs.
<p>
Les étudiants de l'IUT peuvent suivre des enseignements optionnels 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. de l'Université Bordeaux 1 (sport, théâtre) non rattachés à une unité d'enseignement.
</p><p>
Chaque point au-dessus de 10 sur 20 obtenus dans cet enseignement correspond à un % 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. qui augmente la moyenne de chaque UE et la moyenne générale.<br>
Formule : le % = points>moyenne / 2 Formule : pourcentage = (points au dessus de 10) / 2
</p><p>
Par exemple : sport 13/20 : chaque UE sera multipliée par 1+0,015, ainsi que la moyenne générale. Par exemple : sport 13/20 : chaque UE sera multipliée par 1+0,015, ainsi que la moyenne générale.
</p>
""" """
name = "bonus_iutBordeaux1" 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 classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 10.0 seuil_moy_gen = 10.0
amplitude = 0.005 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.
<p>
Les étudiants de l'IUT peuvent suivre des enseignements optionnels
de l'Université (sport, théâtre) non rattachés à une unité d'enseignement.
</p><p>
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.<br>
Formule : pourcentage = (points au dessus de 10) / 2
</p><p>
Par exemple : sport 13/20 : chaque UE sera multipliée par 1+0,015, ainsi que la moyenne générale.
</p>
"""
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.
<ul>
<li> DUT/LP : la meilleure note d'option, si elle est supérieure à 10,
bonifie les moyennes d'UE (<b>sauf l'UE41 dont le code est UE41_E</b>) à raison
de <em>bonus = (option - 10)/10</em>.
</li>
<li> BUT : la meilleure note d'option, si elle est supérieure à 10, bonifie
les moyennes d'UE à raison de <em>bonus = (option - 10) * 3%</em>.</li>
</ul>
"""
name = "bonus_cachan1"
displayed_name = "IUT de Cachan 1"
seuil_moy_gen = 10.0 # tous les points sont comptés
proportion_point = 0.03
classic_use_bonus_ues = True
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus, avec réglage différent suivant le type de formation"""
# Prend la note de chaque modimpl, sans considération d'UE
if len(sem_modimpl_moys_inscrits.shape) > 2: # apc
sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0]
# ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic
note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds
ues = self.formsemestre.query_ues(with_sport=False).all()
ues_idx = [ue.id for ue in ues]
if self.formsemestre.formation.is_apc(): # --- BUT
bonus_moy_arr = np.where(
note_bonus_max > self.seuil_moy_gen,
(note_bonus_max - self.seuil_moy_gen) * self.proportion_point,
0.0,
)
self.bonus_ues = pd.DataFrame(
np.stack([bonus_moy_arr] * len(ues)).T,
index=self.etuds_idx,
columns=ues_idx,
dtype=float,
)
else: # --- DUT
# pareil mais proportion différente et exclusion d'une UE
proportion_point = 0.1
bonus_moy_arr = np.where(
note_bonus_max > self.seuil_moy_gen,
(note_bonus_max - self.seuil_moy_gen) * proportion_point,
0.0,
)
self.bonus_ues = pd.DataFrame(
np.stack([bonus_moy_arr] * len(ues)).T,
index=self.etuds_idx,
columns=ues_idx,
dtype=float,
)
# Pas de bonus sur la ou les ue de code "UE41_E"
ue_exclues = [ue for ue in ues if ue.ue_code == "UE41_E"]
for ue in ue_exclues:
self.bonus_ues[ue.id] = 0.0
class BonusCalais(BonusSportAdditif):
"""Calcul bonus modules optionnels (sport, culture), règle IUT LCO.
Les étudiants de l'IUT LCO peuvent suivre des enseignements optionnels 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
dans la limite de 10 points. 6% de ces points cumulés s'ajoutent :
<ul>
<li><b>en DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant.
<li><b>en BUT et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b> (ex : UE2.1BS, UE32BS)
</ul>
"""
name = "bonus_calais"
displayed_name = "IUT du Littoral"
bonus_max = 0.6
seuil_moy_gen = 10.0 # au dessus de 10
proportion_point = 0.06 # 6%
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
parcours = self.formsemestre.formation.get_parcours()
# Variantes de DUT ?
if (
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.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
for ue in ues_sans_bs:
self.bonus_ues[ue.id] = 0.0
class BonusColmar(BonusSportAdditif): class BonusColmar(BonusSportAdditif):
"""Calcul bonus modules optionels (sport, culture), règle IUT Colmar. """Calcul bonus modules optionnels (sport, culture), règle IUT Colmar.
Les étudiants de l'IUT peuvent suivre des enseignements optionnels Les étudiants de l'IUT peuvent suivre des enseignements optionnels
de l'U.H.A. (sports, musique, deuxième langue, culture, etc) non de l'U.H.A. (sports, musique, deuxième langue, culture, etc) non
@ -374,19 +621,21 @@ class BonusColmar(BonusSportAdditif):
class BonusGrenobleIUT1(BonusSportMultiplicatif): class BonusGrenobleIUT1(BonusSportMultiplicatif):
"""Bonus IUT1 de Grenoble """Bonus IUT1 de Grenoble
<p>
À compter de sept. 2021: À compter de sept. 2021:
La note de sport est sur 20, et on calcule une bonification (en %) 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 qui va s'appliquer à la moyenne de chaque UE du semestre en appliquant
la formule : bonification (en %) = (note-10)*0,5. la formule : bonification (en %) = (note-10)*0,5.
</p><p>
Bonification qui ne s'applique que si la note est >10. <em>La bonification ne s'applique que si la note est supérieure à 10.</em>
</p><p>
(Une note de 10 donne donc 0% de bonif ; note de 20 : 5% de bonif) (Une note de 10 donne donc 0% de bonif, et une note de 20 : 5% de bonif)
</p><p>
Avant sept. 2021, la note de sport allait de 0 à 5 points (sur 20). Avant sept. 2021, la note de sport allait de 0 à 5 points (sur 20).
Chaque point correspondait à 0.25% d'augmentation de la moyenne Chaque point correspondait à 0.25% d'augmentation de la moyenne
générale. générale.
Par exemple : note de sport 2/5 : la moyenne générale était augmentée de 0.5%. Par exemple : note de sport 2/5 : la moyenne générale était augmentée de 0.5%.
</p>
""" """
name = "bonus_iut1grenoble_2017" name = "bonus_iut1grenoble_2017"
@ -411,45 +660,72 @@ class BonusGrenobleIUT1(BonusSportMultiplicatif):
class BonusLaRochelle(BonusSportAdditif): 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. <ul>
Si la note de sport est comprise entre 10 et 20 : ajout de 1% de cette <li>Si la note de sport est comprise entre 0 et 10 : pas d'ajout de point.</li>
note sur la moyenne générale du semestre (ou sur les UE en BUT). <li>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).</li>
</ul>
""" """
name = "bonus_iutlr" name = "bonus_iutlr"
displayed_name = "IUT de La Rochelle" displayed_name = "IUT de La Rochelle"
seuil_moy_gen = 10.0 # tous les points sont comptés seuil_moy_gen = 10.0 # si bonus > 10,
proportion_point = 0.01 seuil_comptage = 0.0 # tous les points sont comptés
proportion_point = 0.01 # 1%
class BonusLeHavre(BonusSportMultiplicatif): class BonusLeHavre(BonusSportAdditif):
"""Bonus sport IUT du Havre sur moyenne générale et UE """Bonus sport IUT du Havre sur les moyennes d'UE
Les points des modules bonus au dessus de 10/20 sont ajoutés, <p>
et les moyennes d'UE augmentées de 5% de ces points. Les enseignements optionnels de langue, préprofessionnalisation,
PIX (compétences numériques), l'entrepreneuriat étudiant, l'engagement
bénévole au sein dassociation dès lors quune grille dévaluation des
compétences existe ainsi que les activités sportives et culturelles
seront traités au niveau semestriel.
</p><p>
Le maximum de bonification quun étudiant peut obtenir sur sa moyenne
est plafonné à 0.5 point.
</p><p>
Lorsquun étudiant suit plus de deux matières qui donnent droit à
bonification, létudiant choisit les deux notes à retenir.
</p><p>
Les points bonus ne sont acquis que pour une note supérieure à 10/20.
</p><p>
La bonification est calculée de la manière suivante :<br>
Pour chaque matière (max. 2) donnant lieu à bonification :<br>
Bonification = (N-10) x 0,05,
N étant la note de lactivité sur 20.
</p>
""" """
# note: ScoDoc ne vérifie pas que le nombre de modules avec inscription n'excède pas 2
name = "bonus_iutlh" name = "bonus_iutlh"
displayed_name = "IUT du Havre" displayed_name = "IUT du Havre"
classic_use_bonus_ues = True # sur les UE, même en DUT et LP
seuil_moy_gen = 10.0 # seuls les points au dessus du seuil sont comptés seuil_moy_gen = 10.0 # seuls les points au dessus du seuil sont comptés
amplitude = 0.005 # multiplie les points au dessus du seuil proportion_point = 0.05
bonus_max = 0.5 #
class BonusLeMans(BonusSportAdditif): class BonusLeMans(BonusSportAdditif):
"""Calcul bonus modules optionnels (sport, culture), règle IUT Le Mans. """Calcul bonus modules optionnels (sport, culture), règle IUT Le Mans.
Les points au-dessus de 10 sur 20 obtenus dans chacune des matières <p>Les points au-dessus de 10 sur 20 obtenus dans chacune des matières
optionnelles sont cumulés. optionnelles sont cumulés.
</p>
<ul>
<li>En BUT: la moyenne de chacune des UE du semestre est augmentée de
2% du cumul des points de bonus;</li>
<li>En DUT/LP: la moyenne générale est augmentée de 5% du cumul des points bonus.
En BUT: la moyenne de chacune des UE du semestre est augmentée de </li>
2% du cumul des points de bonus, </ul>
<p>Dans tous les cas, le bonus est dans la limite de 0,5 point.</p>
En DUT/LP: la moyenne générale est augmentée de 5% du cumul des points bonus.
Dans tous les cas, le bonus est dans la limite de 0,5 point.
""" """
name = "bonus_iutlemans" name = "bonus_iutlemans"
@ -471,14 +747,15 @@ class BonusLeMans(BonusSportAdditif):
# Bonus simple, mais avec changement de paramètres en 2010 ! # Bonus simple, mais avec changement de paramètres en 2010 !
class BonusLille(BonusSportAdditif): 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 <p>Les étudiants de l'IUT peuvent suivre des enseignements optionnels
de l'Université Lille (sports, etc) non rattachés à une unité d'enseignement. de l'Université Lille (sports, etc) non rattachés à une unité d'enseignement.
</p><p>
Les points au-dessus de 10 sur 20 obtenus dans chacune des matières 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 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. s'ajoutent à la moyenne générale du semestre déjà obtenue par l'étudiant.
</p>
""" """
name = "bonus_lille" 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): 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 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 optionnelles sont cumulés et 1,8% de ces points cumulés
@ -512,8 +806,36 @@ class BonusLyonProvisoire(BonusSportAdditif):
bonus_max = 0.5 bonus_max = 0.5
class BonusMantes(BonusSportAdditif):
"""Calcul bonus modules optionnels (investissement, ...), IUT de Mantes en Yvelines.
<p>
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.
</p>
<p>Exemples :</p>
<ul>
<li> pour une note de 20 : bonus de + 0,5</li>
<li> pour une note de 15 : bonus de + 0,25</li>
<li> note de 10 : Ni bonus, ni malus (+0)</li>
<li> note de 5, malus : - 0,25</li>
<li> note de 0,malus : - 0,5</li>
</ul>
"""
name = "bonus_mantes"
displayed_name = "IUT de Mantes en Yvelines"
bonus_min = -0.5 # peut être NEGATIF !
bonus_max = 0.5
seuil_moy_gen = 0.0 # tous les points comptent
seuil_comptage = 10.0 # pivot à 10.
proportion_point = 0.05
class BonusMulhouse(BonusSportAdditif): class BonusMulhouse(BonusSportAdditif):
"""Calcul bonus modules optionnels (sport, culture), règle IUT de Mulhouse """Calcul bonus modules optionnels (sport, culture) à l'IUT de Mulhouse
La moyenne de chacune des UE du semestre sera majorée à hauteur de La moyenne de chacune des UE du semestre sera majorée à hauteur de
5% du cumul des points supérieurs à 10 obtenus en matières optionnelles, 5% du cumul des points supérieurs à 10 obtenus en matières optionnelles,
@ -530,17 +852,19 @@ class BonusMulhouse(BonusSportAdditif):
class BonusNantes(BonusSportAdditif): class BonusNantes(BonusSportAdditif):
"""IUT de Nantes (Septembre 2018) """IUT de Nantes (Septembre 2018)
Nous avons différents types de bonification <p>Nous avons différents types de bonification
(sport, culture, engagement citoyen). (sport, culture, engagement citoyen).
</p><p>
Nous ajoutons aux moyennes une bonification de 0,2 pour chaque item Nous ajoutons aux moyennes une bonification de 0,2 pour chaque item
la bonification totale ne doit pas excéder les 0,5 point. 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. Sur le bulletin nous ne mettons pas une note sur 20 mais directement les bonifications.
</p><p>
Dans ScoDoc: on a déclarera une UE "sport&culture" dans laquelle on aura des modules Dans ScoDoc: on a déclarera une UE "sport&culture" dans laquelle on aura
pour chaque activité (Sport, Associations, ...) 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 avec à chaque fois une note (ScoDoc l'affichera comme une note sur 20,
valeur de la bonification: entrer 0,1/20 signifiera un bonus de 0,1 point la moyenne générale) 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).
</p>
""" """
name = "bonus_nantes" name = "bonus_nantes"
@ -550,6 +874,19 @@ class BonusNantes(BonusSportAdditif):
bonus_max = 0.5 # plafonnement à 0.5 points 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): class BonusRoanne(BonusSportAdditif):
"""IUT de Roanne. """IUT de Roanne.
@ -561,11 +898,46 @@ class BonusRoanne(BonusSportAdditif):
displayed_name = "IUT de Roanne" displayed_name = "IUT de Roanne"
seuil_moy_gen = 0.0 seuil_moy_gen = 0.0
bonus_max = 0.6 # plafonnement à 0.6 points 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:
<ul>
<li>Bonus = (S - 10)/20</li>
</ul>
"""
# Utilisé aussi par St Malo, voir plus bas
name = "bonus_iut_stbrieuc"
displayed_name = "IUT de Saint-Brieuc"
proportion_point = 1 / 20.0
classic_use_bonus_ues = True
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus"""
if self.formsemestre.semestre_id % 2 == 0:
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
class BonusStEtienne(BonusSportAdditif):
"""IUT de Saint-Etienne.
Le bonus est compris entre 0 et 0.6 points.
"""
name = "bonus_iutse"
displayed_name = "IUT de Saint-Etienne"
seuil_moy_gen = 0.0
bonus_max = 0.6 # plafonnement à 0.6 points
proportion_point = 1
class BonusStDenis(BonusSportAdditif): class BonusStDenis(BonusSportAdditif):
"""Calcul bonus modules optionels (sport, culture), règle IUT Saint-Denis """Calcul bonus modules optionnels (sport, culture), règle IUT Saint-Denis
Les étudiants de l'IUT peuvent suivre des enseignements optionnels Les étudiants de l'IUT peuvent suivre des enseignements optionnels
de l'Université Paris 13 (sports, musique, deuxième langue, de l'Université Paris 13 (sports, musique, deuxième langue,
@ -581,16 +953,75 @@ class BonusStDenis(BonusSportAdditif):
bonus_max = 0.5 bonus_max = 0.5
class BonusStMalo(BonusStBrieuc):
# identique à St Brieux, sauf la doc
"""IUT de Saint Malo
Ne s'applique qu'aux semestres pairs (S2, S4, S6), et bonifie les moyennes d'UE:
<ul>
<li>Bonus = (S - 10)/20</li>
</ul>
"""
name = "bonus_iut_stmalo"
displayed_name = "IUT de Saint-Malo"
class BonusTarbes(BonusSportAdditif):
"""Calcul bonus optionnels (sport, culture), règle IUT de Tarbes.
<ul>
<li>Les étudiants opeuvent suivre un ou plusieurs activités optionnelles notées.
La meilleure des notes obtenue est prise en compte, si elle est supérieure à 10/20.
</li>
<li>Le trentième des points au dessus de 10 est ajouté à la moyenne des UE.
</li>
<li> Exemple: un étudiant ayant 16/20 bénéficiera d'un bonus de (16-10)/30 = 0,2 points
sur chaque UE.
</li>
</ul>
"""
name = "bonus_tarbes"
displayed_name = "IUT de Tazrbes"
seuil_moy_gen = 10.0
proportion_point = 1 / 30.0
classic_use_bonus_ues = True
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus"""
# Prend la note de chaque modimpl, sans considération d'UE
if len(sem_modimpl_moys_inscrits.shape) > 2: # apc
sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0]
# ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic
note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds
ues = self.formsemestre.query_ues(with_sport=False).all()
ues_idx = [ue.id for ue in ues]
if self.formsemestre.formation.is_apc(): # --- BUT
bonus_moy_arr = np.where(
note_bonus_max > self.seuil_moy_gen,
(note_bonus_max - self.seuil_moy_gen) * self.proportion_point,
0.0,
)
self.bonus_ues = pd.DataFrame(
np.stack([bonus_moy_arr] * len(ues)).T,
index=self.etuds_idx,
columns=ues_idx,
dtype=float,
)
class BonusTours(BonusDirect): class BonusTours(BonusDirect):
"""Calcul bonus sport & culture IUT Tours. """Calcul bonus sport & culture IUT Tours.
Les notes des UE bonus (ramenées sur 20) sont sommées <p>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, et 1/40 (2,5%) est ajouté aux moyennes: soit à la moyenne générale,
soit pour le BUT à chaque moyenne d'UE. soit pour le BUT à chaque moyenne d'UE.
</p><p>
Attention: en GEII, facteur 1/40, ailleurs facteur 1. <em>Attention: en GEII, facteur 1/40, ailleurs facteur 1.</em>
</p><p>
Le bonus total est limité à 1 point. Le bonus total est limité à 1 point.
</p>
""" """
name = "bonus_tours" name = "bonus_tours"
@ -611,15 +1042,17 @@ class BonusTours(BonusDirect):
class BonusVilleAvray(BonusSport): 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 Les étudiants de l'IUT peuvent suivre des enseignements optionnels
de l'Université Paris 10 (C2I) non rattachés à une unité d'enseignement. 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 <ul>
Si la note est >= 12 et < 16, bonus de 0.2 point <li>Si la note est >= 10 et < 12, bonus de 0.1 point</li>
Si la note est >= 16, bonus de 0.3 point <li>Si la note est >= 12 et < 16, bonus de 0.2 point</li>
Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par <li>Si la note est >= 16, bonus de 0.3 point</li>
l'étudiant. </ul>
<p>Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par
l'étudiant.</p>
""" """
name = "bonus_iutva" name = "bonus_iutva"
@ -627,25 +1060,25 @@ class BonusVilleAvray(BonusSport):
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus""" """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: # Calcule moyenne pondérée des notes de sport:
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
bonus_moy_arr = np.sum( bonus_moy_arr = np.sum(
sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1
) / np.sum(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 np.nan_to_num(bonus_moy_arr, nan=0.0, copy=False)
bonus_moy_arr[bonus_moy_arr >= 12.0] = 0.2 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 >= 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_additif(bonus_moy_arr)
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.
class BonusIUTV(BonusSportAdditif): 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 Les étudiants de l'IUT peuvent suivre des enseignements optionnels
de l'Université Paris 13 (sports, musique, deuxième langue, de l'Université Paris 13 (sports, musique, deuxième langue,
@ -657,7 +1090,7 @@ class BonusIUTV(BonusSportAdditif):
name = "bonus_iutv" name = "bonus_iutv"
displayed_name = "IUT de Villetaneuse" 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): def get_bonus_class_dict(start=BonusSport, d=None):

View File

@ -3,11 +3,9 @@
"""Matrices d'inscription aux modules d'un semestre """Matrices d'inscription aux modules d'un semestre
""" """
import numpy as np
import pandas as pd import pandas as pd
from app import db from app import db
from app import models
# #
# Le chargement des inscriptions est long: matrice nb_module x nb_etuds # 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: def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame:
"""Charge la matrice des inscriptions aux modules du semestre """Charge la matrice des inscriptions aux modules du semestre
rows: etudid (inscrits au semestre, avec DEM et DEF) 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) value: bool (0/1 inscrit ou pas)
""" """
# méthode la moins lente: une requete par module, merge les dataframes # méthode la moins lente: une requete par module, merge les dataframes

View File

@ -16,7 +16,7 @@ from app.scodoc import sco_codes_parcours
class ValidationsSemestre(ResultatsCache): class ValidationsSemestre(ResultatsCache):
""" """ """Les décisions de jury pour un semestre"""
_cached_attrs = ( _cached_attrs = (
"decisions_jury", "decisions_jury",

50
app/comp/moy_mat.py Normal file
View File

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

View File

@ -335,15 +335,17 @@ class ModuleImplResultsAPC(ModuleImplResults):
notes_rat / (eval_rat.note_max / 20.0), notes_rat / (eval_rat.note_max / 20.0),
np.nan, 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 # 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_moy_module = np.where(
etuds_use_rattrapage[:, np.newaxis], etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
np.tile(notes_rat[:, np.newaxis], nb_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( 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( self.etuds_moy_module = pd.DataFrame(
etuds_moy_module, 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 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 remplies: 1 si le coef de ce module dans l'UE est non nul, zéro sinon
(sauf pour module bonus, defaut à 1) (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) Résultat: (evals_poids, liste de UEs du semestre sauf le sport)
""" """
modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
@ -367,6 +373,10 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
ue_ids = [ue.id for ue in ues] ue_ids = [ue.id for ue in ues]
evaluation_ids = [evaluation.id for evaluation in evaluations] evaluation_ids = [evaluation.id for evaluation in evaluations]
evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float) evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float)
if (
modimpl.module.module_type == ModuleType.RESSOURCE
or modimpl.module.module_type == ModuleType.SAE
):
for ue_poids in EvaluationUEPoids.query.join( for ue_poids in EvaluationUEPoids.query.join(
EvaluationUEPoids.evaluation EvaluationUEPoids.evaluation
).filter_by(moduleimpl_id=moduleimpl_id): ).filter_by(moduleimpl_id=moduleimpl_id):

View File

@ -30,8 +30,11 @@
import numpy as np import numpy as np
import pandas as pd 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 etud_moy_ue_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> pd.Series: ) -> pd.Series:
"""Calcule les moyennes générales indicatives de tous les étudiants """Calcule les moyennes générales indicatives de tous les étudiants
@ -48,14 +51,57 @@ def compute_sem_moys_apc(
return moy_gen 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 !<br>
(formation: <a href="{url_for("notes.ue_table",
scodoc_dept=g.scodoc_dept, formation_id=formation_id)}">{formation.get_titre_version()}</a>)"""
)
)
moy_gen = pd.Series(np.NaN, index=etud_moy_ue_df.index)
else:
raise
return moy_gen
def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series):
"""Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur """Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur
numérique) en tenant compte des ex-aequos. numérique) en tenant compte des ex-aequos.
Result: { etudid : rang:str } rang est une chaine decrivant le rang. Result: couple (tuple)
Series { etudid : rang:str } rang est une chaine decrivant le rang,
Series { etudid : rang:int } le rang comme un nombre
""" """
if (notes is None) or (len(notes) == 0):
return (pd.Series([], dtype=object), pd.Series([], dtype=int))
notes = notes.sort_values(ascending=False) # Serie, tri par ordre décroissant notes = notes.sort_values(ascending=False) # Serie, tri par ordre décroissant
rangs = pd.Series(index=notes.index, dtype=str) # le rang est une chaîne rangs_str = pd.Series(index=notes.index, dtype=str) # le rang est une chaîne
rangs_int = pd.Series(index=notes.index, dtype=int) # le rang numérique pour tris
N = len(notes) N = len(notes)
nb_ex = 0 # nb d'ex-aequo consécutifs en cours nb_ex = 0 # nb d'ex-aequo consécutifs en cours
notes_i = notes.iat notes_i = notes.iat
@ -67,6 +113,7 @@ def comp_ranks_series(notes: pd.Series) -> dict[int, str]:
next = None next = None
val = notes_i[i] val = notes_i[i]
if nb_ex: if nb_ex:
rangs_int[etudid] = i + 1 - nb_ex
srang = "%d ex" % (i + 1 - nb_ex) srang = "%d ex" % (i + 1 - nb_ex)
if val == next: if val == next:
nb_ex += 1 nb_ex += 1
@ -74,9 +121,11 @@ def comp_ranks_series(notes: pd.Series) -> dict[int, str]:
nb_ex = 0 nb_ex = 0
else: else:
if val == next: if val == next:
rangs_int[etudid] = i + 1 - nb_ex
srang = "%d ex" % (i + 1 - nb_ex) srang = "%d ex" % (i + 1 - nb_ex)
nb_ex = 1 nb_ex = 1
else: else:
rangs_int[etudid] = i + 1
srang = "%d" % (i + 1) srang = "%d" % (i + 1)
rangs[etudid] = srang rangs_str[etudid] = srang
return rangs return rangs_str, rangs_int

View File

@ -27,7 +27,6 @@
"""Fonctions de calcul des moyennes d'UE (classiques ou BUT) """Fonctions de calcul des moyennes d'UE (classiques ou BUT)
""" """
from re import X
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@ -136,8 +135,13 @@ def df_load_modimpl_coefs(
) )
for mod_coef in mod_coefs: for mod_coef in mod_coefs:
modimpl_coefs_df[mod2impl[mod_coef.module_id]][mod_coef.ue_id] = mod_coef.coef try:
modimpl_coefs_df[mod2impl[mod_coef.module_id]][
mod_coef.ue_id
] = mod_coef.coef
except IndexError:
# il peut y avoir en base des coefs sur des modules ou UE qui ont depuis été retirés de la formation
pass
# Initialisation des poids non fixés: # Initialisation des poids non fixés:
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse # 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
# sur toutes les UE) # sur toutes les UE)
@ -193,6 +197,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id) evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
etuds_moy_module = mod_results.compute_module_moy(evals_poids) etuds_moy_module = mod_results.compute_module_moy(evals_poids)
modimpls_results[modimpl.id] = mod_results modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids
modimpls_notes.append(etuds_moy_module) modimpls_notes.append(etuds_moy_module)
if len(modimpls_notes): if len(modimpls_notes):
cube = notes_sem_assemble_cube(modimpls_notes) cube = notes_sem_assemble_cube(modimpls_notes)
@ -213,28 +218,32 @@ def compute_ue_moys_apc(
ues: list, ues: list,
modimpl_inscr_df: pd.DataFrame, modimpl_inscr_df: pd.DataFrame,
modimpl_coefs_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame,
modimpl_mask: np.array,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Calcul de la moyenne d'UE en mode APC (BUT). """Calcul de la moyenne d'UE en mode APC (BUT).
La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR
NI non inscrit à (au moins un) module de cette UE NI non inscrit à (au moins un) module de cette UE
NA pas de notes disponibles NA pas de notes disponibles
ERR erreur dans une formule utilisateur. [XXX pas encore gérées ici] ERR erreur dans une formule utilisateurs (pas gérées ici).
sem_cube: notes moyennes aux modules sem_cube: notes moyennes aux modules
ndarray (etuds x modimpls x UEs) ndarray (etuds x modimpls x UEs)
(floats avec des NaN) (floats avec des NaN)
etuds : liste des étudiants (dim. 0 du cube) etuds : liste des étudiants (dim. 0 du cube)
modimpls : liste des modules à considérer (dim. 1 du cube) modimpls : liste des module_impl (dim. 1 du cube)
ues : liste des UE (dim. 2 du cube) ues : liste des UE (dim. 2 du cube)
modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl)
modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport
modimpl_mask: liste de booléens, indiquants le module doit être pris ou pas.
(utilisé pour éliminer les bonus, et pourra servir à cacluler
sur des sous-ensembles de modules)
Résultat: DataFrame columns UE (sans sport), rows etudid Résultat: DataFrame columns UE (sans bonus), rows etudid
""" """
nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape
nb_ues_tot = len(ues) nb_ues_tot = len(ues)
assert len(modimpls) == nb_modules assert len(modimpls) == nb_modules
if nb_modules == 0 or nb_etuds == 0: if nb_modules == 0 or nb_etuds == 0 or nb_ues_no_bonus == 0:
return pd.DataFrame( return pd.DataFrame(
index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index
) )
@ -244,7 +253,8 @@ def compute_ue_moys_apc(
assert modimpl_coefs_df.shape[0] == nb_ues_no_bonus assert modimpl_coefs_df.shape[0] == nb_ues_no_bonus
assert modimpl_coefs_df.shape[1] == nb_modules assert modimpl_coefs_df.shape[1] == nb_modules
modimpl_inscr = modimpl_inscr_df.values modimpl_inscr = modimpl_inscr_df.values
modimpl_coefs = modimpl_coefs_df.values # Met à zéro tous les coefs des modules non sélectionnés dans le masque:
modimpl_coefs = np.where(modimpl_mask, modimpl_coefs_df.values, 0.0)
# Duplique les inscriptions sur les UEs non bonus: # Duplique les inscriptions sur les UEs non bonus:
modimpl_inscr_stacked = np.stack([modimpl_inscr] * nb_ues_no_bonus, axis=2) modimpl_inscr_stacked = np.stack([modimpl_inscr] * nb_ues_no_bonus, axis=2)
@ -261,6 +271,8 @@ def compute_ue_moys_apc(
) )
# Annule les coefs des modules NaN # Annule les coefs des modules NaN
modimpl_coefs_etuds_no_nan = np.where(np.isnan(sem_cube), 0.0, modimpl_coefs_etuds) modimpl_coefs_etuds_no_nan = np.where(np.isnan(sem_cube), 0.0, modimpl_coefs_etuds)
if modimpl_coefs_etuds_no_nan.dtype == np.object: # arrive sur des tableaux vides
modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float)
# #
# Version vectorisée # Version vectorisée
# #
@ -283,7 +295,8 @@ def compute_ue_moys_classic(
modimpl_coefs: np.array, modimpl_coefs: np.array,
modimpl_mask: np.array, modimpl_mask: np.array,
) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame]: ) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame]:
"""Calcul de la moyenne d'UE en mode classique. """Calcul de la moyenne d'UE et de la moy. générale en mode classique (DUT, LMD, ...).
La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR
NI non inscrit à (au moins un) module de cette UE NI non inscrit à (au moins un) module de cette UE
NA pas de notes disponibles NA pas de notes disponibles
@ -343,7 +356,8 @@ def compute_ue_moys_classic(
modimpl_coefs_etuds_no_nan = np.where( modimpl_coefs_etuds_no_nan = np.where(
np.isnan(sem_matrix), 0.0, modimpl_coefs_etuds np.isnan(sem_matrix), 0.0, modimpl_coefs_etuds
) )
if modimpl_coefs_etuds_no_nan.dtype == np.object: # arrive sur des tableaux vides
modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float)
# --------------------- Calcul des moyennes d'UE # --------------------- Calcul des moyennes d'UE
ue_modules = np.array( ue_modules = np.array(
[[m.module.ue == ue for m in formsemestre.modimpls_sorted] for ue in ues] [[m.module.ue == ue for m in formsemestre.modimpls_sorted] for ue in ues]
@ -351,8 +365,10 @@ def compute_ue_moys_classic(
modimpl_coefs_etuds_no_nan_stacked = np.stack( modimpl_coefs_etuds_no_nan_stacked = np.stack(
[modimpl_coefs_etuds_no_nan.T] * nb_ues [modimpl_coefs_etuds_no_nan.T] * nb_ues
) )
# nb_ue x nb_etuds x nb_mods : coefs prenant en compte NaN et inscriptions # nb_ue x nb_etuds x nb_mods : coefs prenant en compte NaN et inscriptions:
coefs = (modimpl_coefs_etuds_no_nan_stacked * ue_modules).swapaxes(1, 2) coefs = (modimpl_coefs_etuds_no_nan_stacked * ue_modules).swapaxes(1, 2)
if coefs.dtype == np.object: # arrive sur des tableaux vides
coefs = coefs.astype(np.float)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etud_moy_ue = ( etud_moy_ue = (
np.sum(coefs * sem_matrix_inscrits, axis=2) / np.sum(coefs, axis=2) np.sum(coefs * sem_matrix_inscrits, axis=2) / np.sum(coefs, axis=2)
@ -394,6 +410,68 @@ def compute_ue_moys_classic(
return etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df return etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df
def compute_mat_moys_classic(
sem_matrix: np.array,
modimpl_inscr_df: pd.DataFrame,
modimpl_coefs: np.array,
modimpl_mask: np.array,
) -> pd.Series:
"""Calcul de la moyenne sur un sous-enemble de modules en formation CLASSIQUE
La moyenne est un nombre (note/20 ou NaN.
Le masque modimpl_mask est un tableau de booléens (un par modimpl) qui
permet de sélectionner un sous-ensemble de modules (ceux de la matière d'intérêt).
sem_matrix: notes moyennes aux modules (tous les étuds x tous les modimpls)
ndarray (etuds x modimpls)
(floats avec des NaN)
etuds : listes des étudiants (dim. 0 de la matrice)
modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl)
modimpl_coefs: vecteur des coefficients de modules
modimpl_mask: masque des modimpls à prendre en compte
Résultat:
- moyennes: pd.Series, index etudid
"""
if (not len(modimpl_mask)) or (
sem_matrix.shape[0] == 0
): # aucun module ou aucun étudiant
# etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df
return pd.Series(
[0.0] * len(modimpl_inscr_df.index), index=modimpl_inscr_df.index
)
# Restreint aux modules sélectionnés:
sem_matrix = sem_matrix[:, modimpl_mask]
modimpl_inscr = modimpl_inscr_df.values[:, modimpl_mask]
modimpl_coefs = modimpl_coefs[modimpl_mask]
nb_etuds, nb_modules = sem_matrix.shape
assert len(modimpl_coefs) == nb_modules
# Enlève les NaN du numérateur:
sem_matrix_no_nan = np.nan_to_num(sem_matrix, nan=0.0)
# Ne prend pas en compte les notes des étudiants non inscrits au module:
# Annule les notes:
sem_matrix_inscrits = np.where(modimpl_inscr, sem_matrix_no_nan, 0.0)
# Annule les coefs des modules où l'étudiant n'est pas inscrit:
modimpl_coefs_etuds = np.where(
modimpl_inscr, np.stack([modimpl_coefs.T] * nb_etuds), 0.0
)
# Annule les coefs des modules NaN (nb_etuds x nb_mods)
modimpl_coefs_etuds_no_nan = np.where(
np.isnan(sem_matrix), 0.0, modimpl_coefs_etuds
)
if modimpl_coefs_etuds_no_nan.dtype == np.object: # arrive sur des tableaux vides
modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float)
etud_moy_mat = (modimpl_coefs_etuds_no_nan * sem_matrix_inscrits).sum(
axis=1
) / modimpl_coefs_etuds_no_nan.sum(axis=1)
return pd.Series(etud_moy_mat, index=modimpl_inscr_df.index)
def compute_malus( def compute_malus(
formsemestre: FormSemestre, formsemestre: FormSemestre,
sem_modimpl_moys: np.array, sem_modimpl_moys: np.array,

View File

@ -12,11 +12,13 @@ import pandas as pd
from app import log from app import log
from app.comp import moy_ue, moy_sem, inscr_mod from app.comp import moy_ue, moy_sem, inscr_mod
from app.comp.res_common import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.comp.bonus_spo import BonusSport from app.comp.bonus_spo import BonusSport
from app.models import ScoDocSiteConfig from app.models import ScoDocSiteConfig
from app.models.moduleimpls import ModuleImpl
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc import sco_preferences
class ResultatsSemestreBUT(NotesTableCompat): class ResultatsSemestreBUT(NotesTableCompat):
@ -31,6 +33,9 @@ class ResultatsSemestreBUT(NotesTableCompat):
def __init__(self, formsemestre): def __init__(self, formsemestre):
super().__init__(formsemestre) super().__init__(formsemestre)
self.sem_cube = None
"""ndarray (etuds x modimpl x ue)"""
if not self.load_cached(): if not self.load_cached():
t0 = time.time() t0 = time.time()
self.compute() self.compute()
@ -38,7 +43,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
self.store() self.store()
t2 = time.time() t2 = time.time()
log( log(
f"ResultatsSemestreBUT: cached formsemestre_id={formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s)" f"""ResultatsSemestreBUT: cached formsemestre_id={formsemestre.id
} ({(t1-t0):g}s +{(t2-t1):g}s)"""
) )
def compute(self): def compute(self):
@ -56,14 +62,11 @@ class ResultatsSemestreBUT(NotesTableCompat):
# modimpl_coefs_df.columns.get_loc(modimpl.id) # modimpl_coefs_df.columns.get_loc(modimpl.id)
# idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id) # idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id)
# Elimine les coefs des modimpl bonus sports: # Masque de tous les modules _sauf_ les bonus (sport)
modimpls_sport = [ modimpls_mask = [
modimpl modimpl.module.ue.type != UE_SPORT
for modimpl in self.formsemestre.modimpls_sorted for modimpl in self.formsemestre.modimpls_sorted
if modimpl.module.ue.type == UE_SPORT
] ]
for modimpl in modimpls_sport:
self.modimpl_coefs_df[modimpl.id] = 0
self.etud_moy_ue = moy_ue.compute_ue_moys_apc( self.etud_moy_ue = moy_ue.compute_ue_moys_apc(
self.sem_cube, self.sem_cube,
@ -72,10 +75,11 @@ class ResultatsSemestreBUT(NotesTableCompat):
self.ues, self.ues,
self.modimpl_inscr_df, self.modimpl_inscr_df,
self.modimpl_coefs_df, self.modimpl_coefs_df,
modimpls_mask,
) )
# Les coefficients d'UE ne sont pas utilisés en APC # Les coefficients d'UE ne sont pas utilisés en APC
self.etud_coef_ue_df = pd.DataFrame( self.etud_coef_ue_df = pd.DataFrame(
1.0, index=self.etud_moy_ue.index, columns=self.etud_moy_ue.columns 0.0, index=self.etud_moy_ue.index, columns=self.etud_moy_ue.columns
) )
# --- Modules de MALUS sur les UEs # --- Modules de MALUS sur les UEs
@ -85,7 +89,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
self.etud_moy_ue -= self.malus self.etud_moy_ue -= self.malus
# --- Bonus Sport & Culture # --- Bonus Sport & Culture
if len(modimpls_sport) > 0: if not all(modimpls_mask): # au moins un module bonus
bonus_class = ScoDocSiteConfig.get_bonus_sport_class() bonus_class = ScoDocSiteConfig.get_bonus_sport_class()
if bonus_class is not None: if bonus_class is not None:
bonus: BonusSport = bonus_class( bonus: BonusSport = bonus_class(
@ -100,13 +104,23 @@ class ResultatsSemestreBUT(NotesTableCompat):
self.bonus_ues = bonus.get_bonus_ues() self.bonus_ues = bonus.get_bonus_ues()
if self.bonus_ues is not None: if self.bonus_ues is not None:
self.etud_moy_ue += self.bonus_ues # somme les dataframes self.etud_moy_ue += self.bonus_ues # somme les dataframes
# Clippe toutes les moyennes d'UE dans [0,20]
self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True)
# Moyenne générale indicative: # Moyenne générale indicative:
# (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte # (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte
# donc la moyenne indicative) # donc la moyenne indicative)
self.etud_moy_gen = moy_sem.compute_sem_moys_apc( # self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_coefs(
self.etud_moy_ue, self.modimpl_coefs_df # self.etud_moy_ue, self.modimpl_coefs_df
# )
self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_ects(
self.etud_moy_ue,
[ue.ects for ue in self.ues if ue.type != UE_SPORT],
formation_id=self.formsemestre.formation_id,
skip_empty_ues=sco_preferences.get_preference(
"but_moy_skip_empty_ues", self.formsemestre.id
),
) )
# --- UE capitalisées # --- UE capitalisées
self.apply_capitalisation() self.apply_capitalisation()
@ -134,3 +148,30 @@ class ResultatsSemestreBUT(NotesTableCompat):
(ne dépend pas des modules auxquels est inscrit l'étudiant, ). (ne dépend pas des modules auxquels est inscrit l'étudiant, ).
""" """
return self.modimpl_coefs_df.loc[ue.id].sum() return self.modimpl_coefs_df.loc[ue.id].sum()
def modimpls_in_ue(self, ue_id, etudid, with_bonus=True) -> list[ModuleImpl]:
"""Liste des modimpl ayant des coefs non nuls vers cette UE
et auxquels l'étudiant est inscrit. Inclus modules bonus le cas échéant.
"""
# sert pour l'affichage ou non de l'UE sur le bulletin et la table recap
coefs = self.modimpl_coefs_df # row UE, cols modimpl
modimpls = [
modimpl
for modimpl in self.formsemestre.modimpls_sorted
if (coefs[modimpl.id][ue_id] != 0)
and self.modimpl_inscr_df[modimpl.id][etudid]
]
if not with_bonus:
return [
modimpl for modimpl in modimpls if modimpl.module.ue.type != UE_SPORT
]
return modimpls
def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray:
"""Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue.
Utile pour stats bottom tableau recap.
Résultat: 1d array of float
"""
i = self.modimpl_coefs_df.columns.get_loc(modimpl_id)
j = self.modimpl_coefs_df.index.get_loc(ue_id)
return self.sem_cube[:, i, j]

View File

@ -11,6 +11,14 @@ from app.models import FormSemestre
class ResultatsCache: class ResultatsCache:
"""Résultats cachés (via redis)
L'attribut _cached_attrs donne la liste des noms des attributs à cacher
(doivent être sérialisables facilement, se limiter à des types simples)
store() enregistre les attributs dans le cache, et
load_cached() les recharge.
"""
_cached_attrs = () # virtual _cached_attrs = () # virtual
def __init__(self, formsemestre: FormSemestre, cache_class=None): def __init__(self, formsemestre: FormSemestre, cache_class=None):

View File

@ -15,8 +15,8 @@ from flask import g, url_for
from app import db from app import db
from app import log from app import log
from app.comp import moy_mod, moy_ue, inscr_mod from app.comp import moy_mat, moy_mod, moy_sem, moy_ue, inscr_mod
from app.comp.res_common import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.comp.bonus_spo import BonusSport from app.comp.bonus_spo import BonusSport
from app.models import ScoDocSiteConfig from app.models import ScoDocSiteConfig
from app.models.etudiants import Identite from app.models.etudiants import Identite
@ -24,6 +24,7 @@ from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
@ -34,10 +35,13 @@ class ResultatsSemestreClassic(NotesTableCompat):
"modimpl_coefs", "modimpl_coefs",
"modimpl_idx", "modimpl_idx",
"sem_matrix", "sem_matrix",
"mod_rangs",
) )
def __init__(self, formsemestre): def __init__(self, formsemestre):
super().__init__(formsemestre) super().__init__(formsemestre)
self.sem_matrix: np.ndarray = None
"sem_matrix : 2d-array (etuds x modimpls)"
if not self.load_cached(): if not self.load_cached():
t0 = time.time() t0 = time.time()
@ -46,7 +50,8 @@ class ResultatsSemestreClassic(NotesTableCompat):
self.store() self.store()
t2 = time.time() t2 = time.time()
log( log(
f"ResultatsSemestreClassic: cached formsemestre_id={formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s)" f"""ResultatsSemestreClassic: cached formsemestre_id={
formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s)"""
) )
# recalculé (aussi rapide que de les cacher) # recalculé (aussi rapide que de les cacher)
self.moy_min = self.etud_moy_gen.min() self.moy_min = self.etud_moy_gen.min()
@ -60,7 +65,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
) )
self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre) self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre)
self.modimpl_coefs = np.array( self.modimpl_coefs = np.array(
[m.module.coefficient for m in self.formsemestre.modimpls_sorted] [m.module.coefficient or 0.0 for m in self.formsemestre.modimpls_sorted]
) )
self.modimpl_idx = { self.modimpl_idx = {
m.id: i for i, m in enumerate(self.formsemestre.modimpls_sorted) m.id: i for i, m in enumerate(self.formsemestre.modimpls_sorted)
@ -113,23 +118,60 @@ class ResultatsSemestreClassic(NotesTableCompat):
self.etud_moy_ue += self.bonus_ues # somme les dataframes self.etud_moy_ue += self.bonus_ues # somme les dataframes
self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True)
bonus_mg = bonus.get_bonus_moy_gen() bonus_mg = bonus.get_bonus_moy_gen()
if bonus_mg is not None: if bonus_mg is None and self.bonus_ues is not None:
# pas de bonus explicite sur la moyenne générale
# on l'ajuste pour refléter les modifs d'UE, à l'aide des coefs d'UE.
bonus_mg = (self.etud_coef_ue_df * self.bonus_ues).sum(
axis=1
) / self.etud_coef_ue_df.sum(axis=1)
self.etud_moy_gen += bonus_mg self.etud_moy_gen += bonus_mg
self.etud_moy_gen.clip(lower=0.0, upper=20.0, inplace=True) elif bonus_mg is not None:
self.bonus = ( # Applique le bonus moyenne générale renvoyé
bonus_mg # compat nt, utilisé pour l'afficher sur les bulletins self.etud_moy_gen += bonus_mg
)
# compat nt, utilisé pour l'afficher sur les bulletins:
self.bonus = bonus_mg
# --- UE capitalisées # --- UE capitalisées
self.apply_capitalisation() self.apply_capitalisation()
# Clippe toutes les moyennes dans [0,20]
self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True)
self.etud_moy_gen.clip(lower=0.0, upper=20.0, inplace=True)
# --- Classements: # --- Classements:
self.compute_rangs() self.compute_rangs()
# --- En option, moyennes par matières
if sco_preferences.get_preference("bul_show_matieres", self.formsemestre.id):
self.compute_moyennes_matieres()
def compute_rangs(self):
"""Calcul des rangs (classements) dans le semestre (moy. gen.), les UE
et les modules.
"""
# rangs moy gen et UEs sont calculées par la méthode commune à toutes les formations:
super().compute_rangs()
# les rangs des modules n'existent que dans les formations classiques:
self.mod_rangs = {}
for modimpl_result in self.modimpls_results.values():
# ne prend que les rangs sous forme de chaines:
rangs = moy_sem.comp_ranks_series(modimpl_result.etuds_moy_module)[0]
self.mod_rangs[modimpl_result.moduleimpl_id] = (
rangs,
modimpl_result.nb_inscrits_module,
)
def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float:
"""La moyenne de l'étudiant dans le moduleimpl """La moyenne de l'étudiant dans le moduleimpl
Result: valeur float (peut être NaN) ou chaîne "NI" (non inscrit ou DEM) Result: valeur float (peut être NaN) ou chaîne "NI" (non inscrit ou DEM)
""" """
return self.modimpls_results[moduleimpl_id].etuds_moy_module.get(etudid, "NI") try:
if self.modimpl_inscr_df[moduleimpl_id][etudid]:
return self.modimpls_results[moduleimpl_id].etuds_moy_module[etudid]
except KeyError:
pass
return "NI"
def get_mod_stats(self, moduleimpl_id: int) -> dict: def get_mod_stats(self, moduleimpl_id: int) -> dict:
"""Stats sur les notes obtenues dans un modimpl""" """Stats sur les notes obtenues dans un modimpl"""
@ -150,41 +192,57 @@ class ResultatsSemestreClassic(NotesTableCompat):
), ),
} }
def modimpl_notes(
self,
modimpl_id: int,
ue_id: int = None,
) -> np.ndarray:
"""Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue.
Utile pour stats bottom tableau recap.
ue_id n'est pas utilisé ici (formations classiques)
Résultat: 1d array of float
"""
i = self.modimpl_idx[modimpl_id]
return self.sem_matrix[:, i]
def compute_moyennes_matieres(self):
"""Calcul les moyennes par matière. Doit être appelée au besoin, en fin de compute."""
self.moyennes_matieres = moy_mat.compute_mat_moys_classic(
self.formsemestre,
self.sem_matrix,
self.ues,
self.modimpl_inscr_df,
self.modimpl_coefs,
)
def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float: def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float:
"""Détermine le coefficient de l'UE pour cet étudiant. """Détermine le coefficient de l'UE pour cet étudiant.
N'est utilisé que pour l'injection des UE capitalisées dans la N'est utilisé que pour l'injection des UE capitalisées dans la
moyenne générale. moyenne générale.
Coef = somme des coefs des modules de l'UE auxquels il est inscrit Coef = somme des coefs des modules de l'UE auxquels il est inscrit
""" """
c = comp_etud_sum_coef_modules_ue(self.formsemestre.id, etudid, ue["ue_id"]) coef = comp_etud_sum_coef_modules_ue(self.formsemestre.id, etudid, ue["ue_id"])
if c is not None: # inscrit à au moins un module de cette UE if coef is not None: # inscrit à au moins un module de cette UE
return c return coef
# arfff: aucun moyen de déterminer le coefficient de façon sûre # arfff: aucun moyen de déterminer le coefficient de façon sûre
log( log(
"* oups: calcul coef UE impossible\nformsemestre_id='%s'\netudid='%s'\nue=%s" f"""* oups: calcul coef UE impossible\nformsemestre_id='{self.formsemestre.id
% (self.formsemestre.id, etudid, ue) }'\netudid='{etudid}'\nue={ue}"""
) )
etud: Identite = Identite.query.get(etudid) etud: Identite = Identite.query.get(etudid)
raise ScoValueError( raise ScoValueError(
"""<div class="scovalueerror"><p>Coefficient de l'UE capitalisée %s impossible à déterminer f"""<div class="scovalueerror"><p>Coefficient de l'UE capitalisée {ue.acronyme}
pour l'étudiant <a href="%s" class="discretelink">%s</a></p> impossible à déterminer pour l'étudiant <a href="{
<p>Il faut <a href="%s">saisir le coefficient de cette UE avant de continuer</a></p> url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}" class="discretelink">{etud.nom_disp()}</a></p>
<p>Il faut <a href="{
url_for("notes.formsemestre_edit_uecoefs", scodoc_dept=g.scodoc_dept,
formsemestre_id=self.formsemestre.id, err_ue_id=ue["ue_id"],
)
}">saisir le coefficient de cette UE avant de continuer</a></p>
</div> </div>
""" """
% (
ue.acronyme,
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid),
etud.nom_disp(),
url_for(
"notes.formsemestre_edit_uecoefs",
scodoc_dept=g.scodoc_dept,
formsemestre_id=self.formsemestre.id,
err_ue_id=ue["ue_id"],
),
) )
)
return 0.0 # ?
def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple[np.ndarray, dict]: def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple[np.ndarray, dict]:
@ -215,7 +273,7 @@ def notes_sem_assemble_matrix(modimpls_notes: list[pd.Series]) -> np.ndarray:
(Series rendus par compute_module_moy, index: etud) (Series rendus par compute_module_moy, index: etud)
Resultat: ndarray (etud x module) Resultat: ndarray (etud x module)
""" """
if not len(modimpls_notes): if not modimpls_notes:
return np.zeros((0, 0), dtype=float) return np.zeros((0, 0), dtype=float)
modimpls_notes_arr = [s.values for s in modimpls_notes] modimpls_notes_arr = [s.values for s in modimpls_notes]
modimpls_notes = np.stack(modimpls_notes_arr) modimpls_notes = np.stack(modimpls_notes_arr)

File diff suppressed because it is too large Load Diff

462
app/comp/res_compat.py Normal file
View File

@ -0,0 +1,462 @@
##############################################################################
# ScoDoc
# 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
from flask import flash, g, Markup, url_for
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.comp import res_sem
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
Les méthodes définies dans cette classe sont
pour conserver la compatibilité abvec les codes anciens et
il n'est pas recommandé de les utiliser dans de nouveaux
développements (API malcommode et peu efficace).
"""
_cached_attrs = ResultatsSemestre._cached_attrs + (
"malus",
"etud_moy_gen_ranks",
"etud_moy_gen_ranks_int",
"ue_rangs",
)
def __init__(self, formsemestre: FormSemestre):
super().__init__(formsemestre)
nb_etuds = len(self.etuds)
self.ue_rangs = {u.id: (None, nb_etuds) for u in self.ues}
self.mod_rangs = None # sera surchargé en Classic, mais pas en APC
"""{ modimpl_id : (rangs, effectif) }"""
self.moy_min = "NA"
self.moy_max = "NA"
self.moy_moy = "NA"
self.expr_diagnostics = ""
self.parcours = self.formsemestre.formation.get_parcours()
def get_inscrits(self, include_demdef=True, order_by=False) -> list[Identite]:
"""Liste des étudiants inscrits
order_by = False|'nom'|'moy' tri sur nom ou sur moyenne générale (indicative)
Note: pour récupérer les etudids des inscrits, non triés, il est plus efficace
d'utiliser `[ ins.etudid for ins in nt.formsemestre.inscriptions ]`
"""
etuds = self.formsemestre.get_inscrits(
include_demdef=include_demdef, order=(order_by == "nom")
)
if order_by == "moy":
etuds.sort(
key=lambda e: (
self.etud_moy_gen_ranks_int.get(e.id, 100000),
e.sort_key,
)
)
return etuds
def get_etudids(self) -> list[int]:
"""(deprecated)
Liste des etudids inscrits, incluant les démissionnaires.
triée par ordre alphabetique de NOM
(à éviter: renvoie les etudids, mais est moins efficace que get_inscrits)
"""
# Note: pour avoir les inscrits non triés,
# utiliser [ ins.etudid for ins in self.formsemestre.inscriptions ]
return [x["etudid"] for x in self.inscrlist]
@cached_property
def sem(self) -> dict:
"""le formsemestre, comme un gros et gras dict (nt.sem)"""
return self.formsemestre.get_infos_dict()
@cached_property
def inscrlist(self) -> list[dict]: # utilisé par PE
"""Liste des inscrits au semestre (avec DEM et DEF),
sous forme de dict etud,
classée dans l'ordre alphabétique de noms.
"""
etuds = self.formsemestre.get_inscrits(include_demdef=True, order=True)
return [e.to_dict_scodoc7() for e in etuds]
@cached_property
def stats_moy_gen(self):
"""Stats (moy/min/max) sur la moyenne générale"""
return StatsMoyenne(self.etud_moy_gen)
def get_ues_stat_dict(
self, filter_sport=False, check_apc_ects=True
) -> list[dict]: # was get_ues()
"""Liste des UEs, ordonnée par numero.
Si filter_sport, retire les UE de type SPORT.
Résultat: liste de dicts { champs UE U stats moyenne UE }
"""
ues = self.formsemestre.query_ues(with_sport=not filter_sport)
ues_dict = []
for ue in ues:
d = ue.to_dict()
if ue.type != UE_SPORT:
moys = self.etud_moy_ue[ue.id]
else:
moys = None
d.update(StatsMoyenne(moys).to_dict())
ues_dict.append(d)
if check_apc_ects and self.is_apc and not hasattr(g, "checked_apc_ects"):
g.checked_apc_ects = True
if None in [ue.ects for ue in ues if ue.type != UE_SPORT]:
formation = self.formsemestre.formation
ue_sans_ects = [
ue for ue in ues if ue.type != UE_SPORT and ue.ects is None
]
flash(
Markup(
f"""Calcul moyenne générale impossible: ECTS des UE manquants !<br>
(dans {' ,'.join([ue.acronyme for ue in ue_sans_ects])}
de la formation: <a href="{url_for("notes.ue_table",
scodoc_dept=g.scodoc_dept, formation_id=formation.id)
}">{formation.get_titre_version()}</a>)
)
"""
),
category="danger",
)
return ues_dict
def get_modimpls_dict(self, ue_id=None) -> list[dict]:
"""Liste des modules pour une UE (ou toutes si ue_id==None),
triés par numéros (selon le type de formation)
"""
modimpls_dict = []
for modimpl in self.formsemestre.modimpls_sorted:
if (ue_id is None) or (modimpl.module.ue.id == ue_id):
d = modimpl.to_dict()
# compat ScoDoc < 9.2: ajoute matières
d["mat"] = modimpl.module.matiere.to_dict()
modimpls_dict.append(d)
return modimpls_dict
def compute_rangs(self):
"""Calcule les classements
Moyenne générale: etud_moy_gen_ranks
Par UE (sauf ue bonus)
"""
(
self.etud_moy_gen_ranks,
self.etud_moy_gen_ranks_int,
) = moy_sem.comp_ranks_series(self.etud_moy_gen)
for ue in self.formsemestre.query_ues():
moy_ue = self.etud_moy_ue[ue.id]
self.ue_rangs[ue.id] = (
moy_sem.comp_ranks_series(moy_ue)[0], # juste en chaine
int(moy_ue.count()),
)
# .count() -> nb of non NaN values
def get_etud_ue_rang(self, ue_id, etudid) -> tuple[str, int]:
"""Le rang de l'étudiant dans cette ue
Result: rang:str, effectif:str
"""
rangs, effectif = self.ue_rangs[ue_id]
if rangs is not None:
rang = rangs[etudid]
else:
return "", ""
return rang, effectif
def etud_check_conditions_ues(self, etudid):
"""Vrai si les conditions sur les UE sont remplies.
Ne considère que les UE ayant des notes (moyenne calculée).
(les UE sans notes ne sont pas comptées comme sous la barre)
Prend en compte les éventuelles UE capitalisées.
Pour les parcours habituels, cela revient à vérifier que
les moyennes d'UE sont toutes > à leur barre (sauf celles sans notes)
Pour les parcours non standards (LP2014), cela peut être plus compliqué.
Return: True|False, message explicatif
"""
ue_status_list = []
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 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.
"""
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):
"""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_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' : }
Ne renvoie aucune decision d'UE pour les défaillants
"""
if self.get_etud_etat(etudid) == DEF:
return {}
else:
if not self.validations:
self.validations = res_sem.load_formsemestre_validations(
self.formsemestre
)
return self.validations.decisions_jury_ues.get(etudid, None)
def get_etud_decision_sem(self, etudid: int) -> dict:
"""Decision du jury 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
"""
if self.get_etud_etat(etudid) == DEF:
return {
"code": DEF,
"assidu": False,
"event_date": "",
"compense_formsemestre_id": None,
}
else:
if not self.validations:
self.validations = res_sem.load_formsemestre_validations(
self.formsemestre
)
return self.validations.decisions_jury.get(etudid, None)
def get_etud_mat_moy(self, matiere_id: int, etudid: int) -> str:
"""moyenne d'un étudiant dans une matière (ou NA si pas de notes)"""
if not self.moyennes_matieres:
return "nd"
return (
self.moyennes_matieres[matiere_id].get(etudid, "-")
if matiere_id in self.moyennes_matieres
else "-"
)
def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float:
"""La moyenne de l'étudiant dans le moduleimpl
En APC, il s'agira d'une moyenne indicative sans valeur.
Result: valeur float (peut être naN) ou chaîne "NI" (non inscrit ou DEM)
"""
raise NotImplementedError() # virtual method
def get_etud_moy_gen(self, etudid): # -> float | str
"""Moyenne générale de cet etudiant dans ce semestre.
Prend en compte les UE capitalisées.
Si apc, moyenne indicative.
Si pas de notes: 'NA'
"""
return self.etud_moy_gen[etudid]
def get_etud_ects_pot(self, etudid: int) -> dict:
"""
Un dict avec les champs
ects_pot : (float) nb de crédits ECTS qui seraient validés
(sous réserve de validation par le jury)
ects_pot_fond: (float) nb d'ECTS issus d'UE fondamentales (non électives)
Ce sont les ECTS des UE au dessus de la barre (10/20 en principe), avant le jury (donc non
encore enregistrées).
"""
# was nt.get_etud_moy_infos
# XXX pour compat nt, à remplacer ultérieurement
ues = self.get_etud_ue_validables(etudid)
ects_pot = 0.0
for ue in ues:
if (
ue.id in self.etud_moy_ue
and ue.ects is not None
and self.etud_moy_ue[ue.id][etudid] > self.parcours.NOTES_BARRE_VALID_UE
):
ects_pot += ue.ects
return {
"ects_pot": ects_pot,
"ects_pot_fond": 0.0, # not implemented (anciennemment pour école ingé)
}
def get_etud_rang(self, etudid: int) -> str:
"""Le rang (classement) de l'étudiant dans le semestre.
Result: "13" ou "12 ex"
"""
return self.etud_moy_gen_ranks.get(etudid, 99999)
def get_etud_rang_group(self, etudid: int, group_id: int):
"Le rang de l'étudiant dans ce groupe (NON IMPLEMENTE)"
return (None, 0) # XXX unimplemented TODO
def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]:
"""Liste d'informations (compat NotesTable) sur évaluations completes
de ce module.
Évaluation "complete" ssi toutes notes saisies ou en attente.
"""
modimpl = ModuleImpl.query.get(moduleimpl_id)
modimpl_results = self.modimpls_results.get(moduleimpl_id)
if not modimpl_results:
return [] # safeguard
evals_results = []
for e in modimpl.evaluations:
if modimpl_results.evaluations_completes_dict.get(e.id, False):
d = e.to_dict()
d["heure_debut"] = e.heure_debut # datetime.time
d["heure_fin"] = e.heure_fin
d["jour"] = e.jour # datetime
d["notes"] = {
etud.id: {
"etudid": etud.id,
"value": modimpl_results.evals_notes[e.id][etud.id],
}
for etud in self.etuds
}
d["etat"] = {
"evalattente": modimpl_results.evaluations_etat[e.id].nb_attente,
}
evals_results.append(d)
elif e.id not in modimpl_results.evaluations_completes_dict:
# ne devrait pas arriver ? XXX
log(
f"Warning: 220213 get_evals_in_mod {e.id} not in mod {moduleimpl_id} ?"
)
return evals_results
def get_evaluations_etats(self):
"""[ {...evaluation et son etat...} ]"""
# TODO: à moderniser
from app.scodoc import sco_evaluations
if not hasattr(self, "_evaluations_etats"):
self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem(
self.formsemestre.id
)
return self._evaluations_etats
def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
"""Liste des états des évaluations de ce module"""
# XXX TODO à moderniser: lent, recharge des données que l'on a déjà...
return [
e
for e in self.get_evaluations_etats()
if e["moduleimpl_id"] == moduleimpl_id
]
def get_moduleimpls_attente(self):
"""Liste des modimpls du semestre ayant des notes en attente"""
return [
modimpl
for modimpl in self.formsemestre.modimpls_sorted
if self.modimpls_results[modimpl.id].en_attente
]
def get_mod_stats(self, moduleimpl_id: int) -> dict:
"""Stats sur les notes obtenues dans un modimpl
Vide en APC
"""
return {
"moy": "-",
"max": "-",
"min": "-",
"nb_notes": "-",
"nb_missing": "-",
"nb_valid_evals": "-",
}
def get_nom_short(self, etudid):
"formatte nom d'un etud (pour table recap)"
etud = self.identdict[etudid]
return (
(etud["nom_usuel"] or etud["nom"]).upper()
+ " "
+ etud["prenom"].capitalize()[:2]
+ "."
)
@cached_property
def T(self):
return self.get_table_moyennes_triees()
def get_table_moyennes_triees(self) -> list:
"""Result: liste de tuples
moy_gen, moy_ue_0, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid
"""
table_moyennes = []
etuds_inscriptions = self.formsemestre.etuds_inscriptions
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:
# pas de moyenne: démissionnaire ou def
t = (
["-"]
+ ["0.00"] * len(self.ues)
+ ["NI"] * len(self.formsemestre.modimpls_sorted)
)
else:
moy_ues = []
ue_is_cap = {}
for ue in ues:
ue_status = self.get_etud_ue_status(etudid, ue.id)
if ue_status:
moy_ues.append(ue_status["moy"])
ue_is_cap[ue.id] = ue_status["is_capitalized"]
else:
moy_ues.append("?")
t = [moy_gen] + list(moy_ues)
# Moyennes modules:
for modimpl in self.formsemestre.modimpls_sorted:
if ue_is_cap.get(modimpl.module.ue.id, False):
val = "-c-"
else:
val = self.get_etud_mod_moy(modimpl.id, etudid)
t.append(val)
t.append(etudid)
table_moyennes.append(t)
# tri par moyennes décroissantes,
# en laissant les démissionnaires à la fin, par ordre alphabetique
etuds = [ins.etud for ins in etuds_inscriptions.values()]
etuds.sort(key=lambda e: e.sort_key)
self._rang_alpha = {e.id: i for i, e in enumerate(etuds)}
table_moyennes.sort(key=self._row_key)
return table_moyennes
def _row_key(self, x):
"""clé de tri par moyennes décroissantes,
en laissant les demissionnaires à la fin, par ordre alphabetique.
(moy_gen, rang_alpha)
"""
try:
moy = -float(x[0])
except (ValueError, TypeError):
moy = 1000.0
return (moy, self._rang_alpha[x[-1]])
@cached_property
def identdict(self) -> dict:
"""{ etudid : etud_dict } pour tous les inscrits au semestre"""
return {
ins.etud.id: ins.etud.to_dict_scodoc7()
for ins in self.formsemestre.inscriptions
}

View File

@ -8,11 +8,13 @@
""" """
from flask import g from flask import g
from app import db
from app.comp.jury import ValidationsSemestre from app.comp.jury import ValidationsSemestre
from app.comp.res_common import ResultatsSemestre from app.comp.res_common import ResultatsSemestre
from app.comp.res_classic import ResultatsSemestreClassic from app.comp.res_classic import ResultatsSemestreClassic
from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_but import ResultatsSemestreBUT
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.scodoc import sco_cache
def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre: def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre:
@ -23,6 +25,13 @@ def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre:
Search in local cache (g.formsemestre_result_cache) Search in local cache (g.formsemestre_result_cache)
If not in cache, build it and cache it. If not in cache, build it and cache it.
""" """
is_apc = formsemestre.formation.is_apc()
if is_apc and formsemestre.semestre_id == -1:
formsemestre.semestre_id = 1
db.session.add(formsemestre)
db.session.commit()
sco_cache.invalidate_formsemestre(formsemestre.id)
# --- Try local cache (within the same request context) # --- Try local cache (within the same request context)
if not hasattr(g, "formsemestre_results_cache"): if not hasattr(g, "formsemestre_results_cache"):
g.formsemestre_results_cache = {} g.formsemestre_results_cache = {}
@ -30,11 +39,7 @@ def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre:
if formsemestre.id in g.formsemestre_results_cache: if formsemestre.id in g.formsemestre_results_cache:
return g.formsemestre_results_cache[formsemestre.id] return g.formsemestre_results_cache[formsemestre.id]
klass = ( klass = ResultatsSemestreBUT if is_apc else ResultatsSemestreClassic
ResultatsSemestreBUT
if formsemestre.formation.is_apc()
else ResultatsSemestreClassic
)
g.formsemestre_results_cache[formsemestre.id] = klass(formsemestre) g.formsemestre_results_cache[formsemestre.id] = klass(formsemestre)
return g.formsemestre_results_cache[formsemestre.id] return g.formsemestre_results_cache[formsemestre.id]

View File

@ -1,14 +1,11 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
"""Decorators for permissions, roles and ScoDoc7 Zope compatibility """Decorators for permissions, roles and ScoDoc7 Zope compatibility
""" """
import functools
from functools import wraps from functools import wraps
import inspect import inspect
import types
import logging
import werkzeug import werkzeug
from werkzeug.exceptions import BadRequest
import flask import flask
from flask import g, current_app, request from flask import g, current_app, request
from flask import abort, url_for, redirect from flask import abort, url_for, redirect
@ -193,7 +190,7 @@ def scodoc7func(func):
# necessary for db ids and boolean values # necessary for db ids and boolean values
try: try:
v = int(v) v = int(v)
except ValueError: except (ValueError, TypeError):
pass pass
pos_arg_values.append(v) pos_arg_values.append(v)
# current_app.logger.info("pos_arg_values=%s" % pos_arg_values) # current_app.logger.info("pos_arg_values=%s" % pos_arg_values)

View File

@ -1,30 +1,86 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
from threading import Thread from threading import Thread
from flask import current_app
from flask import current_app, g
from flask_mail import Message from flask_mail import Message
from app import mail from app import mail
from app.scodoc import sco_preferences
def send_async_email(app, msg): def send_async_email(app, msg):
"Send an email, async"
with app.app_context(): with app.app_context():
mail.send(msg) mail.send(msg)
def send_email( def send_email(
subject: str, sender: str, recipients: list, text_body: str, html_body="" subject: str,
sender: str,
recipients: list,
text_body: str,
html_body="",
bcc=(),
attachments=(),
): ):
""" """
Send an email Send an email. _All_ ScoDoc mails SHOULD be sent using this function.
If html_body is specified, build a multipart message with HTML content, If html_body is specified, build a multipart message with HTML content,
else send a plain text email. else send a plain text email.
attachements: list of dict { 'filename', 'mimetype', 'data' }
""" """
msg = Message(subject, sender=sender, recipients=recipients) msg = Message(subject, sender=sender, recipients=recipients, bcc=bcc)
msg.body = text_body msg.body = text_body
msg.html = html_body msg.html = html_body
if attachments:
for attachment in attachments:
msg.attach(
attachment["filename"], attachment["mimetype"], attachment["data"]
)
send_message(msg) send_message(msg)
def send_message(msg): def send_message(msg: Message):
"""Send a message.
All ScoDoc emails MUST be sent by this function.
In mail debug mode, addresses are discarded and all mails are sent to the
specified debugging address.
"""
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(
"email_test_mode_address"
)
if email_test_mode_address:
# Mode spécial test: remplace les adresses de destination
orig_to = msg.recipients
orig_cc = msg.cc
orig_bcc = msg.bcc
msg.recipients = [email_test_mode_address]
msg.cc = None
msg.bcc = None
msg.subject = "[TEST SCODOC] " + msg.subject
msg.body = (
f"""--- Message ScoDoc dérouté pour tests ---
Adresses d'origine:
to : {orig_to}
cc : {orig_cc}
bcc: {orig_bcc}
---
\n\n"""
+ msg.body
)
Thread( Thread(
target=send_async_email, args=(current_app._get_current_object(), msg) target=send_async_email, args=(current_app._get_current_object(), msg)
).start() ).start()

View File

@ -4,10 +4,11 @@
from flask import Blueprint from flask import Blueprint
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.auth.models import User from app.auth.models import User
from app.models import Departement
bp = Blueprint("entreprises", __name__) bp = Blueprint("entreprises", __name__)
LOGS_LEN = 10 LOGS_LEN = 5
@bp.app_template_filter() @bp.app_template_filter()
@ -21,9 +22,21 @@ def format_nom(s):
@bp.app_template_filter() @bp.app_template_filter()
def get_nomcomplet(s): def get_nomcomplet_by_username(s):
user = User.query.filter_by(user_name=s).first() user = User.query.filter_by(user_name=s).first()
return user.get_nomcomplet() return user.get_nomcomplet()
@bp.app_template_filter()
def get_nomcomplet_by_id(id):
user = User.query.filter_by(id=id).first()
return user.get_nomcomplet()
@bp.app_template_filter()
def get_dept_acronym(id):
dept = Departement.query.filter_by(id=id).first()
return dept.acronym
from app.entreprises import routes from app.entreprises import routes

View File

@ -0,0 +1,198 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# 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
# 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
#
#
##############################################################################
import os
from config import Config
import re
import requests
import glob
from flask_login import current_user
from app.entreprises.models import (
Entreprise,
EntrepriseContact,
EntrepriseOffre,
EntrepriseOffreDepartement,
EntreprisePreferences,
)
from app import email
from app.scodoc import sco_preferences
from app.models import Departement
from app.scodoc.sco_permissions import Permission
def get_depts():
"""
Retourne une liste contenant les l'id des départements des roles de l'utilisateur courant
"""
depts = []
for role in current_user.user_roles:
dept_id = get_dept_id_by_acronym(role.dept)
if dept_id is not None:
depts.append(dept_id)
return depts
def get_dept_id_by_acronym(acronym):
"""
Retourne l'id d'un departement a l'aide de son acronym
"""
dept = Departement.query.filter_by(acronym=acronym).first()
if dept is not None:
return dept.id
return None
def check_offre_depts(depts, offre_depts):
"""
Retourne vrai si l'utilisateur a le droit de visibilité sur l'offre
"""
if current_user.has_permission(Permission.RelationsEntreprisesChange, None):
return True
for offre_dept in offre_depts:
if offre_dept.dept_id in depts:
return True
return False
def get_offre_files_and_depts(offre: EntrepriseOffre, depts: list):
"""
Retourne l'offre, les fichiers attachés a l'offre et les département liés
"""
offre_depts = EntrepriseOffreDepartement.query.filter_by(offre_id=offre.id).all()
if not offre_depts or check_offre_depts(depts, offre_depts):
files = []
path = os.path.join(
Config.SCODOC_VAR_DIR,
"entreprises",
f"{offre.entreprise_id}",
f"{offre.id}",
)
if os.path.exists(path):
for dir in glob.glob(
f"{path}/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]"
):
for _file in glob.glob(f"{dir}/*"):
file = [os.path.basename(dir), os.path.basename(_file)]
files.append(file)
return [offre, files, offre_depts]
return None
def send_email_notifications_entreprise(
subject, entreprise: Entreprise, contact: EntrepriseContact
):
txt = [
"Une entreprise est en attente de validation",
"Entreprise:",
f"\tnom: {entreprise.nom}",
f"\tsiret: {entreprise.siret}",
f"\tadresse: {entreprise.adresse}",
f"\tcode postal: {entreprise.codepostal}",
f"\tville: {entreprise.ville}",
f"\tpays: {entreprise.pays}",
"",
"Contact:",
f"nom: {contact.nom}",
f"prenom: {contact.prenom}",
f"telephone: {contact.telephone}",
f"mail: {contact.mail}",
f"poste: {contact.poste}",
f"service: {contact.service}",
]
txt = "\n".join(txt)
email.send_email(
subject,
sco_preferences.get_preference("email_from_addr"),
[EntreprisePreferences.get_email_notifications],
txt,
)
return txt
def verif_contact_data(contact_data):
"""
Verifie les données d'une ligne Excel (contact)
contact_data[0]: nom
contact_data[1]: prenom
contact_data[2]: telephone
contact_data[3]: mail
contact_data[4]: poste
contact_data[5]: service
contact_data[6]: entreprise_id
"""
# champs obligatoires
if contact_data[0] == "" or contact_data[1] == "" or contact_data[6] == "":
return False
# entreprise_id existant
entreprise = Entreprise.query.filter_by(siret=contact_data[6]).first()
if entreprise is None:
return False
# contact possède le meme nom et prénom dans la meme entreprise
contact = EntrepriseContact.query.filter_by(
nom=contact_data[0], prenom=contact_data[1], entreprise_id=entreprise.id
).first()
if contact is not None:
return False
if contact_data[2] == "" and contact_data[3] == "": # 1 moyen de contact
return False
return True
def verif_entreprise_data(entreprise_data):
"""
Verifie les données d'une ligne Excel (entreprise)
"""
if EntreprisePreferences.get_check_siret():
for data in entreprise_data: # champs obligatoires
if data == "":
return False
else:
for data in entreprise_data[1:]: # champs obligatoires
if data == "":
return False
if EntreprisePreferences.get_check_siret():
siret = entreprise_data[0].strip() # vérification sur le siret
if re.match("^\d{14}$", siret) is None:
return False
try:
req = requests.get(
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}"
)
except requests.ConnectionError:
print("no internet")
if req.status_code != 200:
return False
entreprise = Entreprise.query.filter_by(siret=siret).first()
if entreprise is not None:
return False
return True

View File

@ -31,74 +31,85 @@ from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, FileRequired from flask_wtf.file import FileField, FileAllowed, FileRequired
from markupsafe import Markup from markupsafe import Markup
from sqlalchemy import text from sqlalchemy import text
from wtforms import StringField, SubmitField, TextAreaField, SelectField, HiddenField from wtforms import (
from wtforms.fields import EmailField, DateField StringField,
from wtforms.validators import ValidationError, DataRequired, Email SubmitField,
TextAreaField,
SelectField,
HiddenField,
SelectMultipleField,
DateField,
BooleanField,
)
from wtforms.validators import ValidationError, DataRequired, Email, Optional
from wtforms.widgets import ListWidget, CheckboxInput
from app.entreprises.models import Entreprise, EntrepriseContact from app.entreprises.models import Entreprise, EntrepriseContact, EntreprisePreferences
from app.models import Identite from app.models import Identite, Departement
from app.auth.models import User from app.auth.models import User
CHAMP_REQUIS = "Ce champ est requis" CHAMP_REQUIS = "Ce champ est requis"
SUBMIT_MARGE = {"style": "margin-bottom: 10px;"}
def _build_string_field(label, required=True, render_kw=None):
if required:
return StringField(
label,
validators=[DataRequired(message=CHAMP_REQUIS)],
render_kw=render_kw,
)
else:
return StringField(label, validators=[Optional()], render_kw=render_kw)
class EntrepriseCreationForm(FlaskForm): class EntrepriseCreationForm(FlaskForm):
siret = StringField( siret = _build_string_field(
"SIRET", "SIRET (*)",
validators=[DataRequired(message=CHAMP_REQUIS)],
render_kw={"placeholder": "Numéro composé de 14 chiffres", "maxlength": "14"}, render_kw={"placeholder": "Numéro composé de 14 chiffres", "maxlength": "14"},
) )
nom_entreprise = StringField( nom_entreprise = _build_string_field("Nom de l'entreprise (*)")
"Nom de l'entreprise", adresse = _build_string_field("Adresse de l'entreprise (*)")
validators=[DataRequired(message=CHAMP_REQUIS)], codepostal = _build_string_field("Code postal de l'entreprise (*)")
) ville = _build_string_field("Ville de l'entreprise (*)")
adresse = StringField( pays = _build_string_field("Pays de l'entreprise", required=False)
"Adresse de l'entreprise",
validators=[DataRequired(message=CHAMP_REQUIS)],
)
codepostal = StringField(
"Code postal de l'entreprise",
validators=[DataRequired(message=CHAMP_REQUIS)],
)
ville = StringField(
"Ville de l'entreprise",
validators=[DataRequired(message=CHAMP_REQUIS)],
)
pays = StringField(
"Pays de l'entreprise",
validators=[DataRequired(message=CHAMP_REQUIS)],
render_kw={"style": "margin-bottom: 50px;"},
)
nom_contact = StringField( nom_contact = _build_string_field("Nom du contact (*)")
"Nom du contact", validators=[DataRequired(message=CHAMP_REQUIS)] prenom_contact = _build_string_field("Prénom du contact (*)")
telephone = _build_string_field("Téléphone du contact (*)", required=False)
mail = StringField(
"Mail du contact (*)",
validators=[Optional(), Email(message="Adresse e-mail invalide")],
) )
prenom_contact = StringField( poste = _build_string_field("Poste du contact", required=False)
"Prénom du contact", service = _build_string_field("Service du contact", required=False)
validators=[DataRequired(message=CHAMP_REQUIS)], submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
def validate(self):
validate = True
if not FlaskForm.validate(self):
validate = False
if not self.telephone.data and not self.mail.data:
self.telephone.errors.append(
"Saisir un moyen de contact (mail ou téléphone)"
) )
telephone = StringField( self.mail.errors.append("Saisir un moyen de contact (mail ou téléphone)")
"Téléphone du contact", validate = False
validators=[DataRequired(message=CHAMP_REQUIS)],
) return validate
mail = EmailField(
"Mail du contact",
validators=[
DataRequired(message=CHAMP_REQUIS),
Email(message="Adresse e-mail invalide"),
],
)
poste = StringField("Poste du contact", validators=[])
service = StringField("Service du contact", validators=[])
submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"})
def validate_siret(self, siret): def validate_siret(self, siret):
if EntreprisePreferences.get_check_siret():
siret = siret.data.strip() siret = siret.data.strip()
if re.match("^\d{14}$", siret) == None: if re.match("^\d{14}$", siret) is None:
raise ValidationError("Format incorrect") raise ValidationError("Format incorrect")
try:
req = requests.get( req = requests.get(
f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}" f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}"
) )
except requests.ConnectionError:
print("no internet")
if req.status_code != 200: if req.status_code != 200:
raise ValidationError("SIRET inexistant") raise ValidationError("SIRET inexistant")
entreprise = Entreprise.query.filter_by(siret=siret).first() entreprise = Entreprise.query.filter_by(siret=siret).first()
@ -110,136 +121,191 @@ class EntrepriseCreationForm(FlaskForm):
class EntrepriseModificationForm(FlaskForm): class EntrepriseModificationForm(FlaskForm):
siret = StringField("SIRET", validators=[], render_kw={"disabled": ""}) hidden_entreprise_siret = HiddenField()
nom = StringField( siret = StringField("SIRET (*)")
"Nom de l'entreprise", nom = _build_string_field("Nom de l'entreprise (*)")
validators=[DataRequired(message=CHAMP_REQUIS)], adresse = _build_string_field("Adresse (*)")
) codepostal = _build_string_field("Code postal (*)")
adresse = StringField("Adresse", validators=[DataRequired(message=CHAMP_REQUIS)]) ville = _build_string_field("Ville (*)")
codepostal = StringField( pays = _build_string_field("Pays", required=False)
"Code postal", validators=[DataRequired(message=CHAMP_REQUIS)] submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE)
)
ville = StringField("Ville", validators=[DataRequired(message=CHAMP_REQUIS)]) def __init__(self, *args, **kwargs):
pays = StringField("Pays", validators=[DataRequired(message=CHAMP_REQUIS)]) super().__init__(*args, **kwargs)
submit = SubmitField("Modifier", render_kw={"style": "margin-bottom: 10px;"}) self.siret.render_kw = {
"disabled": "",
"value": self.hidden_entreprise_siret.data,
}
class MultiCheckboxField(SelectMultipleField):
widget = ListWidget(prefix_label=False)
option_widget = CheckboxInput()
class OffreCreationForm(FlaskForm): class OffreCreationForm(FlaskForm):
intitule = StringField("Intitulé", validators=[DataRequired(message=CHAMP_REQUIS)]) intitule = _build_string_field("Intitulé (*)")
description = TextAreaField( description = TextAreaField(
"Description", validators=[DataRequired(message=CHAMP_REQUIS)] "Description (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
) )
type_offre = SelectField( type_offre = SelectField(
"Type de l'offre", "Type de l'offre (*)",
choices=[("Stage"), ("Alternance")], choices=[("Stage"), ("Alternance")],
validators=[DataRequired(message=CHAMP_REQUIS)], validators=[DataRequired(message=CHAMP_REQUIS)],
) )
missions = TextAreaField( missions = TextAreaField(
"Missions", validators=[DataRequired(message=CHAMP_REQUIS)] "Missions (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
) )
duree = StringField("Durée", validators=[DataRequired(message=CHAMP_REQUIS)]) duree = _build_string_field("Durée (*)")
submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) depts = MultiCheckboxField("Départements", validators=[Optional()], coerce=int)
expiration_date = DateField("Date expiration", validators=[Optional()])
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.depts.choices = [
(dept.id, dept.acronym) for dept in Departement.query.all()
]
class OffreModificationForm(FlaskForm): class OffreModificationForm(FlaskForm):
intitule = StringField("Intitulé", validators=[DataRequired(message=CHAMP_REQUIS)]) intitule = _build_string_field("Intitulé (*)")
description = TextAreaField( description = TextAreaField(
"Description", validators=[DataRequired(message=CHAMP_REQUIS)] "Description (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
) )
type_offre = SelectField( type_offre = SelectField(
"Type de l'offre", "Type de l'offre (*)",
choices=[("Stage"), ("Alternance")], choices=[("Stage"), ("Alternance")],
validators=[DataRequired(message=CHAMP_REQUIS)], validators=[DataRequired(message=CHAMP_REQUIS)],
) )
missions = TextAreaField( missions = TextAreaField(
"Missions", validators=[DataRequired(message=CHAMP_REQUIS)] "Missions (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
) )
duree = StringField("Durée", validators=[DataRequired(message=CHAMP_REQUIS)]) duree = _build_string_field("Durée (*)")
submit = SubmitField("Modifier", render_kw={"style": "margin-bottom: 10px;"}) depts = MultiCheckboxField("Départements", validators=[Optional()], coerce=int)
expiration_date = DateField("Date expiration", validators=[Optional()])
submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.depts.choices = [
(dept.id, dept.acronym) for dept in Departement.query.all()
]
class ContactCreationForm(FlaskForm): class ContactCreationForm(FlaskForm):
hidden_entreprise_id = HiddenField() hidden_entreprise_id = HiddenField()
nom = StringField("Nom", validators=[DataRequired(message=CHAMP_REQUIS)]) nom = _build_string_field("Nom (*)")
prenom = StringField("Prénom", validators=[DataRequired(message=CHAMP_REQUIS)]) prenom = _build_string_field("Prénom (*)")
telephone = StringField( telephone = _build_string_field("Téléphone (*)", required=False)
"Téléphone", validators=[DataRequired(message=CHAMP_REQUIS)] mail = StringField(
"Mail (*)",
validators=[Optional(), Email(message="Adresse e-mail invalide")],
) )
mail = EmailField( poste = _build_string_field("Poste", required=False)
"Mail", service = _build_string_field("Service", required=False)
validators=[ submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
DataRequired(message=CHAMP_REQUIS),
Email(message="Adresse e-mail invalide"),
],
)
poste = StringField("Poste", validators=[])
service = StringField("Service", validators=[])
submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"})
def validate(self): def validate(self):
rv = FlaskForm.validate(self) validate = True
if not rv: if not FlaskForm.validate(self):
return False validate = False
contact = EntrepriseContact.query.filter_by( contact = EntrepriseContact.query.filter_by(
entreprise_id=self.hidden_entreprise_id.data, entreprise_id=self.hidden_entreprise_id.data,
nom=self.nom.data, nom=self.nom.data,
prenom=self.prenom.data, prenom=self.prenom.data,
).first() ).first()
if contact is not None: if contact is not None:
self.nom.errors.append("Ce contact existe déjà (même nom et prénom)") self.nom.errors.append("Ce contact existe déjà (même nom et prénom)")
self.prenom.errors.append("") self.prenom.errors.append("")
return False validate = False
return True if not self.telephone.data and not self.mail.data:
self.telephone.errors.append(
"Saisir un moyen de contact (mail ou téléphone)"
)
self.mail.errors.append("Saisir un moyen de contact (mail ou téléphone)")
validate = False
return validate
class ContactModificationForm(FlaskForm): class ContactModificationForm(FlaskForm):
nom = StringField("Nom", validators=[DataRequired(message=CHAMP_REQUIS)]) hidden_contact_id = HiddenField()
prenom = StringField("Prénom", validators=[DataRequired(message=CHAMP_REQUIS)]) hidden_entreprise_id = HiddenField()
telephone = StringField( nom = _build_string_field("Nom (*)")
"Téléphone", validators=[DataRequired(message=CHAMP_REQUIS)] prenom = _build_string_field("Prénom (*)")
telephone = _build_string_field("Téléphone (*)", required=False)
mail = StringField(
"Mail (*)",
validators=[Optional(), Email(message="Adresse e-mail invalide")],
) )
mail = EmailField( poste = _build_string_field("Poste", required=False)
"Mail", service = _build_string_field("Service", required=False)
validators=[ submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE)
DataRequired(message=CHAMP_REQUIS),
Email(message="Adresse e-mail invalide"), def validate(self):
], validate = True
if not FlaskForm.validate(self):
validate = False
contact = EntrepriseContact.query.filter(
EntrepriseContact.id != self.hidden_contact_id.data,
EntrepriseContact.entreprise_id == self.hidden_entreprise_id.data,
EntrepriseContact.nom == self.nom.data,
EntrepriseContact.prenom == self.prenom.data,
).first()
if contact is not None:
self.nom.errors.append("Ce contact existe déjà (même nom et prénom)")
self.prenom.errors.append("")
validate = False
if not self.telephone.data and not self.mail.data:
self.telephone.errors.append(
"Saisir un moyen de contact (mail ou téléphone)"
) )
poste = StringField("Poste", validators=[]) self.mail.errors.append("Saisir un moyen de contact (mail ou téléphone)")
service = StringField("Service", validators=[]) validate = False
submit = SubmitField("Modifier", render_kw={"style": "margin-bottom: 10px;"})
return validate
class HistoriqueCreationForm(FlaskForm): class HistoriqueCreationForm(FlaskForm):
etudiant = StringField( etudiant = _build_string_field(
"Étudiant", "Étudiant (*)",
validators=[DataRequired(message=CHAMP_REQUIS)], render_kw={"placeholder": "Tapez le nom de l'étudiant"},
render_kw={"placeholder": "Tapez le nom de l'étudiant puis selectionnez"},
) )
type_offre = SelectField( type_offre = SelectField(
"Type de l'offre", "Type de l'offre (*)",
choices=[("Stage"), ("Alternance")], choices=[("Stage"), ("Alternance")],
validators=[DataRequired(message=CHAMP_REQUIS)], validators=[DataRequired(message=CHAMP_REQUIS)],
) )
date_debut = DateField( date_debut = DateField(
"Date début", validators=[DataRequired(message=CHAMP_REQUIS)] "Date début (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
) )
date_fin = DateField("Date fin", validators=[DataRequired(message=CHAMP_REQUIS)]) date_fin = DateField(
submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) "Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)]
)
submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
def validate(self): def validate(self):
rv = FlaskForm.validate(self) validate = True
if not rv: if not FlaskForm.validate(self):
return False validate = False
if self.date_debut.data > self.date_fin.data: 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_debut.errors.append("Les dates sont incompatibles")
self.date_fin.errors.append("Les dates sont incompatibles") self.date_fin.errors.append("Les dates sont incompatibles")
return False validate = False
return True
return validate
def validate_etudiant(self, etudiant): def validate_etudiant(self, etudiant):
etudiant_data = etudiant.data.upper().strip() etudiant_data = etudiant.data.upper().strip()
@ -254,11 +320,11 @@ class HistoriqueCreationForm(FlaskForm):
class EnvoiOffreForm(FlaskForm): class EnvoiOffreForm(FlaskForm):
responsable = StringField( responsable = _build_string_field(
"Responsable de formation", "Responsable de formation (*)",
validators=[DataRequired(message=CHAMP_REQUIS)], render_kw={"placeholder": "Tapez le nom du responsable de formation"},
) )
submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE)
def validate_responsable(self, responsable): def validate_responsable(self, responsable):
responsable_data = responsable.data.upper().strip() responsable_data = responsable.data.upper().strip()
@ -276,14 +342,38 @@ class EnvoiOffreForm(FlaskForm):
class AjoutFichierForm(FlaskForm): class AjoutFichierForm(FlaskForm):
fichier = FileField( fichier = FileField(
"Fichier", "Fichier (*)",
validators=[ validators=[
FileRequired(message=CHAMP_REQUIS), FileRequired(message=CHAMP_REQUIS),
FileAllowed(["pdf", "docx"], "Fichier .pdf ou .docx uniquement"), FileAllowed(["pdf", "docx"], "Fichier .pdf ou .docx uniquement"),
], ],
) )
submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) submit = SubmitField("Ajouter", render_kw=SUBMIT_MARGE)
class SuppressionConfirmationForm(FlaskForm): class SuppressionConfirmationForm(FlaskForm):
submit = SubmitField("Supprimer", render_kw={"style": "margin-bottom: 10px;"}) submit = SubmitField("Supprimer", render_kw=SUBMIT_MARGE)
class ValidationConfirmationForm(FlaskForm):
submit = SubmitField("Valider", render_kw=SUBMIT_MARGE)
class ImportForm(FlaskForm):
fichier = FileField(
"Fichier (*)",
validators=[
FileRequired(message=CHAMP_REQUIS),
FileAllowed(["xlsx"], "Fichier .xlsx uniquement"),
],
)
submit = SubmitField("Importer", render_kw=SUBMIT_MARGE)
class PreferencesForm(FlaskForm):
mail_entreprise = StringField(
"Mail notifications",
validators=[Optional(), Email(message="Adresse e-mail invalide")],
)
check_siret = BooleanField("Vérification SIRET")
submit = SubmitField("Valider", render_kw=SUBMIT_MARGE)

View File

@ -2,14 +2,15 @@ from app import db
class Entreprise(db.Model): class Entreprise(db.Model):
__tablename__ = "entreprises" __tablename__ = "are_entreprises"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
siret = db.Column(db.Text) siret = db.Column(db.Text)
nom = db.Column(db.Text) nom = db.Column(db.Text)
adresse = db.Column(db.Text) adresse = db.Column(db.Text)
codepostal = db.Column(db.Text) codepostal = db.Column(db.Text)
ville = db.Column(db.Text) ville = db.Column(db.Text)
pays = db.Column(db.Text) pays = db.Column(db.Text, default="FRANCE")
visible = db.Column(db.Boolean, default=False)
contacts = db.relationship( contacts = db.relationship(
"EntrepriseContact", "EntrepriseContact",
backref="entreprise", backref="entreprise",
@ -26,19 +27,19 @@ class Entreprise(db.Model):
def to_dict(self): def to_dict(self):
return { return {
"siret": self.siret, "siret": self.siret,
"nom": self.nom, "nom_entreprise": self.nom,
"adresse": self.adresse, "adresse": self.adresse,
"codepostal": self.codepostal, "code_postal": self.codepostal,
"ville": self.ville, "ville": self.ville,
"pays": self.pays, "pays": self.pays,
} }
class EntrepriseContact(db.Model): class EntrepriseContact(db.Model):
__tablename__ = "entreprise_contact" __tablename__ = "are_contacts"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
entreprise_id = db.Column( entreprise_id = db.Column(
db.Integer, db.ForeignKey("entreprises.id", ondelete="cascade") db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade")
) )
nom = db.Column(db.Text) nom = db.Column(db.Text)
prenom = db.Column(db.Text) prenom = db.Column(db.Text)
@ -48,6 +49,7 @@ class EntrepriseContact(db.Model):
service = db.Column(db.Text) service = db.Column(db.Text)
def to_dict(self): def to_dict(self):
entreprise = Entreprise.query.filter_by(id=self.entreprise_id).first()
return { return {
"nom": self.nom, "nom": self.nom,
"prenom": self.prenom, "prenom": self.prenom,
@ -55,31 +57,15 @@ class EntrepriseContact(db.Model):
"mail": self.mail, "mail": self.mail,
"poste": self.poste, "poste": self.poste,
"service": self.service, "service": self.service,
} "entreprise_siret": entreprise.siret,
def to_dict_export(self):
entreprise = Entreprise.query.get(self.entreprise_id)
return {
"nom": self.nom,
"prenom": self.prenom,
"telephone": self.telephone,
"mail": self.mail,
"poste": self.poste,
"service": self.service,
"siret": entreprise.siret,
"nom_entreprise": entreprise.nom,
"adresse_entreprise": entreprise.adresse,
"codepostal": entreprise.codepostal,
"ville": entreprise.ville,
"pays": entreprise.pays,
} }
class EntrepriseOffre(db.Model): class EntrepriseOffre(db.Model):
__tablename__ = "entreprise_offre" __tablename__ = "are_offres"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
entreprise_id = db.Column( entreprise_id = db.Column(
db.Integer, db.ForeignKey("entreprises.id", ondelete="cascade") db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade")
) )
date_ajout = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) date_ajout = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
intitule = db.Column(db.Text) intitule = db.Column(db.Text)
@ -87,6 +73,8 @@ class EntrepriseOffre(db.Model):
type_offre = db.Column(db.Text) type_offre = db.Column(db.Text)
missions = db.Column(db.Text) missions = db.Column(db.Text)
duree = db.Column(db.Text) duree = db.Column(db.Text)
expiration_date = db.Column(db.Date)
expired = db.Column(db.Boolean, default=False)
def to_dict(self): def to_dict(self):
return { return {
@ -99,7 +87,7 @@ class EntrepriseOffre(db.Model):
class EntrepriseLog(db.Model): class EntrepriseLog(db.Model):
__tablename__ = "entreprise_log" __tablename__ = "are_logs"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
authenticated_user = db.Column(db.Text) authenticated_user = db.Column(db.Text)
@ -108,9 +96,11 @@ class EntrepriseLog(db.Model):
class EntrepriseEtudiant(db.Model): class EntrepriseEtudiant(db.Model):
__tablename__ = "entreprise_etudiant" __tablename__ = "are_etudiants"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
entreprise_id = db.Column(db.Integer, db.ForeignKey("entreprises.id")) entreprise_id = db.Column(
db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade")
)
etudid = db.Column(db.Integer) etudid = db.Column(db.Integer)
type_offre = db.Column(db.Text) type_offre = db.Column(db.Text)
date_debut = db.Column(db.Date) date_debut = db.Column(db.Date)
@ -120,18 +110,78 @@ class EntrepriseEtudiant(db.Model):
class EntrepriseEnvoiOffre(db.Model): class EntrepriseEnvoiOffre(db.Model):
__tablename__ = "entreprise_envoi_offre" __tablename__ = "are_envoi_offre"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey("user.id")) sender_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="cascade"))
receiver_id = db.Column(db.Integer, db.ForeignKey("user.id")) receiver_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="cascade"))
offre_id = db.Column(db.Integer, db.ForeignKey("entreprise_offre.id")) offre_id = db.Column(db.Integer, db.ForeignKey("are_offres.id", ondelete="cascade"))
date_envoi = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) date_envoi = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
class EntrepriseEnvoiOffreEtudiant(db.Model): class EntrepriseEnvoiOffreEtudiant(db.Model):
__tablename__ = "entreprise_envoi_offre_etudiant" __tablename__ = "are_envoi_offre_etudiant"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey("user.id")) sender_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="cascade"))
receiver_id = db.Column(db.Integer, db.ForeignKey("identite.id")) receiver_id = db.Column(
offre_id = db.Column(db.Integer, db.ForeignKey("entreprise_offre.id")) db.Integer, db.ForeignKey("identite.id", ondelete="cascade")
)
offre_id = db.Column(db.Integer, db.ForeignKey("are_offres.id", ondelete="cascade"))
date_envoi = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) date_envoi = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
class EntrepriseOffreDepartement(db.Model):
__tablename__ = "are_offre_departement"
id = db.Column(db.Integer, primary_key=True)
offre_id = db.Column(db.Integer, db.ForeignKey("are_offres.id", ondelete="cascade"))
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id", ondelete="cascade"))
class EntreprisePreferences(db.Model):
__tablename__ = "are_preferences"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Text)
value = db.Column(db.Text)
@classmethod
def get_email_notifications(cls):
mail = EntreprisePreferences.query.filter_by(
name="mail_notifications_entreprise"
).first()
if mail is None:
return ""
else:
return mail.value
@classmethod
def set_email_notifications(cls, mail: str):
if mail != cls.get_email_notifications():
m = EntreprisePreferences.query.filter_by(
name="mail_notifications_entreprise"
).first()
if m is None:
prefs = EntreprisePreferences(
name="mail_notifications_entreprise",
value=mail,
)
db.session.add(prefs)
else:
m.value = mail
db.session.commit()
@classmethod
def get_check_siret(cls):
check_siret = EntreprisePreferences.query.filter_by(name="check_siret").first()
if check_siret is None:
return 1
else:
return int(check_siret.value)
@classmethod
def set_check_siret(cls, check_siret: int):
cs = EntreprisePreferences.query.filter_by(name="check_siret").first()
if cs is None:
prefs = EntreprisePreferences(name="check_siret", value=check_siret)
db.session.add(prefs)
else:
cs.value = check_siret
db.session.commit()

File diff suppressed because it is too large Load Diff

1
app/forms/__init__.py Normal file
View File

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

View File

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

View File

@ -29,17 +29,13 @@
Formulaires configuration Exports Apogée (codes) Formulaires configuration Exports Apogée (codes)
""" """
from flask import flash, url_for, redirect, render_template
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import SubmitField, validators from wtforms import SubmitField, validators
from wtforms.fields.simple import StringField from wtforms.fields.simple import StringField
from app import models
from app.models import ScoDocSiteConfig
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
from app.scodoc import sco_utils as scu
def _build_code_field(code): def _build_code_field(code):
@ -61,6 +57,7 @@ def _build_code_field(code):
class CodesDecisionsForm(FlaskForm): class CodesDecisionsForm(FlaskForm):
"Formulaire code décisions Apogée"
ADC = _build_code_field("ADC") ADC = _build_code_field("ADC")
ADJ = _build_code_field("ADJ") ADJ = _build_code_field("ADJ")
ADM = _build_code_field("ADM") ADM = _build_code_field("ADM")

View File

@ -30,17 +30,15 @@ Formulaires configuration logos
Contrib @jmp, dec 21 Contrib @jmp, dec 21
""" """
import re
from flask import flash, url_for, redirect, render_template from flask import flash, url_for, redirect, render_template
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed from flask_wtf.file import FileField, FileAllowed
from wtforms import SelectField, SubmitField, FormField, validators, FieldList from wtforms import SubmitField, FormField, validators, FieldList
from wtforms import ValidationError
from wtforms.fields.simple import StringField, HiddenField from wtforms.fields.simple import StringField, HiddenField
from app import AccessDenied
from app.models import Departement from app.models import Departement
from app.models import ScoDocSiteConfig
from app.scodoc import sco_logos, html_sco_header from app.scodoc import sco_logos, html_sco_header
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_config_actions import ( from app.scodoc.sco_config_actions import (
@ -49,10 +47,9 @@ from app.scodoc.sco_config_actions import (
LogoInsert, LogoInsert,
) )
from flask_login import current_user
from app.scodoc.sco_logos import find_logo from app.scodoc.sco_logos import find_logo
JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [] JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + []
CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS
@ -111,6 +108,15 @@ def dept_key_to_id(dept_key):
return dept_key return dept_key
def logo_name_validator(message=None):
def validate_logo_name(form, field):
name = field.data if field.data else ""
if not scu.is_valid_filename(name):
raise ValidationError(message)
return validate_logo_name
class AddLogoForm(FlaskForm): class AddLogoForm(FlaskForm):
"""Formulaire permettant l'ajout d'un logo (dans un département)""" """Formulaire permettant l'ajout d'un logo (dans un département)"""
@ -118,11 +124,7 @@ class AddLogoForm(FlaskForm):
name = StringField( name = StringField(
label="Nom", label="Nom",
validators=[ validators=[
validators.regexp( logo_name_validator("Nom de logo invalide (alphanumérique, _)"),
r"^[a-zA-Z0-9-_]*$",
re.IGNORECASE,
"Ne doit comporter que lettres, chiffres, _ ou -",
),
validators.Length( validators.Length(
max=20, message="Un nom ne doit pas dépasser 20 caractères" max=20, message="Un nom ne doit pas dépasser 20 caractères"
), ),
@ -373,11 +375,11 @@ def config_logos():
if action: if action:
action.execute() action.execute()
flash(action.message) flash(action.message)
return redirect( return redirect(url_for("scodoc.configure_logos"))
url_for( else:
"scodoc.configure_logos", if not form.validate():
) scu.flash_errors(form)
)
return render_template( return render_template(
"config_logos.html", "config_logos.html",
scodoc_dept=None, scodoc_dept=None,

View File

@ -31,13 +31,14 @@ Formulaires configuration Exports Apogée (codes)
from flask import flash, url_for, redirect, request, render_template from flask import flash, url_for, redirect, request, render_template
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import SelectField, SubmitField from wtforms import BooleanField, SelectField, SubmitField
import app import app
from app.models import ScoDocSiteConfig from app.models import ScoDocSiteConfig
import app.scodoc.sco_utils as scu
class ScoDocConfigurationForm(FlaskForm): class BonusConfigurationForm(FlaskForm):
"Panneau de configuration des logos" "Panneau de configuration des logos"
bonus_sport_func_name = SelectField( bonus_sport_func_name = SelectField(
label="Fonction de calcul des bonus sport&culture", label="Fonction de calcul des bonus sport&culture",
@ -46,31 +47,57 @@ class ScoDocConfigurationForm(FlaskForm):
for (name, displayed_name) in ScoDocSiteConfig.get_bonus_sport_class_list() for (name, displayed_name) in ScoDocSiteConfig.get_bonus_sport_class_list()
], ],
) )
submit = SubmitField("Valider") submit_bonus = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) cancel_bonus = SubmitField("Annuler", render_kw={"formnovalidate": True})
class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration avancée"
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
submit_scodoc = SubmitField("Valider")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
def configuration(): def configuration():
"Page de configuration principale" "Page de configuration principale"
# nb: le contrôle d'accès (SuperAdmin) doit être fait dans la vue # nb: le contrôle d'accès (SuperAdmin) doit être fait dans la vue
form = ScoDocConfigurationForm( form_bonus = BonusConfigurationForm(
data={ data={
"bonus_sport_func_name": ScoDocSiteConfig.get_bonus_sport_class_name(), "bonus_sport_func_name": ScoDocSiteConfig.get_bonus_sport_class_name(),
} }
) )
if request.method == "POST" and form.cancel.data: # cancel button form_scodoc = ScoDocConfigurationForm(
data={"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled()}
)
if request.method == "POST" and (
form_bonus.cancel_bonus.data or form_scodoc.cancel_scodoc.data
): # cancel button
return redirect(url_for("scodoc.index")) return redirect(url_for("scodoc.index"))
if form.validate_on_submit(): if form_bonus.submit_bonus.data and form_bonus.validate():
if ( if (
form.data["bonus_sport_func_name"] form_bonus.data["bonus_sport_func_name"]
!= ScoDocSiteConfig.get_bonus_sport_class_name() != ScoDocSiteConfig.get_bonus_sport_class_name()
): ):
ScoDocSiteConfig.set_bonus_sport_class(form.data["bonus_sport_func_name"]) ScoDocSiteConfig.set_bonus_sport_class(
form_bonus.data["bonus_sport_func_name"]
)
app.clear_scodoc_cache() app.clear_scodoc_cache()
flash(f"Fonction bonus sport&culture configurée.") flash(f"Fonction bonus sport&culture configurée.")
return redirect(url_for("scodoc.index")) return redirect(url_for("scodoc.index"))
elif form_scodoc.submit_scodoc.data and form_scodoc.validate():
if ScoDocSiteConfig.enable_entreprises(
enabled=form_scodoc.data["enable_entreprises"]
):
flash(
"Module entreprise "
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
)
return redirect(url_for("scodoc.index"))
return render_template( return render_template(
"configuration.html", "configuration.html",
form=form, form_bonus=form_bonus,
form_scodoc=form_scodoc,
scu=scu,
title="Configuration",
) )

View File

@ -29,7 +29,6 @@
Formulaires création département Formulaires création département
""" """
from flask import flash, url_for, redirect, render_template
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import SubmitField, validators from wtforms import SubmitField, validators
from wtforms.fields.simple import StringField, BooleanField from wtforms.fields.simple import StringField, BooleanField

View File

@ -81,6 +81,9 @@ class ApcReferentielCompetences(db.Model, XMLModel):
) )
formations = db.relationship("Formation", backref="referentiel_competence") formations = db.relationship("Formation", backref="referentiel_competence")
def __repr__(self):
return f"<ApcReferentielCompetences {self.id} {self.specialite}>"
def to_dict(self): def to_dict(self):
"""Représentation complète du ref. de comp. """Représentation complète du ref. de comp.
comme un dict. comme un dict.
@ -110,7 +113,8 @@ class ApcCompetence(db.Model, XMLModel):
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), 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 # les compétences dans Orébut sont identifiées par leur id unique
id_orebut = db.Column(db.Text(), nullable=True, index=True, unique=True) # (mais id_orebut n'est pas unique car le même ref. pourra être chargé dans plusieurs depts)
id_orebut = db.Column(db.Text(), nullable=True, index=True)
titre = db.Column(db.Text(), nullable=False, index=True) titre = db.Column(db.Text(), nullable=False, index=True)
titre_long = db.Column(db.Text()) titre_long = db.Column(db.Text())
couleur = db.Column(db.Text()) couleur = db.Column(db.Text())
@ -139,6 +143,9 @@ class ApcCompetence(db.Model, XMLModel):
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
def __repr__(self):
return f"<ApcCompetence {self.id} {self.titre}>"
def to_dict(self): def to_dict(self):
return { return {
"id_orebut": self.id_orebut, "id_orebut": self.id_orebut,

View File

@ -69,6 +69,7 @@ class ScoDocSiteConfig(db.Model):
"INSTITUTION_ADDRESS": str, "INSTITUTION_ADDRESS": str,
"INSTITUTION_CITY": str, "INSTITUTION_CITY": str,
"DEFAULT_PDF_FOOTER_TEMPLATE": str, "DEFAULT_PDF_FOOTER_TEMPLATE": str,
"enable_entreprises": bool,
} }
def __init__(self, name, value): def __init__(self, name, value):
@ -207,3 +208,27 @@ class ScoDocSiteConfig(db.Model):
cfg.value = code_apo cfg.value = code_apo
db.session.add(cfg) db.session.add(cfg)
db.session.commit() db.session.commit()
@classmethod
def is_entreprises_enabled(cls) -> bool:
"""True si on doit activer le module entreprise"""
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
if (cfg is None) or not cfg.value:
return False
return True
@classmethod
def enable_entreprises(cls, enabled=True) -> bool:
"""Active (ou déactive) le module entreprises. True si changement."""
if enabled != ScoDocSiteConfig.is_entreprises_enabled():
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
if cfg is None:
cfg = ScoDocSiteConfig(
name="enable_entreprises", value="on" if enabled else ""
)
else:
cfg.value = "on" if enabled else ""
db.session.add(cfg)
db.session.commit()
return True
return False

View File

@ -55,6 +55,9 @@ def create_dept(acronym: str, visible=True) -> Departement:
"Create new departement" "Create new departement"
from app.models import ScoPreference from app.models import ScoPreference
existing = Departement.query.filter_by(acronym=acronym).count()
if existing:
raise ValueError(f"acronyme {acronym} déjà existant")
departement = Departement(acronym=acronym, visible=visible) departement = Departement(acronym=acronym, visible=visible)
p1 = ScoPreference(name="DeptName", value=acronym, departement=departement) p1 = ScoPreference(name="DeptName", value=acronym, departement=departement)
db.session.add(p1) db.session.add(p1)

View File

@ -4,16 +4,19 @@
et données rattachées (adresses, annotations, ...) et données rattachées (adresses, annotations, ...)
""" """
import datetime
from functools import cached_property from functools import cached_property
from flask import abort, url_for from flask import abort, url_for
from flask import g, request from flask import g, request
import sqlalchemy import sqlalchemy
from sqlalchemy import desc, text
from app import db from app import db, log
from app import models from app import models
from app.scodoc import notesdb as ndb from app.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat from app.scodoc.sco_bac import Baccalaureat
import app.scodoc.sco_utils as scu
class Identite(db.Model): class Identite(db.Model):
@ -74,6 +77,18 @@ class Identite(db.Model):
""" """
return {"M": "M.", "F": "Mme", "X": ""}[self.civilite] return {"M": "M.", "F": "Mme", "X": ""}[self.civilite]
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()}"
if no_accents:
return scu.suppress_accents(s)
return s
@property
def e(self):
"terminaison en français: 'ne', '', 'ou '(e)'"
return {"M": "", "F": "e"}.get(self.civilite, "(e)")
def nom_disp(self) -> str: def nom_disp(self) -> str:
"Nom à afficher" "Nom à afficher"
if self.nom_usuel: if self.nom_usuel:
@ -108,14 +123,22 @@ class Identite(db.Model):
r.append("-".join([x.lower().capitalize() for x in fields])) r.append("-".join([x.lower().capitalize() for x in fields]))
return " ".join(r) return " ".join(r)
@property
def nom_short(self):
"Nom et début du prénom pour table recap: 'DUPONT Pi.'"
return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}."
@cached_property @cached_property
def sort_key(self) -> tuple: def sort_key(self) -> tuple:
"clé pour tris par ordre alphabétique" "clé pour tris par ordre alphabétique"
return (self.nom_usuel or self.nom).lower(), self.prenom.lower() return (
scu.suppress_accents(self.nom_usuel or self.nom or "").lower(),
scu.suppress_accents(self.prenom or "").lower(),
)
def get_first_email(self, field="email") -> str: def get_first_email(self, field="email") -> str:
"Le mail associé à la première adrese de l'étudiant, ou None" "Le mail associé à la première adrese de l'étudiant, ou None"
return self.adresses[0].email or None if self.adresses.count() > 0 else None return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None
def to_dict_scodoc7(self): def to_dict_scodoc7(self):
"""Représentation dictionnaire, """Représentation dictionnaire,
@ -126,30 +149,41 @@ class Identite(db.Model):
# ScoDoc7 output_formators: (backward compat) # ScoDoc7 output_formators: (backward compat)
e["etudid"] = self.id e["etudid"] = self.id
e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"]) e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"])
e["ne"] = self.e
return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty
def to_dict_bul(self, include_urls=True): def to_dict_bul(self, include_urls=True):
"""Infos exportées dans les bulletins""" """Infos exportées dans les bulletins
L'étudiant, et sa première adresse.
"""
from app.scodoc import sco_photos from app.scodoc import sco_photos
d = { d = {
"civilite": self.civilite, "civilite": self.civilite,
"code_ine": self.code_ine, "code_ine": self.code_ine or "",
"code_nip": self.code_nip, "code_nip": self.code_nip or "",
"date_naissance": self.date_naissance.isoformat() "date_naissance": self.date_naissance.strftime("%d/%m/%Y")
if self.date_naissance if self.date_naissance
else None, else "",
"email": self.get_first_email(), "email": self.get_first_email() or "",
"emailperso": self.get_first_email("emailperso"), "emailperso": self.get_first_email("emailperso"),
"etudid": self.id, "etudid": self.id,
"nom": self.nom_disp(), "nom": self.nom_disp(),
"prenom": self.prenom, "prenom": self.prenom or "",
"nomprenom": self.nomprenom or "",
"lieu_naissance": self.lieu_naissance or "",
"dept_naissance": self.dept_naissance or "",
"nationalite": self.nationalite or "",
"boursier": self.boursier or "",
} }
if include_urls: if include_urls:
d["fiche_url"] = url_for( d["fiche_url"] = url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id
) )
d["photo_url"] = (sco_photos.get_etud_photo_url(self.id),) d["photo_url"] = sco_photos.get_etud_photo_url(self.id)
adresse = self.adresses.first()
if adresse:
d.update(adresse.to_dict(convert_nulls_to_str=True))
return d return d
def inscription_courante(self): def inscription_courante(self):
@ -163,6 +197,23 @@ class Identite(db.Model):
] ]
return r[0] if r else None return r[0] if r else None
def inscriptions_courantes(self) -> list: # -> list[FormSemestreInscription]:
"""Liste des inscriptions à des semestres _courants_
(il est rare qu'il y en ai plus d'une, mais c'est possible).
Triées par date de début de semestre décroissante (le plus récent en premier).
"""
from app.models.formsemestre import FormSemestre, FormSemestreInscription
return (
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
.filter(
FormSemestreInscription.etudid == self.id,
text("date_debut < now() and date_fin > now()"),
)
.order_by(desc(FormSemestre.date_debut))
.all()
)
def inscription_courante_date(self, date_debut, date_fin): def inscription_courante_date(self, date_debut, date_fin):
"""La première inscription à un formsemestre incluant la """La première inscription à un formsemestre incluant la
période [date_debut, date_fin] période [date_debut, date_fin]
@ -174,8 +225,8 @@ class Identite(db.Model):
] ]
return r[0] if r else None return r[0] if r else None
def etat_inscription(self, formsemestre_id): def inscription_etat(self, formsemestre_id):
"""etat de l'inscription de cet étudiant au semestre: """État de l'inscription de cet étudiant au semestre:
False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF
""" """
# voir si ce n'est pas trop lent: # voir si ce n'est pas trop lent:
@ -186,6 +237,110 @@ class Identite(db.Model):
return ins.etat return ins.etat
return False return False
def inscription_descr(self) -> dict:
"""Description de l'état d'inscription"""
inscription_courante = self.inscription_courante()
if inscription_courante:
titre_sem = inscription_courante.formsemestre.titre_mois()
return {
"etat_in_cursem": inscription_courante.etat,
"inscription_courante": inscription_courante,
"inscription": titre_sem,
"inscription_str": "Inscrit en " + titre_sem,
"situation": self.descr_situation_etud(),
}
else:
if self.formsemestre_inscriptions:
# cherche l'inscription la plus récente:
fin_dernier_sem = max(
[
inscr.formsemestre.date_debut
for inscr in self.formsemestre_inscriptions
]
)
if fin_dernier_sem > datetime.date.today():
inscription = "futur"
situation = "futur élève"
else:
inscription = "ancien"
situation = "ancien élève"
else:
inscription = ("non inscrit",)
situation = inscription
return {
"etat_in_cursem": "?",
"inscription_courante": None,
"inscription": inscription,
"inscription_str": inscription,
"situation": situation,
}
def descr_situation_etud(self) -> str:
"""Chaîne décrivant la situation _actuelle_ de l'étudiant.
Exemple:
"inscrit en BUT R&T semestre 2 FI (Jan 2022 - Jul 2022) le 16/01/2022"
ou
"non inscrit"
"""
inscriptions_courantes = self.inscriptions_courantes()
if inscriptions_courantes:
inscr = inscriptions_courantes[0]
if inscr.etat == scu.INSCRIT:
situation = f"inscrit{self.e} en {inscr.formsemestre.titre_mois()}"
# Cherche la date d'inscription dans scolar_events:
events = models.ScolarEvent.query.filter_by(
etudid=self.id,
formsemestre_id=inscr.formsemestre.id,
event_type="INSCRIPTION",
).all()
if not events:
log(
f"*** situation inconsistante pour {self} (inscrit mais pas d'event)"
)
situation += " (inscription non enregistrée)" # ???
else:
date_ins = events[0].event_date
situation += date_ins.strftime(" le %d/%m/%Y")
else:
situation = f"démission de {inscr.formsemestre.titre_mois()}"
# Cherche la date de demission dans scolar_events:
events = models.ScolarEvent.query.filter_by(
etudid=self.id,
formsemestre_id=inscr.formsemestre.id,
event_type="DEMISSION",
).all()
if not events:
log(
f"*** situation inconsistante pour {self} (demission mais pas d'event)"
)
date_dem = "???" # ???
else:
date_dem = events[0].event_date
situation += date_dem.strftime(" le %d/%m/%Y")
else:
situation = "non inscrit" + self.e
return situation
def photo_html(self, title=None, size="small") -> str:
"""HTML img tag for the photo, either in small size (h90)
or original size (size=="orig")
"""
from app.scodoc import sco_photos
# sco_photo traite des dicts:
return sco_photos.etud_photo_html(
etud=dict(
etudid=self.id,
code_nip=self.code_nip,
nomprenom=self.nomprenom,
nom_disp=self.nom_disp(),
photo_filename=self.photo_filename,
),
title=title,
size=size,
)
def make_etud_args( def make_etud_args(
etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True
@ -209,12 +364,15 @@ def make_etud_args(
vals = request.args vals = request.args
else: else:
vals = {} vals = {}
try:
if "etudid" in vals: if "etudid" in vals:
args = {"etudid": int(vals["etudid"])} args = {"etudid": int(vals["etudid"])}
elif "code_nip" in vals: elif "code_nip" in vals:
args = {"code_nip": str(vals["code_nip"])} args = {"code_nip": str(vals["code_nip"])}
elif "code_ine" in vals: elif "code_ine" in vals:
args = {"code_ine": str(vals["code_ine"])} args = {"code_ine": str(vals["code_ine"])}
except ValueError:
args = {}
if not args: if not args:
if abort_404: if abort_404:
abort(404, "pas d'étudiant sélectionné") abort(404, "pas d'étudiant sélectionné")
@ -250,6 +408,14 @@ class Adresse(db.Model):
) )
description = db.Column(db.Text) description = db.Column(db.Text)
def to_dict(self, convert_nulls_to_str=False):
"""Représentation dictionnaire,"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
if convert_nulls_to_str:
return {k: e[k] or "" for k in e}
return e
class Admission(db.Model): class Admission(db.Model):
"""Informations liées à l'admission d'un étudiant""" """Informations liées à l'admission d'un étudiant"""

View File

@ -59,6 +59,10 @@ class Formation(db.Model):
"""get l'instance de TypeParcours de cette formation""" """get l'instance de TypeParcours de cette formation"""
return sco_codes_parcours.get_parcours_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"""
return f"{self.acronyme} {self.titre} v{self.version}"
def is_apc(self): def is_apc(self):
"True si formation APC avec SAE (BUT)" "True si formation APC avec SAE (BUT)"
return self.get_parcours().APC_SAE return self.get_parcours().APC_SAE

View File

@ -12,7 +12,6 @@ from app import log
from app.models import APO_CODE_STR_LEN from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN from app.models import CODE_STR_LEN
from app.models import UniteEns
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.models.ues import UniteEns from app.models.ues import UniteEns
@ -23,6 +22,7 @@ from app.scodoc import sco_codes_parcours
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV
class FormSemestre(db.Model): class FormSemestre(db.Model):
@ -104,6 +104,11 @@ class FormSemestre(db.Model):
lazy=True, lazy=True,
backref=db.backref("formsemestres", lazy=True), backref=db.backref("formsemestres", lazy=True),
) )
partitions = db.relationship(
"Partition",
backref=db.backref("formsemestre", lazy=True),
lazy="dynamic",
)
# Ancien id ScoDoc7 pour les migrations de bases anciennes # Ancien id ScoDoc7 pour les migrations de bases anciennes
# ne pas utiliser après migrate_scodoc7_dept_archives # ne pas utiliser après migrate_scodoc7_dept_archives
scodoc7_id = db.Column(db.Text(), nullable=True) scodoc7_id = db.Column(db.Text(), nullable=True)
@ -117,10 +122,12 @@ class FormSemestre(db.Model):
return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>" return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>"
def to_dict(self): def to_dict(self):
"dict (compatible ScoDoc7)"
d = dict(self.__dict__) d = dict(self.__dict__)
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
# ScoDoc7 output_formators: (backward compat) # ScoDoc7 output_formators: (backward compat)
d["formsemestre_id"] = self.id d["formsemestre_id"] = self.id
d["titre_num"] = self.titre_num()
if self.date_debut: if self.date_debut:
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y") d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
d["date_debut_iso"] = self.date_debut.isoformat() d["date_debut_iso"] = self.date_debut.isoformat()
@ -154,10 +161,9 @@ class FormSemestre(db.Model):
d["periode"] = 1 # typiquement, début en septembre: S1, S3... d["periode"] = 1 # typiquement, début en septembre: S1, S3...
else: else:
d["periode"] = 2 # typiquement, début en février: S2, S4... d["periode"] = 2 # typiquement, début en février: S2, S4...
d["titre_num"] = self.titre_num()
d["titreannee"] = self.titre_annee() d["titreannee"] = self.titre_annee()
d["mois_debut"] = f"{self.date_debut.month} {self.date_debut.year}" d["mois_debut"] = self.mois_debut()
d["mois_fin"] = f"{self.date_fin.month} {self.date_fin.year}" d["mois_fin"] = self.mois_fin()
d["titremois"] = "%s %s (%s - %s)" % ( d["titremois"] = "%s %s (%s - %s)" % (
d["titre_num"], d["titre_num"],
self.modalite or "", self.modalite or "",
@ -167,7 +173,6 @@ class FormSemestre(db.Model):
d["session_id"] = self.session_id() d["session_id"] = self.session_id()
d["etapes"] = self.etapes_apo_vdi() d["etapes"] = self.etapes_apo_vdi()
d["etapes_apo_str"] = self.etapes_apo_str() d["etapes_apo_str"] = self.etapes_apo_str()
d["responsables"] = [u.id for u in self.responsables] # liste des ids
return d return d
def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery: def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery:
@ -200,7 +205,11 @@ class FormSemestre(db.Model):
modimpls = self.modimpls.all() modimpls = self.modimpls.all()
if self.formation.is_apc(): if self.formation.is_apc():
modimpls.sort( modimpls.sort(
key=lambda m: (m.module.module_type, m.module.numero, m.module.code) key=lambda m: (
m.module.module_type or 0,
m.module.numero or 0,
m.module.code or 0,
)
) )
else: else:
modimpls.sort( modimpls.sort(
@ -283,6 +292,7 @@ class FormSemestre(db.Model):
"""chaîne "J. Dupond, X. Martin" """chaîne "J. Dupond, X. Martin"
ou "Jacques Dupond, Xavier Martin" ou "Jacques Dupond, Xavier Martin"
""" """
# was "nomcomplet"
if not self.responsables: if not self.responsables:
return "" return ""
if abbrev_prenom: if abbrev_prenom:
@ -290,10 +300,22 @@ class FormSemestre(db.Model):
else: else:
return ", ".join([u.get_nomcomplet() for u in self.responsables]) return ", ".join([u.get_nomcomplet() for u in self.responsables])
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 annee_scolaire_str(self): def annee_scolaire_str(self):
"2021 - 2022" "2021 - 2022"
return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month) return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month)
def mois_debut(self) -> str:
"Oct 2021"
return f"{MONTH_NAMES_ABBREV[self.date_debut.month - 1]} {self.date_debut.year}"
def mois_fin(self) -> str:
"Jul 2022"
return f"{MONTH_NAMES_ABBREV[self.date_fin.month - 1]} {self.date_fin.year}"
def session_id(self) -> str: def session_id(self) -> str:
"""identifiant externe de semestre de formation """identifiant externe de semestre de formation
Exemple: RT-DUT-FI-S1-ANNEE Exemple: RT-DUT-FI-S1-ANNEE
@ -354,7 +376,7 @@ class FormSemestre(db.Model):
def get_abs_count(self, etudid): def get_abs_count(self, etudid):
"""Les comptes d'absences de cet étudiant dans ce semestre: """Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs non justifiées, nb abs justifiées) tuple (nb abs, nb abs justifiées)
Utilise un cache. Utilise un cache.
""" """
from app.scodoc import sco_abs from app.scodoc import sco_abs
@ -363,19 +385,23 @@ class FormSemestre(db.Model):
etudid, self.date_debut.isoformat(), self.date_fin.isoformat() etudid, self.date_debut.isoformat(), self.date_fin.isoformat()
) )
def get_inscrits(self, include_demdef=False) -> list[Identite]: def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]:
"""Liste des étudiants inscrits à ce semestre """Liste des étudiants inscrits à ce semestre
Si include_demdef, tous les étudiants, avec les démissionnaires Si include_demdef, tous les étudiants, avec les démissionnaires
et défaillants. et défaillants.
Si order, tri par clé sort_key
""" """
if include_demdef: if include_demdef:
return [ins.etud for ins in self.inscriptions] etuds = [ins.etud for ins in self.inscriptions]
else: else:
return [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] etuds = [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT]
if order:
etuds.sort(key=lambda e: e.sort_key)
return etuds
@cached_property @cached_property
def etudids_actifs(self) -> set: def etudids_actifs(self) -> set:
"Set des etudids inscrits non démissionnaires" "Set des etudids inscrits non démissionnaires et non défaillants"
return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT} return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT}
@cached_property @cached_property
@ -554,6 +580,9 @@ class FormSemestreInscription(db.Model):
# etape apogee d'inscription (experimental 2020) # etape apogee d'inscription (experimental 2020)
etape = db.Column(db.String(APO_CODE_STR_LEN)) etape = db.Column(db.String(APO_CODE_STR_LEN))
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={self.formsemestre_id} etat={self.etat}>"
class NotesSemSet(db.Model): class NotesSemSet(db.Model):
"""semsets: ensemble de formsemestres pour exports Apogée""" """semsets: ensemble de formsemestres pour exports Apogée"""

View File

@ -31,6 +31,11 @@ class Partition(db.Model):
show_in_lists = db.Column( show_in_lists = db.Column(
db.Boolean(), nullable=False, default=True, server_default="true" db.Boolean(), nullable=False, default=True, server_default="true"
) )
groups = db.relationship(
"GroupDescr",
backref=db.backref("partition", lazy=True),
lazy="dynamic",
)
def __init__(self, **kwargs): def __init__(self, **kwargs):
super(Partition, self).__init__(**kwargs) super(Partition, self).__init__(**kwargs)
@ -42,6 +47,9 @@ class Partition(db.Model):
else: else:
self.numero = 1 self.numero = 1
def __repr__(self):
return f"""<{self.__class__.__name__} {self.id} "{self.partition_name or '(default)'}">"""
class GroupDescr(db.Model): class GroupDescr(db.Model):
"""Description d'un groupe d'une partition""" """Description d'un groupe d'une partition"""
@ -55,6 +63,17 @@ class GroupDescr(db.Model):
# "A", "C2", ... (NULL for 'all'): # "A", "C2", ... (NULL for 'all'):
group_name = db.Column(db.String(GROUPNAME_STR_LEN)) group_name = db.Column(db.String(GROUPNAME_STR_LEN))
etuds = db.relationship(
"Identite",
secondary="group_membership",
lazy="dynamic",
)
def __repr__(self):
return (
f"""<{self.__class__.__name__} {self.id} "{self.group_name or '(tous)'}">"""
)
group_membership = db.Table( group_membership = db.Table(
"group_membership", "group_membership",

View File

@ -2,10 +2,12 @@
"""ScoDoc models: moduleimpls """ScoDoc models: moduleimpls
""" """
import pandas as pd import pandas as pd
import flask_sqlalchemy
from app import db from app import db
from app.comp import df_cache from app.comp import df_cache
from app.models import Identite, Module from app.models.etudiants import Identite
from app.models.modules import Module
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -79,7 +81,7 @@ class ModuleImpl(db.Model):
) )
def to_dict(self): def to_dict(self):
"""as a dict, with the same conversions as in ScoDoc7""" """as a dict, with the same conversions as in ScoDoc7, including module"""
e = dict(self.__dict__) e = dict(self.__dict__)
e.pop("_sa_instance_state", None) e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators: (backward compat) # ScoDoc7 output_formators: (backward compat)
@ -129,14 +131,38 @@ class ModuleImplInscription(db.Model):
) )
@classmethod @classmethod
def nb_inscriptions_dans_ue( def etud_modimpls_in_ue(
cls, formsemestre_id: int, etudid: int, ue_id: int cls, formsemestre_id: int, etudid: int, ue_id: int
) -> int: ) -> flask_sqlalchemy.BaseQuery:
"""Nombre de moduleimpls de l'UE auxquels l'étudiant est inscrit""" """moduleimpls de l'UE auxquels l'étudiant est inscrit.
(Attention: inutile en APC, il faut considérer les coefficients)
"""
return ModuleImplInscription.query.filter( return ModuleImplInscription.query.filter(
ModuleImplInscription.etudid == etudid, ModuleImplInscription.etudid == etudid,
ModuleImplInscription.moduleimpl_id == ModuleImpl.id, ModuleImplInscription.moduleimpl_id == ModuleImpl.id,
ModuleImpl.formsemestre_id == formsemestre_id, ModuleImpl.formsemestre_id == formsemestre_id,
ModuleImpl.module_id == Module.id, ModuleImpl.module_id == Module.id,
Module.ue_id == ue_id, Module.ue_id == ue_id,
).count() )
@classmethod
def nb_inscriptions_dans_ue(
cls, formsemestre_id: int, etudid: int, ue_id: int
) -> int:
"""Nombre de moduleimpls de l'UE auxquels l'étudiant est inscrit"""
return cls.etud_modimpls_in_ue(formsemestre_id, etudid, ue_id).count()
@classmethod
def sum_coefs_modimpl_ue(
cls, formsemestre_id: int, etudid: int, ue_id: int
) -> float:
"""Somme des coefficients des modules auxquels l'étudiant est inscrit
dans l'UE du semestre indiqué.
N'utilise que les coefficients, donc inadapté aux formations APC.
"""
return sum(
[
inscr.modimpl.module.coefficient
for inscr in cls.etud_modimpls_in_ue(formsemestre_id, etudid, ue_id)
]
)

View File

@ -33,8 +33,8 @@ class Module(db.Model):
numero = db.Column(db.Integer) # ordre de présentation numero = db.Column(db.Integer) # ordre de présentation
# id de l'element pedagogique Apogee correspondant: # id de l'element pedagogique Apogee correspondant:
code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) code_apogee = db.Column(db.String(APO_CODE_STR_LEN))
# Type: ModuleType: DEFAULT, MALUS, RESSOURCE, MODULE_SAE (enum) # Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)
module_type = db.Column(db.Integer) module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0")
# Relations: # Relations:
modimpls = db.relationship("ModuleImpl", backref="module", lazy="dynamic") modimpls = db.relationship("ModuleImpl", backref="module", lazy="dynamic")
ues_apc = db.relationship("UniteEns", secondary="module_ue_coef", viewonly=True) ues_apc = db.relationship("UniteEns", secondary="module_ue_coef", viewonly=True)
@ -76,6 +76,11 @@ class Module(db.Model):
def type_name(self): def type_name(self):
return scu.MODULE_TYPE_NAMES[self.module_type] return scu.MODULE_TYPE_NAMES[self.module_type]
def type_abbrv(self):
""" "mod", "malus", "res", "sae"
(utilisées pour style css)"""
return scu.ModuleType.get_abbrev(self.module_type)
def set_ue_coef(self, ue, coef: float) -> None: def set_ue_coef(self, ue, coef: float) -> None:
"""Set coef module vers cette UE""" """Set coef module vers cette UE"""
self.update_ue_coef_dict({ue.id: coef}) self.update_ue_coef_dict({ue.id: coef})

View File

@ -4,8 +4,9 @@
""" """
from app import db from app import db
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
class BulAppreciations(db.Model): class BulAppreciations(db.Model):
@ -67,3 +68,29 @@ class NotesNotesLog(db.Model):
comment = db.Column(db.Text) # texte libre comment = db.Column(db.Text) # texte libre
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
uid = db.Column(db.Integer, db.ForeignKey("user.id")) uid = db.Column(db.Integer, db.ForeignKey("user.id"))
def etud_has_notes_attente(etudid, formsemestre_id):
"""Vrai si cet etudiant a au moins une note en attente dans ce semestre.
(ne compte que les notes en attente dans des évaluations avec coef. non nul).
"""
cursor = db.session.execute(
"""SELECT COUNT(*)
FROM notes_notes n, notes_evaluation e, notes_moduleimpl m,
notes_moduleimpl_inscription i
WHERE n.etudid = :etudid
and n.value = :code_attente
and n.evaluation_id = e.id
and e.moduleimpl_id = m.id
and m.formsemestre_id = :formsemestre_id
and e.coefficient != 0
and m.id = i.moduleimpl_id
and i.etudid = :etudid
""",
{
"formsemestre_id": formsemestre_id,
"etudid": etudid,
"code_attente": scu.NOTES_ATTENTE,
},
)
return cursor.fetchone()[0] > 0

View File

@ -4,7 +4,6 @@
from app import db from app import db
from app.models import APO_CODE_STR_LEN from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.scodoc import notesdb as ndb
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -54,13 +53,15 @@ class UniteEns(db.Model):
'EXTERNE' if self.is_external else ''})>""" 'EXTERNE' if self.is_external else ''})>"""
def to_dict(self): def to_dict(self):
"""as a dict, with the same conversions as in ScoDoc7""" """as a dict, with the same conversions as in ScoDoc7
(except ECTS: keep None)
"""
e = dict(self.__dict__) e = dict(self.__dict__)
e.pop("_sa_instance_state", None) e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators # ScoDoc7 output_formators
e["ue_id"] = self.id e["ue_id"] = self.id
e["numero"] = e["numero"] if e["numero"] else 0 e["numero"] = e["numero"] if e["numero"] else 0
e["ects"] = e["ects"] if e["ects"] else 0.0 e["ects"] = e["ects"]
e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0 e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0
e["code_apogee"] = e["code_apogee"] or "" # pas de None e["code_apogee"] = e["code_apogee"] or "" # pas de None
return e return e

1
app/pe/__init__.py Normal file
View File

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

View File

@ -87,7 +87,7 @@ def get_tags_latex(code_latex):
""" """
if code_latex: if code_latex:
# changé par EV: était r"([\*]{2}[a-zA-Z0-9:éèàâêëïôöù]+[\*]{2})" # changé par EV: était r"([\*]{2}[a-zA-Z0-9:éèàâêëïôöù]+[\*]{2})"
res = re.findall(r"([\*]{2}[^ \t\n\r\f\v\*]+[\*]{2})", code_latex) res = re.findall(r"([\*]{2}[^\t\n\r\f\v\*]+[\*]{2})", code_latex)
return [tag[2:-2] for tag in res] return [tag[2:-2] for tag in res]
else: else:
return [] return []

View File

@ -47,7 +47,7 @@ import os
from zipfile import ZipFile from zipfile import ZipFile
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_common import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre from app.models import FormSemestre
from app.scodoc.gen_tables import GenTable, SeqGenTable from app.scodoc.gen_tables import GenTable, SeqGenTable
@ -322,12 +322,10 @@ class JuryPE(object):
etudiants = [] etudiants = []
for sem in semsListe: # pour chacun des semestres de la liste for sem in semsListe: # pour chacun des semestres de la liste
# nt = self.get_notes_d_un_semestre( sem['formsemestre_id'] )
nt = self.get_cache_notes_d_un_semestre(sem["formsemestre_id"]) nt = self.get_cache_notes_d_un_semestre(sem["formsemestre_id"])
# sco_cache.NotesTableCache.get( sem['formsemestre_id'])
etudiantsDuSemestre = ( etudiantsDuSemestre = (
nt.get_etudids() nt.get_etudids()
) # nt.identdict.keys() # identification des etudiants du semestre ) # identification des etudiants du semestre
if pe_tools.PE_DEBUG: if pe_tools.PE_DEBUG:
pe_tools.pe_print( pe_tools.pe_print(

View File

@ -38,11 +38,10 @@ Created on Fri Sep 9 09:15:05 2016
from app import log from app import log
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_common import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre from app.models import FormSemestre
from app.models.moduleimpls import ModuleImpl from app.models.moduleimpls import ModuleImpl
from app.models.ues import UniteEns
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
from app.scodoc import sco_tag_module from app.scodoc import sco_tag_module
from app.pe import pe_tagtable from app.pe import pe_tagtable
@ -194,12 +193,14 @@ class SemestreTag(pe_tagtable.TableTag):
return tagdict return tagdict
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def comp_MoyennesTag(self, tag, force=False): def comp_MoyennesTag(self, tag, force=False) -> list:
"""Calcule et renvoie les "moyennes" de tous les étudiants du SemTag (non défaillants) """Calcule et renvoie les "moyennes" de tous les étudiants du SemTag
à un tag donné, en prenant en compte (non défaillants) à un tag donné, en prenant en compte
tous les modimpl_id concerné par le tag, leur coeff et leur pondération. tous les modimpl_id concerné par le tag, leur coeff et leur pondération.
Force ou non le calcul de la moyenne lorsque des notes sont manquantes. Force ou non le calcul de la moyenne lorsque des notes sont manquantes.
Renvoie les informations sous la forme d'une liste [ (moy, somme_coeff_normalise, etudid), ...]
Renvoie les informations sous la forme d'une liste
[ (moy, somme_coeff_normalise, etudid), ...]
""" """
lesMoyennes = [] lesMoyennes = []
for etudid in self.get_etudids(): for etudid in self.get_etudids():

View File

@ -38,6 +38,7 @@ Created on Thu Sep 8 09:36:33 2016
""" """
import datetime import datetime
import numpy as np
from app.scodoc import notes_table from app.scodoc import notes_table
@ -287,47 +288,52 @@ class TableTag(object):
# ********************************************* # *********************************************
def moyenne_ponderee_terme_a_terme(notes, coeffs=None, force=False): def moyenne_ponderee_terme_a_terme(notes, coefs=None, force=False):
""" """
Calcule la moyenne pondérée d'une liste de notes avec d'éventuels coeffs de pondération. Calcule la moyenne pondérée d'une liste de notes avec d'éventuels coeffs de pondération.
Renvoie le résultat sous forme d'un tuple (moy, somme_coeff) Renvoie le résultat sous forme d'un tuple (moy, somme_coeff)
La liste de notes contient soit : 1) des valeurs numériques 2) des strings "-NA-" (pas de notes) ou "-NI-" (pas inscrit) La liste de notes contient soit :
ou "-c-" ue capitalisée, 3) None. 1) des valeurs numériques
2) des strings "-NA-" (pas de notes) ou "-NI-" (pas inscrit) ou "-c-" ue capitalisée,
3) None.
Le paramètre force indique si le calcul de la moyenne doit être forcée ou non, c'est à Le paramètre force indique si le calcul de la moyenne doit être forcée ou non, c'est à
dire s'il y a ou non omission des notes non numériques (auquel cas la moyenne est calculée sur les dire s'il y a ou non omission des notes non numériques (auquel cas la moyenne est
notes disponibles) ; sinon renvoie (None, None). calculée sur les notes disponibles) ; sinon renvoie (None, None).
""" """
# Vérification des paramètres d'entrée # Vérification des paramètres d'entrée
if not isinstance(notes, list) or ( if not isinstance(notes, list) or (
coeffs != None and not isinstance(coeffs, list) and len(coeffs) != len(notes) coefs != None and not isinstance(coefs, list) and len(coefs) != len(notes)
): ):
raise ValueError("Erreur de paramètres dans moyenne_ponderee_terme_a_terme") raise ValueError("Erreur de paramètres dans moyenne_ponderee_terme_a_terme")
# Récupération des valeurs des paramètres d'entrée # Récupération des valeurs des paramètres d'entrée
coeffs = [1] * len(notes) if coeffs == None else coeffs coefs = [1] * len(notes) if coefs is None else coefs
# S'il n'y a pas de notes # S'il n'y a pas de notes
if not notes: # Si notes = [] if not notes: # Si notes = []
return (None, None) return (None, None)
notesValides = [ # Liste indiquant les notes valides
(1 if isinstance(note, float) or isinstance(note, int) else 0) for note in notes notes_valides = [
] # Liste indiquant les notes valides (isinstance(note, float) and not np.isnan(note)) or isinstance(note, int)
if force == True or ( for note in notes
force == False and sum(notesValides) == len(notes) ]
): # Si on force le calcul de la moyenne ou qu'on ne le force pas et qu'on a le bon nombre de notes # Si on force le calcul de la moyenne ou qu'on ne le force pas
(moyenne, ponderation) = (0.0, 0.0) # et qu'on a le bon nombre de notes
if force or sum(notes_valides) == len(notes):
moyenne, ponderation = 0.0, 0.0
for i in range(len(notes)): for i in range(len(notes)):
if notesValides[i]: if notes_valides[i]:
moyenne += coeffs[i] * notes[i] moyenne += coefs[i] * notes[i]
ponderation += coeffs[i] ponderation += coefs[i]
return ( return (
(moyenne / (ponderation * 1.0), ponderation) (moyenne / (ponderation * 1.0), ponderation)
if ponderation != 0 if ponderation != 0
else (None, 0) else (None, 0)
) )
else: # Si on ne force pas le calcul de la moyenne # Si on ne force pas le calcul de la moyenne
return (None, None) return (None, None)

View File

@ -36,6 +36,7 @@
""" """
from flask import send_file, request from flask import send_file, request
from app.scodoc.sco_exceptions import ScoValueError
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
@ -50,27 +51,34 @@ from app.pe import pe_avislatex
def _pe_view_sem_recap_form(formsemestre_id): def _pe_view_sem_recap_form(formsemestre_id):
H = [ H = [
html_sco_header.sco_header(page_title="Avis de poursuite d'études"), html_sco_header.sco_header(page_title="Avis de poursuite d'études"),
"""<h2 class="formsemestre">Génération des avis de poursuites d'études</h2> f"""<h2 class="formsemestre">Génération des avis de poursuites d'études</h2>
<p class="help"> <p class="help">
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.
<br/> <br/>
De nombreux aspects sont paramétrables: De nombreux aspects sont paramétrables:
<a href="https://scodoc.org/AvisPoursuiteEtudes" target="_blank" rel="noopener noreferrer"> <a href="https://scodoc.org/AvisPoursuiteEtudes" target="_blank" rel="noopener">
voir la documentation</a>. voir la documentation</a>.
</p> </p>
<form method="post" action="pe_view_sem_recap" id="pe_view_sem_recap_form" enctype="multipart/form-data"> <form method="post" action="pe_view_sem_recap" id="pe_view_sem_recap_form"
enctype="multipart/form-data">
<div class="pe_template_up"> <div class="pe_template_up">
Les templates sont généralement installés sur le serveur ou dans le paramétrage de ScoDoc.<br/> Les templates sont généralement installés sur le serveur ou dans le
Au besoin, vous pouvez spécifier ici votre propre fichier de template (<tt>un_avis.tex</tt>): paramétrage de ScoDoc.
<div class="pe_template_upb">Template: <input type="file" size="30" name="avis_tmpl_file"/></div> <br/>
<div class="pe_template_upb">Pied de page: <input type="file" size="30" name="footer_tmpl_file"/></div> Au besoin, vous pouvez spécifier ici votre propre fichier de template
(<tt>un_avis.tex</tt>):
<div class="pe_template_upb">Template:
<input type="file" size="30" name="avis_tmpl_file"/>
</div>
<div class="pe_template_upb">Pied de page:
<input type="file" size="30" name="footer_tmpl_file"/>
</div>
</div> </div>
<input type="submit" value="Générer les documents"/> <input type="submit" value="Générer les documents"/>
<input type="hidden" name="formsemestre_id" value="{formsemestre_id}"> <input type="hidden" name="formsemestre_id" value="{formsemestre_id}">
</form> </form>
""".format( """,
formsemestre_id=formsemestre_id
),
] ]
return "\n".join(H) + html_sco_header.sco_footer() return "\n".join(H) + html_sco_header.sco_footer()
@ -97,8 +105,12 @@ def pe_view_sem_recap(
template_latex = "" template_latex = ""
# template fourni via le formulaire Web # template fourni via le formulaire Web
if avis_tmpl_file: if avis_tmpl_file:
template_latex = avis_tmpl_file.read().decode('utf-8') try:
template_latex = template_latex template_latex = avis_tmpl_file.read().decode("utf-8")
except UnicodeDecodeError as e:
raise ScoValueError(
"Données (template) invalides (caractères non UTF8 ?)"
) from e
else: else:
# template indiqué dans préférences ScoDoc ? # template indiqué dans préférences ScoDoc ?
template_latex = pe_avislatex.get_code_latex_from_scodoc_preference( template_latex = pe_avislatex.get_code_latex_from_scodoc_preference(
@ -114,8 +126,7 @@ def pe_view_sem_recap(
footer_latex = "" footer_latex = ""
# template fourni via le formulaire Web # template fourni via le formulaire Web
if footer_tmpl_file: if footer_tmpl_file:
footer_latex = footer_tmpl_file.read().decode('utf-8') footer_latex = footer_tmpl_file.read().decode("utf-8")
footer_latex = footer_latex
else: else:
footer_latex = pe_avislatex.get_code_latex_from_scodoc_preference( footer_latex = pe_avislatex.get_code_latex_from_scodoc_preference(
formsemestre_id, champ="pe_avis_latex_footer" formsemestre_id, champ="pe_avis_latex_footer"

View File

@ -254,13 +254,13 @@ class TF(object):
continue # allowed empty field, skip continue # allowed empty field, skip
# type # type
typ = descr.get("type", "string") typ = descr.get("type", "string")
if val != "" and val != None: if val != "" and val is not None:
# check only non-null values # check only non-null values
if typ[:3] == "int": if typ[:3] == "int":
try: try:
val = int(val) val = int(val)
self.values[field] = val self.values[field] = val
except: except ValueError:
msg.append( msg.append(
"La valeur du champ '%s' doit être un nombre entier" % field "La valeur du champ '%s' doit être un nombre entier" % field
) )
@ -270,35 +270,36 @@ class TF(object):
try: try:
val = float(val.replace(",", ".")) # allow , val = float(val.replace(",", ".")) # allow ,
self.values[field] = val self.values[field] = val
except: except ValueError:
msg.append( msg.append(
"La valeur du champ '%s' doit être un nombre" % field "La valeur du champ '%s' doit être un nombre" % field
) )
ok = 0 ok = 0
if typ[:3] == "int" or typ == "float" or typ == "real":
if ( if (
val != "" ok
and (typ[:3] == "int" or typ == "float" or typ == "real")
and val != ""
and val != None and val != None
and "min_value" in descr
and val < descr["min_value"]
): ):
if "min_value" in descr and self.values[field] < descr["min_value"]:
msg.append( msg.append(
"La valeur (%d) du champ '%s' est trop petite (min=%s)" "La valeur (%d) du champ '%s' est trop petite (min=%s)"
% (val, field, descr["min_value"]) % (val, field, descr["min_value"])
) )
ok = 0 ok = 0
if "max_value" in descr and self.values[field] > descr["max_value"]:
if (
val != ""
and val != None
and "max_value" in descr
and val > descr["max_value"]
):
msg.append( msg.append(
"La valeur (%s) du champ '%s' est trop grande (max=%s)" "La valeur (%s) du champ '%s' est trop grande (max=%s)"
% (val, field, descr["max_value"]) % (val, field, descr["max_value"])
) )
ok = 0 ok = 0
if ok and (typ[:3] == "str") and "max_length" in descr:
if len(self.values[field]) > descr["max_length"]:
msg.append(
"Le champ '%s' est trop long (max %d caractères)"
% (field, descr["max_length"])
)
ok = 0
# allowed values # allowed values
if "allowed_values" in descr: if "allowed_values" in descr:

View File

@ -63,12 +63,15 @@ from app.scodoc.sco_pdf import SU
from app import log from app import log
def mark_paras(L, tags): def mark_paras(L, tags) -> list[str]:
"""Put each (string) element of L between <b>""" """Put each (string) element of L between <tag>...</tag>,
for each supplied tag.
Leave non string elements untouched.
"""
for tag in tags: for tag in tags:
b = "<" + tag + ">" start = "<" + tag + ">"
c = "</" + tag.split()[0] + ">" end = "</" + tag.split()[0] + ">"
L = [b + (x or "") + c for x in L] L = [(start + (x or "") + end) if isinstance(x, str) else x for x in L]
return L return L
@ -233,6 +236,9 @@ class GenTable(object):
colspan_count -= 1 colspan_count -= 1
# if colspan_count > 0: # if colspan_count > 0:
# continue # skip cells after a span # continue # skip cells after a span
if pdf_mode:
content = row.get(f"_{cid}_pdf", "") or row.get(cid, "") or ""
else:
content = row.get(cid, "") or "" # nota: None converted to '' content = row.get(cid, "") or "" # nota: None converted to ''
colspan = row.get("_%s_colspan" % cid, 0) colspan = row.get("_%s_colspan" % cid, 0)
if colspan > 1: if colspan > 1:
@ -547,9 +553,16 @@ class GenTable(object):
omit_hidden_lines=True, omit_hidden_lines=True,
) )
try: try:
Pt = [ Pt = []
[Paragraph(SU(str(x)), CellStyle) for x in line] for line in data_list for line in data_list:
Pt.append(
[
Paragraph(SU(str(x)), CellStyle)
if (not isinstance(x, Paragraph))
else x
for x in line
] ]
)
except ValueError as exc: except ValueError as exc:
raise ScoPDFFormatError(str(exc)) from exc raise ScoPDFFormatError(str(exc)) from exc
pdf_style_list += self.pdf_table_style pdf_style_list += self.pdf_table_style
@ -748,7 +761,7 @@ if __name__ == "__main__":
doc = io.BytesIO() doc = io.BytesIO()
document = sco_pdf.BaseDocTemplate(doc) document = sco_pdf.BaseDocTemplate(doc)
document.addPageTemplates( document.addPageTemplates(
sco_pdf.ScolarsPageTemplate( sco_pdf.ScoDocPageTemplate(
document, document,
) )
) )

View File

@ -30,12 +30,12 @@
import html import html
from flask import g from flask import render_template
from flask import request from flask import request
from flask_login import current_user from flask_login import current_user
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import log from app import scodoc_flash_status_messages
from app.scodoc import html_sidebar from app.scodoc import html_sidebar
import sco_version import sco_version
@ -105,7 +105,6 @@ _HTML_BEGIN = """<!DOCTYPE html>
<link href="/ScoDoc/static/css/scodoc.css" rel="stylesheet" type="text/css" /> <link href="/ScoDoc/static/css/scodoc.css" rel="stylesheet" type="text/css" />
<link href="/ScoDoc/static/css/menu.css" rel="stylesheet" type="text/css" /> <link href="/ScoDoc/static/css/menu.css" rel="stylesheet" type="text/css" />
<script src="/ScoDoc/static/libjs/menu.js"></script> <script src="/ScoDoc/static/libjs/menu.js"></script>
<script src="/ScoDoc/static/libjs/sorttable.js"></script>
<script src="/ScoDoc/static/libjs/bubble.js"></script> <script src="/ScoDoc/static/libjs/bubble.js"></script>
<script> <script>
window.onload=function(){enableTooltips("gtrcontent")}; window.onload=function(){enableTooltips("gtrcontent")};
@ -153,13 +152,14 @@ def sco_header(
"Main HTML page header for ScoDoc" "Main HTML page header for ScoDoc"
from app.scodoc.sco_formsemestre_status import formsemestre_page_title from app.scodoc.sco_formsemestre_status import formsemestre_page_title
scodoc_flash_status_messages()
# Get head message from http request: # Get head message from http request:
if not head_message: if not head_message:
if request.method == "POST": if request.method == "POST":
head_message = request.form.get("head_message", "") head_message = request.form.get("head_message", "")
elif request.method == "GET": elif request.method == "GET":
head_message = request.args.get("head_message", "") head_message = request.args.get("head_message", "")
params = { params = {
"page_title": page_title or sco_version.SCONAME, "page_title": page_title or sco_version.SCONAME,
"no_side_bar": no_side_bar, "no_side_bar": no_side_bar,
@ -249,6 +249,9 @@ def sco_header(
'<link rel="stylesheet" type="text/css" href="/ScoDoc/static/DataTables/datatables.min.css"/>' '<link rel="stylesheet" type="text/css" href="/ScoDoc/static/DataTables/datatables.min.css"/>'
) )
H.append('<script src="/ScoDoc/static/DataTables/datatables.min.js"></script>') H.append('<script src="/ScoDoc/static/DataTables/datatables.min.js"></script>')
# H.append(
# '<link href="/ScoDoc/static/css/tooltip.css" rel="stylesheet" type="text/css" />'
# )
# JS additionels # JS additionels
for js in javascripts: for js in javascripts:
H.append("""<script src="/ScoDoc/static/%s"></script>\n""" % js) H.append("""<script src="/ScoDoc/static/%s"></script>\n""" % js)
@ -280,6 +283,9 @@ def sco_header(
if not no_side_bar: if not no_side_bar:
H.append(html_sidebar.sidebar()) H.append(html_sidebar.sidebar())
H.append("""<div id="gtrcontent">""") H.append("""<div id="gtrcontent">""")
# En attendant le replacement complet de cette fonction,
# inclusion ici des messages flask
H.append(render_template("flashed_messages.html"))
# #
# Barre menu semestre: # Barre menu semestre:
H.append(formsemestre_page_title()) H.append(formsemestre_page_title())

View File

@ -51,8 +51,8 @@ from app.scodoc.sco_formsemestre import (
from app.scodoc.sco_codes_parcours import ( from app.scodoc.sco_codes_parcours import (
DEF, DEF,
UE_SPORT, UE_SPORT,
UE_is_fondamentale, ue_is_fondamentale,
UE_is_professionnelle, ue_is_professionnelle,
) )
from app.scodoc.sco_parcours_dut import formsemestre_get_etud_capitalisation from app.scodoc.sco_parcours_dut import formsemestre_get_etud_capitalisation
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
@ -171,7 +171,7 @@ class NotesTable:
def __init__(self, formsemestre_id): def __init__(self, formsemestre_id):
# log(f"NotesTable( formsemestre_id={formsemestre_id} )") # log(f"NotesTable( formsemestre_id={formsemestre_id} )")
# raise NotImplementedError() # XXX raise NotImplementedError() # XXX
if not formsemestre_id: if not formsemestre_id:
raise ValueError("invalid formsemestre_id (%s)" % formsemestre_id) raise ValueError("invalid formsemestre_id (%s)" % formsemestre_id)
self.formsemestre_id = formsemestre_id self.formsemestre_id = formsemestre_id
@ -826,11 +826,11 @@ class NotesTable:
and mu["moy"] >= self.parcours.NOTES_BARRE_VALID_UE and mu["moy"] >= self.parcours.NOTES_BARRE_VALID_UE
): ):
mu["ects_pot"] = ue["ects"] or 0.0 mu["ects_pot"] = ue["ects"] or 0.0
if UE_is_fondamentale(ue["type"]): if ue_is_fondamentale(ue["type"]):
mu["ects_pot_fond"] = mu["ects_pot"] mu["ects_pot_fond"] = mu["ects_pot"]
else: else:
mu["ects_pot_fond"] = 0.0 mu["ects_pot_fond"] = 0.0
if UE_is_professionnelle(ue["type"]): if ue_is_professionnelle(ue["type"]):
mu["ects_pot_pro"] = mu["ects_pot"] mu["ects_pot_pro"] = mu["ects_pot"]
else: else:
mu["ects_pot_pro"] = 0.0 mu["ects_pot_pro"] = 0.0
@ -954,9 +954,12 @@ class NotesTable:
Return: True|False, message explicatif Return: True|False, message explicatif
""" """
return self.parcours.check_barre_ues( ue_status_list = []
[self.get_etud_ue_status(etudid, ue["ue_id"]) for ue in self._ues] for ue in self._ues:
) ue_status = self.get_etud_ue_status(etudid, ue["ue_id"])
if ue_status:
ue_status_list.append(ue_status)
return self.parcours.check_barre_ues(ue_status_list)
def get_table_moyennes_triees(self): def get_table_moyennes_triees(self):
return self.T return self.T
@ -1160,9 +1163,11 @@ class NotesTable:
nt_cap = sco_cache.NotesTableCache.get( nt_cap = sco_cache.NotesTableCache.get(
ue_cap["formsemestre_id"] ue_cap["formsemestre_id"]
) # > UE capitalisees par un etud ) # > UE capitalisees par un etud
moy_ue_cap = nt_cap.get_etud_ue_status(etudid, ue_cap["ue_id"])[ ue_cap_status = nt_cap.get_etud_ue_status(etudid, ue_cap["ue_id"])
"moy" if ue_cap_status:
] moy_ue_cap = ue_cap_status["moy"]
else:
moy_ue_cap = ""
ue_cap["moy_ue"] = moy_ue_cap ue_cap["moy_ue"] = moy_ue_cap
if ( if (
isinstance(moy_ue_cap, float) isinstance(moy_ue_cap, float)

View File

@ -53,7 +53,11 @@ def _isFarFutur(jour):
# check si jour est dans le futur "lointain" # check si jour est dans le futur "lointain"
# pour autoriser les saisies dans le futur mais pas a plus de 6 mois # pour autoriser les saisies dans le futur mais pas a plus de 6 mois
y, m, d = [int(x) for x in jour.split("-")] y, m, d = [int(x) for x in jour.split("-")]
try:
j = datetime.date(y, m, d) j = datetime.date(y, m, d)
except ValueError:
# les dates erronées, genre année 20022, sont considéres dans le futur
return True
# 6 mois ~ 182 jours: # 6 mois ~ 182 jours:
return j - datetime.date.today() > datetime.timedelta(182) return j - datetime.date.today() > datetime.timedelta(182)
@ -225,8 +229,11 @@ def DateRangeISO(date_beg, date_end, workable=1):
date_end = date_beg date_end = date_beg
r = [] r = []
work_saturday = is_work_saturday() work_saturday = is_work_saturday()
try:
cur = ddmmyyyy(date_beg, work_saturday=work_saturday) cur = ddmmyyyy(date_beg, work_saturday=work_saturday)
end = ddmmyyyy(date_end, work_saturday=work_saturday) end = ddmmyyyy(date_end, work_saturday=work_saturday)
except (AttributeError, ValueError) as e:
raise ScoValueError("date invalide !") from e
while cur <= end: while cur <= end:
if (not workable) or cur.iswork(): if (not workable) or cur.iswork():
r.append(cur) r.append(cur)
@ -479,7 +486,7 @@ def _get_abs_description(a, cursor=None):
) )
if Mlist: if Mlist:
M = Mlist[0] M = Mlist[0]
module += "%s " % M["module"]["code"] module += "%s " % (M["module"]["code"] or "(module sans code)")
if desc: if desc:
return "(%s) %s" % (desc, module) return "(%s) %s" % (desc, module)
@ -631,7 +638,7 @@ def add_absence(
): ):
"Ajoute une absence dans la bd" "Ajoute une absence dans la bd"
if _isFarFutur(jour): if _isFarFutur(jour):
raise ScoValueError("date absence trop loin dans le futur !") raise ScoValueError("date absence erronée ou trop loin dans le futur !")
estjust = _toboolean(estjust) estjust = _toboolean(estjust)
matin = _toboolean(matin) matin = _toboolean(matin)
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
@ -1037,7 +1044,7 @@ def get_abs_count(etudid, sem):
def get_abs_count_in_interval(etudid, date_debut_iso, date_fin_iso): def get_abs_count_in_interval(etudid, date_debut_iso, date_fin_iso):
"""Les comptes d'absences de cet étudiant entre ces deux dates, incluses: """Les comptes d'absences de cet étudiant entre ces deux dates, incluses:
tuple (nb abs non justifiées, nb abs justifiées) tuple (nb abs, nb abs justifiées)
Utilise un cache. Utilise un cache.
""" """
key = str(etudid) + "_" + date_debut_iso + "_" + date_fin_iso key = str(etudid) + "_" + date_debut_iso + "_" + date_fin_iso

View File

@ -35,6 +35,7 @@ import datetime
from flask import g, url_for from flask import g, url_for
from flask_mail import Message from flask_mail import Message
from app.models.formsemestre import FormSemestre
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -55,27 +56,30 @@ def abs_notify(etudid, date):
""" """
from app.scodoc import sco_abs from app.scodoc import sco_abs
sem = retreive_current_formsemestre(etudid, date) formsemestre = retreive_current_formsemestre(etudid, date)
if not sem: if not formsemestre:
return # non inscrit a la date, pas de notification return # non inscrit a la date, pas de notification
nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem) nbabs, nbabsjust = sco_abs.get_abs_count_in_interval(
do_abs_notify(sem, etudid, date, nbabs, nbabsjust) etudid, formsemestre.date_debut.isoformat(), formsemestre.date_fin.isoformat()
)
do_abs_notify(formsemestre, etudid, date, nbabs, nbabsjust)
def do_abs_notify(sem, etudid, date, nbabs, nbabsjust): def do_abs_notify(formsemestre: FormSemestre, etudid, date, nbabs, nbabsjust):
"""Given new counts of absences, check if notifications are requested and send them.""" """Given new counts of absences, check if notifications are requested and send them."""
# prefs fallback to global pref if sem is None: # prefs fallback to global pref if sem is None:
if sem: if formsemestre:
formsemestre_id = sem["formsemestre_id"] formsemestre_id = formsemestre.id
else: else:
formsemestre_id = None formsemestre_id = None
prefs = sco_preferences.SemPreferences(formsemestre_id=sem["formsemestre_id"]) prefs = sco_preferences.SemPreferences(formsemestre_id=formsemestre_id)
destinations = abs_notify_get_destinations( destinations = abs_notify_get_destinations(
sem, prefs, etudid, date, nbabs, nbabsjust formsemestre, prefs, etudid, date, nbabs, nbabsjust
) )
msg = abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust)
msg = abs_notification_message(formsemestre, prefs, etudid, nbabs, nbabsjust)
if not msg: if not msg:
return # abort return # abort
@ -131,19 +135,19 @@ def abs_notify_send(destinations, etudid, msg, nbabs, nbabsjust, formsemestre_id
) )
def abs_notify_get_destinations(sem, prefs, etudid, date, nbabs, nbabsjust): def abs_notify_get_destinations(
formsemestre: FormSemestre, prefs, etudid, date, nbabs, nbabsjust
) -> set:
"""Returns set of destination emails to be notified""" """Returns set of destination emails to be notified"""
formsemestre_id = sem["formsemestre_id"]
destinations = [] # list of email address to notify destinations = [] # list of email address to notify
if abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre_id): if abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre.id):
if sem and prefs["abs_notify_respsem"]: if prefs["abs_notify_respsem"]:
# notifie chaque responsable du semestre # notifie chaque responsable du semestre
for responsable_id in sem["responsables"]: for responsable in formsemestre.responsables:
u = sco_users.user_info(responsable_id) if responsable.email:
if u["email"]: destinations.append(responsable.email)
destinations.append(u["email"])
if prefs["abs_notify_chief"] and prefs["email_chefdpt"]: if prefs["abs_notify_chief"] and prefs["email_chefdpt"]:
destinations.append(prefs["email_chefdpt"]) destinations.append(prefs["email_chefdpt"])
if prefs["abs_notify_email"]: if prefs["abs_notify_email"]:
@ -156,7 +160,7 @@ def abs_notify_get_destinations(sem, prefs, etudid, date, nbabs, nbabsjust):
# Notification (à chaque fois) des resp. de modules ayant des évaluations # Notification (à chaque fois) des resp. de modules ayant des évaluations
# à cette date # à cette date
# nb: on pourrait prevoir d'utiliser un autre format de message pour ce cas # nb: on pourrait prevoir d'utiliser un autre format de message pour ce cas
if sem and prefs["abs_notify_respeval"]: if prefs["abs_notify_respeval"]:
mods = mod_with_evals_at_date(date, etudid) mods = mod_with_evals_at_date(date, etudid)
for mod in mods: for mod in mods:
u = sco_users.user_info(mod["responsable_id"]) u = sco_users.user_info(mod["responsable_id"])
@ -232,7 +236,9 @@ def user_nbdays_since_last_notif(email_addr, etudid):
return None return None
def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust): def abs_notification_message(
formsemestre: FormSemestre, prefs, etudid, nbabs, nbabsjust
):
"""Mime notification message based on template. """Mime notification message based on template.
returns a Message instance returns a Message instance
or None if sending should be canceled (empty template). or None if sending should be canceled (empty template).
@ -242,13 +248,13 @@ def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust):
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
# Variables accessibles dans les balises du template: %(nom_variable)s : # Variables accessibles dans les balises du template: %(nom_variable)s :
values = sco_bulletins.make_context_dict(sem, etud) values = sco_bulletins.make_context_dict(formsemestre, etud)
values["nbabs"] = nbabs values["nbabs"] = nbabs
values["nbabsjust"] = nbabsjust values["nbabsjust"] = nbabsjust
values["nbabsnonjust"] = nbabs - nbabsjust values["nbabsnonjust"] = nbabs - nbabsjust
values["url_ficheetud"] = url_for( values["url_ficheetud"] = url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True
) )
template = prefs["abs_notification_mail_tmpl"] template = prefs["abs_notification_mail_tmpl"]
@ -264,9 +270,11 @@ def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust):
return msg return msg
def retreive_current_formsemestre(etudid, cur_date): def retreive_current_formsemestre(etudid: int, cur_date) -> FormSemestre:
"""Get formsemestre dans lequel etudid est (ou était) inscrit a la date indiquée """Get formsemestre dans lequel etudid est (ou était) inscrit a la date indiquée
date est une chaine au format ISO (yyyy-mm-dd) date est une chaine au format ISO (yyyy-mm-dd)
Result: FormSemestre ou None si pas inscrit à la date indiquée
""" """
req = """SELECT i.formsemestre_id req = """SELECT i.formsemestre_id
FROM notes_formsemestre_inscription i, notes_formsemestre sem FROM notes_formsemestre_inscription i, notes_formsemestre sem
@ -278,8 +286,8 @@ def retreive_current_formsemestre(etudid, cur_date):
if not r: if not r:
return None return None
# s'il y a plusieurs semestres, prend le premier (rarissime et non significatif): # s'il y a plusieurs semestres, prend le premier (rarissime et non significatif):
sem = sco_formsemestre.get_formsemestre(r[0]["formsemestre_id"]) formsemestre = FormSemestre.query.get(r[0]["formsemestre_id"])
return sem return formsemestre
def mod_with_evals_at_date(date_abs, etudid): def mod_with_evals_at_date(date_abs, etudid):

View File

@ -33,7 +33,9 @@ import datetime
from flask import url_for, g, request, abort from flask import url_for, g, request, abort
from app import log from app import log
from app.models import Identite from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Identite, FormSemestre
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc import notesdb as ndb from app.scodoc import notesdb as ndb
from app.scodoc.scolog import logdb from app.scodoc.scolog import logdb
@ -118,13 +120,16 @@ def doSignaleAbsence(
if moduleimpl_id and moduleimpl_id != "NULL": if moduleimpl_id and moduleimpl_id != "NULL":
mod = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] mod = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0]
formsemestre_id = mod["formsemestre_id"] formsemestre_id = mod["formsemestre_id"]
nt = sco_cache.NotesTableCache.get(formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
ues = nt.get_ues_stat_dict() ues = nt.get_ues_stat_dict()
for ue in ues: for ue in ues:
modimpls = nt.get_modimpls_dict(ue_id=ue["ue_id"]) modimpls = nt.get_modimpls_dict(ue_id=ue["ue_id"])
for modimpl in modimpls: for modimpl in modimpls:
if modimpl["moduleimpl_id"] == moduleimpl_id: if modimpl["moduleimpl_id"] == moduleimpl_id:
indication_module = "dans le module %s" % modimpl["module"]["code"] indication_module = "dans le module %s" % (
modimpl["module"]["code"] or "(pas de code)"
)
H = [ H = [
html_sco_header.sco_header( html_sco_header.sco_header(
page_title=f"Signalement d'une absence pour {etud.nomprenom}", page_title=f"Signalement d'une absence pour {etud.nomprenom}",
@ -179,11 +184,12 @@ def SignaleAbsenceEtud(): # etudid implied
menu_module = "" menu_module = ""
else: else:
formsemestre_id = etud["cursem"]["formsemestre_id"] formsemestre_id = etud["cursem"]["formsemestre_id"]
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
ues = nt.get_ues_stat_dict()
require_module = sco_preferences.get_preference( require_module = sco_preferences.get_preference(
"abs_require_module", formsemestre_id "abs_require_module", formsemestre_id
) )
nt = sco_cache.NotesTableCache.get(formsemestre_id)
ues = nt.get_ues_stat_dict()
if require_module: if require_module:
menu_module = """ menu_module = """
<script type="text/javascript"> <script type="text/javascript">
@ -214,7 +220,7 @@ def SignaleAbsenceEtud(): # etudid implied
"""<option value="%(modimpl_id)s">%(modname)s</option>\n""" """<option value="%(modimpl_id)s">%(modname)s</option>\n"""
% { % {
"modimpl_id": modimpl["moduleimpl_id"], "modimpl_id": modimpl["moduleimpl_id"],
"modname": modimpl["module"]["code"], "modname": modimpl["module"]["code"] or "",
} }
) )
menu_module += """</select></p>""" menu_module += """</select></p>"""
@ -718,6 +724,7 @@ def CalAbs(etudid, sco_year=None):
anneescolaire = int(scu.AnneeScolaire(sco_year)) anneescolaire = int(scu.AnneeScolaire(sco_year))
datedebut = str(anneescolaire) + "-08-01" datedebut = str(anneescolaire) + "-08-01"
datefin = str(anneescolaire + 1) + "-07-31" datefin = str(anneescolaire + 1) + "-07-31"
annee_courante = scu.AnneeScolaire()
nbabs = sco_abs.count_abs(etudid=etudid, debut=datedebut, fin=datefin) nbabs = sco_abs.count_abs(etudid=etudid, debut=datedebut, fin=datefin)
nbabsjust = sco_abs.count_abs_just(etudid=etudid, debut=datedebut, fin=datefin) nbabsjust = sco_abs.count_abs_just(etudid=etudid, debut=datedebut, fin=datefin)
events = [] events = []
@ -770,7 +777,7 @@ def CalAbs(etudid, sco_year=None):
"""Année scolaire %s-%s""" % (anneescolaire, anneescolaire + 1), """Année scolaire %s-%s""" % (anneescolaire, anneescolaire + 1),
"""&nbsp;&nbsp;Changer année: <select name="sco_year" onchange="document.f.submit()">""", """&nbsp;&nbsp;Changer année: <select name="sco_year" onchange="document.f.submit()">""",
] ]
for y in range(anneescolaire, anneescolaire - 10, -1): for y in range(annee_courante, min(annee_courante - 6, anneescolaire - 6), -1):
H.append("""<option value="%s" """ % y) H.append("""<option value="%s" """ % y)
if y == anneescolaire: if y == anneescolaire:
H.append("selected") H.append("selected")
@ -960,10 +967,10 @@ def _tables_abs_etud(
ex.append( ex.append(
f"""<a href="{url_for('notes.moduleimpl_status', f"""<a href="{url_for('notes.moduleimpl_status',
scodoc_dept=g.scodoc_dept, moduleimpl_id=mod["moduleimpl_id"])} scodoc_dept=g.scodoc_dept, moduleimpl_id=mod["moduleimpl_id"])}
">{mod["module"]["code"]}</a>""" ">{mod["module"]["code"] or "(module sans code)"}</a>"""
) )
else: else:
ex.append(mod["module"]["code"]) ex.append(mod["module"]["code"] or "(module sans code)")
if ex: if ex:
return ", ".join(ex) return ", ".join(ex)
return "" return ""
@ -978,10 +985,10 @@ def _tables_abs_etud(
ex.append( ex.append(
f"""<a href="{url_for('notes.moduleimpl_status', f"""<a href="{url_for('notes.moduleimpl_status',
scodoc_dept=g.scodoc_dept, moduleimpl_id=mod["moduleimpl_id"])} scodoc_dept=g.scodoc_dept, moduleimpl_id=mod["moduleimpl_id"])}
">{mod["module"]["code"]}</a>""" ">{mod["module"]["code"] or '(module sans code)'}</a>"""
) )
else: else:
ex.append(mod["module"]["code"]) ex.append(mod["module"]["code"] or "(module sans code)")
if ex: if ex:
return ", ".join(ex) return ", ".join(ex)
return "" return ""

View File

@ -95,9 +95,12 @@ from flask import send_file
# Pour la détection auto de l'encodage des fichiers Apogée: # Pour la détection auto de l'encodage des fichiers Apogée:
from chardet import detect as chardet_detect from chardet import detect as chardet_detect
from app import log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, Identite
from app.models.config import ScoDocSiteConfig from app.models.config import ScoDocSiteConfig
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import log
from app.scodoc.sco_exceptions import ScoValueError, ScoFormatError from app.scodoc.sco_exceptions import ScoValueError, ScoFormatError
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_vdi import ApoEtapeVDI
@ -108,7 +111,6 @@ from app.scodoc.sco_codes_parcours import (
NAR, NAR,
RAT, RAT,
) )
from app.scodoc import sco_cache
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
from app.scodoc import sco_parcours_dut from app.scodoc import sco_parcours_dut
from app.scodoc import sco_etud from app.scodoc import sco_etud
@ -370,7 +372,9 @@ class ApoEtud(dict):
dict: with N, B, J, R keys, ou None si elt non trouvé dict: with N, B, J, R keys, ou None si elt non trouvé
""" """
etudid = self.etud["etudid"] etudid = self.etud["etudid"]
nt = sco_cache.NotesTableCache.get(sem["formsemestre_id"]) formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if etudid not in nt.identdict: if etudid not in nt.identdict:
return None # etudiant non inscrit dans ce semestre return None # etudiant non inscrit dans ce semestre
@ -419,7 +423,7 @@ class ApoEtud(dict):
ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"])
code_decision_ue = decisions_ue[ue["ue_id"]]["code"] code_decision_ue = decisions_ue[ue["ue_id"]]["code"]
return dict( return dict(
N=_apo_fmt_note(ue_status["moy"]), N=_apo_fmt_note(ue_status["moy"] if ue_status else ""),
B=20, B=20,
J="", J="",
R=ScoDocSiteConfig.get_code_apo(code_decision_ue), R=ScoDocSiteConfig.get_code_apo(code_decision_ue),
@ -449,6 +453,12 @@ class ApoEtud(dict):
def comp_elt_semestre(self, nt, decision, etudid): def comp_elt_semestre(self, nt, decision, etudid):
"""Calcul résultat apo semestre""" """Calcul résultat apo semestre"""
if decision is None:
etud = Identite.query.get(etudid)
nomprenom = etud.nomprenom if etud else "(inconnu)"
raise ScoValueError(
f"decision absente pour l'étudiant {nomprenom} ({etudid})"
)
# resultat du semestre # resultat du semestre
decision_apo = ScoDocSiteConfig.get_code_apo(decision["code"]) decision_apo = ScoDocSiteConfig.get_code_apo(decision["code"])
note = nt.get_etud_moy_gen(etudid) note = nt.get_etud_moy_gen(etudid)
@ -476,7 +486,8 @@ class ApoEtud(dict):
# l'étudiant n'a pas de semestre courant ?! # l'étudiant n'a pas de semestre courant ?!
log("comp_elt_annuel: etudid %s has no cur_sem" % etudid) log("comp_elt_annuel: etudid %s has no cur_sem" % etudid)
return VOID_APO_RES return VOID_APO_RES
cur_nt = sco_cache.NotesTableCache.get(cur_sem["formsemestre_id"]) cur_formsemestre = FormSemestre.query.get_or_404(cur_sem["formsemestre_id"])
cur_nt: NotesTableCompat = res_sem.load_formsemestre_results(cur_formsemestre)
cur_decision = cur_nt.get_etud_decision_sem(etudid) cur_decision = cur_nt.get_etud_decision_sem(etudid)
if not cur_decision: if not cur_decision:
# pas de decision => pas de résultat annuel # pas de decision => pas de résultat annuel
@ -493,7 +504,10 @@ class ApoEtud(dict):
decision_apo = ScoDocSiteConfig.get_code_apo(cur_decision["code"]) decision_apo = ScoDocSiteConfig.get_code_apo(cur_decision["code"])
autre_nt = sco_cache.NotesTableCache.get(autre_sem["formsemestre_id"]) autre_formsemestre = FormSemestre.query.get_or_404(autre_sem["formsemestre_id"])
autre_nt: NotesTableCompat = res_sem.load_formsemestre_results(
autre_formsemestre
)
autre_decision = autre_nt.get_etud_decision_sem(etudid) autre_decision = autre_nt.get_etud_decision_sem(etudid)
if not autre_decision: if not autre_decision:
# pas de decision dans l'autre => pas de résultat annuel # pas de decision dans l'autre => pas de résultat annuel
@ -554,7 +568,8 @@ class ApoEtud(dict):
# prend le plus recent avec decision # prend le plus recent avec decision
cur_sem = None cur_sem = None
for sem in cur_sems: for sem in cur_sems:
nt = sco_cache.NotesTableCache.get(sem["formsemestre_id"]) formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
decision = nt.get_etud_decision_sem(self.etud["etudid"]) decision = nt.get_etud_decision_sem(self.etud["etudid"])
if decision: if decision:
cur_sem = sem cur_sem = sem
@ -614,7 +629,8 @@ class ApoEtud(dict):
else: else:
autre_sem = None autre_sem = None
for sem in autres_sems: for sem in autres_sems:
nt = sco_cache.NotesTableCache.get(sem["formsemestre_id"]) formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
decision = nt.get_etud_decision_sem(self.etud["etudid"]) decision = nt.get_etud_decision_sem(self.etud["etudid"])
if decision: if decision:
autre_sem = sem autre_sem = sem
@ -947,7 +963,8 @@ class ApoData(object):
s.add(code) s.add(code)
continue continue
# associé à une UE: # associé à une UE:
nt = sco_cache.NotesTableCache.get(sem["formsemestre_id"]) formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
for ue in nt.get_ues_stat_dict(): for ue in nt.get_ues_stat_dict():
if ue["code_apogee"] and code in ue["code_apogee"].split(","): if ue["code_apogee"] and code in ue["code_apogee"].split(","):
s.add(code) s.add(code)

View File

@ -70,13 +70,13 @@ from app.scodoc.sco_exceptions import (
) )
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_bulletins_pdf from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_excel
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc import sco_groups_view from app.scodoc import sco_groups_view
from app.scodoc import sco_permissions_check from app.scodoc import sco_permissions_check
from app.scodoc import sco_pvjury from app.scodoc import sco_pvjury
from app.scodoc import sco_pvpdf from app.scodoc import sco_pvpdf
from app.scodoc.sco_exceptions import ScoValueError
class BaseArchiver(object): class BaseArchiver(object):
@ -254,7 +254,7 @@ class BaseArchiver(object):
self.initialize() self.initialize()
if not scu.is_valid_filename(filename): if not scu.is_valid_filename(filename):
log('Archiver.get: invalid filename "%s"' % filename) log('Archiver.get: invalid filename "%s"' % filename)
raise ValueError("invalid filename") raise ScoValueError("archive introuvable (déjà supprimée ?)")
fname = os.path.join(archive_id, filename) fname = os.path.join(archive_id, filename)
log("reading archive file %s" % fname) log("reading archive file %s" % fname)
with open(fname, "rb") as f: with open(fname, "rb") as f:

View File

@ -132,19 +132,29 @@ BACS_S = {t[0]: t[2:] for t in _BACS}
class Baccalaureat: class Baccalaureat:
def __init__(self, bac, specialite=""): def __init__(self, bac, specialite=""):
self.bac = bac self.bac = bac or ""
self.specialite = specialite self.specialite = specialite or ""
self._abbrev, self._type = BACS_SSP.get((bac, specialite), (None, None)) self._abbrev, self._type = BACS_SSP.get(
(self.bac, self.specialite), (None, None)
)
# Parfois, la specialite commence par la serie: essaye # Parfois, la specialite commence par la serie: essaye
if self._type is None and specialite and specialite.startswith(bac): if (
specialite = specialite[len(bac) :].strip(" -") self._type is None
self._abbrev, self._type = BACS_SSP.get((bac, specialite), (None, None)) and self.specialite
and self.specialite.startswith(self.bac)
):
specialite = self.specialite[len(self.bac) :].strip(" -")
self._abbrev, self._type = BACS_SSP.get(
(self.bac, specialite), (None, None)
)
# Cherche la forme serie specialite # Cherche la forme serie specialite
if self._type is None and specialite: if self._type is None and specialite:
self._abbrev, self._type = BACS_S.get(bac + " " + specialite, (None, None)) self._abbrev, self._type = BACS_S.get(
self.bac + " " + specialite, (None, None)
)
# Cherche avec juste le bac, sans specialite # Cherche avec juste le bac, sans specialite
if self._type is None: if self._type is None:
self._abbrev, self._type = BACS_S.get(bac, (None, None)) self._abbrev, self._type = BACS_S.get(self.bac, (None, None))
def abbrev(self): def abbrev(self):
"abbreviation for this bac" "abbreviation for this bac"

View File

@ -28,30 +28,19 @@
"""Génération des bulletins de notes """Génération des bulletins de notes
""" """
from app.models import formsemestre
import time
import pprint
import email import email
from email.mime.multipart import MIMEMultipart import time
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email.header import Header
from reportlab.lib.colors import Color
import urllib
from flask import g, request from flask import g, request
from flask import url_for from flask import flash, render_template, url_for
from flask_login import current_user from flask_login import current_user
from flask_mail import Message
from app.models.moduleimpls import ModuleImplInscription
import app.scodoc.sco_utils as scu from app import email
from app.scodoc.sco_utils import ModuleType
import app.scodoc.notesdb as ndb
from app import log from app import log
from app.but import bulletin_but
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_common import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre from app.models import FormSemestre, Identite, ModuleImplInscription
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
@ -60,9 +49,9 @@ from app.scodoc import sco_abs
from app.scodoc import sco_abs_views from app.scodoc import sco_abs_views
from app.scodoc import sco_bulletins_generator from app.scodoc import sco_bulletins_generator
from app.scodoc import sco_bulletins_json from app.scodoc import sco_bulletins_json
from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_bulletins_xml from app.scodoc import sco_bulletins_xml
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
from app.scodoc import sco_cache
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.scodoc import sco_evaluation_db from app.scodoc import sco_evaluation_db
from app.scodoc import sco_formations from app.scodoc import sco_formations
@ -73,7 +62,9 @@ from app.scodoc import sco_photos
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_pvjury from app.scodoc import sco_pvjury
from app.scodoc import sco_users from app.scodoc import sco_users
from app import email import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType, fmt_note
import app.scodoc.notesdb as ndb
# ----- CLASSES DE BULLETINS DE NOTES # ----- CLASSES DE BULLETINS DE NOTES
from app.scodoc import sco_bulletins_standard from app.scodoc import sco_bulletins_standard
@ -85,33 +76,20 @@ from app.scodoc import sco_bulletins_legacy
from app.scodoc import sco_bulletins_ucac # format expérimental UCAC Cameroun from app.scodoc import sco_bulletins_ucac # format expérimental UCAC Cameroun
def make_context_dict(sem, etud): def make_context_dict(formsemestre: FormSemestre, etud: dict) -> dict:
"""Construit dictionnaire avec valeurs pour substitution des textes """Construit dictionnaire avec valeurs pour substitution des textes
(preferences bul_pdf_*) (preferences bul_pdf_*)
""" """
C = sem.copy() C = formsemestre.get_infos_dict()
C["responsable"] = " ,".join( C["responsable"] = formsemestre.responsables_str()
[ C["anneesem"] = C["annee"] # backward compat
sco_users.user_info(responsable_id)["prenomnom"]
for responsable_id in sem["responsables"]
]
)
annee_debut = sem["date_debut"].split("/")[2]
annee_fin = sem["date_fin"].split("/")[2]
if annee_debut != annee_fin:
annee = "%s - %s" % (annee_debut, annee_fin)
else:
annee = annee_debut
C["anneesem"] = annee
C.update(etud) C.update(etud)
# copie preferences # copie preferences
# XXX devrait acceder directement à un dict de preferences, à revoir
for name in sco_preferences.get_base_preferences().prefs_name: for name in sco_preferences.get_base_preferences().prefs_name:
C[name] = sco_preferences.get_preference(name, sem["formsemestre_id"]) C[name] = sco_preferences.get_preference(name, formsemestre.id)
# ajoute groupes et group_0, group_1, ... # ajoute groupes et group_0, group_1, ...
sco_groups.etud_add_group_infos(etud, sem) sco_groups.etud_add_group_infos(etud, formsemestre.id)
C["groupes"] = etud["groupes"] C["groupes"] = etud["groupes"]
n = 0 n = 0
for partition_id in etud["partitions"]: for partition_id in etud["partitions"]:
@ -132,7 +110,8 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
Le contenu du dictionnaire dépend des options (rangs, ...) Le contenu du dictionnaire dépend des options (rangs, ...)
et de la version choisie (short, long, selectedevals). et de la version choisie (short, long, selectedevals).
Cette fonction est utilisée pour les bulletins HTML et PDF, mais pas ceux en XML. Cette fonction est utilisée pour les bulletins CLASSIQUES (DUT, ...)
en HTML et PDF, mais pas ceux en XML.
""" """
from app.scodoc import sco_abs from app.scodoc import sco_abs
@ -140,7 +119,6 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
raise ValueError("invalid version code !") raise ValueError("invalid version code !")
prefs = sco_preferences.SemPreferences(formsemestre_id) prefs = sco_preferences.SemPreferences(formsemestre_id)
# nt = sco_cache.NotesTableCache.get(formsemestre_id) # > toutes notes
formsemestre = FormSemestre.query.get_or_404(formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if not nt.get_etud_etat(etudid): if not nt.get_etud_etat(etudid):
@ -191,39 +169,23 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
show_mention=prefs["bul_show_mention"], show_mention=prefs["bul_show_mention"],
) )
if dpv:
I["decision_sem"] = dpv["decisions"][0]["decision_sem"]
else:
I["decision_sem"] = ""
I.update(infos) I.update(infos)
I["etud_etat_html"] = _get_etud_etat_html( I["etud_etat_html"] = _get_etud_etat_html(
formsemestre.etuds_inscriptions[etudid].etat formsemestre.etuds_inscriptions[etudid].etat
) )
I["etud_etat"] = nt.get_etud_etat(etudid) I["etud_etat"] = nt.get_etud_etat(etudid)
I["filigranne"] = "" I["filigranne"] = sco_bulletins_pdf.get_filigranne(
I["etud_etat"], prefs, decision_sem=I["decision_sem"]
)
I["demission"] = "" I["demission"] = ""
if I["etud_etat"] == "D": if I["etud_etat"] == scu.DEMISSION:
I["demission"] = "(Démission)" I["demission"] = "(Démission)"
I["filigranne"] = "Démission"
elif I["etud_etat"] == sco_codes_parcours.DEF: elif I["etud_etat"] == sco_codes_parcours.DEF:
I["demission"] = "(Défaillant)" I["demission"] = "(Défaillant)"
I["filigranne"] = "Défaillant"
elif (prefs["bul_show_temporary"] and not I["decision_sem"]) or prefs[
"bul_show_temporary_forced"
]:
I["filigranne"] = prefs["bul_temporary_txt"]
# --- Appreciations # --- Appreciations
cnx = ndb.GetDBConnexion() I.update(get_appreciations_list(formsemestre_id, etudid))
apprecs = sco_etud.appreciations_list(
cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id}
)
I["appreciations_list"] = apprecs
I["appreciations_txt"] = [x["date"] + ": " + x["comment"] for x in apprecs]
I["appreciations"] = I[
"appreciations_txt"
] # deprecated / keep it for backward compat in templates
# --- Notes # --- Notes
ues = nt.get_ues_stat_dict() ues = nt.get_ues_stat_dict()
@ -292,15 +254,17 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
I["matieres_modules"] = {} I["matieres_modules"] = {}
I["matieres_modules_capitalized"] = {} I["matieres_modules_capitalized"] = {}
for ue in ues: for ue in ues:
u = ue.copy()
ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"])
if ( if (
ModuleImplInscription.nb_inscriptions_dans_ue( ModuleImplInscription.nb_inscriptions_dans_ue(
formsemestre_id, etudid, ue["ue_id"] formsemestre_id, etudid, ue["ue_id"]
) )
== 0 == 0
): ) and not ue_status["is_capitalized"]:
# saute les UE où l'on est pas inscrit et n'avons pas de capitalisation
continue continue
u = ue.copy()
ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"])
u["ue_status"] = ue_status # { 'moy', 'coef_ue', ...} u["ue_status"] = ue_status # { 'moy', 'coef_ue', ...}
if ue["type"] != sco_codes_parcours.UE_SPORT: if ue["type"] != sco_codes_parcours.UE_SPORT:
u["cur_moy_ue_txt"] = scu.fmt_note(ue_status["cur_moy_ue"]) u["cur_moy_ue_txt"] = scu.fmt_note(ue_status["cur_moy_ue"])
@ -315,14 +279,14 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
else: else:
u["cur_moy_ue_txt"] = "bonus appliqué sur les UEs" u["cur_moy_ue_txt"] = "bonus appliqué sur les UEs"
else: else:
u["cur_moy_ue_txt"] = "bonus de %.3g points" % x u["cur_moy_ue_txt"] = f"bonus de {fmt_note(x)} points"
if nt.bonus_ues is not None:
u["cur_moy_ue_txt"] += " (+ues)"
u["moy_ue_txt"] = scu.fmt_note(ue_status["moy"]) u["moy_ue_txt"] = scu.fmt_note(ue_status["moy"])
if ue_status["coef_ue"] != None: if ue_status["coef_ue"] != None:
u["coef_ue_txt"] = scu.fmt_coef(ue_status["coef_ue"]) u["coef_ue_txt"] = scu.fmt_coef(ue_status["coef_ue"])
else: else:
# C'est un bug: u["coef_ue_txt"] = "-"
log("u=" + pprint.pformat(u))
raise Exception("invalid None coef for ue")
if ( if (
dpv dpv
@ -363,10 +327,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
>{u["ue_descr_txt"]} pouet</a> >{u["ue_descr_txt"]} pouet</a>
""" """
if ue_status["moy"] != "NA" and ue_status["formsemestre_id"]: if ue_status["moy"] != "NA" and ue_status["formsemestre_id"]:
# detail des modules de l'UE capitalisee # détail des modules de l'UE capitalisée
# nt_cap = sco_cache.NotesTableCache.get(
# ue_status["formsemestre_id"]
# ) # > toutes notes
formsemestre_cap = FormSemestre.query.get_or_404( formsemestre_cap = FormSemestre.query.get_or_404(
ue_status["formsemestre_id"] ue_status["formsemestre_id"]
) )
@ -409,13 +370,28 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
I["matieres_modules"].update(_sort_mod_by_matiere(modules, nt, etudid)) I["matieres_modules"].update(_sort_mod_by_matiere(modules, nt, etudid))
# #
C = make_context_dict(I["sem"], I["etud"]) C = make_context_dict(formsemestre, I["etud"])
C.update(I) C.update(I)
# #
# log( 'C = \n%s\n' % pprint.pformat(C) ) # tres pratique pour voir toutes les infos dispo # log( 'C = \n%s\n' % pprint.pformat(C) ) # tres pratique pour voir toutes les infos dispo
return C return C
def get_appreciations_list(formsemestre_id: int, etudid: int) -> dict:
"""Appréciations pour cet étudiant dans ce semestre"""
cnx = ndb.GetDBConnexion()
apprecs = sco_etud.appreciations_list(
cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id}
)
d = {
"appreciations_list": apprecs,
"appreciations_txt": [x["date"] + ": " + x["comment"] for x in apprecs],
}
# deprecated / keep it for backward compat in templates:
d["appreciations"] = d["appreciations_txt"]
return d
def _get_etud_etat_html(etat: str) -> str: def _get_etud_etat_html(etat: str) -> str:
"""chaine html représentant l'état (backward compat sco7)""" """chaine html représentant l'état (backward compat sco7)"""
if etat == scu.INSCRIT: # "I" if etat == scu.INSCRIT: # "I"
@ -443,7 +419,9 @@ def _sort_mod_by_matiere(modlist, nt, etudid):
return matmod return matmod
def _ue_mod_bulletin(etudid, formsemestre_id, ue_id, modimpls, nt, version): def _ue_mod_bulletin(
etudid, formsemestre_id, ue_id, modimpls, nt: NotesTableCompat, version
):
"""Infos sur les modules (et évaluations) dans une UE """Infos sur les modules (et évaluations) dans une UE
(ajoute les informations aux modimpls) (ajoute les informations aux modimpls)
Result: liste de modules de l'UE avec les infos dans chacun (seulement ceux où l'étudiant est inscrit). Result: liste de modules de l'UE avec les infos dans chacun (seulement ceux où l'étudiant est inscrit).
@ -518,7 +496,7 @@ def _ue_mod_bulletin(etudid, formsemestre_id, ue_id, modimpls, nt, version):
) )
if sco_preferences.get_preference("bul_show_codemodules", formsemestre_id): if sco_preferences.get_preference("bul_show_codemodules", formsemestre_id):
mod["code"] = modimpl["module"]["code"] mod["code"] = modimpl["module"]["code"]
mod["code_html"] = link_mod + mod["code"] + "</a>" mod["code_html"] = link_mod + (mod["code"] or "") + "</a>"
else: else:
mod["code"] = mod["code_html"] = "" mod["code"] = mod["code_html"] = ""
mod["name"] = ( mod["name"] = (
@ -536,7 +514,7 @@ def _ue_mod_bulletin(etudid, formsemestre_id, ue_id, modimpls, nt, version):
% (modimpl["moduleimpl_id"], mod_descr) % (modimpl["moduleimpl_id"], mod_descr)
) )
if sco_preferences.get_preference("bul_show_codemodules", formsemestre_id): if sco_preferences.get_preference("bul_show_codemodules", formsemestre_id):
mod["code_txt"] = modimpl["module"]["code"] mod["code_txt"] = modimpl["module"]["code"] or ""
mod["code_html"] = link_mod + mod["code_txt"] + "</a>" mod["code_html"] = link_mod + mod["code_txt"] + "</a>"
else: else:
mod["code_txt"] = "" mod["code_txt"] = ""
@ -691,6 +669,7 @@ def etud_descr_situation_semestre(
descr_defaillance : "Défaillant" ou vide si non défaillant. descr_defaillance : "Défaillant" ou vide si non défaillant.
decision_jury : "Validé", "Ajourné", ... (code semestre) decision_jury : "Validé", "Ajourné", ... (code semestre)
descr_decision_jury : "Décision jury: Validé" (une phrase) descr_decision_jury : "Décision jury: Validé" (une phrase)
decision_sem :
decisions_ue : noms (acronymes) des UE validées, séparées par des virgules. decisions_ue : noms (acronymes) des UE validées, séparées par des virgules.
descr_decisions_ue : ' UE acquises: UE1, UE2', ou vide si pas de dec. ou si pas show_uevalid descr_decisions_ue : ' UE acquises: UE1, UE2', ou vide si pas de dec. ou si pas show_uevalid
descr_mention : 'Mention Bien', ou vide si pas de mention ou si pas show_mention descr_mention : 'Mention Bien', ou vide si pas de mention ou si pas show_mention
@ -700,7 +679,7 @@ def etud_descr_situation_semestre(
# --- Situation et décisions jury # --- Situation et décisions jury
# demission/inscription ? # démission/inscription ?
events = sco_etud.scolar_events_list( events = sco_etud.scolar_events_list(
cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id} cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id}
) )
@ -767,11 +746,15 @@ def etud_descr_situation_semestre(
infos["situation"] += " " + infos["descr_defaillance"] infos["situation"] += " " + infos["descr_defaillance"]
dpv = sco_pvjury.dict_pvjury(formsemestre_id, etudids=[etudid]) dpv = sco_pvjury.dict_pvjury(formsemestre_id, etudids=[etudid])
if dpv:
infos["decision_sem"] = dpv["decisions"][0]["decision_sem"]
else:
infos["decision_sem"] = ""
if not show_decisions: if not show_decisions:
return infos, dpv return infos, dpv
# Decisions de jury: # Décisions de jury:
pv = dpv["decisions"][0] pv = dpv["decisions"][0]
dec = "" dec = ""
if pv["decision_sem_descr"]: if pv["decision_sem_descr"]:
@ -808,27 +791,23 @@ def etud_descr_situation_semestre(
# ------ Page bulletin # ------ Page bulletin
def formsemestre_bulletinetud( def formsemestre_bulletinetud(
etudid=None, etud: Identite = None,
formsemestre_id=None, formsemestre_id=None,
format="html", format=None,
version="long", version="long",
xml_with_decisions=False, xml_with_decisions=False,
force_publishing=False, # force publication meme si semestre non publie sur "portail" force_publishing=False, # force publication meme si semestre non publie sur "portail"
prefer_mail_perso=False, prefer_mail_perso=False,
): ):
"page bulletin de notes" "page bulletin de notes"
try: format = format or "html"
etud = sco_etud.get_etud_info(filled=True)[0] formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
etudid = etud["etudid"] if not formsemestre:
except: raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
sco_etud.log_unknown_etud()
raise ScoValueError("étudiant inconnu")
# API, donc erreurs admises en ScoValueError
sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True)
bulletin = do_formsemestre_bulletinetud( bulletin = do_formsemestre_bulletinetud(
formsemestre_id, formsemestre,
etudid, etud.id,
format=format, format=format,
version=version, version=version,
xml_with_decisions=xml_with_decisions, xml_with_decisions=xml_with_decisions,
@ -836,52 +815,25 @@ def formsemestre_bulletinetud(
prefer_mail_perso=prefer_mail_perso, prefer_mail_perso=prefer_mail_perso,
)[0] )[0]
if format not in {"html", "pdfmail"}: if format not in {"html", "pdfmail"}:
filename = scu.bul_filename(sem, etud, format) filename = scu.bul_filename(formsemestre, etud, format)
return scu.send_file(bulletin, filename, mime=scu.get_mime_suffix(format)[0]) return scu.send_file(bulletin, filename, mime=scu.get_mime_suffix(format)[0])
elif format == "pdfmail":
sem = sco_formsemestre.get_formsemestre(formsemestre_id) return ""
H = [ H = [
_formsemestre_bulletinetud_header_html( _formsemestre_bulletinetud_header_html(etud, formsemestre, format, version),
etud, etudid, sem, formsemestre_id, format, version
),
bulletin, bulletin,
render_template(
"bul_foot.html",
appreciations=None, # déjà affichées
css_class="bul_classic_foot",
etud=etud,
formsemestre=formsemestre,
inscription_courante=etud.inscription_courante(),
inscription_str=etud.inscription_descr()["inscription_str"],
),
html_sco_header.sco_footer(),
] ]
H.append("""<p>Situation actuelle: """)
if etud["inscription_formsemestre_id"]:
H.append(
f"""<a class="stdlink" href="{url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=etud["inscription_formsemestre_id"])
}">"""
)
H.append(etud["inscriptionstr"])
if etud["inscription_formsemestre_id"]:
H.append("""</a>""")
H.append("""</p>""")
if sem["modalite"] == "EXT":
H.append(
"""<p><a
href="formsemestre_ext_edit_ue_validations?formsemestre_id=%s&etudid=%s"
class="stdlink">
Editer les validations d'UE dans ce semestre extérieur
</a></p>"""
% (formsemestre_id, etudid)
)
# Place du diagramme radar
H.append(
"""<form id="params">
<input type="hidden" name="etudid" id="etudid" value="%s"/>
<input type="hidden" name="formsemestre_id" id="formsemestre_id" value="%s"/>
</form>"""
% (etudid, formsemestre_id)
)
H.append('<div id="radar_bulletin"></div>')
# --- Pied de page
H.append(html_sco_header.sco_footer())
return "".join(H) return "".join(H)
@ -896,23 +848,23 @@ def can_send_bulletin_by_mail(formsemestre_id):
def do_formsemestre_bulletinetud( def do_formsemestre_bulletinetud(
formsemestre_id, formsemestre: FormSemestre,
etudid, etudid: int,
version="long", # short, long, selectedevals version="long", # short, long, selectedevals
format="html", format=None,
nohtml=False, xml_with_decisions=False, # force décisions dans XML
xml_with_decisions=False, # force decisions dans XML force_publishing=False, # force publication meme si semestre non publié sur "portail"
force_publishing=False, # force publication meme si semestre non publie sur "portail" prefer_mail_perso=False, # mails envoyés sur adresse perso si non vide
prefer_mail_perso=False, # mails envoyes sur adresse perso si non vide
): ):
"""Génère le bulletin au format demandé. """Génère le bulletin au format demandé.
Retourne: (bul, filigranne) Retourne: (bul, filigranne)
bul est str ou bytes au format demandé (html, pdf, pdfmail, pdfpart, xml, json) bul est str ou bytes au format demandé (html, pdf, pdfmail, pdfpart, xml, json)
et filigranne est un message à placer en "filigranne" (eg "Provisoire"). et filigranne est un message à placer en "filigranne" (eg "Provisoire").
""" """
format = format or "html"
if format == "xml": if format == "xml":
bul = sco_bulletins_xml.make_xml_formsemestre_bulletinetud( bul = sco_bulletins_xml.make_xml_formsemestre_bulletinetud(
formsemestre_id, formsemestre.id,
etudid, etudid,
xml_with_decisions=xml_with_decisions, xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing, force_publishing=force_publishing,
@ -923,7 +875,7 @@ def do_formsemestre_bulletinetud(
elif format == "json": elif format == "json":
bul = sco_bulletins_json.make_json_formsemestre_bulletinetud( bul = sco_bulletins_json.make_json_formsemestre_bulletinetud(
formsemestre_id, formsemestre.id,
etudid, etudid,
xml_with_decisions=xml_with_decisions, xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing, force_publishing=force_publishing,
@ -931,7 +883,12 @@ def do_formsemestre_bulletinetud(
) )
return bul, "" return bul, ""
I = formsemestre_bulletinetud_dict(formsemestre_id, etudid) if formsemestre.formation.is_apc():
etudiant = Identite.query.get(etudid)
r = bulletin_but.BulletinBUT(formsemestre)
I = r.bulletin_etud_complet(etudiant, version=version)
else:
I = formsemestre_bulletinetud_dict(formsemestre.id, etudid)
etud = I["etud"] etud = I["etud"]
if format == "html": if format == "html":
@ -958,16 +915,9 @@ def do_formsemestre_bulletinetud(
elif format == "pdfmail": elif format == "pdfmail":
# format pdfmail: envoie le pdf par mail a l'etud, et affiche le html # format pdfmail: envoie le pdf par mail a l'etud, et affiche le html
# check permission # check permission
if not can_send_bulletin_by_mail(formsemestre_id): if not can_send_bulletin_by_mail(formsemestre.id):
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
if nohtml:
htm = "" # speed up if html version not needed
else:
htm, _ = sco_bulletins_generator.make_formsemestre_bulletinetud(
I, version=version, format="html"
)
pdfdata, filename = sco_bulletins_generator.make_formsemestre_bulletinetud( pdfdata, filename = sco_bulletins_generator.make_formsemestre_bulletinetud(
I, version=version, format="pdf" I, version=version, format="pdf"
) )
@ -975,30 +925,17 @@ def do_formsemestre_bulletinetud(
if prefer_mail_perso: if prefer_mail_perso:
recipient_addr = etud.get("emailperso", "") or etud.get("email", "") recipient_addr = etud.get("emailperso", "") or etud.get("email", "")
else: else:
recipient_addr = etud["email_default"] recipient_addr = etud.get("email", "") or etud.get("emailperso", "")
if not recipient_addr: if not recipient_addr:
if nohtml: flash(f"{etud['nomprenom']} n'a pas d'adresse e-mail !")
h = "" # permet de compter les non-envois return False, I["filigranne"]
else: else:
h = ( mail_bulletin(formsemestre.id, I, pdfdata, filename, recipient_addr)
"<div class=\"boldredmsg\">%s n'a pas d'adresse e-mail !</div>" flash(f"mail envoyé à {recipient_addr}")
% etud["nomprenom"]
) + htm return True, I["filigranne"]
return h, I["filigranne"]
#
mail_bulletin(formsemestre_id, I, pdfdata, filename, recipient_addr)
emaillink = '<a class="stdlink" href="mailto:%s">%s</a>' % (
recipient_addr,
recipient_addr,
)
return (
('<div class="head_message">Message mail envoyé à %s</div>' % (emaillink))
+ htm,
I["filigranne"],
)
else:
raise ValueError("do_formsemestre_bulletinetud: invalid format (%s)" % format) raise ValueError("do_formsemestre_bulletinetud: invalid format (%s)" % format)
@ -1015,11 +952,16 @@ def mail_bulletin(formsemestre_id, I, pdfdata, filename, recipient_addr):
intro_mail = sco_preferences.get_preference("bul_intro_mail", formsemestre_id) intro_mail = sco_preferences.get_preference("bul_intro_mail", formsemestre_id)
if intro_mail: if intro_mail:
try:
hea = intro_mail % { hea = intro_mail % {
"nomprenom": etud["nomprenom"], "nomprenom": etud["nomprenom"],
"dept": dept, "dept": dept,
"webmaster": webmaster, "webmaster": webmaster,
} }
except KeyError as e:
raise ScoValueError(
"format 'Message d'accompagnement' (bul_intro_mail) invalide, revoir les réglages dans les préférences"
) from e
else: else:
hea = "" hea = ""
@ -1035,81 +977,32 @@ def mail_bulletin(formsemestre_id, I, pdfdata, filename, recipient_addr):
bcc = copy_addr.strip() bcc = copy_addr.strip()
else: else:
bcc = "" bcc = ""
msg = Message(subject, sender=sender, recipients=recipients, bcc=[bcc])
msg.body = hea
# Attach pdf # Attach pdf
msg.attach(filename, scu.PDF_MIMETYPE, pdfdata)
log("mail bulletin a %s" % recipient_addr) log("mail bulletin a %s" % recipient_addr)
email.send_message(msg) email.send_email(
subject,
sender,
def _formsemestre_bulletinetud_header_html( recipients,
etud, bcc=[bcc],
etudid, text_body=hea,
sem, attachments=[
formsemestre_id=None, {"filename": filename, "mimetype": scu.PDF_MIMETYPE, "data": pdfdata}
format=None,
version=None,
):
H = [
html_sco_header.sco_header(
page_title="Bulletin de %(nomprenom)s" % etud,
javascripts=[
"js/bulletin.js",
"libjs/d3.v3.min.js",
"js/radar_bulletin.js",
], ],
cssstyles=["css/radar_bulletin.css"], )
),
"""<table class="bull_head"><tr><td>
<h2><a class="discretelink" href="%s">%s</a></h2>
"""
% (
url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"]
),
etud["nomprenom"],
),
"""
<form name="f" method="GET" action="%s">"""
% request.base_url,
f"""Bulletin <span class="bull_liensemestre"><a href="{
url_for("notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=sem["formsemestre_id"])}
">{sem["titremois"]}</a></span>
<br/>"""
% sem,
"""<table><tr>""",
"""<td>établi le %s (notes sur 20)</td>""" % time.strftime("%d/%m/%Y à %Hh%M"),
"""<td><span class="rightjust">
<input type="hidden" name="formsemestre_id" value="%s"></input>"""
% formsemestre_id,
"""<input type="hidden" name="etudid" value="%s"></input>""" % etudid,
"""<input type="hidden" name="format" value="%s"></input>""" % format,
"""<select name="version" onchange="document.f.submit()" class="noprint">""",
]
for (v, e) in (
("short", "Version courte"),
("selectedevals", "Version intermédiaire"),
("long", "Version complète"),
):
if v == version:
selected = " selected"
else:
selected = ""
H.append('<option value="%s"%s>%s</option>' % (v, selected, e))
H.append("""</select></td>""")
# Menu
endpoint = "notes.formsemestre_bulletinetud"
menuBul = [
def make_menu_autres_operations(
formsemestre: FormSemestre, etud: Identite, endpoint: str, version: str
) -> str:
etud_email = etud.get_first_email() or ""
etud_perso = etud.get_first_email("emailperso") or ""
menu_items = [
{ {
"title": "Réglages bulletins", "title": "Réglages bulletins",
"endpoint": "notes.formsemestre_edit_options", "endpoint": "notes.formsemestre_edit_options",
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
# "target_url": url_for( # "target_url": url_for(
# "notes.formsemestre_bulletinetud", # "notes.formsemestre_bulletinetud",
# scodoc_dept=g.scodoc_dept, # scodoc_dept=g.scodoc_dept,
@ -1117,54 +1010,52 @@ def _formsemestre_bulletinetud_header_html(
# etudid=etudid, # etudid=etudid,
# ), # ),
}, },
"enabled": (current_user.id in sem["responsables"]) "enabled": formsemestre.can_be_edited_by(current_user),
or current_user.has_permission(Permission.ScoImplement),
}, },
{ {
"title": 'Version papier (pdf, format "%s")' "title": 'Version papier (pdf, format "%s")'
% sco_bulletins_generator.bulletin_get_class_name_displayed( % sco_bulletins_generator.bulletin_get_class_name_displayed(
formsemestre_id formsemestre.id
), ),
"endpoint": endpoint, "endpoint": endpoint,
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
"etudid": etudid, "etudid": etud.id,
"version": version, "version": version,
"format": "pdf", "format": "pdf",
}, },
}, },
{ {
"title": "Envoi par mail à %s" % etud["email"], "title": f"Envoi par mail à {etud_email}",
"endpoint": endpoint, "endpoint": endpoint,
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
"etudid": etudid, "etudid": etud.id,
"version": version, "version": version,
"format": "pdfmail", "format": "pdfmail",
}, },
# possible slt si on a un mail... # possible slt si on a un mail...
"enabled": etud["email"] and can_send_bulletin_by_mail(formsemestre_id), "enabled": etud_email and can_send_bulletin_by_mail(formsemestre.id),
}, },
{ {
"title": "Envoi par mail à %s (adr. personnelle)" % etud["emailperso"], "title": f"Envoi par mail à {etud_perso} (adr. personnelle)",
"endpoint": endpoint, "endpoint": endpoint,
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
"etudid": etudid, "etudid": etud.id,
"version": version, "version": version,
"format": "pdfmail", "format": "pdfmail",
"prefer_mail_perso": 1, "prefer_mail_perso": 1,
}, },
# possible slt si on a un mail... # possible slt si on a un mail...
"enabled": etud["emailperso"] "enabled": etud_perso and can_send_bulletin_by_mail(formsemestre.id),
and can_send_bulletin_by_mail(formsemestre_id),
}, },
{ {
"title": "Version json", "title": "Version json",
"endpoint": endpoint, "endpoint": endpoint,
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
"etudid": etudid, "etudid": etud.id,
"version": version, "version": version,
"format": "json", "format": "json",
}, },
@ -1173,8 +1064,8 @@ def _formsemestre_bulletinetud_header_html(
"title": "Version XML", "title": "Version XML",
"endpoint": endpoint, "endpoint": endpoint,
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
"etudid": etudid, "etudid": etud.id,
"version": version, "version": version,
"format": "xml", "format": "xml",
}, },
@ -1183,20 +1074,20 @@ def _formsemestre_bulletinetud_header_html(
"title": "Ajouter une appréciation", "title": "Ajouter une appréciation",
"endpoint": "notes.appreciation_add_form", "endpoint": "notes.appreciation_add_form",
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
"etudid": etudid, "etudid": etud.id,
}, },
"enabled": ( "enabled": (
(current_user.id in sem["responsables"]) formsemestre.can_be_edited_by(current_user)
or (current_user.has_permission(Permission.ScoEtudInscrit)) or current_user.has_permission(Permission.ScoEtudInscrit)
), ),
}, },
{ {
"title": "Enregistrer un semestre effectué ailleurs", "title": "Enregistrer un semestre effectué ailleurs",
"endpoint": "notes.formsemestre_ext_create_form", "endpoint": "notes.formsemestre_ext_create_form",
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
"etudid": etudid, "etudid": etud.id,
}, },
"enabled": current_user.has_permission(Permission.ScoImplement), "enabled": current_user.has_permission(Permission.ScoImplement),
}, },
@ -1204,71 +1095,72 @@ def _formsemestre_bulletinetud_header_html(
"title": "Enregistrer une validation d'UE antérieure", "title": "Enregistrer une validation d'UE antérieure",
"endpoint": "notes.formsemestre_validate_previous_ue", "endpoint": "notes.formsemestre_validate_previous_ue",
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
"etudid": etudid, "etudid": etud.id,
}, },
"enabled": sco_permissions_check.can_validate_sem(formsemestre_id), "enabled": sco_permissions_check.can_validate_sem(formsemestre.id),
}, },
{ {
"title": "Enregistrer note d'une UE externe", "title": "Enregistrer note d'une UE externe",
"endpoint": "notes.external_ue_create_form", "endpoint": "notes.external_ue_create_form",
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
"etudid": etudid, "etudid": etud.id,
}, },
"enabled": sco_permissions_check.can_validate_sem(formsemestre_id), "enabled": sco_permissions_check.can_validate_sem(formsemestre.id),
}, },
{ {
"title": "Entrer décisions jury", "title": "Entrer décisions jury",
"endpoint": "notes.formsemestre_validation_etud_form", "endpoint": "notes.formsemestre_validation_etud_form",
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
"etudid": etudid, "etudid": etud.id,
}, },
"enabled": sco_permissions_check.can_validate_sem(formsemestre_id), "enabled": sco_permissions_check.can_validate_sem(formsemestre.id),
}, },
{ {
"title": "Editer PV jury", "title": "Éditer PV jury",
"endpoint": "notes.formsemestre_pvjury_pdf", "endpoint": "notes.formsemestre_pvjury_pdf",
"args": { "args": {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre.id,
"etudid": etudid, "etudid": etud.id,
}, },
"enabled": True, "enabled": True,
}, },
] ]
return htmlutils.make_menu("Autres opérations", menu_items, alone=True)
H.append("""<td class="bulletin_menubar"><div class="bulletin_menubar">""")
H.append(htmlutils.make_menu("Autres opérations", menuBul, alone=True)) def _formsemestre_bulletinetud_header_html(
H.append("""</div></td>""") etud,
H.append( formsemestre: FormSemestre,
'<td> <a href="%s">%s</a></td>' format=None,
% ( version=None,
url_for( ):
"notes.formsemestre_bulletinetud", H = [
scodoc_dept=g.scodoc_dept, html_sco_header.sco_header(
formsemestre_id=formsemestre_id, page_title=f"Bulletin de {etud.nomprenom}",
etudid=etudid, javascripts=[
format="pdf", "js/bulletin.js",
"libjs/d3.v3.min.js",
"js/radar_bulletin.js",
],
cssstyles=["css/radar_bulletin.css"],
),
render_template(
"bul_head.html",
etud=etud,
format=format,
formsemestre=formsemestre,
menu_autres_operations=make_menu_autres_operations(
etud=etud,
formsemestre=formsemestre,
endpoint="notes.formsemestre_bulletinetud",
version=version, version=version,
), ),
scu.ICON_PDF, scu=scu,
) time=time,
) version=version,
H.append("""</tr></table>""") ),
# ]
H.append( return "\n".join(H)
"""</form></span></td><td class="bull_photo"><a href="%s">%s</a>
"""
% (
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid),
sco_photos.etud_photo_html(etud, title="fiche de " + etud["nom"]),
)
)
H.append(
"""</td></tr>
</table>
"""
)
return "".join(H)

View File

@ -49,7 +49,14 @@ import traceback
import reportlab import reportlab
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak from reportlab.platypus import (
SimpleDocTemplate,
DocIf,
Paragraph,
Spacer,
Frame,
PageBreak,
)
from reportlab.platypus import Table, TableStyle, Image, KeepInFrame from reportlab.platypus import Table, TableStyle, Image, KeepInFrame
from flask import request from flask import request
@ -63,48 +70,16 @@ from app.scodoc import sco_pdf
from app.scodoc.sco_pdf import PDFLOCK from app.scodoc.sco_pdf import PDFLOCK
import sco_version import sco_version
# Liste des types des classes de générateurs de bulletins PDF:
BULLETIN_CLASSES = collections.OrderedDict()
class BulletinGenerator:
def register_bulletin_class(klass):
BULLETIN_CLASSES[klass.__name__] = klass
def bulletin_class_descriptions():
return [x.description for x in BULLETIN_CLASSES.values()]
def bulletin_class_names():
return list(BULLETIN_CLASSES.keys())
def bulletin_default_class_name():
return bulletin_class_names()[0]
def bulletin_get_class(class_name):
return BULLETIN_CLASSES[class_name]
def bulletin_get_class_name_displayed(formsemestre_id):
"""Le nom du générateur utilisé, en clair"""
from app.scodoc import sco_preferences
bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id)
try:
gen_class = bulletin_get_class(bul_class_name)
return gen_class.description
except:
return "invalide ! (voir paramètres)"
class BulletinGenerator(object):
"Virtual superclass for PDF bulletin generators" "" "Virtual superclass for PDF bulletin generators" ""
# Here some helper methods # Here some helper methods
# see sco_bulletins_standard.BulletinGeneratorStandard subclass for real methods # see sco_bulletins_standard.BulletinGeneratorStandard subclass for real methods
supported_formats = [] # should list supported formats, eg [ 'html', 'pdf' ] supported_formats = [] # should list supported formats, eg [ 'html', 'pdf' ]
description = "superclass for bulletins" # description for user interface description = "superclass for bulletins" # description for user interface
list_in_menu = True # la classe doit-elle est montrée dans le menu de config ?
scale_table_in_page = True # rescale la table sur 1 page
multi_pages = False
def __init__( def __init__(
self, self,
@ -151,7 +126,7 @@ class BulletinGenerator(object):
def get_filename(self): def get_filename(self):
"""Build a filename to be proposed to the web client""" """Build a filename to be proposed to the web client"""
sem = sco_formsemestre.get_formsemestre(self.infos["formsemestre_id"]) sem = sco_formsemestre.get_formsemestre(self.infos["formsemestre_id"])
return scu.bul_filename(sem, self.infos["etud"], "pdf") return scu.bul_filename_old(sem, self.infos["etud"], "pdf")
def generate(self, format="", stand_alone=True): def generate(self, format="", stand_alone=True):
"""Return bulletin in specified format""" """Return bulletin in specified format"""
@ -187,29 +162,47 @@ class BulletinGenerator(object):
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
formsemestre_id = self.infos["formsemestre_id"] formsemestre_id = self.infos["formsemestre_id"]
marque_debut_bulletin = sco_pdf.DebutBulletin(
self.infos["etud"]["nomprenom"],
filigranne=self.infos["filigranne"],
footer_content=f"""ScoDoc - Bulletin de {self.infos["etud"]["nomprenom"]} - {time.strftime("%d/%m/%Y %H:%M")}""",
)
story = []
# partie haute du bulletin # partie haute du bulletin
objects = self.bul_title_pdf() # pylint: disable=no-member story += self.bul_title_pdf() # pylint: disable=no-member
# table des notes index_obj_debut = len(story)
objects += self.bul_table(format="pdf") # pylint: disable=no-member
# infos sous la table
objects += self.bul_part_below(format="pdf") # pylint: disable=no-member
# signatures
objects += self.bul_signatures_pdf() # pylint: disable=no-member
# table des notes
story += self.bul_table(format="pdf") # pylint: disable=no-member
# infos sous la table
story += self.bul_part_below(format="pdf") # pylint: disable=no-member
# signatures
story += self.bul_signatures_pdf() # pylint: disable=no-member
if self.scale_table_in_page:
# Réduit sur une page # Réduit sur une page
objects = [KeepInFrame(0, 0, objects, mode="shrink")] story = [marque_debut_bulletin, KeepInFrame(0, 0, story, mode="shrink")]
else:
# Insere notre marqueur qui permet de générer les bookmarks et filigrannes:
story.insert(index_obj_debut, marque_debut_bulletin)
# #
# objects.append(sco_pdf.FinBulletin())
if not stand_alone: if not stand_alone:
objects.append(PageBreak()) # insert page break at end if self.multi_pages:
return objects # Bulletins sur plusieurs page, force début suivant sur page impaire
story.append(
DocIf("doc.page%2 == 1", [PageBreak(), PageBreak()], [PageBreak()])
)
else:
story.append(PageBreak()) # insert page break at end
return story
else: else:
# Generation du document PDF # Generation du document PDF
sem = sco_formsemestre.get_formsemestre(formsemestre_id) sem = sco_formsemestre.get_formsemestre(formsemestre_id)
report = io.BytesIO() # in-memory document, no disk file report = io.BytesIO() # in-memory document, no disk file
document = sco_pdf.BaseDocTemplate(report) document = sco_pdf.BaseDocTemplate(report)
document.addPageTemplates( document.addPageTemplates(
sco_pdf.ScolarsPageTemplate( sco_pdf.ScoDocPageTemplate(
document, document,
author="%s %s (E. Viennet) [%s]" author="%s %s (E. Viennet) [%s]"
% (sco_version.SCONAME, sco_version.SCOVERSION, self.description), % (sco_version.SCONAME, sco_version.SCOVERSION, self.description),
@ -222,7 +215,7 @@ class BulletinGenerator(object):
preferences=sco_preferences.SemPreferences(formsemestre_id), preferences=sco_preferences.SemPreferences(formsemestre_id),
) )
) )
document.build(objects) document.build(story)
data = report.getvalue() data = report.getvalue()
return data return data
@ -253,7 +246,7 @@ class BulletinGenerator(object):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def make_formsemestre_bulletinetud( def make_formsemestre_bulletinetud(
infos, infos,
version="long", # short, long, selectedevals version=None, # short, long, selectedevals
format="pdf", # html, pdf format="pdf", # html, pdf
stand_alone=True, stand_alone=True,
): ):
@ -265,14 +258,25 @@ def make_formsemestre_bulletinetud(
""" """
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
version = version or "long"
if not version in scu.BULLETINS_VERSIONS: if not version in scu.BULLETINS_VERSIONS:
raise ValueError("invalid version code !") raise ValueError("invalid version code !")
formsemestre_id = infos["formsemestre_id"] formsemestre_id = infos["formsemestre_id"]
bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id) bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id)
try:
gen_class = None
for bul_class_name in (
sco_preferences.get_preference("bul_class_name", formsemestre_id),
# si pas trouvé (modifs locales bizarres ,), ré-essaye avec la valeur par défaut
bulletin_default_class_name(),
):
if infos.get("type") == "BUT" and format.startswith("pdf"):
gen_class = bulletin_get_class(bul_class_name + "BUT")
if gen_class is None:
gen_class = bulletin_get_class(bul_class_name) gen_class = bulletin_get_class(bul_class_name)
except:
if gen_class is None:
raise ValueError( raise ValueError(
"Type de bulletin PDF invalide (paramètre: %s)" % bul_class_name "Type de bulletin PDF invalide (paramètre: %s)" % bul_class_name
) )
@ -313,3 +317,52 @@ def make_formsemestre_bulletinetud(
filename = bul_generator.get_filename() filename = bul_generator.get_filename()
return data, filename return data, filename
####
# Liste des types des classes de générateurs de bulletins PDF:
BULLETIN_CLASSES = collections.OrderedDict()
def register_bulletin_class(klass):
BULLETIN_CLASSES[klass.__name__] = klass
def bulletin_class_descriptions():
return [
BULLETIN_CLASSES[class_name].description
for class_name in BULLETIN_CLASSES
if BULLETIN_CLASSES[class_name].list_in_menu
]
def bulletin_class_names() -> list[str]:
"Liste les noms des classes de bulletins à présenter à l'utilisateur"
return [
class_name
for class_name in BULLETIN_CLASSES
if BULLETIN_CLASSES[class_name].list_in_menu
]
def bulletin_default_class_name():
return bulletin_class_names()[0]
def bulletin_get_class(class_name: str) -> BulletinGenerator:
"""La class de génération de bulletin de ce nom,
ou None si pas trouvée
"""
return BULLETIN_CLASSES.get(class_name)
def bulletin_get_class_name_displayed(formsemestre_id):
"""Le nom du générateur utilisé, en clair"""
from app.scodoc import sco_preferences
bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id)
gen_class = bulletin_get_class(bul_class_name)
if gen_class is None:
return "invalide ! (voir paramètres)"
return gen_class.description

View File

@ -33,7 +33,7 @@ import json
from app.but import bulletin_but from app.but import bulletin_but
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_common import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.models.etudiants import Identite from app.models.etudiants import Identite
@ -138,7 +138,7 @@ def formsemestre_bulletinetud_published_dict(
if not published: if not published:
return d # stop ! return d # stop !
etat_inscription = etud.etat_inscription(formsemestre.id) etat_inscription = etud.inscription_etat(formsemestre.id)
if etat_inscription != scu.INSCRIT: if etat_inscription != scu.INSCRIT:
d.update(dict_decision_jury(etudid, formsemestre_id, with_decisions=True)) d.update(dict_decision_jury(etudid, formsemestre_id, with_decisions=True))
return d return d
@ -209,7 +209,7 @@ def formsemestre_bulletinetud_published_dict(
acronyme=scu.quote_xml_attr(ue["acronyme"]), acronyme=scu.quote_xml_attr(ue["acronyme"]),
titre=scu.quote_xml_attr(ue["titre"]), titre=scu.quote_xml_attr(ue["titre"]),
note=dict( note=dict(
value=scu.fmt_note(ue_status["cur_moy_ue"]), value=scu.fmt_note(ue_status["cur_moy_ue"] if ue_status else ""),
min=scu.fmt_note(ue["min"]), min=scu.fmt_note(ue["min"]),
max=scu.fmt_note(ue["max"]), max=scu.fmt_note(ue["max"]),
moy=scu.fmt_note( moy=scu.fmt_note(

View File

@ -34,17 +34,19 @@
CE FORMAT N'EVOLUERA PLUS ET EST CONSIDERE COMME OBSOLETE. CE FORMAT N'EVOLUERA PLUS ET EST CONSIDERE COMME OBSOLETE.
""" """
from reportlab.lib.colors import Color, blue
from reportlab.lib.units import cm, mm
from reportlab.platypus import Paragraph, Spacer, Table
import app.scodoc.sco_utils as scu
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_formsemestre
from app.scodoc import sco_pdf
from app.scodoc.sco_pdf import Color, Paragraph, Spacer, Table
from app.scodoc.sco_pdf import blue, cm, mm
from app.scodoc.sco_pdf import SU
from app.scodoc import sco_preferences
from app.scodoc import sco_bulletins_generator from app.scodoc import sco_bulletins_generator
from app.scodoc import sco_bulletins_pdf from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_formsemestre
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_pdf
from app.scodoc.sco_pdf import SU
from app.scodoc import sco_preferences
import app.scodoc.sco_utils as scu
# Important: Le nom de la classe ne doit pas changer (bien le choisir), car il sera stocké en base de données (dans les préférences) # Important: Le nom de la classe ne doit pas changer (bien le choisir), car il sera stocké en base de données (dans les préférences)
class BulletinGeneratorLegacy(sco_bulletins_generator.BulletinGenerator): class BulletinGeneratorLegacy(sco_bulletins_generator.BulletinGenerator):
@ -476,8 +478,8 @@ def _bulletin_pdf_table_legacy(I, version="long"):
else: else:
rang_minmax = mod["mod_rang_txt"] # vide si pas option rang rang_minmax = mod["mod_rang_txt"] # vide si pas option rang
t = [ t = [
mod["code"], mod["code"] or "",
mod["name"], mod["name"] or "",
rang_minmax, rang_minmax,
mod["mod_moy_txt"], mod["mod_moy_txt"],
mod["mod_coef_txt"], mod["mod_coef_txt"],

View File

@ -51,37 +51,39 @@ Chaque semestre peut si nécessaire utiliser un type de bulletin différent.
""" """
import io import io
import pprint
import pydoc
import re import re
import time import time
import traceback import traceback
from pydoc import html
from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate
from flask import g, request from flask import g, request
import app.scodoc.sco_utils as scu
from app import log, ScoValueError from app import log, ScoValueError
from app.models import FormSemestre
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_formsemestre from app.scodoc import sco_codes_parcours
from app.scodoc import sco_pdf from app.scodoc import sco_pdf
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_etud from app.scodoc import sco_etud
import sco_version
from app.scodoc.sco_logos import find_logo from app.scodoc.sco_logos import find_logo
import app.scodoc.sco_utils as scu
import sco_version
def pdfassemblebulletins( def assemble_bulletins_pdf(
formsemestre_id, formsemestre_id: int,
objects, story: list,
bul_title, bul_title: str,
infos, infos,
pagesbookmarks, pagesbookmarks=None,
filigranne=None, filigranne=None,
server_name="", server_name="",
): ):
"generate PDF document from a list of PLATYPUS objects" "Generate PDF document from a story (list of PLATYPUS objects)."
if not objects: if not story:
return "" return ""
# Paramètres de mise en page # Paramètres de mise en page
margins = ( margins = (
@ -90,11 +92,10 @@ def pdfassemblebulletins(
sco_preferences.get_preference("right_margin", formsemestre_id), sco_preferences.get_preference("right_margin", formsemestre_id),
sco_preferences.get_preference("bottom_margin", formsemestre_id), sco_preferences.get_preference("bottom_margin", formsemestre_id),
) )
report = io.BytesIO() # in-memory document, no disk file report = io.BytesIO() # in-memory document, no disk file
document = BaseDocTemplate(report) document = sco_pdf.BulletinDocTemplate(report)
document.addPageTemplates( document.addPageTemplates(
sco_pdf.ScolarsPageTemplate( sco_pdf.ScoDocPageTemplate(
document, document,
author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION), author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION),
title="Bulletin %s" % bul_title, title="Bulletin %s" % bul_title,
@ -106,7 +107,7 @@ def pdfassemblebulletins(
preferences=sco_preferences.SemPreferences(formsemestre_id), preferences=sco_preferences.SemPreferences(formsemestre_id),
) )
) )
document.build(objects) document.multiBuild(story)
data = report.getvalue() data = report.getvalue()
return data return data
@ -118,7 +119,8 @@ def replacement_function(match):
if logo is not None: if logo is not None:
return r'<img %s src="%s"%s/>' % (match.group(2), logo.filepath, match.group(4)) return r'<img %s src="%s"%s/>' % (match.group(2), logo.filepath, match.group(4))
raise ScoValueError( raise ScoValueError(
'balise "%s": logo "%s" introuvable' % (html.escape(balise), html.escape(name)) 'balise "%s": logo "%s" introuvable'
% (pydoc.html.escape(balise), pydoc.html.escape(name))
) )
@ -139,7 +141,11 @@ def process_field(field, cdict, style, suppress_empty_pars=False, format="pdf"):
cdict cdict
) # note that None values are mapped to empty strings ) # note that None values are mapped to empty strings
except: except:
log("process_field: invalid format=%s" % field) log(
f"""process_field: invalid format. field={field!r}
values={pprint.pformat(cdict)}
"""
)
text = ( text = (
"<para><i>format invalide !</i></para><para>" "<para><i>format invalide !</i></para><para>"
+ traceback.format_exc() + traceback.format_exc()
@ -171,30 +177,23 @@ def process_field(field, cdict, style, suppress_empty_pars=False, format="pdf"):
def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"):
"document pdf et filename" "Document pdf avec tous les bulletins du semestre, et filename"
from app.scodoc import sco_bulletins from app.scodoc import sco_bulletins
cached = sco_cache.SemBulletinsPDFCache.get(str(formsemestre_id) + "_" + version) cached = sco_cache.SemBulletinsPDFCache.get(str(formsemestre_id) + "_" + version)
if cached: if cached:
return cached[1], cached[0] return cached[1], cached[0]
fragments = [] fragments = []
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
# Make each bulletin # Make each bulletin
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > get_etudids, get_sexnom formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
bookmarks = {} for etud in formsemestre.get_inscrits(include_demdef=True, order=True):
filigrannes = {} frag, _ = sco_bulletins.do_formsemestre_bulletinetud(
i = 1 formsemestre,
for etudid in nt.get_etudids(): etud.id,
frag, filigranne = sco_bulletins.do_formsemestre_bulletinetud(
formsemestre_id,
etudid,
format="pdfpart", format="pdfpart",
version=version, version=version,
) )
fragments += frag fragments += frag
filigrannes[i] = filigranne
bookmarks[i] = scu.suppress_accents(nt.get_sexnom(etudid))
i = i + 1
# #
infos = {"DeptName": sco_preferences.get_preference("DeptName", formsemestre_id)} infos = {"DeptName": sco_preferences.get_preference("DeptName", formsemestre_id)}
if request: if request:
@ -203,20 +202,18 @@ def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"):
server_name = "" server_name = ""
try: try:
sco_pdf.PDFLOCK.acquire() sco_pdf.PDFLOCK.acquire()
pdfdoc = pdfassemblebulletins( pdfdoc = assemble_bulletins_pdf(
formsemestre_id, formsemestre_id,
fragments, fragments,
sem["titremois"], formsemestre.titre_mois(),
infos, infos,
bookmarks,
filigranne=filigrannes,
server_name=server_name, server_name=server_name,
) )
finally: finally:
sco_pdf.PDFLOCK.release() sco_pdf.PDFLOCK.release()
# #
dt = time.strftime("%Y-%m-%d") date_iso = time.strftime("%Y-%m-%d")
filename = "bul-%s-%s.pdf" % (sem["titre_num"], dt) filename = "bul-%s-%s.pdf" % (formsemestre.titre_num(), date_iso)
filename = scu.unescape_html(filename).replace(" ", "_").replace("&", "") filename = scu.unescape_html(filename).replace(" ", "_").replace("&", "")
# fill cache # fill cache
sco_cache.SemBulletinsPDFCache.set( sco_cache.SemBulletinsPDFCache.set(
@ -235,8 +232,9 @@ def get_etud_bulletins_pdf(etudid, version="selectedevals"):
filigrannes = {} filigrannes = {}
i = 1 i = 1
for sem in etud["sems"]: for sem in etud["sems"]:
formsemestre = FormSemestre.query.get(sem["formsemestre_id"])
frag, filigranne = sco_bulletins.do_formsemestre_bulletinetud( frag, filigranne = sco_bulletins.do_formsemestre_bulletinetud(
sem["formsemestre_id"], formsemestre,
etudid, etudid,
format="pdfpart", format="pdfpart",
version=version, version=version,
@ -252,7 +250,7 @@ def get_etud_bulletins_pdf(etudid, version="selectedevals"):
server_name = "" server_name = ""
try: try:
sco_pdf.PDFLOCK.acquire() sco_pdf.PDFLOCK.acquire()
pdfdoc = pdfassemblebulletins( pdfdoc = assemble_bulletins_pdf(
None, None,
fragments, fragments,
etud["nomprenom"], etud["nomprenom"],
@ -271,3 +269,16 @@ def get_etud_bulletins_pdf(etudid, version="selectedevals"):
) )
return pdfdoc, filename return pdfdoc, filename
def get_filigranne(etud_etat: str, prefs, decision_sem=None) -> str:
"""Texte à placer en "filigranne" sur le bulletin pdf"""
if etud_etat == scu.DEMISSION:
return "Démission"
elif etud_etat == sco_codes_parcours.DEF:
return "Défaillant"
elif (prefs["bul_show_temporary"] and not decision_sem) or prefs[
"bul_show_temporary_forced"
]:
return prefs["bul_temporary_txt"]
return ""

View File

@ -46,10 +46,13 @@ de la forme %(XXX)s sont remplacées par la valeur de XXX, pour XXX dans:
Balises img: actuellement interdites. Balises img: actuellement interdites.
""" """
from reportlab.platypus import KeepTogether, Paragraph, Spacer, Table
from reportlab.lib.units import cm, mm
from reportlab.lib.colors import Color, blue
from app.models import FormSemestre
from app.scodoc.sco_exceptions import ScoBugCatcher
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc.sco_pdf import Color, Paragraph, Spacer, Table
from app.scodoc.sco_pdf import blue, cm, mm
from app.scodoc.sco_pdf import SU from app.scodoc.sco_pdf import SU
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
@ -66,12 +69,13 @@ from app.scodoc import sco_groups
from app.scodoc import sco_evaluations from app.scodoc import sco_evaluations
from app.scodoc import gen_tables from app.scodoc import gen_tables
# Important: Le nom de la classe ne doit pas changer (bien le choisir), car il sera stocké en base de données (dans les préférences) # Important: Le nom de la classe ne doit pas changer (bien le choisir),
# car il sera stocké en base de données (dans les préférences)
class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
description = "standard ScoDoc (version 2011)" # la description doit être courte: elle apparait dans le menu de paramètrage ScoDoc description = "standard ScoDoc (version 2011)" # la description doit être courte: elle apparait dans le menu de paramètrage ScoDoc
supported_formats = ["html", "pdf"] supported_formats = ["html", "pdf"]
def bul_title_pdf(self): def bul_title_pdf(self) -> list:
"""Génère la partie "titre" du bulletin de notes. """Génère la partie "titre" du bulletin de notes.
Renvoie une liste d'objets platypus Renvoie une liste d'objets platypus
""" """
@ -113,11 +117,11 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
- en PDF: une liste d'objets platypus - en PDF: une liste d'objets platypus
""" """
H = [] # html H = [] # html
Op = [] # objets platypus story = [] # objets platypus
# ----- ABSENCES # ----- ABSENCES
if self.preferences["bul_show_abs"]: if self.preferences["bul_show_abs"]:
nbabs = self.infos["nbabs"] nbabs = self.infos["nbabs"]
Op.append(Spacer(1, 2 * mm)) story.append(Spacer(1, 2 * mm))
if nbabs: if nbabs:
H.append( H.append(
"""<p class="bul_abs"> """<p class="bul_abs">
@ -128,7 +132,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
""" """
% self.infos % self.infos
) )
Op.append( story.append(
Paragraph( Paragraph(
SU( SU(
"%(nbabs)s absences (1/2 journées), dont %(nbabsjust)s justifiées." "%(nbabs)s absences (1/2 journées), dont %(nbabsjust)s justifiées."
@ -139,7 +143,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
) )
else: else:
H.append("""<p class="bul_abs">Pas d'absences signalées.</p>""") H.append("""<p class="bul_abs">Pas d'absences signalées.</p>""")
Op.append(Paragraph(SU("Pas d'absences signalées."), self.CellStyle)) story.append(Paragraph(SU("Pas d'absences signalées."), self.CellStyle))
# ---- APPRECIATIONS # ---- APPRECIATIONS
# le dir. des etud peut ajouter des appreciations, # le dir. des etud peut ajouter des appreciations,
@ -166,10 +170,10 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
% self.infos % self.infos
) )
H.append("</div>") H.append("</div>")
# Appreciations sur PDF: # Appréciations sur PDF:
if self.infos.get("appreciations_list", False): if self.infos.get("appreciations_list", False):
Op.append(Spacer(1, 3 * mm)) story.append(Spacer(1, 3 * mm))
Op.append( story.append(
Paragraph( Paragraph(
SU("Appréciation : " + "\n".join(self.infos["appreciations_txt"])), SU("Appréciation : " + "\n".join(self.infos["appreciations_txt"])),
self.CellStyle, self.CellStyle,
@ -178,7 +182,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
# ----- DECISION JURY # ----- DECISION JURY
if self.preferences["bul_show_decision"]: if self.preferences["bul_show_decision"]:
Op += sco_bulletins_pdf.process_field( story += sco_bulletins_pdf.process_field(
self.preferences["bul_pdf_caption"], self.preferences["bul_pdf_caption"],
self.infos, self.infos,
self.FieldStyle, self.FieldStyle,
@ -194,7 +198,12 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
# ----- # -----
if format == "pdf": if format == "pdf":
return Op if self.scale_table_in_page:
# le scaling (pour tenir sur une page) semble incompatible avec
# le KeepTogether()
return story
else:
return [KeepTogether(story)]
elif format == "html": elif format == "html":
return "\n".join(H) return "\n".join(H)
@ -263,12 +272,12 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
) )
def build_bulletin_table(self): def build_bulletin_table(self):
"""Génère la table centrale du bulletin de notes """Génère la table centrale du bulletin de notes classique (pas BUT)
Renvoie: colkeys, P, pdf_style, colWidths Renvoie: col_keys, P, pdf_style, col_widths
- colkeys: nom des colonnes de la table (clés) - col_keys: nom des colonnes de la table (clés)
- table (liste de dicts de chaines de caracteres) - table: liste de dicts de chaines de caractères
- style (commandes table Platypus) - pdf_style: commandes table Platypus
- largeurs de colonnes pour PDF - col_widths: largeurs de colonnes pour PDF
""" """
I = self.infos I = self.infos
P = [] # elems pour générer table avec gen_table (liste de dicts) P = [] # elems pour générer table avec gen_table (liste de dicts)
@ -284,28 +293,28 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
) )
with_col_moypromo = prefs["bul_show_moypromo"] with_col_moypromo = prefs["bul_show_moypromo"]
with_col_rang = prefs["bul_show_rangs"] with_col_rang = prefs["bul_show_rangs"]
with_col_coef = prefs["bul_show_coef"] with_col_coef = prefs["bul_show_coef"] or prefs["bul_show_ue_coef"]
with_col_ects = prefs["bul_show_ects"] with_col_ects = prefs["bul_show_ects"]
colkeys = ["titre", "module"] # noms des colonnes à afficher col_keys = ["titre", "module"] # noms des colonnes à afficher
if with_col_rang: if with_col_rang:
colkeys += ["rang"] col_keys += ["rang"]
if with_col_minmax: if with_col_minmax:
colkeys += ["min"] col_keys += ["min"]
if with_col_moypromo: if with_col_moypromo:
colkeys += ["moy"] col_keys += ["moy"]
if with_col_minmax: if with_col_minmax:
colkeys += ["max"] col_keys += ["max"]
colkeys += ["note"] col_keys += ["note"]
if with_col_coef: if with_col_coef:
colkeys += ["coef"] col_keys += ["coef"]
if with_col_ects: if with_col_ects:
colkeys += ["ects"] col_keys += ["ects"]
if with_col_abs: if with_col_abs:
colkeys += ["abs"] col_keys += ["abs"]
colidx = {} # { nom_colonne : indice à partir de 0 } (pour styles platypus) colidx = {} # { nom_colonne : indice à partir de 0 } (pour styles platypus)
i = 0 i = 0
for k in colkeys: for k in col_keys:
colidx[k] = i colidx[k] = i
i += 1 i += 1
@ -313,7 +322,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
bul_pdf_mod_colwidth = float(prefs["bul_pdf_mod_colwidth"]) * cm bul_pdf_mod_colwidth = float(prefs["bul_pdf_mod_colwidth"]) * cm
else: else:
bul_pdf_mod_colwidth = None bul_pdf_mod_colwidth = None
colWidths = { col_widths = {
"titre": None, "titre": None,
"module": bul_pdf_mod_colwidth, "module": bul_pdf_mod_colwidth,
"min": 1.5 * cm, "min": 1.5 * cm,
@ -373,10 +382,10 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
t = { t = {
"titre": "Moyenne générale:", "titre": "Moyenne générale:",
"rang": I["rang_nt"], "rang": I["rang_nt"],
"note": I["moy_gen"], "note": I.get("moy_gen", "-"),
"min": I["moy_min"], "min": I.get("moy_min", "-"),
"max": I["moy_max"], "max": I.get("moy_max", "-"),
"moy": I["moy_moy"], "moy": I.get("moy_moy", "-"),
"abs": "%s / %s" % (nbabs, nbabsjust), "abs": "%s / %s" % (nbabs, nbabsjust),
"_css_row_class": "notes_bulletin_row_gen", "_css_row_class": "notes_bulletin_row_gen",
"_titre_colspan": 2, "_titre_colspan": 2,
@ -409,7 +418,8 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
# Chaque UE: # Chaque UE:
for ue in I["ues"]: for ue in I["ues"]:
ue_type = None ue_type = None
coef_ue = ue["coef_ue_txt"] coef_ue = ue["coef_ue_txt"] if prefs["bul_show_ue_coef"] else ""
ue_descr = ue["ue_descr_txt"] ue_descr = ue["ue_descr_txt"]
rowstyle = "" rowstyle = ""
plusminus = minuslink # plusminus = minuslink #
@ -471,9 +481,10 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
ects_txt = str(int(ue["ects"])) ects_txt = str(int(ue["ects"]))
except: except:
ects_txt = "-" ects_txt = "-"
titre = f"{ue['acronyme'] or ''} {ue['titre'] or ''}"
t = { t = {
"titre": ue["acronyme"] + " " + ue["titre"], "titre": titre,
"_titre_html": minuslink + ue["acronyme"] + " " + ue["titre"], "_titre_html": minuslink + titre,
"_titre_colspan": 2, "_titre_colspan": 2,
"module": ue["titre"], "module": ue["titre"],
"rang": ue_descr, "rang": ue_descr,
@ -540,7 +551,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
("BOX", (0, 0), (-1, -1), 0.4, blue), # ajoute cadre extérieur bleu: ("BOX", (0, 0), (-1, -1), 0.4, blue), # ajoute cadre extérieur bleu:
] ]
# #
return colkeys, P, pdf_style, colWidths return col_keys, P, pdf_style, col_widths
def _list_modules( def _list_modules(
self, self,
@ -591,7 +602,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
"_titre_colspan": 2, "_titre_colspan": 2,
"rang": mod["mod_rang_txt"], # vide si pas option rang "rang": mod["mod_rang_txt"], # vide si pas option rang
"note": mod["mod_moy_txt"], "note": mod["mod_moy_txt"],
"coef": mod["mod_coef_txt"], "coef": mod["mod_coef_txt"] if prefs["bul_show_coef"] else "",
"abs": mod.get( "abs": mod.get(
"mod_abs_txt", "" "mod_abs_txt", ""
), # absent si pas option show abs module ), # absent si pas option show abs module
@ -655,7 +666,9 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
eval_style = "" eval_style = ""
t = { t = {
"module": '<bullet indent="2mm">&bull;</bullet>&nbsp;' + e["name"], "module": '<bullet indent="2mm">&bull;</bullet>&nbsp;' + e["name"],
"coef": "<i>" + e["coef_txt"] + "</i>", "coef": ("<i>" + e["coef_txt"] + "</i>")
if prefs["bul_show_coef"]
else "",
"_hidden": hidden, "_hidden": hidden,
"_module_target": e["target_html"], "_module_target": e["target_html"],
# '_module_help' : , # '_module_help' : ,

View File

@ -32,16 +32,12 @@ On redéfini la table centrale du bulletin de note et hérite de tout le reste d
E. Viennet, juillet 2011 E. Viennet, juillet 2011
""" """
from reportlab.lib.colors import Color
from reportlab.lib.units import mm
import app.scodoc.sco_utils as scu
from app.scodoc.sco_pdf import blue, cm, mm
from app.scodoc.sco_pdf import Color, Paragraph, Spacer, Table
from app.scodoc import sco_preferences
from app.scodoc import sco_bulletins_generator
from app.scodoc import sco_bulletins_standard from app.scodoc import sco_bulletins_standard
from app.scodoc import gen_tables from app.scodoc import sco_preferences
import app.scodoc.sco_utils as scu
class BulletinGeneratorUCAC(sco_bulletins_standard.BulletinGeneratorStandard): class BulletinGeneratorUCAC(sco_bulletins_standard.BulletinGeneratorStandard):

View File

@ -45,7 +45,7 @@ from xml.etree import ElementTree
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_common import NotesTableCompat from app.comp.res_compat import NotesTableCompat
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app import log from app import log
@ -217,7 +217,7 @@ def make_xml_formsemestre_bulletinetud(
) )
doc.append(x_ue) doc.append(x_ue)
if ue["type"] != sco_codes_parcours.UE_SPORT: if ue["type"] != sco_codes_parcours.UE_SPORT:
v = ue_status["cur_moy_ue"] v = ue_status["cur_moy_ue"] if ue_status else ""
else: else:
v = nt.bonus[etudid] if nt.bonus is not None else 0.0 v = nt.bonus[etudid] if nt.bonus is not None else 0.0
x_ue.append( x_ue.append(
@ -252,7 +252,7 @@ def make_xml_formsemestre_bulletinetud(
x_mod = Element( x_mod = Element(
"module", "module",
id=str(modimpl["moduleimpl_id"]), id=str(modimpl["moduleimpl_id"]),
code=str(mod["code"]), code=str(mod["code"] or ""),
coefficient=str(mod["coefficient"]), coefficient=str(mod["coefficient"]),
numero=str(mod["numero"]), numero=str(mod["numero"]),
titre=scu.quote_xml_attr(mod["titre"]), titre=scu.quote_xml_attr(mod["titre"]),

View File

@ -33,17 +33,12 @@
""" """
# API ScoDoc8 pour les caches: # API pour les caches:
# sco_cache.NotesTableCache.get( formsemestre_id) # sco_cache.MyCache.get( formsemestre_id)
# => sco_cache.NotesTableCache.get(formsemestre_id) # => sco_cache.MyCache.get(formsemestre_id)
# #
# sco_core.inval_cache(formsemestre_id=None, pdfonly=False, formsemestre_id_list=None) # sco_cache.MyCache.delete(formsemestre_id)
# => deprecated, NotesTableCache.invalidate_formsemestre(formsemestre_id=None, pdfonly=False) # sco_cache.MyCache.delete_many(formsemestre_id_list)
#
#
# Nouvelles fonctions:
# sco_cache.NotesTableCache.delete(formsemestre_id)
# sco_cache.NotesTableCache.delete_many(formsemestre_id_list)
# #
# Bulletins PDF: # Bulletins PDF:
# sco_cache.SemBulletinsPDFCache.get(formsemestre_id, version) # sco_cache.SemBulletinsPDFCache.get(formsemestre_id, version)
@ -203,49 +198,6 @@ class SemInscriptionsCache(ScoDocCache):
duration = 12 * 60 * 60 # ttl 12h duration = 12 * 60 * 60 # ttl 12h
class NotesTableCache(ScoDocCache):
"""Cache pour les NotesTable
Clé: formsemestre_id
Valeur: NotesTable instance
"""
prefix = "NT"
@classmethod
def get(cls, formsemestre_id, compute=True):
"""Returns NotesTable for this formsemestre
Search in local cache (g.nt_cache) or global app cache (eg REDIS)
If not in cache:
If compute is True, build it and cache it
Else return None
"""
# try local cache (same request)
if not hasattr(g, "nt_cache"):
g.nt_cache = {}
else:
if formsemestre_id in g.nt_cache:
return g.nt_cache[formsemestre_id]
# try REDIS
key = cls._get_key(formsemestre_id)
nt = CACHE.get(key)
if nt:
g.nt_cache[formsemestre_id] = nt # cache locally (same request)
return nt
if not compute:
return None
# Recompute requested table:
from app.scodoc import notes_table
t0 = time.time()
nt = notes_table.NotesTable(formsemestre_id)
t1 = time.time()
_ = cls.set(formsemestre_id, nt) # cache in REDIS
t2 = time.time()
log(f"cached formsemestre_id={formsemestre_id} ({(t1-t0):g}s +{(t2-t1):g}s)")
g.nt_cache[formsemestre_id] = nt
return nt
def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=False) def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=False)
formsemestre_id=None, pdfonly=False formsemestre_id=None, pdfonly=False
): ):
@ -278,22 +230,24 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa
if not pdfonly: if not pdfonly:
# Delete cached notes and evaluations # Delete cached notes and evaluations
NotesTableCache.delete_many(formsemestre_ids)
if formsemestre_id: if formsemestre_id:
for fid in formsemestre_ids: for fid in formsemestre_ids:
EvaluationCache.invalidate_sem(fid) EvaluationCache.invalidate_sem(fid)
if hasattr(g, "nt_cache") and fid in g.nt_cache: if (
del g.nt_cache[fid] hasattr(g, "formsemestre_results_cache")
and fid in g.formsemestre_results_cache
):
del g.formsemestre_results_cache[fid]
else: else:
# optimization when we invalidate all evaluations: # optimization when we invalidate all evaluations:
EvaluationCache.invalidate_all_sems() EvaluationCache.invalidate_all_sems()
if hasattr(g, "nt_cache"): if hasattr(g, "formsemestre_results_cache"):
del g.nt_cache del g.formsemestre_results_cache
SemInscriptionsCache.delete_many(formsemestre_ids) SemInscriptionsCache.delete_many(formsemestre_ids)
SemBulletinsPDFCache.invalidate_sems(formsemestre_ids)
ResultatsSemestreCache.delete_many(formsemestre_ids) ResultatsSemestreCache.delete_many(formsemestre_ids)
ValidationsSemestreCache.delete_many(formsemestre_ids) ValidationsSemestreCache.delete_many(formsemestre_ids)
SemBulletinsPDFCache.invalidate_sems(formsemestre_ids)
class DefferedSemCacheManager: class DefferedSemCacheManager:

View File

@ -81,11 +81,11 @@ UE_PROFESSIONNELLE = 5 # UE "professionnelle" (ISCID, ...)
UE_OPTIONNELLE = 6 # UE non fondamentales (ILEPS, ...) UE_OPTIONNELLE = 6 # UE non fondamentales (ILEPS, ...)
def UE_is_fondamentale(ue_type): def ue_is_fondamentale(ue_type):
return ue_type in (UE_STANDARD, UE_STAGE_LP, UE_PROFESSIONNELLE) return ue_type in (UE_STANDARD, UE_STAGE_LP, UE_PROFESSIONNELLE)
def UE_is_professionnelle(ue_type): def ue_is_professionnelle(ue_type):
return ( return (
ue_type == UE_PROFESSIONNELLE ue_type == UE_PROFESSIONNELLE
) # NB: les UE_PROFESSIONNELLE sont à la fois fondamentales et pro ) # NB: les UE_PROFESSIONNELLE sont à la fois fondamentales et pro
@ -211,7 +211,7 @@ DEVENIRS_NEXT2 = {NEXT_OR_NEXT2: 1, NEXT2: 1}
NO_SEMESTRE_ID = -1 # code semestre si pas de semestres NO_SEMESTRE_ID = -1 # code semestre si pas de semestres
# Regles gestion parcours # Règles gestion parcours
class DUTRule(object): class DUTRule(object):
def __init__(self, rule_id, premise, conclusion): def __init__(self, rule_id, premise, conclusion):
self.rule_id = rule_id self.rule_id = rule_id
@ -222,13 +222,13 @@ class DUTRule(object):
def match(self, state): def match(self, state):
"True if state match rule premise" "True if state match rule premise"
assert len(state) == len(self.premise) assert len(state) == len(self.premise)
for i in range(len(state)): for i, stat in enumerate(state):
prem = self.premise[i] prem = self.premise[i]
if isinstance(prem, (list, tuple)): if isinstance(prem, (list, tuple)):
if not state[i] in prem: if not stat in prem:
return False return False
else: else:
if prem != ALL and prem != state[i]: if prem not in (ALL, stat):
return False return False
return True return True
@ -244,6 +244,7 @@ class TypeParcours(object):
COMPENSATION_UE = True # inutilisé COMPENSATION_UE = True # inutilisé
BARRE_MOY = 10.0 BARRE_MOY = 10.0
BARRE_UE_DEFAULT = 8.0 BARRE_UE_DEFAULT = 8.0
BARRE_UE_DISPLAY_WARNING = 8.0
BARRE_UE = {} BARRE_UE = {}
NOTES_BARRE_VALID_UE_TH = 10.0 # seuil pour valider UE NOTES_BARRE_VALID_UE_TH = 10.0 # seuil pour valider UE
NOTES_BARRE_VALID_UE = NOTES_BARRE_VALID_UE_TH - NOTES_TOLERANCE # barre sur UE NOTES_BARRE_VALID_UE = NOTES_BARRE_VALID_UE_TH - NOTES_TOLERANCE # barre sur UE
@ -282,7 +283,7 @@ class TypeParcours(object):
return [ return [
ue_status ue_status
for ue_status in ues_status for ue_status in ues_status
if ue_status["coef_ue"] > 0 if ue_status["coef_ue"]
and isinstance(ue_status["moy"], float) and isinstance(ue_status["moy"], float)
and ue_status["moy"] < self.get_barre_ue(ue_status["ue"]["type"]) and ue_status["moy"] < self.get_barre_ue(ue_status["ue"]["type"])
] ]
@ -587,7 +588,7 @@ class ParcoursILEPS(TypeParcours):
# SESSION_ABBRV = 'A' # A1, A2, ... # SESSION_ABBRV = 'A' # A1, A2, ...
COMPENSATION_UE = False COMPENSATION_UE = False
UNUSED_CODES = set((ADC, ATT, ATB, ATJ)) UNUSED_CODES = set((ADC, ATT, ATB, ATJ))
ALLOWED_UE_TYPES = [UE_STANDARD, UE_OPTIONNELLE] ALLOWED_UE_TYPES = [UE_STANDARD, UE_OPTIONNELLE, UE_SPORT]
# Barre moy gen. pour validation semestre: # Barre moy gen. pour validation semestre:
BARRE_MOY = 10.0 BARRE_MOY = 10.0
# Barre pour UE ILEPS: 8/20 pour UE standards ("fondamentales") # Barre pour UE ILEPS: 8/20 pour UE standards ("fondamentales")

View File

@ -46,7 +46,9 @@ CONFIG.LOGO_HEADER_HEIGHT = 28
# #
# server_url: URL du serveur ScoDoc # server_url: URL du serveur ScoDoc
# scodoc_name: le nom du logiciel (ScoDoc actuellement, voir sco_version.py) # scodoc_name: le nom du logiciel (ScoDoc actuellement, voir sco_version.py)
CONFIG.DEFAULT_PDF_FOOTER_TEMPLATE = "Edité par %(scodoc_name)s le %(day)s/%(month)s/%(year)s à %(hour)sh%(minute)s sur %(server_url)s" CONFIG.DEFAULT_PDF_FOOTER_TEMPLATE = (
"Edité par %(scodoc_name)s le %(day)s/%(month)s/%(year)s à %(hour)sh%(minute)s"
)
# ------------- Capitalisation des UEs ------------- # ------------- Capitalisation des UEs -------------

View File

@ -65,7 +65,7 @@ def formsemestre_table_estim_cost(
Mod = M["module"] Mod = M["module"]
T.append( T.append(
{ {
"code": Mod["code"], "code": Mod["code"] or "",
"titre": Mod["titre"], "titre": Mod["titre"],
"heures_cours": Mod["heures_cours"], "heures_cours": Mod["heures_cours"],
"heures_td": Mod["heures_td"] * n_group_td, "heures_td": Mod["heures_td"] * n_group_td,

View File

@ -31,15 +31,17 @@ Rapport (table) avec dernier semestre fréquenté et débouché de chaque étudi
import http import http
from flask import url_for, g, request from flask import url_for, g, request
from app import log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app import log
from app.scodoc.sco_exceptions import AccessDenied from app.scodoc.sco_exceptions import AccessDenied
from app.scodoc.scolog import logdb from app.scodoc.scolog import logdb
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
from app.scodoc import safehtml from app.scodoc import safehtml
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_cache
from app.scodoc import sco_permissions_check from app.scodoc import sco_permissions_check
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_tag_module from app.scodoc import sco_tag_module
@ -115,7 +117,7 @@ def get_etudids_with_debouche(start_year):
def table_debouche_etudids(etudids, keep_numeric=True): def table_debouche_etudids(etudids, keep_numeric=True):
"""Rapport pour ces etudiants""" """Rapport pour ces étudiants"""
L = [] L = []
for etudid in etudids: for etudid in etudids:
etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
@ -124,7 +126,8 @@ def table_debouche_etudids(etudids, keep_numeric=True):
es = [(s["date_fin_iso"], i) for i, s in enumerate(sems)] es = [(s["date_fin_iso"], i) for i, s in enumerate(sems)]
imax = max(es)[1] imax = max(es)[1]
last_sem = sems[imax] last_sem = sems[imax]
nt = sco_cache.NotesTableCache.get(last_sem["formsemestre_id"]) formsemestre = FormSemestre.query.get_or_404(last_sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
row = { row = {
"etudid": etudid, "etudid": etudid,
"civilite": etud["civilite"], "civilite": etud["civilite"],

View File

@ -56,7 +56,7 @@ def index_html(showcodes=0, showsemtable=0):
H.append(sco_news.scolar_news_summary_html()) H.append(sco_news.scolar_news_summary_html())
# Avertissement de mise à jour: # Avertissement de mise à jour:
H.append(sco_up_to_date.html_up_to_date_box()) H.append("""<div id="update_warning"></div>""")
# Liste de toutes les sessions: # Liste de toutes les sessions:
sems = sco_formsemestre.do_formsemestre_list() sems = sco_formsemestre.do_formsemestre_list()
@ -293,7 +293,6 @@ def delete_dept(dept_id: int):
"create temp table formsemestres_temp as select id from notes_formsemestre where dept_id = %(dept_id)s", "create temp table formsemestres_temp as select id from notes_formsemestre where dept_id = %(dept_id)s",
"create temp table moduleimpls_temp as select id from notes_moduleimpl where formsemestre_id in (select id from formsemestres_temp)", "create temp table moduleimpls_temp as select id from notes_moduleimpl where formsemestre_id in (select id from formsemestres_temp)",
"create temp table formations_temp as select id from notes_formations where dept_id = %(dept_id)s", "create temp table formations_temp as select id from notes_formations where dept_id = %(dept_id)s",
"create temp table entreprises_temp as select id from entreprises where dept_id = %(dept_id)s",
"create temp table tags_temp as select id from notes_tags where dept_id = %(dept_id)s", "create temp table tags_temp as select id from notes_tags where dept_id = %(dept_id)s",
] ]
for r in reqs: for r in reqs:
@ -345,13 +344,9 @@ def delete_dept(dept_id: int):
"delete from notes_formsemestre where dept_id = %(dept_id)s", "delete from notes_formsemestre where dept_id = %(dept_id)s",
"delete from scolar_news where dept_id = %(dept_id)s", "delete from scolar_news where dept_id = %(dept_id)s",
"delete from notes_semset where dept_id = %(dept_id)s", "delete from notes_semset where dept_id = %(dept_id)s",
"delete from entreprise_contact where entreprise_id in (select id from entreprises_temp) ",
"delete from entreprise_correspondant where entreprise_id in (select id from entreprises_temp) ",
"delete from entreprises where dept_id = %(dept_id)s",
"delete from notes_formations where dept_id = %(dept_id)s", "delete from notes_formations where dept_id = %(dept_id)s",
"delete from departement where id = %(dept_id)s", "delete from departement where id = %(dept_id)s",
"drop table tags_temp", "drop table tags_temp",
"drop table entreprises_temp",
"drop table formations_temp", "drop table formations_temp",
"drop table moduleimpls_temp", "drop table moduleimpls_temp",
"drop table etudids_temp", "drop table etudids_temp",

View File

@ -51,13 +51,12 @@ import fcntl
import subprocess import subprocess
import requests import requests
from flask import g, request
from flask_login import current_user from flask_login import current_user
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import log from app import log
from app.scodoc import html_sco_header
from app.scodoc import sco_preferences
from app.scodoc import sco_users from app.scodoc import sco_users
import sco_version import sco_version
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
@ -65,10 +64,9 @@ from app.scodoc.sco_exceptions import ScoValueError
SCO_DUMP_LOCK = "/tmp/scodump.lock" SCO_DUMP_LOCK = "/tmp/scodump.lock"
def sco_dump_and_send_db(): def sco_dump_and_send_db(message: str = "", request_url: str = ""):
"""Dump base de données et l'envoie anonymisée pour debug""" """Dump base de données et l'envoie anonymisée pour debug"""
H = [html_sco_header.sco_header(page_title="Assistance technique")] # get current (dept) DB name:
# get currect (dept) DB name:
cursor = ndb.SimpleQuery("SELECT current_database()", {}) cursor = ndb.SimpleQuery("SELECT current_database()", {})
db_name = cursor.fetchone()[0] db_name = cursor.fetchone()[0]
ano_db_name = "ANO" + db_name ano_db_name = "ANO" + db_name
@ -94,28 +92,8 @@ def sco_dump_and_send_db():
_anonymize_db(ano_db_name) _anonymize_db(ano_db_name)
# Send # Send
r = _send_db(ano_db_name) r = _send_db(ano_db_name, message, request_url)
if ( code = r.status_code
r.status_code
== requests.codes.INSUFFICIENT_STORAGE # pylint: disable=no-member
):
H.append(
"""<p class="warning">
Erreur: espace serveur trop plein.
Merci de contacter <a href="mailto:{0}">{0}</a></p>""".format(
scu.SCO_DEV_MAIL
)
)
elif r.status_code == requests.codes.OK: # pylint: disable=no-member
H.append("""<p>Opération effectuée.</p>""")
else:
H.append(
"""<p class="warning">
Erreur: code <tt>{0} {1}</tt>
Merci de contacter <a href="mailto:{2}">{2}</a></p>""".format(
r.status_code, r.reason, scu.SCO_DEV_MAIL
)
)
finally: finally:
# Drop anonymized database # Drop anonymized database
@ -124,7 +102,8 @@ def sco_dump_and_send_db():
fcntl.flock(x, fcntl.LOCK_UN) fcntl.flock(x, fcntl.LOCK_UN)
log("sco_dump_and_send_db: done.") log("sco_dump_and_send_db: done.")
return "\n".join(H) + html_sco_header.sco_footer()
return code
def _duplicate_db(db_name, ano_db_name): def _duplicate_db(db_name, ano_db_name):
@ -173,7 +152,7 @@ def _get_scodoc_serial():
return 0 return 0
def _send_db(ano_db_name): def _send_db(ano_db_name: str, message: str = "", request_url: str = ""):
"""Dump this (anonymized) database and send it to tech support""" """Dump this (anonymized) database and send it to tech support"""
log(f"dumping anonymized database {ano_db_name}") log(f"dumping anonymized database {ano_db_name}")
try: try:
@ -182,15 +161,20 @@ def _send_db(ano_db_name):
) )
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
log(f"sco_dump_and_send_db: exception in anonymisation: {e}") log(f"sco_dump_and_send_db: exception in anonymisation: {e}")
raise ScoValueError(f"erreur lors de l'anonymisation de la base {ano_db_name}") raise ScoValueError(
f"erreur lors de l'anonymisation de la base {ano_db_name}"
) from e
log("uploading anonymized dump...") log("uploading anonymized dump...")
files = {"file": (ano_db_name + ".dump", dump)} files = {"file": (ano_db_name + ".dump", dump)}
try:
r = requests.post( r = requests.post(
scu.SCO_DUMP_UP_URL, scu.SCO_DUMP_UP_URL,
files=files, files=files,
data={ data={
"dept_name": sco_preferences.get_preference("DeptName"), "dept_name": getattr(g, "scodoc_dept", "-"),
"message": message or "",
"request_url": request_url or request.url,
"serial": _get_scodoc_serial(), "serial": _get_scodoc_serial(),
"sco_user": str(current_user), "sco_user": str(current_user),
"sent_by": sco_users.user_info(str(current_user))["nomcomplet"], "sent_by": sco_users.user_info(str(current_user))["nomcomplet"],
@ -198,6 +182,15 @@ def _send_db(ano_db_name):
"sco_fullversion": scu.get_scodoc_version(), "sco_fullversion": scu.get_scodoc_version(),
}, },
) )
except requests.exceptions.ConnectionError as exc:
raise ScoValueError(
"""
Impossible de joindre le serveur d'assistance (scodoc.org).
Veuillez contacter le service informatique de votre établissement pour
corriger la configuration de ScoDoc. Dans la plupart des cas, il
s'agit d'un proxy mal configuré.
"""
) from exc
return r return r

View File

@ -52,6 +52,7 @@ def html_edit_formation_apc(
""" """
parcours = formation.get_parcours() parcours = formation.get_parcours()
assert parcours.APC_SAE assert parcours.APC_SAE
ressources = formation.modules.filter_by(module_type=ModuleType.RESSOURCE).order_by( ressources = formation.modules.filter_by(module_type=ModuleType.RESSOURCE).order_by(
Module.semestre_id, Module.numero, Module.code Module.semestre_id, Module.numero, Module.code
) )
@ -68,6 +69,19 @@ def html_edit_formation_apc(
).order_by( ).order_by(
Module.semestre_id, Module.module_type.desc(), Module.numero, Module.code Module.semestre_id, Module.module_type.desc(), Module.numero, Module.code
) )
ues_by_sem = {}
ects_by_sem = {}
for semestre_idx in semestre_ids:
ues_by_sem[semestre_idx] = formation.ues.filter_by(
semestre_idx=semestre_idx
).order_by(UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme)
ects = [ue.ects for ue in ues_by_sem[semestre_idx]]
if None in ects:
ects_by_sem[semestre_idx] = '<span class="missing_ue_ects">manquant</span>'
else:
ects_by_sem[semestre_idx] = sum(ects)
arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags() arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags()
icons = { icons = {
@ -93,7 +107,8 @@ def html_edit_formation_apc(
editable=editable, editable=editable,
tag_editable=tag_editable, tag_editable=tag_editable,
icons=icons, icons=icons,
UniteEns=UniteEns, ues_by_sem=ues_by_sem,
ects_by_sem=ects_by_sem,
), ),
] ]
for semestre_idx in semestre_ids: for semestre_idx in semestre_ids:
@ -151,7 +166,7 @@ def html_edit_formation_apc(
def html_ue_infos(ue): def html_ue_infos(ue):
"""page d'information sur une UE""" """Page d'information sur une UE"""
from app.views import ScoData from app.views import ScoData
formsemestres = ( formsemestres = (
@ -174,7 +189,6 @@ def html_ue_infos(ue):
) )
return render_template( return render_template(
"pn/ue_infos.html", "pn/ue_infos.html",
# "pn/tmp.html",
titre=f"UE {ue.acronyme} {ue.titre}", titre=f"UE {ue.acronyme} {ue.titre}",
ue=ue, ue=ue,
formsemestres=formsemestres, formsemestres=formsemestres,

View File

@ -365,6 +365,7 @@ def ue_move(ue_id, after=0, redirect=1):
if len({o.numero for o in others}) != len(others): if len({o.numero for o in others}) != len(others):
# il y a des numeros identiques ! # il y a des numeros identiques !
scu.objects_renumber(db, others) scu.objects_renumber(db, others)
ue.formation.invalidate_cached_sems()
if len(others) > 1: if len(others) > 1:
idx = [u.id for u in others].index(ue.id) idx = [u.id for u in others].index(ue.id)
neigh = None # object to swap with neigh = None # object to swap with

View File

@ -88,13 +88,14 @@ def do_matiere_create(args):
r = _matiereEditor.create(cnx, args) r = _matiereEditor.create(cnx, args)
# news # news
F = sco_formations.formation_list(args={"formation_id": ue["formation_id"]})[0] formation = Formation.query.get(ue["formation_id"])
sco_news.add( sco_news.add(
typ=sco_news.NEWS_FORM, typ=sco_news.NEWS_FORM,
object=ue["formation_id"], object=ue["formation_id"],
text="Modification de la formation %(acronyme)s" % F, text=f"Modification de la formation {formation.acronyme}",
max_frequency=3, max_frequency=3,
) )
formation.invalidate_cached_sems()
return r return r
@ -195,13 +196,14 @@ def do_matiere_delete(oid):
_matiereEditor.delete(cnx, oid) _matiereEditor.delete(cnx, oid)
# news # news
F = sco_formations.formation_list(args={"formation_id": ue["formation_id"]})[0] formation = Formation.query.get(ue["formation_id"])
sco_news.add( sco_news.add(
typ=sco_news.NEWS_FORM, typ=sco_news.NEWS_FORM,
object=ue["formation_id"], object=ue["formation_id"],
text="Modification de la formation %(acronyme)s" % F, text=f"Modification de la formation {formation.acronyme}",
max_frequency=3, max_frequency=3,
) )
formation.invalidate_cached_sems()
def matiere_delete(matiere_id=None): def matiere_delete(matiere_id=None):

View File

@ -41,7 +41,6 @@ from app.models import FormSemestre, ModuleImpl
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
@ -105,13 +104,14 @@ def do_module_create(args) -> int:
r = _moduleEditor.create(cnx, args) r = _moduleEditor.create(cnx, args)
# news # news
F = sco_formations.formation_list(args={"formation_id": args["formation_id"]})[0] formation = Formation.query.get(args["formation_id"])
sco_news.add( sco_news.add(
typ=sco_news.NEWS_FORM, typ=sco_news.NEWS_FORM,
object=args["formation_id"], object=formation.id,
text="Modification de la formation %(acronyme)s" % F, text=f"Modification de la formation {formation.acronyme}",
max_frequency=3, max_frequency=3,
) )
formation.invalidate_cached_sems()
return r return r
@ -196,7 +196,6 @@ def module_create(
}, },
), ),
] ]
semestres_indices = list(range(1, parcours.NB_SEM + 1))
if is_apc: if is_apc:
module_types = scu.ModuleType # tous les types module_types = scu.ModuleType # tous les types
@ -396,13 +395,14 @@ def do_module_delete(oid):
_moduleEditor.delete(cnx, oid) _moduleEditor.delete(cnx, oid)
# news # news
F = sco_formations.formation_list(args={"formation_id": mod["formation_id"]})[0] formation = module.formation
sco_news.add( sco_news.add(
typ=sco_news.NEWS_FORM, typ=sco_news.NEWS_FORM,
object=mod["formation_id"], object=mod["formation_id"],
text="Modification de la formation %(acronyme)s" % F, text=f"Modification de la formation {formation.acronyme}",
max_frequency=3, max_frequency=3,
) )
formation.invalidate_cached_sems()
def module_delete(module_id=None): def module_delete(module_id=None):
@ -451,8 +451,6 @@ def module_delete(module_id=None):
def do_module_edit(vals: dict) -> None: def do_module_edit(vals: dict) -> None:
"edit a module" "edit a module"
from app.scodoc import sco_edit_formation
# check # check
mod = module_list({"module_id": vals["module_id"]})[0] mod = module_list({"module_id": vals["module_id"]})[0]
if module_is_locked(mod["module_id"]): if module_is_locked(mod["module_id"]):
@ -502,20 +500,27 @@ def module_edit(module_id=None):
matieres = matieres.filter(UniteEns.semestre_idx == a_module.ue.semestre_idx) matieres = matieres.filter(UniteEns.semestre_idx == a_module.ue.semestre_idx)
if is_apc: if is_apc:
# ne conserve que la 1ere matière de chaque UE,
# et celle à laquelle ce module est rattaché
matieres = [
mat
for mat in matieres
if a_module.matiere.id == mat.id or mat.id == mat.ue.matieres.first().id
]
mat_names = [ mat_names = [
"S%s / %s" % (mat.ue.semestre_idx, mat.ue.acronyme) for mat in matieres "S%s / %s" % (mat.ue.semestre_idx, mat.ue.acronyme) for mat in matieres
] ]
else: else:
mat_names = ["%s / %s" % (mat.ue.acronyme, mat.titre or "") for mat in matieres] mat_names = ["%s / %s" % (mat.ue.acronyme, mat.titre or "") for mat in matieres]
ue_mat_ids = ["%s!%s" % (mat.ue.id, mat.id) for mat in matieres]
ue_mat_ids = ["%s!%s" % (mat.ue.id, mat.id) for mat in matieres]
module["ue_matiere_id"] = "%s!%s" % (module["ue_id"], module["matiere_id"]) module["ue_matiere_id"] = "%s!%s" % (module["ue_id"], module["matiere_id"])
semestres_indices = list(range(1, parcours.NB_SEM + 1)) semestres_indices = list(range(1, parcours.NB_SEM + 1))
H = [ H = [
html_sco_header.sco_header( html_sco_header.sco_header(
page_title="Modification du module %(titre)s" % module, page_title=f"Modification du module {a_module.code or a_module.titre or ''}",
cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css"], cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css"],
javascripts=[ javascripts=[
"libjs/jQuery-tagEditor/jquery.tag-editor.min.js", "libjs/jQuery-tagEditor/jquery.tag-editor.min.js",
@ -523,7 +528,7 @@ def module_edit(module_id=None):
"js/module_tag_editor.js", "js/module_tag_editor.js",
], ],
), ),
"""<h2>Modification du module %(titre)s""" % module, f"""<h2>Modification du module {a_module.code or ''} {a_module.titre or ''}""",
""" (formation %(acronyme)s, version %(version)s)</h2>""" % formation, """ (formation %(acronyme)s, version %(version)s)</h2>""" % formation,
render_template( render_template(
"scodoc/help/modules.html", "scodoc/help/modules.html",
@ -546,14 +551,18 @@ def module_edit(module_id=None):
# ne propose pas SAE et Ressources, sauf si déjà de ce type... # ne propose pas SAE et Ressources, sauf si déjà de ce type...
module_types = ( module_types = (
set(scu.ModuleType) - {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE} set(scu.ModuleType) - {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE}
) | {a_module.module_type} ) | {
scu.ModuleType(a_module.module_type)
if a_module.module_type
else scu.ModuleType.STANDARD
}
descr = [ descr = [
( (
"code", "code",
{ {
"size": 10, "size": 10,
"explanation": "code du module (doit être unique dans la formation)", "explanation": "code du module (issu du programme, exemple M1203 ou R2.01. Doit être unique dans la formation)",
"allow_null": False, "allow_null": False,
"validator": lambda val, field, formation_id=formation_id: check_module_code_unicity( "validator": lambda val, field, formation_id=formation_id: check_module_code_unicity(
val, field, formation_id, module_id=module_id val, field, formation_id, module_id=module_id
@ -692,7 +701,10 @@ def module_edit(module_id=None):
{ {
"title": "Code Apogée", "title": "Code Apogée",
"size": 25, "size": 25,
"explanation": "(optionnel) code élément pédagogique Apogée ou liste de codes ELP séparés par des virgules", "explanation": """(optionnel) code élément pédagogique Apogée ou liste de codes ELP
séparés par des virgules (ce code est propre à chaque établissement, se rapprocher
du référent Apogée).
""",
"validator": lambda val, _: len(val) < APO_CODE_STR_LEN, "validator": lambda val, _: len(val) < APO_CODE_STR_LEN,
}, },
), ),
@ -736,8 +748,11 @@ def module_edit(module_id=None):
else: else:
# l'UE de rattachement peut changer # l'UE de rattachement peut changer
tf[2]["ue_id"], tf[2]["matiere_id"] = tf[2]["ue_matiere_id"].split("!") tf[2]["ue_id"], tf[2]["matiere_id"] = tf[2]["ue_matiere_id"].split("!")
x, y = tf[2]["ue_matiere_id"].split("!")
tf[2]["ue_id"] = int(x)
tf[2]["matiere_id"] = int(y)
old_ue_id = a_module.ue.id old_ue_id = a_module.ue.id
new_ue_id = int(tf[2]["ue_id"]) new_ue_id = tf[2]["ue_id"]
if (old_ue_id != new_ue_id) and in_use: if (old_ue_id != new_ue_id) and in_use:
new_ue = UniteEns.query.get_or_404(new_ue_id) new_ue = UniteEns.query.get_or_404(new_ue_id)
if new_ue.semestre_idx != a_module.ue.semestre_idx: if new_ue.semestre_idx != a_module.ue.semestre_idx:
@ -845,7 +860,7 @@ def formation_add_malus_modules(formation_id, titre=None, redirect=True):
[ [
mod mod
for mod in module_list(args={"ue_id": ue["ue_id"]}) for mod in module_list(args={"ue_id": ue["ue_id"]})
if mod["module_type"] == ModuleType.MALUS if mod["module_type"] == scu.ModuleType.MALUS
] ]
) )
if nb_mod_malus == 0: if nb_mod_malus == 0:
@ -897,7 +912,7 @@ def ue_add_malus_module(ue_id, titre=None, code=None):
"matiere_id": matiere_id, "matiere_id": matiere_id,
"formation_id": ue["formation_id"], "formation_id": ue["formation_id"],
"semestre_id": semestre_id, "semestre_id": semestre_id,
"module_type": ModuleType.MALUS, "module_type": scu.ModuleType.MALUS,
}, },
) )

View File

@ -29,13 +29,13 @@
""" """
import flask import flask
from flask import url_for, render_template from flask import flash, render_template, url_for
from flask import g, request from flask import g, request
from flask_login import current_user from flask_login import current_user
from app import db from app import db
from app import log from app import log
from app.models import APO_CODE_STR_LEN from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN
from app.models import Formation, UniteEns, ModuleImpl, Module from app.models import Formation, UniteEns, ModuleImpl, Module
from app.models.formations import Matiere from app.models.formations import Matiere
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
@ -89,6 +89,7 @@ _ueEditor = ndb.EditableTable(
input_formators={ input_formators={
"type": ndb.int_null_is_zero, "type": ndb.int_null_is_zero,
"is_external": ndb.bool_or_str, "is_external": ndb.bool_or_str,
"ects": ndb.float_null_is_null,
}, },
output_formators={ output_formators={
"numero": ndb.int_null_is_zero, "numero": ndb.int_null_is_zero,
@ -107,8 +108,6 @@ def ue_list(*args, **kw):
def do_ue_create(args): def do_ue_create(args):
"create an ue" "create an ue"
from app.scodoc import sco_formations
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
# check duplicates # check duplicates
ues = ue_list({"formation_id": args["formation_id"], "acronyme": args["acronyme"]}) ues = ue_list({"formation_id": args["formation_id"], "acronyme": args["acronyme"]})
@ -117,6 +116,14 @@ def do_ue_create(args):
f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé ! f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé !
(chaque UE doit avoir un acronyme unique dans la formation)""" (chaque UE doit avoir un acronyme unique dans la formation)"""
) )
if not "ue_code" in args:
# évite les conflits de code
while True:
cursor = db.session.execute("select notes_newid_ucod();")
code = cursor.fetchone()[0]
if UniteEns.query.filter_by(ue_code=code).count() == 0:
break
args["ue_code"] = code
# create # create
ue_id = _ueEditor.create(cnx, args) ue_id = _ueEditor.create(cnx, args)
@ -128,13 +135,16 @@ def do_ue_create(args):
formation = Formation.query.get(args["formation_id"]) formation = Formation.query.get(args["formation_id"])
formation.invalidate_module_coefs() formation.invalidate_module_coefs()
# news # news
F = sco_formations.formation_list(args={"formation_id": args["formation_id"]})[0] ue = UniteEns.query.get(ue_id)
flash(f"UE créée (code {ue.ue_code})")
formation = Formation.query.get(args["formation_id"])
sco_news.add( sco_news.add(
typ=sco_news.NEWS_FORM, typ=sco_news.NEWS_FORM,
object=args["formation_id"], object=args["formation_id"],
text="Modification de la formation %(acronyme)s" % F, text=f"Modification de la formation {formation.acronyme}",
max_frequency=3, max_frequency=3,
) )
formation.invalidate_cached_sems()
return ue_id return ue_id
@ -295,7 +305,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
( (
"numero", "numero",
{ {
"size": 2, "size": 4,
"explanation": "numéro (1,2,3,4) de l'UE pour l'ordre d'affichage", "explanation": "numéro (1,2,3,4) de l'UE pour l'ordre d'affichage",
"type": "int", "type": "int",
}, },
@ -337,7 +347,8 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"size": 4, "size": 4,
"type": "float", "type": "float",
"title": "ECTS", "title": "ECTS",
"explanation": "nombre de crédits ECTS", "explanation": "nombre de crédits ECTS (indiquer 0 si UE bonus)",
"allow_null": not is_apc, # ects requis en APC
}, },
), ),
( (
@ -361,7 +372,10 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
{ {
"size": 12, "size": 12,
"title": "Code UE", "title": "Code UE",
"explanation": "code interne (optionnel). Toutes les UE partageant le même code (et le même code de formation) sont compatibles (compensation de semestres, capitalisation d'UE). Voir liste ci-dessous.", "max_length": SHORT_STR_LEN,
"explanation": """code interne (non vide). Toutes les UE partageant le même code
(et le même code de formation) sont compatibles (compensation de semestres, capitalisation d'UE).
Voir liste ci-dessous.""",
}, },
), ),
( (
@ -370,7 +384,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"title": "Code Apogée", "title": "Code Apogée",
"size": 25, "size": 25,
"explanation": "(optionnel) code élément pédagogique Apogée ou liste de codes ELP séparés par des virgules", "explanation": "(optionnel) code élément pédagogique Apogée ou liste de codes ELP séparés par des virgules",
"validator": lambda val, _: len(val) < APO_CODE_STR_LEN, "max_length": APO_CODE_STR_LEN,
}, },
), ),
( (
@ -461,8 +475,10 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"semestre_id": tf[2]["semestre_idx"], "semestre_id": tf[2]["semestre_idx"],
}, },
) )
flash("UE créée")
else: else:
do_ue_edit(tf[2]) do_ue_edit(tf[2])
flash("UE modifiée")
return flask.redirect( return flask.redirect(
url_for( url_for(
"notes.ue_table", "notes.ue_table",
@ -600,7 +616,12 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
_add_ue_semestre_id(ues_externes, is_apc) _add_ue_semestre_id(ues_externes, is_apc)
ues.sort(key=lambda u: (u["semestre_id"], u["numero"])) ues.sort(key=lambda u: (u["semestre_id"], u["numero"]))
ues_externes.sort(key=lambda u: (u["semestre_id"], u["numero"])) ues_externes.sort(key=lambda u: (u["semestre_id"], u["numero"]))
has_duplicate_ue_codes = len(set([ue["ue_code"] for ue in ues])) != len(ues) # Codes dupliqués (pour aider l'utilisateur)
seen = set()
duplicated_codes = {
ue["ue_code"] for ue in ues if ue["ue_code"] in seen or seen.add(ue["ue_code"])
}
ues_with_duplicated_code = [ue for ue in ues if ue["ue_code"] in duplicated_codes]
has_perm_change = current_user.has_permission(Permission.ScoChangeFormation) has_perm_change = current_user.has_permission(Permission.ScoChangeFormation)
# editable = (not locked) and has_perm_change # editable = (not locked) and has_perm_change
@ -663,11 +684,17 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
if msg: if msg:
H.append('<p class="msg">' + msg + "</p>") H.append('<p class="msg">' + msg + "</p>")
if has_duplicate_ue_codes: if ues_with_duplicated_code:
H.append( H.append(
"""<div class="ue_warning"><span>Attention: plusieurs UE de cette f"""<div class="ue_warning"><span>Attention: plusieurs UE de cette
formation ont le même code. Il faut corriger cela ci-dessous, formation ont le même code : <tt>{
sinon les calculs d'ECTS seront erronés !</span></div>""" ', '.join([
'<a class="stdlink" href="' + url_for( "notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"] )
+ '">' + ue["acronyme"] + " (code " + ue["ue_code"] + ")</a>"
for ue in ues_with_duplicated_code ])
}</tt>.
Il faut corriger cela, sinon les capitalisations et ECTS seront
erronés !</span></div>"""
) )
# Description de la formation # Description de la formation
@ -698,16 +725,19 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
<a href="{url_for('notes.refcomp_show', <a href="{url_for('notes.refcomp_show',
scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id)}"> scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id)}">
{formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long} {formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long}
</a> """ </a>&nbsp;"""
msg_refcomp = "changer" msg_refcomp = "changer"
H.append(f"""<ul><li>{descr_refcomp}""")
if current_user.has_permission(Permission.ScoChangeFormation):
H.append( H.append(
f""" f"""<a class="stdlink" href="{url_for('notes.refcomp_assoc_formation',
<ul>
<li>{descr_refcomp}&nbsp; <a class="stdlink" href="{url_for('notes.refcomp_assoc_formation',
scodoc_dept=g.scodoc_dept, formation_id=formation_id) scodoc_dept=g.scodoc_dept, formation_id=formation_id)
}">{msg_refcomp}</a> }">{msg_refcomp}</a>"""
</li> )
<li><a class="stdlink" href="{
H.append(
f"""</li>
<li> <a class="stdlink" href="{
url_for('notes.edit_modules_ue_coefs', scodoc_dept=g.scodoc_dept, formation_id=formation_id, semestre_idx=semestre_idx) url_for('notes.edit_modules_ue_coefs', scodoc_dept=g.scodoc_dept, formation_id=formation_id, semestre_idx=semestre_idx)
}">éditer les coefficients des ressources et SAÉs</a> }">éditer les coefficients des ressources et SAÉs</a>
</li> </li>
@ -734,6 +764,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
) )
) )
else: else:
H.append('<div class="formation_classic_infos">')
H.append( H.append(
_ue_table_ues( _ue_table_ues(
parcours, parcours,
@ -763,7 +794,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
</ul> </ul>
""" """
) )
H.append("</div>")
H.append("</div>") # formation_ue_list H.append("</div>") # formation_ue_list
if ues_externes: if ues_externes:
@ -912,10 +943,10 @@ def _ue_table_ues(
cur_ue_semestre_id = None cur_ue_semestre_id = None
iue = 0 iue = 0
for ue in ues: for ue in ues:
if ue["ects"]: if ue["ects"] is None:
ue["ects_str"] = ", %g ECTS" % ue["ects"]
else:
ue["ects_str"] = "" ue["ects_str"] = ""
else:
ue["ects_str"] = ", %g ECTS" % ue["ects"]
if editable: if editable:
klass = "span_apo_edit" klass = "span_apo_edit"
else: else:
@ -929,13 +960,13 @@ def _ue_table_ues(
if cur_ue_semestre_id != ue["semestre_id"]: if cur_ue_semestre_id != ue["semestre_id"]:
cur_ue_semestre_id = ue["semestre_id"] cur_ue_semestre_id = ue["semestre_id"]
if iue > 0:
H.append("</ul>")
if ue["semestre_id"] == sco_codes_parcours.UE_SEM_DEFAULT: if ue["semestre_id"] == sco_codes_parcours.UE_SEM_DEFAULT:
lab = "Pas d'indication de semestre:" lab = "Pas d'indication de semestre:"
else: else:
lab = "Semestre %s:" % ue["semestre_id"] lab = "Semestre %s:" % ue["semestre_id"]
H.append('<div class="ue_list_tit_sem">%s</div>' % lab) H.append(
'<div class="ue_list_div"><div class="ue_list_tit_sem">%s</div>' % lab
)
H.append('<ul class="notes_ue_list">') H.append('<ul class="notes_ue_list">')
H.append('<li class="notes_ue_list">') H.append('<li class="notes_ue_list">')
if iue != 0 and editable: if iue != 0 and editable:
@ -952,7 +983,6 @@ def _ue_table_ues(
) )
else: else:
H.append(arrow_none) H.append(arrow_none)
iue += 1
ue["acro_titre"] = str(ue["acronyme"]) ue["acro_titre"] = str(ue["acronyme"])
if ue["titre"] != ue["acronyme"]: if ue["titre"] != ue["acronyme"]:
ue["acro_titre"] += " " + str(ue["titre"]) ue["acro_titre"] += " " + str(ue["titre"])
@ -1000,6 +1030,16 @@ def _ue_table_ues(
delete_disabled_icon, delete_disabled_icon,
) )
) )
if (iue >= len(ues) - 1) or ue["semestre_id"] != ues[iue + 1]["semestre_id"]:
H.append(
f"""</ul><ul><li><a href="{url_for('notes.ue_create', scodoc_dept=g.scodoc_dept,
formation_id=ue['formation_id'], semestre_idx=ue['semestre_id'])
}">Ajouter une UE dans le semestre {ue['semestre_id'] or ''}</a></li></ul>
</div>
"""
)
iue += 1
return "\n".join(H) return "\n".join(H)
@ -1267,7 +1307,6 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé ! f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé !
(chaque UE doit avoir un acronyme unique dans la formation)""" (chaque UE doit avoir un acronyme unique dans la formation)"""
) )
# On ne peut pas supprimer le code UE: # On ne peut pas supprimer le code UE:
if "ue_code" in args and not args["ue_code"]: if "ue_code" in args and not args["ue_code"]:
del args["ue_code"] del args["ue_code"]

View File

@ -1,324 +0,0 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# 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
# 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
#
##############################################################################
"""Fonctions sur les entreprises
"""
# codes anciens déplacés de ZEntreprise
import datetime
from operator import itemgetter
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc.notesdb import ScoDocCursor, EditableTable, DateISOtoDMY, DateDMYtoISO
def _format_nom(nom):
"formatte nom (filtre en entree db) d'une entreprise"
if not nom:
return nom
return nom[0].upper() + nom[1:]
class EntreprisesEditor(EditableTable):
def delete(self, cnx, oid):
"delete correspondants and contacts, then self"
# first, delete all correspondants and contacts
cursor = cnx.cursor(cursor_factory=ScoDocCursor)
cursor.execute(
"delete from entreprise_contact where entreprise_id=%(entreprise_id)s",
{"entreprise_id": oid},
)
cursor.execute(
"delete from entreprise_correspondant where entreprise_id=%(entreprise_id)s",
{"entreprise_id": oid},
)
cnx.commit()
EditableTable.delete(self, cnx, oid)
def list(
self,
cnx,
args={},
operator="and",
test="=",
sortkey=None,
sort_on_contact=False,
limit="",
offset="",
):
# list, then sort on date of last contact
R = EditableTable.list(
self,
cnx,
args=args,
operator=operator,
test=test,
sortkey=sortkey,
limit=limit,
offset=offset,
)
if sort_on_contact:
for r in R:
c = do_entreprise_contact_list(
args={"entreprise_id": r["entreprise_id"]},
disable_formatting=True,
)
if c:
r["date"] = max([x["date"] or datetime.date.min for x in c])
else:
r["date"] = datetime.date.min
# sort
R.sort(key=itemgetter("date"))
for r in R:
r["date"] = DateISOtoDMY(r["date"])
return R
def list_by_etud(
self, cnx, args={}, sort_on_contact=False, disable_formatting=False
):
"cherche rentreprise ayant eu contact avec etudiant"
cursor = cnx.cursor(cursor_factory=ScoDocCursor)
cursor.execute(
"select E.*, I.nom as etud_nom, I.prenom as etud_prenom, C.date from entreprises E, entreprise_contact C, identite I where C.entreprise_id = E.entreprise_id and C.etudid = I.etudid and I.nom ~* %(etud_nom)s ORDER BY E.nom",
args,
)
_, res = [x[0] for x in cursor.description], cursor.dictfetchall()
R = []
for r in res:
r["etud_prenom"] = r["etud_prenom"] or ""
d = {}
for key in r:
v = r[key]
# format value
if not disable_formatting and key in self.output_formators:
v = self.output_formators[key](v)
d[key] = v
R.append(d)
# sort
if sort_on_contact:
R.sort(key=lambda x: (x["date"] or datetime.date.min))
for r in R:
r["date"] = DateISOtoDMY(r["date"] or datetime.date.min)
return R
_entreprisesEditor = EntreprisesEditor(
"entreprises",
"entreprise_id",
(
"entreprise_id",
"nom",
"adresse",
"ville",
"codepostal",
"pays",
"contact_origine",
"secteur",
"privee",
"localisation",
"qualite_relation",
"plus10salaries",
"note",
"date_creation",
),
filter_dept=True,
sortkey="nom",
input_formators={
"nom": _format_nom,
"plus10salaries": bool,
},
)
# ----------- Correspondants
_entreprise_correspEditor = EditableTable(
"entreprise_correspondant",
"entreprise_corresp_id",
(
"entreprise_corresp_id",
"entreprise_id",
"civilite",
"nom",
"prenom",
"fonction",
"phone1",
"phone2",
"mobile",
"fax",
"mail1",
"mail2",
"note",
),
sortkey="nom",
)
# ----------- Contacts
_entreprise_contactEditor = EditableTable(
"entreprise_contact",
"entreprise_contact_id",
(
"entreprise_contact_id",
"date",
"type_contact",
"entreprise_id",
"entreprise_corresp_id",
"etudid",
"description",
"enseignant",
),
sortkey="date",
output_formators={"date": DateISOtoDMY},
input_formators={"date": DateDMYtoISO},
)
def do_entreprise_create(args):
"entreprise_create"
cnx = ndb.GetDBConnexion()
r = _entreprisesEditor.create(cnx, args)
return r
def do_entreprise_delete(oid):
"entreprise_delete"
cnx = ndb.GetDBConnexion()
_entreprisesEditor.delete(cnx, oid)
def do_entreprise_list(**kw):
"entreprise_list"
cnx = ndb.GetDBConnexion()
return _entreprisesEditor.list(cnx, **kw)
def do_entreprise_list_by_etud(**kw):
"entreprise_list_by_etud"
cnx = ndb.GetDBConnexion()
return _entreprisesEditor.list_by_etud(cnx, **kw)
def do_entreprise_edit(*args, **kw):
"entreprise_edit"
cnx = ndb.GetDBConnexion()
_entreprisesEditor.edit(cnx, *args, **kw)
def do_entreprise_correspondant_create(args):
"entreprise_correspondant_create"
cnx = ndb.GetDBConnexion()
r = _entreprise_correspEditor.create(cnx, args)
return r
def do_entreprise_correspondant_delete(oid):
"entreprise_correspondant_delete"
cnx = ndb.GetDBConnexion()
_entreprise_correspEditor.delete(cnx, oid)
def do_entreprise_correspondant_list(**kw):
"entreprise_correspondant_list"
cnx = ndb.GetDBConnexion()
return _entreprise_correspEditor.list(cnx, **kw)
def do_entreprise_correspondant_edit(*args, **kw):
"entreprise_correspondant_edit"
cnx = ndb.GetDBConnexion()
_entreprise_correspEditor.edit(cnx, *args, **kw)
def do_entreprise_correspondant_listnames(args={}):
"-> liste des noms des correspondants (pour affichage menu)"
C = do_entreprise_correspondant_list(args=args)
return [(x["prenom"] + " " + x["nom"], str(x["entreprise_corresp_id"])) for x in C]
def do_entreprise_contact_delete(oid):
"entreprise_contact_delete"
cnx = ndb.GetDBConnexion()
_entreprise_contactEditor.delete(cnx, oid)
def do_entreprise_contact_list(**kw):
"entreprise_contact_list"
cnx = ndb.GetDBConnexion()
return _entreprise_contactEditor.list(cnx, **kw)
def do_entreprise_contact_edit(*args, **kw):
"entreprise_contact_edit"
cnx = ndb.GetDBConnexion()
_entreprise_contactEditor.edit(cnx, *args, **kw)
def do_entreprise_contact_create(args):
"entreprise_contact_create"
cnx = ndb.GetDBConnexion()
r = _entreprise_contactEditor.create(cnx, args)
return r
def do_entreprise_check_etudiant(etudiant):
"""Si etudiant est vide, ou un ETUDID valide, ou un nom unique,
retourne (1, ETUDID).
Sinon, retourne (0, 'message explicatif')
"""
etudiant = etudiant.strip().translate(
str.maketrans("", "", "'()")
) # suppress parens and quote from name
if not etudiant:
return 1, None
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ScoDocCursor)
cursor.execute(
"select etudid, nom, prenom from identite where upper(nom) ~ upper(%(etudiant)s) or etudid=%(etudiant)s",
{"etudiant": etudiant},
)
r = cursor.fetchall()
if len(r) < 1:
return 0, 'Aucun etudiant ne correspond à "%s"' % etudiant
elif len(r) > 10:
return (
0,
"<b>%d etudiants</b> correspondent à ce nom (utilisez le code)" % len(r),
)
elif len(r) > 1:
e = ['<ul class="entreprise_etud_list">']
for x in r:
e.append(
"<li>%s %s (code %s)</li>" % ((x[1]).upper(), x[2] or "", x[0].strip())
)
e.append("</ul>")
return (
0,
"Les étudiants suivants correspondent: préciser le nom complet ou le code\n"
+ "\n".join(e),
)
else: # une seule reponse !
return 1, r[0][0].strip()

View File

@ -384,8 +384,8 @@ print apo_csv_list_stored_archives()
groups_infos = sco_groups_view.DisplayedGroupsInfos( [sco_groups.get_default_group(formsemestre_id)], formsemestre_id=formsemestre_id) groups_infos = sco_groups_view.DisplayedGroupsInfos( [sco_groups.get_default_group(formsemestre_id)], formsemestre_id=formsemestre_id)
nt = sco_cache.NotesTableCache.get( formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
# #
s = SemSet('NSS29902') s = SemSet('NSS29902')
apo_data = sco_apogee_csv.ApoData(open('/opt/scodoc/var/scodoc/archives/apo_csv/RT/2015-2/2016-07-10-11-26-15/V1RT.csv').read(), periode=1) apo_data = sco_apogee_csv.ApoData(open('/opt/scodoc/var/scodoc/archives/apo_csv/RT/2015-2/2016-07-10-11-26-15/V1RT.csv').read(), periode=1)

View File

@ -53,7 +53,7 @@ from app.scodoc.sco_exceptions import ScoValueError
def apo_semset_maq_status( def apo_semset_maq_status(
semset_id="", semset_id: int,
allow_missing_apo=False, allow_missing_apo=False,
allow_missing_decisions=False, allow_missing_decisions=False,
allow_missing_csv=False, allow_missing_csv=False,
@ -65,7 +65,7 @@ def apo_semset_maq_status(
): ):
"""Page statut / tableau de bord""" """Page statut / tableau de bord"""
if not semset_id: if not semset_id:
raise ValueError("invalid null semset_id") raise ScoValueError("invalid null semset_id")
semset = sco_semset.SemSet(semset_id=semset_id) semset = sco_semset.SemSet(semset_id=semset_id)
semset.fill_formsemestres() semset.fill_formsemestres()
# autorise export meme si etudiants Apo manquants: # autorise export meme si etudiants Apo manquants:

View File

@ -33,8 +33,7 @@ import os
import time import time
from operator import itemgetter from operator import itemgetter
from flask import url_for, g, request from flask import url_for, g
from flask_mail import Message
from app import email from app import email
from app import log from app import log
@ -46,7 +45,6 @@ from app.scodoc.sco_exceptions import ScoGenError, ScoValueError
from app.scodoc import safehtml from app.scodoc import safehtml
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc.scolog import logdb from app.scodoc.scolog import logdb
from app.scodoc.TrivialFormulator import TrivialFormulator
def format_etud_ident(etud): def format_etud_ident(etud):
@ -451,7 +449,6 @@ _adresseEditor = ndb.EditableTable(
"telephonemobile", "telephonemobile",
"fax", "fax",
"typeadresse", "typeadresse",
"entreprise_id",
"description", "description",
), ),
convert_null_outputs_to_empty=True, convert_null_outputs_to_empty=True,
@ -860,18 +857,14 @@ def list_scolog(etudid):
return cursor.dictfetchall() return cursor.dictfetchall()
def fill_etuds_info(etuds, add_admission=True): def fill_etuds_info(etuds: list[dict], add_admission=True):
"""etuds est une liste d'etudiants (mappings) """etuds est une liste d'etudiants (mappings)
Pour chaque etudiant, ajoute ou formatte les champs Pour chaque etudiant, ajoute ou formatte les champs
-> informations pour fiche etudiant ou listes diverses -> informations pour fiche etudiant ou listes diverses
Si add_admission: ajoute au dict le schamps "admission" s'il n'y sont pas déjà. Si add_admission: ajoute au dict le schamps "admission" s'il n'y sont pas déjà.
""" """
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
# open('/tmp/t','w').write( str(etuds) )
for etud in etuds: for etud in etuds:
etudid = etud["etudid"] etudid = etud["etudid"]
etud["dept"] = g.scodoc_dept etud["dept"] = g.scodoc_dept
@ -894,49 +887,7 @@ def fill_etuds_info(etuds, add_admission=True):
etud.update(adr) etud.update(adr)
format_etud_ident(etud) format_etud_ident(etud)
# Semestres dans lesquel il est inscrit etud.update(etud_inscriptions_infos(etudid, etud["ne"]))
ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
{"etudid": etudid}
)
etud["ins"] = ins
sems = []
cursem = None # semestre "courant" ou il est inscrit
for i in ins:
sem = sco_formsemestre.get_formsemestre(i["formsemestre_id"])
if sco_formsemestre.sem_est_courant(sem):
cursem = sem
curi = i
sem["ins"] = i
sems.append(sem)
# trie les semestres par date de debut, le plus recent d'abord
# (important, ne pas changer (suivi cohortes))
sems.sort(key=itemgetter("dateord"), reverse=True)
etud["sems"] = sems
etud["cursem"] = cursem
if cursem:
etud["inscription"] = cursem["titremois"]
etud["inscriptionstr"] = "Inscrit en " + cursem["titremois"]
etud["inscription_formsemestre_id"] = cursem["formsemestre_id"]
etud["etatincursem"] = curi["etat"]
etud["situation"] = descr_situation_etud(etudid, etud["ne"])
# XXX est-ce utile ? sco_groups.etud_add_group_infos( etud, cursem)
else:
if etud["sems"]:
if etud["sems"][0]["dateord"] > time.strftime(
"%Y-%m-%d", time.localtime()
):
etud["inscription"] = "futur"
etud["situation"] = "futur élève"
else:
etud["inscription"] = "ancien"
etud["situation"] = "ancien élève"
else:
etud["inscription"] = "non inscrit"
etud["situation"] = etud["inscription"]
etud["inscriptionstr"] = etud["inscription"]
etud["inscription_formsemestre_id"] = None
# XXXetud['partitions'] = {} # ne va pas chercher les groupes des anciens semestres
etud["etatincursem"] = "?"
# nettoyage champs souvent vides # nettoyage champs souvent vides
if etud.get("nomlycee"): if etud.get("nomlycee"):
@ -974,8 +925,59 @@ def fill_etuds_info(etuds, add_admission=True):
etud["telephonemobilestr"] = "" etud["telephonemobilestr"] = ""
def descr_situation_etud(etudid, ne=""): def etud_inscriptions_infos(etudid: int, ne="") -> dict:
"""chaine decrivant la situation actuelle de l'etudiant""" """Dict avec les informations sur les semestres passés et courant"""
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions
etud = {}
# Semestres dans lesquel il est inscrit
ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
{"etudid": etudid}
)
etud["ins"] = ins
sems = []
cursem = None # semestre "courant" ou il est inscrit
for i in ins:
sem = sco_formsemestre.get_formsemestre(i["formsemestre_id"])
if sco_formsemestre.sem_est_courant(sem):
cursem = sem
curi = i
sem["ins"] = i
sems.append(sem)
# trie les semestres par date de debut, le plus recent d'abord
# (important, ne pas changer (suivi cohortes))
sems.sort(key=itemgetter("dateord"), reverse=True)
etud["sems"] = sems
etud["cursem"] = cursem
if cursem:
etud["inscription"] = cursem["titremois"]
etud["inscriptionstr"] = "Inscrit en " + cursem["titremois"]
etud["inscription_formsemestre_id"] = cursem["formsemestre_id"]
etud["etatincursem"] = curi["etat"]
etud["situation"] = descr_situation_etud(etudid, ne)
else:
if etud["sems"]:
if etud["sems"][0]["dateord"] > time.strftime("%Y-%m-%d", time.localtime()):
etud["inscription"] = "futur"
etud["situation"] = "futur élève"
else:
etud["inscription"] = "ancien"
etud["situation"] = "ancien élève"
else:
etud["inscription"] = "non inscrit"
etud["situation"] = etud["inscription"]
etud["inscriptionstr"] = etud["inscription"]
etud["inscription_formsemestre_id"] = None
etud["etatincursem"] = "?"
return etud
def descr_situation_etud(etudid: int, ne="") -> str:
"""Chaîne décrivant la situation actuelle de l'étudiant
XXX Obsolete, utiliser Identite.descr_situation_etud() dans
les nouveaux codes
"""
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
@ -992,7 +994,7 @@ def descr_situation_etud(etudid, ne=""):
) )
r = cursor.dictfetchone() r = cursor.dictfetchone()
if not r: if not r:
situation = "non inscrit" situation = "non inscrit" + ne
else: else:
sem = sco_formsemestre.get_formsemestre(r["formsemestre_id"]) sem = sco_formsemestre.get_formsemestre(r["formsemestre_id"])
if r["etat"] == "I": if r["etat"] == "I":

View File

@ -237,7 +237,11 @@ def formsemestre_check_absences_html(formsemestre_id):
if evals: if evals:
H.append( H.append(
'<div class="module_check_absences"><h2><a href="moduleimpl_status?moduleimpl_id=%s">%s: %s</a></h2>' '<div class="module_check_absences"><h2><a href="moduleimpl_status?moduleimpl_id=%s">%s: %s</a></h2>'
% (M["moduleimpl_id"], M["module"]["code"], M["module"]["abbrev"]) % (
M["moduleimpl_id"],
M["module"]["code"] or "",
M["module"]["abbrev"] or "",
)
) )
for E in evals: for E in evals:
H.append( H.append(

View File

@ -175,6 +175,7 @@ def do_evaluation_create(
# news # news
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0]
sco_cache.invalidate_formsemestre(formsemestre_id=M["formsemestre_id"])
mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
mod["moduleimpl_id"] = M["moduleimpl_id"] mod["moduleimpl_id"] = M["moduleimpl_id"]
mod["url"] = "Notes/moduleimpl_status?moduleimpl_id=%(moduleimpl_id)s" % mod mod["url"] = "Notes/moduleimpl_status?moduleimpl_id=%(moduleimpl_id)s" % mod

View File

@ -122,7 +122,7 @@ def evaluation_create_form(
# #
mod_descr = '<a href="moduleimpl_status?moduleimpl_id=%s">%s %s</a> %s' % ( mod_descr = '<a href="moduleimpl_status?moduleimpl_id=%s">%s %s</a> %s' % (
moduleimpl_id, moduleimpl_id,
mod["code"], mod["code"] or "module sans code",
mod["titre"], mod["titre"],
link, link,
) )

View File

@ -39,7 +39,7 @@ from flask import request
from app import log from app import log
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_common import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre from app.models import FormSemestre
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -403,8 +403,8 @@ def do_evaluation_etat_in_mod(nt, moduleimpl_id):
def formsemestre_evaluations_cal(formsemestre_id): def formsemestre_evaluations_cal(formsemestre_id):
"""Page avec calendrier de toutes les evaluations de ce semestre""" """Page avec calendrier de toutes les evaluations de ce semestre"""
sem = sco_formsemestre.get_formsemestre(formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > liste evaluations nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
evals = nt.get_evaluations_etats() evals = nt.get_evaluations_etats()
nb_evals = len(evals) nb_evals = len(evals)
@ -415,8 +415,8 @@ def formsemestre_evaluations_cal(formsemestre_id):
today = time.strftime("%Y-%m-%d") today = time.strftime("%Y-%m-%d")
year = int(sem["annee_debut"]) year = formsemestre.date_debut.year
if sem["mois_debut_ord"] < 8: if formsemestre.date_debut.month < 8:
year -= 1 # calendrier septembre a septembre year -= 1 # calendrier septembre a septembre
events = {} # (day, halfday) : event events = {} # (day, halfday) : event
for e in evals: for e in evals:
@ -536,10 +536,10 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, format="html"):
"""Experimental: un tableau indiquant pour chaque évaluation """Experimental: un tableau indiquant pour chaque évaluation
le nombre de jours avant la publication des notes. le nombre de jours avant la publication des notes.
N'indique pas les évaluations de ratrapage ni celles des modules de bonus/malus. N'indique pas les évaluations de rattrapage ni celles des modules de bonus/malus.
""" """
sem = sco_formsemestre.get_formsemestre(formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > liste evaluations nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
evals = nt.get_evaluations_etats() evals = nt.get_evaluations_etats()
T = [] T = []
@ -605,7 +605,7 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, format="html"):
origin="Généré par %s le " % sco_version.SCONAME origin="Généré par %s le " % sco_version.SCONAME
+ scu.timedate_human_repr() + scu.timedate_human_repr()
+ "", + "",
filename=scu.make_filename("evaluations_delais_" + sem["titreannee"]), filename=scu.make_filename("evaluations_delais_" + formsemestre.titre_annee()),
) )
return tab.make_page(format=format) return tab.make_page(format=format)
@ -633,9 +633,13 @@ def evaluation_describe(evaluation_id="", edit_in_place=True):
'<span class="evallink"><a class="stdlink" href="evaluation_listenotes?moduleimpl_id=%s">voir toutes les notes du module</a></span>' '<span class="evallink"><a class="stdlink" href="evaluation_listenotes?moduleimpl_id=%s">voir toutes les notes du module</a></span>'
% moduleimpl_id % moduleimpl_id
) )
mod_descr = ( mod_descr = '<a href="moduleimpl_status?moduleimpl_id=%s">%s %s</a> <span class="resp">(resp. <a title="%s">%s</a>)</span> %s' % (
'<a href="moduleimpl_status?moduleimpl_id=%s">%s %s</a> <span class="resp">(resp. <a title="%s">%s</a>)</span> %s' moduleimpl_id,
% (moduleimpl_id, Mod["code"], Mod["titre"], nomcomplet, resp, link) Mod["code"] or "",
Mod["titre"] or "?",
nomcomplet,
resp,
link,
) )
etit = E["description"] or "" etit = E["description"] or ""

View File

@ -47,9 +47,12 @@ class ScoValueError(ScoException):
self.dest_url = dest_url self.dest_url = dest_url
class ScoBugCatcher(ScoException):
"bug avec enquete en cours"
class NoteProcessError(ScoValueError): class NoteProcessError(ScoValueError):
"Valeurs notes invalides" "Valeurs notes invalides"
pass
class InvalidEtudId(NoteProcessError): class InvalidEtudId(NoteProcessError):
@ -112,8 +115,9 @@ class ScoNonEmptyFormationObject(ScoValueError):
class ScoInvalidIdType(ScoValueError): class ScoInvalidIdType(ScoValueError):
"""Pour les clients qui s'obstinnent à utiliser des bookmarks ou """Pour les clients qui s'obstinent à utiliser des bookmarks
historiques anciens avec des ID ScoDoc7""" ou historiques anciens avec des ID ScoDoc7.
"""
def __init__(self, msg=""): def __init__(self, msg=""):
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu

View File

@ -29,6 +29,9 @@
""" """
from flask import url_for, g, request from flask import url_for, g, request
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import log from app import log
@ -77,7 +80,8 @@ def _build_results_table(start_date=None, end_date=None, types_parcours=[]):
{} {}
) # etudid : { formsemestre_id d'inscription le plus recent dans les dates considérées, etud } ) # etudid : { formsemestre_id d'inscription le plus recent dans les dates considérées, etud }
for formsemestre_id in formsemestre_ids_parcours: for formsemestre_id in formsemestre_ids_parcours:
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > get_etudids formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
etudids = nt.get_etudids() etudids = nt.get_etudids()
for etudid in etudids: for etudid in etudids:
if etudid not in etuds_infos: # pas encore traité ? if etudid not in etuds_infos: # pas encore traité ?

View File

@ -39,6 +39,7 @@ from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc.sco_exceptions import ScoException
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
@ -179,7 +180,9 @@ def search_etud_in_dept(expnom=""):
e["_nomprenom_target"] = target e["_nomprenom_target"] = target
e["inscription_target"] = target e["inscription_target"] = target
e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"]) e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"])
sco_groups.etud_add_group_infos(e, e["cursem"]) sco_groups.etud_add_group_infos(
e, e["cursem"]["formsemestre_id"] if e["cursem"] else None
)
tab = GenTable( tab = GenTable(
columns_ids=("nomprenom", "code_nip", "inscription", "groupes"), columns_ids=("nomprenom", "code_nip", "inscription", "groupes"),
@ -221,7 +224,10 @@ def search_etuds_infos(expnom=None, code_nip=None):
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
if expnom and not may_be_nip: if expnom and not may_be_nip:
expnom = expnom.upper() # les noms dans la BD sont en uppercase expnom = expnom.upper() # les noms dans la BD sont en uppercase
try:
etuds = sco_etud.etudident_list(cnx, args={"nom": expnom}, test="~") etuds = sco_etud.etudident_list(cnx, args={"nom": expnom}, test="~")
except ScoException:
etuds = []
else: else:
code_nip = code_nip or expnom code_nip = code_nip or expnom
if code_nip: if code_nip:

View File

@ -151,8 +151,14 @@ def formation_export(
if mod["ects"] is None: if mod["ects"] is None:
del mod["ects"] del mod["ects"]
filename = f"scodoc_formation_{formation.departement.acronym}_{formation.acronyme or ''}_v{formation.version}"
return scu.sendResult( return scu.sendResult(
F, name="formation", format=format, force_outer_xml_tag=False, attached=True F,
name="formation",
format=format,
force_outer_xml_tag=False,
attached=True,
filename=filename,
) )
@ -322,11 +328,15 @@ def formation_list_table(formation_id=None, args={}):
"session_id)s<a> " % s "session_id)s<a> " % s
for s in f["sems"] for s in f["sems"]
] ]
+ [ + (
[
'<a class="stdlink" id="add-semestre-%s" ' '<a class="stdlink" id="add-semestre-%s" '
'href="formsemestre_createwithmodules?formation_id=%s&semestre_id=1">ajouter</a> ' 'href="formsemestre_createwithmodules?formation_id=%s&semestre_id=1">ajouter</a> '
% (f["acronyme"].lower().replace(" ", "-"), f["formation_id"]) % (f["acronyme"].lower().replace(" ", "-"), f["formation_id"])
] ]
if current_user.has_permission(Permission.ScoImplement)
else []
)
) )
if f["sems"]: if f["sems"]:
f["date_fin_dernier_sem"] = max([s["date_fin_iso"] for s in f["sems"]]) f["date_fin_dernier_sem"] = max([s["date_fin_iso"] for s in f["sems"]])

View File

@ -78,7 +78,7 @@ def formsemestre_createwithmodules():
H = [ H = [
html_sco_header.sco_header( html_sco_header.sco_header(
page_title="Création d'un semestre", page_title="Création d'un semestre",
javascripts=["libjs/AutoSuggest.js"], javascripts=["libjs/AutoSuggest.js", "js/formsemestre_edit.js"],
cssstyles=["css/autosuggest_inquisitor.css"], cssstyles=["css/autosuggest_inquisitor.css"],
bodyOnLoad="init_tf_form('')", bodyOnLoad="init_tf_form('')",
), ),
@ -99,7 +99,7 @@ def formsemestre_editwithmodules(formsemestre_id):
H = [ H = [
html_sco_header.html_sem_header( html_sco_header.html_sem_header(
"Modification du semestre", "Modification du semestre",
javascripts=["libjs/AutoSuggest.js"], javascripts=["libjs/AutoSuggest.js", "js/formsemestre_edit.js"],
cssstyles=["css/autosuggest_inquisitor.css"], cssstyles=["css/autosuggest_inquisitor.css"],
bodyOnLoad="init_tf_form('')", bodyOnLoad="init_tf_form('')",
) )
@ -213,7 +213,10 @@ def do_formsemestre_createwithmodules(edit=False):
# en APC, ne permet pas de changer de semestre # en APC, ne permet pas de changer de semestre
semestre_id_list = [formsemestre.semestre_id] semestre_id_list = [formsemestre.semestre_id]
else: else:
semestre_id_list = [-1] + list(range(1, NB_SEM + 1)) semestre_id_list = list(range(1, NB_SEM + 1))
if not formation.is_apc():
# propose "pas de semestre" seulement en classique
semestre_id_list.insert(0, -1)
semestre_id_labels = [] semestre_id_labels = []
for sid in semestre_id_list: for sid in semestre_id_list:
@ -341,6 +344,9 @@ def do_formsemestre_createwithmodules(edit=False):
"explanation": "en BUT, on ne peut pas modifier le semestre après création" "explanation": "en BUT, on ne peut pas modifier le semestre après création"
if formation.is_apc() if formation.is_apc()
else "", else "",
"attributes": ['onchange="change_semestre_id();"']
if formation.is_apc()
else "",
}, },
), ),
) )
@ -493,7 +499,8 @@ def do_formsemestre_createwithmodules(edit=False):
{ {
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"title": "", "title": "",
"explanation": "Autoriser tous les enseignants associés à un module à y créer des évaluations", "explanation": """Autoriser tous les enseignants associés
à un module à y créer des évaluations""",
}, },
), ),
( (
@ -534,11 +541,19 @@ def do_formsemestre_createwithmodules(edit=False):
] ]
nbmod = 0 nbmod = 0
if edit:
templ_sep = "<tr><td>%(label)s</td><td><b>Responsable</b></td><td><b>Inscrire</b></td></tr>"
else:
templ_sep = "<tr><td>%(label)s</td><td><b>Responsable</b></td></tr>"
for semestre_id in semestre_ids: for semestre_id in semestre_ids:
if formation.is_apc():
# pour restreindre l'édition aux module du semestre sélectionné
tr_class = f'class="sem{semestre_id}"'
else:
tr_class = ""
if edit:
templ_sep = f"""<tr {tr_class}><td>%(label)s</td><td><b>Responsable</b></td><td><b>Inscrire</b></td></tr>"""
else:
templ_sep = (
f"""<tr {tr_class}><td>%(label)s</td><td><b>Responsable</b></td></tr>"""
)
modform.append( modform.append(
( (
"sep", "sep",
@ -588,12 +603,12 @@ def do_formsemestre_createwithmodules(edit=False):
) )
fcg += "</select>" fcg += "</select>"
itemtemplate = ( itemtemplate = (
"""<tr><td class="tf-fieldlabel">%(label)s</td><td class="tf-field">%(elem)s</td><td>""" f"""<tr {tr_class}><td class="tf-fieldlabel">%(label)s</td><td class="tf-field">%(elem)s</td><td>"""
+ fcg + fcg
+ "</td></tr>" + "</td></tr>"
) )
else: else:
itemtemplate = """<tr><td class="tf-fieldlabel">%(label)s</td><td class="tf-field">%(elem)s</td></tr>""" itemtemplate = f"""<tr {tr_class}><td class="tf-fieldlabel">%(label)s</td><td class="tf-field">%(elem)s</td></tr>"""
modform.append( modform.append(
( (
"MI" + str(mod["module_id"]), "MI" + str(mod["module_id"]),
@ -601,7 +616,7 @@ def do_formsemestre_createwithmodules(edit=False):
"input_type": "text_suggest", "input_type": "text_suggest",
"size": 50, "size": 50,
"withcheckbox": True, "withcheckbox": True,
"title": "%s %s" % (mod["code"], mod["titre"]), "title": "%s %s" % (mod["code"] or "", mod["titre"] or ""),
"allowed_values": allowed_user_names, "allowed_values": allowed_user_names,
"template": itemtemplate, "template": itemtemplate,
"text_suggest_options": { "text_suggest_options": {
@ -633,7 +648,7 @@ def do_formsemestre_createwithmodules(edit=False):
# 'allowed_values' : ['X'], 'labels' : [ '' ], # 'allowed_values' : ['X'], 'labels' : [ '' ],
# 'title' : '' , # 'title' : '' ,
# 'explanation' : 'inscrire tous les étudiants du semestre aux modules ajoutés'}) ) # 'explanation' : 'inscrire tous les étudiants du semestre aux modules ajoutés'}) )
submitlabel = "Modifier ce semestre de formation" submitlabel = "Modifier ce semestre"
else: else:
submitlabel = "Créer ce semestre de formation" submitlabel = "Créer ce semestre de formation"
# #
@ -802,7 +817,9 @@ def do_formsemestre_createwithmodules(edit=False):
} }
moduleimpl_id = sco_moduleimpl.do_moduleimpl_create(modargs) moduleimpl_id = sco_moduleimpl.do_moduleimpl_create(modargs)
mod = sco_edit_module.module_list({"module_id": module_id})[0] mod = sco_edit_module.module_list({"module_id": module_id})[0]
msg += ["création de %s (%s)" % (mod["code"], mod["titre"])] msg += [
"création de %s (%s)" % (mod["code"] or "?", mod["titre"] or "?")
]
# INSCRIPTIONS DES ETUDIANTS # INSCRIPTIONS DES ETUDIANTS
log( log(
'inscription module: %s = "%s"' 'inscription module: %s = "%s"'
@ -824,7 +841,7 @@ def do_formsemestre_createwithmodules(edit=False):
) )
msg += [ msg += [
"inscription de %d étudiants au module %s" "inscription de %d étudiants au module %s"
% (len(etudids), mod["code"]) % (len(etudids), mod["code"] or "(module sans code)")
] ]
else: else:
log( log(
@ -919,11 +936,19 @@ def formsemestre_delete_moduleimpls(formsemestre_id, module_ids_to_del):
if evals: if evals:
msg += [ msg += [
'<b>impossible de supprimer %s (%s) car il y a %d évaluations définies (<a href="moduleimpl_status?moduleimpl_id=%s" class="stdlink">supprimer les d\'abord</a>)</b>' '<b>impossible de supprimer %s (%s) car il y a %d évaluations définies (<a href="moduleimpl_status?moduleimpl_id=%s" class="stdlink">supprimer les d\'abord</a>)</b>'
% (mod["code"], mod["titre"], len(evals), moduleimpl_id) % (
mod["code"] or "(module sans code)",
mod["titre"],
len(evals),
moduleimpl_id,
)
] ]
ok = False ok = False
else: else:
msg += ["suppression de %s (%s)" % (mod["code"], mod["titre"])] msg += [
"suppression de %s (%s)"
% (mod["code"] or "(module sans code)", mod["titre"] or "")
]
sco_moduleimpl.do_moduleimpl_delete( sco_moduleimpl.do_moduleimpl_delete(
moduleimpl_id, formsemestre_id=formsemestre_id moduleimpl_id, formsemestre_id=formsemestre_id
) )

View File

@ -37,6 +37,9 @@ import flask
from flask import url_for, g, request from flask import url_for, g, request
from flask_login import current_user from flask_login import current_user
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app import log from app import log
@ -260,7 +263,8 @@ def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid):
def _make_page(etud, sem, tf, message=""): def _make_page(etud, sem, tf, message=""):
nt = sco_cache.NotesTableCache.get(sem["formsemestre_id"]) formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
moy_gen = nt.get_etud_moy_gen(etud["etudid"]) moy_gen = nt.get_etud_moy_gen(etud["etudid"])
H = [ H = [
html_sco_header.sco_header( html_sco_header.sco_header(

View File

@ -32,14 +32,16 @@ import time
import flask import flask
from flask import url_for, g, request from flask import url_for, g, request
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import log from app import log
from app.scodoc.scolog import logdb from app.scodoc.scolog import logdb
from app.scodoc.sco_exceptions import ScoException, ScoValueError from app.scodoc.sco_exceptions import ScoException, ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_codes_parcours import UE_STANDARD, UE_SPORT, UE_TYPE_NAME from app.scodoc.sco_codes_parcours import UE_STANDARD, UE_SPORT, UE_TYPE_NAME
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app.scodoc.TrivialFormulator import TrivialFormulator, TF from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc import sco_find_etud from app.scodoc import sco_find_etud
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
from app.scodoc import sco_moduleimpl from app.scodoc import sco_moduleimpl
@ -186,7 +188,9 @@ def do_formsemestre_desinscription(etudid, formsemestre_id):
raise ScoValueError("desinscription impossible: semestre verrouille") raise ScoValueError("desinscription impossible: semestre verrouille")
# -- Si decisions de jury, desinscription interdite # -- Si decisions de jury, desinscription interdite
nt = sco_cache.NotesTableCache.get(formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if nt.etud_has_decision(etudid): if nt.etud_has_decision(etudid):
raise ScoValueError( raise ScoValueError(
"desinscription impossible: l'étudiant a une décision de jury (la supprimer avant si nécessaire)" "desinscription impossible: l'étudiant a une décision de jury (la supprimer avant si nécessaire)"
@ -475,7 +479,8 @@ def formsemestre_inscription_option(etudid, formsemestre_id):
raise ScoValueError("Modification impossible: semestre verrouille") raise ScoValueError("Modification impossible: semestre verrouille")
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > get_etud_ue_status formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
F = html_sco_header.sco_footer() F = html_sco_header.sco_footer()
H = [ H = [
@ -503,7 +508,7 @@ def formsemestre_inscription_option(etudid, formsemestre_id):
modimpls_by_ue_ids[ue_id].append(mod["moduleimpl_id"]) modimpls_by_ue_ids[ue_id].append(mod["moduleimpl_id"])
modimpls_by_ue_names[ue_id].append( modimpls_by_ue_names[ue_id].append(
"%s %s" % (mod["module"]["code"], mod["module"]["titre"]) "%s %s" % (mod["module"]["code"] or "", mod["module"]["titre"] or "")
) )
vals = scu.get_request_args() vals = scu.get_request_args()
if not vals.get("tf_submitted", False): if not vals.get("tf_submitted", False):
@ -527,7 +532,7 @@ def formsemestre_inscription_option(etudid, formsemestre_id):
if ue["type"] != UE_STANDARD: if ue["type"] != UE_STANDARD:
ue_descr += " <em>%s</em>" % UE_TYPE_NAME[ue["type"]] ue_descr += " <em>%s</em>" % UE_TYPE_NAME[ue["type"]]
ue_status = nt.get_etud_ue_status(etudid, ue_id) ue_status = nt.get_etud_ue_status(etudid, ue_id)
if ue_status["is_capitalized"]: if ue_status and ue_status["is_capitalized"]:
sem_origin = sco_formsemestre.get_formsemestre(ue_status["formsemestre_id"]) sem_origin = sco_formsemestre.get_formsemestre(ue_status["formsemestre_id"])
ue_descr += ' <a class="discretelink" href="formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s" title="%s">(capitalisée le %s)' % ( ue_descr += ' <a class="discretelink" href="formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s" title="%s">(capitalisée le %s)' % (
sem_origin["formsemestre_id"], sem_origin["formsemestre_id"],
@ -648,7 +653,7 @@ function chkbx_select(field_id, state) {
"%s (%s)" "%s (%s)"
% ( % (
modsdict[x]["module"]["titre"], modsdict[x]["module"]["titre"],
modsdict[x]["module"]["code"], modsdict[x]["module"]["code"] or "(module sans code)",
) )
for x in a_desinscrire for x in a_desinscrire
] ]
@ -667,7 +672,7 @@ function chkbx_select(field_id, state) {
"%s (%s)" "%s (%s)"
% ( % (
modsdict[x]["module"]["titre"], modsdict[x]["module"]["titre"],
modsdict[x]["module"]["code"], modsdict[x]["module"]["code"] or "(module sans code)",
) )
for x in a_inscrire for x in a_inscrire
] ]
@ -785,7 +790,9 @@ def list_inscrits_ailleurs(formsemestre_id):
Pour chacun, donne la liste des semestres. Pour chacun, donne la liste des semestres.
{ etudid : [ liste de sems ] } { etudid : [ liste de sems ] }
""" """
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > get_etudids formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
etudids = nt.get_etudids() etudids = nt.get_etudids()
d = {} d = {}
for etudid in etudids: for etudid in etudids:

View File

@ -31,13 +31,13 @@
from flask import current_app from flask import current_app
from flask import g from flask import g
from flask import request from flask import request
from flask import url_for from flask import render_template, url_for
from flask_login import current_user from flask_login import current_user
from app import log from app import log
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Module from app.models import Module
from app.models import formsemestre
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
@ -51,7 +51,6 @@ from app.scodoc import sco_archives
from app.scodoc import sco_bulletins from app.scodoc import sco_bulletins
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
from app.scodoc import sco_compute_moy from app.scodoc import sco_compute_moy
from app.scodoc import sco_cache
from app.scodoc import sco_edit_ue from app.scodoc import sco_edit_ue
from app.scodoc import sco_evaluations from app.scodoc import sco_evaluations
from app.scodoc import sco_evaluation_db from app.scodoc import sco_evaluation_db
@ -412,7 +411,7 @@ def formsemestre_status_menubar(sem):
"enabled": sco_permissions_check.can_validate_sem(formsemestre_id), "enabled": sco_permissions_check.can_validate_sem(formsemestre_id),
}, },
{ {
"title": "Editer les PV et archiver les résultats", "title": "Éditer les PV et archiver les résultats",
"endpoint": "notes.formsemestre_archive", "endpoint": "notes.formsemestre_archive",
"args": {"formsemestre_id": formsemestre_id}, "args": {"formsemestre_id": formsemestre_id},
"enabled": sco_permissions_check.can_edit_pv(formsemestre_id), "enabled": sco_permissions_check.can_edit_pv(formsemestre_id),
@ -446,6 +445,7 @@ def retreive_formsemestre_from_request() -> int:
"""Cherche si on a de quoi déduire le semestre affiché à partir des """Cherche si on a de quoi déduire le semestre affiché à partir des
arguments de la requête: arguments de la requête:
formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id
Returns None si pas défini.
""" """
if request.method == "GET": if request.method == "GET":
args = request.args args = request.args
@ -506,34 +506,17 @@ def formsemestre_page_title():
return "" return ""
try: try:
formsemestre_id = int(formsemestre_id) formsemestre_id = int(formsemestre_id)
sem = sco_formsemestre.get_formsemestre(formsemestre_id).copy() formsemestre = FormSemestre.query.get(formsemestre_id)
except: except:
log("can't find formsemestre_id %s" % formsemestre_id) log("can't find formsemestre_id %s" % formsemestre_id)
return "" return ""
fill_formsemestre(sem) h = render_template(
"formsemestre_page_title.html",
h = f"""<div class="formsemestre_page_title"> formsemestre=formsemestre,
<div class="infos"> scu=scu,
<span class="semtitle"><a class="stdlink" title="{sem['session_id']}" sem_menu_bar=formsemestre_status_menubar(formsemestre.to_dict()),
href="{url_for('notes.formsemestre_status', )
scodoc_dept=g.scodoc_dept, formsemestre_id=sem['formsemestre_id'])}"
>{sem['titre']}</a><a
title="{sem['etape_apo_str']}">{sem['num_sem']}</a>{sem['modalitestr']}</span><span
class="dates"><a
title="du {sem['date_debut']} au {sem['date_fin']} "
>{sem['mois_debut']} - {sem['mois_fin']}</a></span><span
class="resp"><a title="{sem['nomcomplet']}">{sem['resp']}</a></span><span
class="nbinscrits"><a class="discretelink"
href="{url_for("scolar.groups_view",
scodoc_dept=g.scodoc_dept, formsemestre_id=sem['formsemestre_id'])}"
>{sem['nbinscrits']} inscrits</a></span><span
class="lock">{sem['locklink']}</span><span
class="eye">{sem['eyelink']}</span>
</div>
{formsemestre_status_menubar(sem)}
</div>
"""
return h return h
@ -596,10 +579,12 @@ def formsemestre_description_table(formsemestre_id, with_evals=False):
"""Description du semestre sous forme de table exportable """Description du semestre sous forme de table exportable
Liste des modules et de leurs coefficients Liste des modules et de leurs coefficients
""" """
sem = sco_formsemestre.get_formsemestre(formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > liste evaluations nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id) use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id)
F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0] F = sco_formations.formation_list(args={"formation_id": formsemestre.formation_id})[
0
]
parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"]) parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"])
Mlist = sco_moduleimpl.moduleimpl_withmodule_list( Mlist = sco_moduleimpl.moduleimpl_withmodule_list(
formsemestre_id=formsemestre_id, sort_by_ue=True formsemestre_id=formsemestre_id, sort_by_ue=True
@ -638,7 +623,7 @@ def formsemestre_description_table(formsemestre_id, with_evals=False):
) )
l = { l = {
"UE": M["ue"]["acronyme"], "UE": M["ue"]["acronyme"],
"Code": M["module"]["code"], "Code": M["module"]["code"] or "",
"Module": M["module"]["abbrev"] or M["module"]["titre"], "Module": M["module"]["abbrev"] or M["module"]["titre"],
"_Module_class": "scotext", "_Module_class": "scotext",
"Inscrits": len(ModInscrits), "Inscrits": len(ModInscrits),
@ -709,7 +694,7 @@ def formsemestre_description_table(formsemestre_id, with_evals=False):
titles["coefficient"] = "Coef. éval." titles["coefficient"] = "Coef. éval."
titles["evalcomplete_str"] = "Complète" titles["evalcomplete_str"] = "Complète"
titles["publish_incomplete_str"] = "Toujours Utilisée" titles["publish_incomplete_str"] = "Toujours Utilisée"
title = "%s %s" % (parcours.SESSION_NAME.capitalize(), sem["titremois"]) title = "%s %s" % (parcours.SESSION_NAME.capitalize(), formsemestre.titre_mois())
return GenTable( return GenTable(
columns_ids=columns_ids, columns_ids=columns_ids,
@ -986,12 +971,10 @@ Il y a des notes en attente ! Le classement des étudiants n'a qu'une valeur ind
def formsemestre_status(formsemestre_id=None): def formsemestre_status(formsemestre_id=None):
"""Tableau de bord semestre HTML""" """Tableau de bord semestre HTML"""
# porté du DTML # porté du DTML
cnx = ndb.GetDBConnexion()
sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True) sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True)
modimpls = sco_moduleimpl.moduleimpl_withmodule_list( modimpls = sco_moduleimpl.moduleimpl_withmodule_list(
formsemestre_id=formsemestre_id formsemestre_id=formsemestre_id
) )
# nt = sco_cache.NotesTableCache.get(formsemestre_id)
formsemestre = FormSemestre.query.get_or_404(formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt = res_sem.load_formsemestre_results(formsemestre) nt = res_sem.load_formsemestre_results(formsemestre)
@ -1078,7 +1061,7 @@ def formsemestre_status(formsemestre_id=None):
"</p>", "</p>",
] ]
if use_ue_coefs: if use_ue_coefs and not formsemestre.formation.is_apc():
H.append( H.append(
""" """
<p class="infop">utilise les coefficients d'UE pour calculer la moyenne générale.</p> <p class="infop">utilise les coefficients d'UE pour calculer la moyenne générale.</p>
@ -1187,8 +1170,10 @@ def formsemestre_tableau_modules(
H.append('<tr class="formsemestre_status%s">' % fontorange) H.append('<tr class="formsemestre_status%s">' % fontorange)
H.append( H.append(
'<td class="formsemestre_status_code"><a href="moduleimpl_status?moduleimpl_id=%s" title="%s" class="stdlink">%s</a></td>' f"""<td class="formsemestre_status_code""><a
% (modimpl["moduleimpl_id"], mod_descr, mod.code) href="{url_for('notes.moduleimpl_status',
scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl['moduleimpl_id'])}"
title="{mod_descr}" class="stdlink">{mod.code}</a></td>"""
) )
H.append( H.append(
'<td class="scotext"><a href="moduleimpl_status?moduleimpl_id=%s" title="%s" class="formsemestre_status_link">%s</a></td>' '<td class="scotext"><a href="moduleimpl_status?moduleimpl_id=%s" title="%s" class="formsemestre_status_link">%s</a></td>'

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