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

This commit is contained in:
leonard_montalbano 2022-04-11 08:25:38 +02:00
commit f1273f7bb2
540 changed files with 171183 additions and 23898 deletions

View File

@ -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,6 +10,7 @@ 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
@ -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)
@ -336,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")

View File

@ -173,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"
@ -270,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):
@ -281,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"
@ -292,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"
@ -402,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,11 +7,14 @@
"""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.comp.res_but import ResultatsSemestreBUT from app.comp.res_but import ResultatsSemestreBUT
from app.models import FormSemestre, Identite 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, sco_utils as scu
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_pdf
@ -61,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,
@ -85,6 +85,11 @@ class BulletinBUT:
"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 self.prefs["bul_show_ue_rangs"]: if self.prefs["bul_show_ue_rangs"]:
rangs, effectif = res.ue_rangs[ue.id] rangs, effectif = res.ue_rangs[ue.id]
@ -171,10 +176,15 @@ 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 = { poids = {
ue.acronyme: self.res.modimpls_evals_poids[e.moduleimpl_id][ue.id][e.id] ue.acronyme: modimpls_evals_poids[ue.id][e.id]
for ue in self.res.ues 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,
@ -212,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
] ]
@ -272,29 +283,39 @@ 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": { }
if self.prefs["bul_show_abs"]:
semestre_infos["absences"] = {
"injustifie": nbabs - nbabsjust, "injustifie": nbabs - nbabsjust,
"total": nbabs, "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( "ressources": self.etud_mods_results(
@ -302,11 +323,15 @@ class BulletinBUT:
), ),
"saes": self.etud_mods_results(etud, res.saes, 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,
}, },
@ -349,7 +374,7 @@ class BulletinBUT:
d["filigranne"] = sco_bulletins_pdf.get_filigranne( d["filigranne"] = sco_bulletins_pdf.get_filigranne(
etud_etat, etud_etat,
self.prefs, self.prefs,
decision_sem=d["semestre"].get("decision_sem"), decision_sem=d["semestre"].get("decision"),
) )
if etud_etat == scu.DEMISSION: if etud_etat == scu.DEMISSION:
d["demission"] = "(Démission)" d["demission"] = "(Démission)"

View File

@ -6,14 +6,13 @@
"""Génération bulletin BUT au format PDF standard """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 reportlab.platypus import Paragraph, Spacer
from app.scodoc.sco_pdf import blue, cm, mm
from app.scodoc import gen_tables
from app.scodoc.sco_utils import fmt_note
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard 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): class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
@ -22,8 +21,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
self.infos est le dict issu de BulletinBUT.bulletin_etud_complet() self.infos est le dict issu de BulletinBUT.bulletin_etud_complet()
""" """
list_in_menu = False # spécialisation du BulletinGeneratorStandard, ne pas présenter à l'utilisateur # spécialisation du BulletinGeneratorStandard, ne pas présenter à l'utilisateur:
scale_table_in_page = False 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"): def bul_table(self, format="html"):
"""Génère la table centrale du bulletin de notes """Génère la table centrale du bulletin de notes
@ -77,16 +79,29 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
"coef": 2 * cm, "coef": 2 * cm,
} }
title_bg = tuple(x / 255.0 for x in title_bg) 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) # elems pour générer table avec gen_table (liste de dicts)
rows = [ rows = [
# Ligne de titres # Ligne de titres
{ {
"titre": "Unités d'enseignement", "titre": "Unités d'enseignement",
"moyenne": "Note/20", "moyenne": Paragraph("<para align=right><b>Note/20</b></para>"),
"coef": "Coef.", "coef": "Coef.",
"_coef_pdf": Paragraph("<para align=right><b><i>Coef.</i></b></para>"), "_coef_pdf": Paragraph("<para align=right><b><i>Coef.</i></b></para>"),
"_css_row_class": "note_bold", "_css_row_class": "note_bold",
"_pdf_row_markup": ["b"], "_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": [ "_pdf_style": [
("BACKGROUND", (0, 0), (-1, 0), title_bg), ("BACKGROUND", (0, 0), (-1, 0), title_bg),
("BOTTOMPADDING", (0, 0), (-1, 0), 7), ("BOTTOMPADDING", (0, 0), (-1, 0), 7),
@ -98,15 +113,29 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
blue, blue,
), ),
], ],
} },
] ]
col_keys = ["titre", "coef", "moyenne"] # noms des colonnes à afficher col_keys = ["titre", "coef", "moyenne"] # noms des colonnes à afficher
for ue_acronym, ue in self.infos["ues"].items(): 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 # 1er ligne titre UE
moy_ue = ue.get("moyenne") moy_ue = ue.get("moyenne")
t = { t = {
"titre": f"{ue_acronym} - {ue['titre']}", "titre": f"{ue_acronym} - {ue['titre']}",
"moyenne": moy_ue.get("value", "-") if moy_ue is not None else "-", "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", "_css_row_class": "note_bold",
"_pdf_row_markup": ["b"], "_pdf_row_markup": ["b"],
"_pdf_style": [ "_pdf_style": [
@ -122,42 +151,39 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
], ],
} }
rows.append(t) 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) # 2eme ligne titre UE (bonus/malus/ects)
if "ECTS" in ue:
ects_txt = f'ECTS: {ue["ECTS"]["acquis"]:.3g} / {ue["ECTS"]["total"]:.3g}' ects_txt = f'ECTS: {ue["ECTS"]["acquis"]:.3g} / {ue["ECTS"]["total"]:.3g}'
else:
ects_txt = ""
t = { t = {
"titre": f"""Bonus: {ue['bonus']} - Malus: { "titre": f"""Bonus: {ue['bonus']} - Malus: {
ue["malus"]}""", ue["malus"]}""",
"coef": ects_txt, "coef": ects_txt,
"_coef_pdf": Paragraph(f"""<para align=right>{ects_txt}</para>"""), "_coef_pdf": Paragraph(f"""<para align=left>{ects_txt}</para>"""),
"_coef_colspan": 2, "_coef_colspan": 2,
# "_css_row_class": "",
# "_pdf_row_markup": [""],
"_pdf_style": [ "_pdf_style": [
("BACKGROUND", (0, 0), (-1, 0), title_bg), ("BACKGROUND", (0, 0), (-1, 0), title_bg),
( ("LINEBELOW", (0, 0), (-1, 0), self.PDF_LINEWIDTH, self.PDF_LINECOLOR),
"LINEBELOW", # cadre autour du bonus/malus, gris clair
(0, 0), ("BOX", (0, 0), (0, 0), self.PDF_LINEWIDTH, (0.7, 0.7, 0.7)),
(-1, 0),
self.PDF_LINEWIDTH,
self.PDF_LINECOLOR,
),
# cadre autour du bonus/malus
(
"BOX",
(0, 0),
(0, 0),
self.PDF_LINEWIDTH,
(0.7, 0.7, 0.7), # gris clair
),
], ],
} }
rows.append(t) rows.append(t)
# Liste chaque ressource puis SAE
# Liste chaque ressource puis chaque SAE
for mod_type in ("ressources", "saes"): for mod_type in ("ressources", "saes"):
for mod_code, mod in ue[mod_type].items(): for mod_code, mod in ue[mod_type].items():
t = { t = {
"titre": f"{mod_code} {self.infos[mod_type][mod_code]['titre']}", "titre": f"{mod_code} {self.infos[mod_type][mod_code]['titre']}",
"moyenne": mod["moyenne"], "moyenne": Paragraph(f'<para align=right>{mod["moyenne"]}</para>'),
"coef": mod["coef"], "coef": mod["coef"],
"_coef_pdf": Paragraph( "_coef_pdf": Paragraph(
f"<para align=right><i>{mod['coef']}</i></para>" f"<para align=right><i>{mod['coef']}</i></para>"
@ -173,12 +199,17 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
], ],
} }
rows.append(t) rows.append(t)
# Global pdf style commands:
pdf_style = [ def ue_sport_rows(self, rows: list, ue: dict, title_bg: tuple):
("VALIGN", (0, 0), (-1, -1), "TOP"), "Lignes décrivant l'UE bonus dans la table de synthèse"
("BOX", (0, 0), (-1, -1), 0.4, blue), # ajoute cadre extérieur bleu: # UE BONUS
] for mod_code, mod in ue["modules"].items():
return col_keys, rows, pdf_style, col_widths rows.append(
{
"titre": f"{mod_code} {mod['titre']}",
}
)
self.evaluations_rows(rows, mod["evaluations"])
def but_table_ressources(self): def but_table_ressources(self):
"""La table de synthèse; pour chaque ressources, note et liste d'évaluations """La table de synthèse; pour chaque ressources, note et liste d'évaluations
@ -203,9 +234,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
- pdf_style : commandes table Platypus - pdf_style : commandes table Platypus
- largeurs de colonnes pour PDF - largeurs de colonnes pour PDF
""" """
poids_fontsize = "8"
# UE à utiliser pour les poids (# colonne/UE) # UE à utiliser pour les poids (# colonne/UE)
ue_acros = list(self.infos["ues"].keys()) # ['RT1.1', 'RT2.1', 'RT3.1'] 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: # Colonnes à afficher:
col_keys = ["titre"] + ue_acros + ["coef", "moyenne"] col_keys = ["titre"] + ue_acros + ["coef", "moyenne"]
# Largeurs des colonnes: # Largeurs des colonnes:
@ -243,7 +276,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
} }
for ue_acro in ue_acros: for ue_acro in ue_acros:
t[ue_acro] = Paragraph( t[ue_acro] = Paragraph(
f"<para align=right fontSize={poids_fontsize}><i>{ue_acro}</i></para>" f"<para align=right fontSize={self.small_fontsize}><i>{ue_acro}</i></para>"
) )
rows = [t] rows = [t]
for mod_code, mod in self.infos[mod_type].items(): for mod_code, mod in self.infos[mod_type].items():
@ -267,13 +300,27 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
} }
rows.append(t) rows.append(t)
# Evaluations: # Evaluations:
for e in mod["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 = { t = {
"titre": f"{e['description']}", "titre": f"{e['description']}",
"moyenne": e["note"]["value"], "moyenne": e["note"]["value"],
"_moyenne_pdf": Paragraph(
f"""<para align=right>{e["note"]["value"]}</para>"""
),
"coef": e["coef"], "coef": e["coef"],
"_coef_pdf": Paragraph( "_coef_pdf": Paragraph(
f"<para align=right fontSize={poids_fontsize}><i>{e['coef']}</i></para>" f"<para align=right fontSize={self.small_fontsize}><i>{e['coef']}</i></para>"
), ),
"_pdf_style": [ "_pdf_style": [
( (
@ -288,7 +335,8 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
col_idx = 1 # 1ere col. poids col_idx = 1 # 1ere col. poids
for ue_acro in ue_acros: for ue_acro in ue_acros:
t[ue_acro] = Paragraph( t[ue_acro] = Paragraph(
f"""<para align=right fontSize={poids_fontsize}><i>{e["poids"].get(ue_acro, "")}</i></para>""" f"""<para align=right fontSize={self.small_fontsize}><i>{
e["poids"].get(ue_acro, "") or ""}</i></para>"""
) )
t["_pdf_style"].append( t["_pdf_style"].append(
( (
@ -301,9 +349,3 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
) )
col_idx += 1 col_idx += 1
rows.append(t) rows.append(t)
# 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

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

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

View File

@ -13,7 +13,6 @@ Les classes de Bonus fournissent deux méthodes:
""" """
import datetime import datetime
import math
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@ -89,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
@ -200,10 +199,11 @@ class BonusSportAdditif(BonusSport):
""" """
seuil_moy_gen = 10.0 # seuls les bonus au dessus du seuil sont pris en compte seuil_moy_gen = 10.0 # seuls les bonus au dessus du seuil sont pris en compte
seuil_comptage = ( # les points au dessus du seuil sont comptés (defaut: seuil_moy_gen):
None # 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
@ -220,19 +220,16 @@ class BonusSportAdditif(BonusSport):
) )
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)
& (modimpl_coefs_etuds_no_nan > 0),
(sem_modimpl_moys_inscrits - seuil_comptage) * 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 20.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) self.bonus_additif(bonus_moy_arr)
@ -510,14 +507,14 @@ class BonusCachan1(BonusSportAdditif):
</li> </li>
<li> BUT : la meilleure note d'option, si elle est supérieure à 10, bonifie <li> BUT : la meilleure note d'option, si elle est supérieure à 10, bonifie
les moyennes d'UE à raison de <em>bonus = (option - 10)*5%</em>.</li> les moyennes d'UE à raison de <em>bonus = (option - 10) * 3%</em>.</li>
</ul> </ul>
""" """
name = "bonus_cachan1" name = "bonus_cachan1"
displayed_name = "IUT de Cachan 1" displayed_name = "IUT de Cachan 1"
seuil_moy_gen = 10.0 # tous les points sont comptés seuil_moy_gen = 10.0 # tous les points sont comptés
proportion_point = 0.05 proportion_point = 0.03
classic_use_bonus_ues = True classic_use_bonus_ues = True
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):
@ -679,17 +676,40 @@ class BonusLaRochelle(BonusSportAdditif):
proportion_point = 0.01 # 1% 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):
@ -754,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
@ -769,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,
@ -809,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.
@ -831,9 +909,9 @@ class BonusStBrieuc(BonusSportAdditif):
<ul> <ul>
<li>Bonus = (S - 10)/20</li> <li>Bonus = (S - 10)/20</li>
</ul> </ul>
<div class="warning">(XXX vérifier si S6 est éligible au bonus, et le S2 du DUT XXX)</div>
""" """
# Utilisé aussi par St Malo, voir plus bas
name = "bonus_iut_stbrieuc" name = "bonus_iut_stbrieuc"
displayed_name = "IUT de Saint-Brieuc" displayed_name = "IUT de Saint-Brieuc"
proportion_point = 1 / 20.0 proportion_point = 1 / 20.0
@ -845,6 +923,19 @@ class BonusStBrieuc(BonusSportAdditif):
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) 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 optionnels (sport, culture), règle IUT Saint-Denis """Calcul bonus modules optionnels (sport, culture), règle IUT Saint-Denis
@ -862,6 +953,19 @@ 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): class BonusTarbes(BonusSportAdditif):
"""Calcul bonus optionnels (sport, culture), règle IUT de Tarbes. """Calcul bonus optionnels (sport, culture), règle IUT de Tarbes.

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

View File

@ -30,7 +30,8 @@
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from flask import flash from flask import flash, g, Markup, url_for
from app.models.formations import Formation
def compute_sem_moys_apc_using_coefs( def compute_sem_moys_apc_using_coefs(
@ -51,7 +52,7 @@ def compute_sem_moys_apc_using_coefs(
def compute_sem_moys_apc_using_ects( def compute_sem_moys_apc_using_ects(
etud_moy_ue_df: pd.DataFrame, ects: list, formation_id=None etud_moy_ue_df: pd.DataFrame, ects: list, formation_id=None, skip_empty_ues=False
) -> 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
= moyenne des moyennes d'UE, pondérée par leurs ECTS. = moyenne des moyennes d'UE, pondérée par leurs ECTS.
@ -59,13 +60,29 @@ def compute_sem_moys_apc_using_ects(
etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid
ects: liste de floats ou None, 1 par UE 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) Result: panda Series, index etudid, valeur float (moyenne générale)
""" """
try: 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) moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / sum(ects)
except TypeError: except TypeError:
if None in ects: if None in ects:
flash("""Calcul moyenne générale impossible: ECTS des UE manquants !""") 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) moy_gen = pd.Series(np.NaN, index=etud_moy_ue_df.index)
else: else:
raise raise
@ -76,8 +93,12 @@ 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: Series { 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_str = 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 rangs_int = pd.Series(index=notes.index, dtype=int) # le rang numérique pour tris

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, formsemestre 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):
@ -112,6 +118,9 @@ class ResultatsSemestreBUT(NotesTableCompat):
self.etud_moy_ue, self.etud_moy_ue,
[ue.ects for ue in self.ues if ue.type != UE_SPORT], [ue.ects for ue in self.ues if ue.type != UE_SPORT],
formation_id=self.formsemestre.formation_id, 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()
@ -139,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_mat, 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
@ -35,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()
@ -47,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()
@ -142,6 +146,22 @@ class ResultatsSemestreClassic(NotesTableCompat):
if sco_preferences.get_preference("bul_show_matieres", self.formsemestre.id): if sco_preferences.get_preference("bul_show_matieres", self.formsemestre.id):
self.compute_moyennes_matieres() 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)
@ -172,6 +192,19 @@ 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): def compute_moyennes_matieres(self):
"""Calcul les moyennes par matière. Doit être appelée au besoin, en fin de compute.""" """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.moyennes_matieres = moy_mat.compute_mat_moys_classic(
@ -188,35 +221,28 @@ class ResultatsSemestreClassic(NotesTableCompat):
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]:
@ -247,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

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

View File

@ -15,6 +15,7 @@ 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)

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")
@ -73,5 +70,16 @@ class CodesDecisionsForm(FlaskForm):
DEM = _build_code_field("DEM") DEM = _build_code_field("DEM")
NAR = _build_code_field("NAR") NAR = _build_code_field("NAR")
RAT = _build_code_field("RAT") RAT = _build_code_field("RAT")
NOTES_FMT = StringField(
label="Format notes exportées",
description="""Format des notes. Par défaut <tt style="font-family: monotype;">%3.2f</tt> (deux chiffres après la virgule)""",
validators=[
validators.Length(
max=SHORT_STR_LEN,
message=f"Le format ne doit pas dépasser {SHORT_STR_LEN} caractères",
),
validators.DataRequired("format requis"),
],
)
submit = SubmitField("Valider") submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -47,8 +47,6 @@ from app.scodoc.sco_config_actions import (
LogoInsert, LogoInsert,
) )
from app.scodoc import sco_utils as scu
from app.scodoc.sco_logos import find_logo from app.scodoc.sco_logos import find_logo

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

@ -36,6 +36,7 @@ CODES_SCODOC_TO_APO = {
DEM: "NAR", DEM: "NAR",
NAR: "NAR", NAR: "NAR",
RAT: "ATT", RAT: "ATT",
"NOTES_FMT": "%3.2f",
} }
@ -69,6 +70,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):
@ -156,32 +158,6 @@ class ScoDocSiteConfig(db.Model):
class_list.sort(key=lambda x: x[1].replace(" du ", " de ")) class_list.sort(key=lambda x: x[1].replace(" du ", " de "))
return [("", "")] + class_list return [("", "")] + class_list
@classmethod
def get_bonus_sport_func(cls):
"""Fonction bonus_sport ScoDoc 7 XXX
Transitoire pour les tests durant la transition #sco92
"""
"""returns bonus func with specified name.
If name not specified, return the configured function.
None if no bonus function configured.
Raises ScoValueError if func_name not found in module bonus_sport.
"""
from app.scodoc import bonus_sport
c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first()
if c is None:
return None
func_name = c.value
if func_name == "": # pas de bonus défini
return None
try:
return getattr(bonus_sport, func_name)
except AttributeError:
raise ScoValueError(
f"""Fonction de calcul de l'UE bonus inexistante: "{func_name}".
(contacter votre administrateur local)."""
)
@classmethod @classmethod
def get_code_apo(cls, code: str) -> str: def get_code_apo(cls, code: str) -> str:
"""La représentation d'un code pour les exports Apogée. """La représentation d'un code pour les exports Apogée.
@ -207,3 +183,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

@ -16,6 +16,7 @@ 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
from app.scodoc.sco_exceptions import ScoValueError
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -123,10 +124,18 @@ 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"
@ -145,28 +154,37 @@ class Identite(db.Model):
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, "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):
@ -280,7 +298,7 @@ class Identite(db.Model):
log( log(
f"*** situation inconsistante pour {self} (inscrit mais pas d'event)" f"*** situation inconsistante pour {self} (inscrit mais pas d'event)"
) )
date_ins = "???" # ??? situation += " (inscription non enregistrée)" # ???
else: else:
date_ins = events[0].event_date date_ins = events[0].event_date
situation += date_ins.strftime(" le %d/%m/%Y") situation += date_ins.strftime(" le %d/%m/%Y")
@ -337,7 +355,10 @@ def make_etud_args(
""" """
args = None args = None
if etudid: if etudid:
args = {"etudid": etudid} try:
args = {"etudid": int(etudid)}
except ValueError as exc:
raise ScoValueError("Adresse invalide") from exc
elif code_nip: elif code_nip:
args = {"code_nip": code_nip} args = {"code_nip": code_nip}
elif use_request: # use form from current request (Flask global) elif use_request: # use form from current request (Flask global)
@ -347,12 +368,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é")
@ -388,6 +412,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

@ -161,7 +161,6 @@ 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"] = self.mois_debut() d["mois_debut"] = self.mois_debut()
d["mois_fin"] = self.mois_fin() d["mois_fin"] = self.mois_fin()
@ -174,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:
@ -302,6 +300,10 @@ 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)
@ -312,7 +314,7 @@ class FormSemestre(db.Model):
def mois_fin(self) -> str: def mois_fin(self) -> str:
"Jul 2022" "Jul 2022"
return f"{MONTH_NAMES_ABBREV[self.date_fin.month - 1]} {self.date_debut.year}" 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
@ -399,7 +401,7 @@ class FormSemestre(db.Model):
@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
@ -578,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

@ -134,7 +134,9 @@ class ModuleImplInscription(db.Model):
def etud_modimpls_in_ue( def etud_modimpls_in_ue(
cls, formsemestre_id: int, etudid: int, ue_id: int cls, formsemestre_id: int, etudid: int, ue_id: int
) -> flask_sqlalchemy.BaseQuery: ) -> flask_sqlalchemy.BaseQuery:
"""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,

View File

@ -33,7 +33,7 @@ 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, nullable=False, default=0, server_default="0") 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")
@ -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

@ -72,23 +72,20 @@ class NotesNotesLog(db.Model):
def etud_has_notes_attente(etudid, formsemestre_id): def etud_has_notes_attente(etudid, formsemestre_id):
"""Vrai si cet etudiant a au moins une note en attente dans ce semestre. """Vrai si cet etudiant a au moins une note en attente dans ce semestre.
(ne compte que les notes en attente dans des évaluation avec coef. non nul). (ne compte que les notes en attente dans des évaluations avec coef. non nul).
""" """
# XXX ancienne méthode de notes_table à ré-écrire cursor = db.session.execute(
cnx = ndb.GetDBConnexion() """SELECT COUNT(*)
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(
"""SELECT n.*
FROM notes_notes n, notes_evaluation e, notes_moduleimpl m, FROM notes_notes n, notes_evaluation e, notes_moduleimpl m,
notes_moduleimpl_inscription i notes_moduleimpl_inscription i
WHERE n.etudid = %(etudid)s WHERE n.etudid = :etudid
and n.value = %(code_attente)s and n.value = :code_attente
and n.evaluation_id = e.id and n.evaluation_id = e.id
and e.moduleimpl_id = m.id and e.moduleimpl_id = m.id
and m.formsemestre_id = %(formsemestre_id)s and m.formsemestre_id = :formsemestre_id
and e.coefficient != 0 and e.coefficient != 0
and m.id = i.moduleimpl_id and m.id = i.moduleimpl_id
and i.etudid=%(etudid)s and i.etudid = :etudid
""", """,
{ {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre_id,
@ -96,4 +93,4 @@ def etud_has_notes_attente(etudid, formsemestre_id):
"code_attente": scu.NOTES_ATTENTE, "code_attente": scu.NOTES_ATTENTE,
}, },
) )
return len(cursor.fetchall()) > 0 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

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

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

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

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

@ -51,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()
@ -120,7 +127,6 @@ def pe_view_sem_recap(
# 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

@ -293,6 +293,13 @@ class TF(object):
% (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

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

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

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

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

View File

@ -254,7 +254,7 @@ def abs_notification_message(
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"]

View File

@ -34,7 +34,7 @@ from flask import url_for, g, request, abort
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 Identite, FormSemestre 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
@ -724,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 = []
@ -776,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")

View File

@ -83,6 +83,7 @@ XXX A vérifier:
import collections import collections
import datetime import datetime
from functools import reduce from functools import reduce
import functools
import io import io
import os import os
import pprint import pprint
@ -97,7 +98,7 @@ from chardet import detect as chardet_detect
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, Identite 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
@ -125,7 +126,7 @@ APO_SEP = "\t"
APO_NEWLINE = "\r\n" APO_NEWLINE = "\r\n"
def _apo_fmt_note(note): def _apo_fmt_note(note, fmt="%3.2f"):
"Formatte une note pour Apogée (séparateur décimal: ',')" "Formatte une note pour Apogée (séparateur décimal: ',')"
# if not note and isinstance(note, float): changé le 31/1/2022, étrange ? # if not note and isinstance(note, float): changé le 31/1/2022, étrange ?
# return "" # return ""
@ -133,7 +134,7 @@ def _apo_fmt_note(note):
val = float(note) val = float(note)
except ValueError: except ValueError:
return "" return ""
return ("%3.2f" % val).replace(".", APO_DECIMAL_SEP) return (fmt % val).replace(".", APO_DECIMAL_SEP)
def guess_data_encoding(text, threshold=0.6): def guess_data_encoding(text, threshold=0.6):
@ -270,6 +271,9 @@ class ApoEtud(dict):
self.export_res_modules = export_res_modules self.export_res_modules = export_res_modules
self.export_res_sdj = export_res_sdj # export meme si pas de decision de jury self.export_res_sdj = export_res_sdj # export meme si pas de decision de jury
self.export_res_rat = export_res_rat self.export_res_rat = export_res_rat
self.fmt_note = functools.partial(
_apo_fmt_note, fmt=ScoDocSiteConfig.get_code_apo("NOTES_FMT") or "3.2f"
)
def __repr__(self): def __repr__(self):
return "ApoEtud( nom='%s', nip='%s' )" % (self["nom"], self["nip"]) return "ApoEtud( nom='%s', nip='%s' )" % (self["nom"], self["nip"])
@ -423,7 +427,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"] if ue_status else ""), N=self.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),
@ -443,7 +447,7 @@ class ApoEtud(dict):
].split(","): ].split(","):
n = nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) n = nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid)
if n != "NI" and self.export_res_modules: if n != "NI" and self.export_res_modules:
return dict(N=_apo_fmt_note(n), B=20, J="", R="") return dict(N=self.fmt_note(n), B=20, J="", R="")
else: else:
module_code_found = True module_code_found = True
if module_code_found: if module_code_found:
@ -465,7 +469,7 @@ class ApoEtud(dict):
if decision_apo == "DEF" or decision["code"] == DEM or decision["code"] == DEF: if decision_apo == "DEF" or decision["code"] == DEM or decision["code"] == DEF:
note_str = "0,01" # note non nulle pour les démissionnaires note_str = "0,01" # note non nulle pour les démissionnaires
else: else:
note_str = _apo_fmt_note(note) note_str = self.fmt_note(note)
return dict(N=note_str, B=20, J="", R=decision_apo, M="") return dict(N=note_str, B=20, J="", R=decision_apo, M="")
def comp_elt_annuel(self, etudid, cur_sem, autre_sem): def comp_elt_annuel(self, etudid, cur_sem, autre_sem):
@ -531,7 +535,7 @@ class ApoEtud(dict):
moy_annuelle = (note + autre_note) / 2 moy_annuelle = (note + autre_note) / 2
except TypeError: except TypeError:
moy_annuelle = "" moy_annuelle = ""
note_str = _apo_fmt_note(moy_annuelle) note_str = self.fmt_note(moy_annuelle)
if code_semestre_validant(autre_decision["code"]): if code_semestre_validant(autre_decision["code"]):
decision_apo_annuelle = decision_apo decision_apo_annuelle = decision_apo

View File

@ -32,14 +32,14 @@ import email
import time import time
from flask import g, request from flask import g, request
from flask import render_template, url_for from flask import flash, render_template, url_for
from flask_login import current_user from flask_login import current_user
from app import email from app import email
from app import log from app import log
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 import FormSemestre, Identite, ModuleImplInscription 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
@ -791,7 +791,7 @@ 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=None, format=None,
version="long", version="long",
@ -801,14 +801,13 @@ def formsemestre_bulletinetud(
): ):
"page bulletin de notes" "page bulletin de notes"
format = format or "html" format = format or "html"
etud: Identite = Identite.query.get_or_404(etudid)
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
if not formsemestre: if not formsemestre:
raise ScoValueError(f"semestre {formsemestre_id} inconnu !") raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
bulletin = do_formsemestre_bulletinetud( bulletin = do_formsemestre_bulletinetud(
formsemestre, formsemestre,
etudid, etud.id,
format=format, format=format,
version=version, version=version,
xml_with_decisions=xml_with_decisions, xml_with_decisions=xml_with_decisions,
@ -818,12 +817,15 @@ def formsemestre_bulletinetud(
if format not in {"html", "pdfmail"}: if format not in {"html", "pdfmail"}:
filename = scu.bul_filename(formsemestre, 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":
return ""
H = [ H = [
_formsemestre_bulletinetud_header_html(etud, formsemestre, format, version), _formsemestre_bulletinetud_header_html(etud, formsemestre, format, version),
bulletin, bulletin,
render_template( render_template(
"bul_foot.html", "bul_foot.html",
appreciations=None, # déjà affichées
css_class="bul_classic_foot",
etud=etud, etud=etud,
formsemestre=formsemestre, formsemestre=formsemestre,
inscription_courante=etud.inscription_courante(), inscription_courante=etud.inscription_courante(),
@ -850,7 +852,6 @@ def do_formsemestre_bulletinetud(
etudid: int, etudid: int,
version="long", # short, long, selectedevals version="long", # short, long, selectedevals
format=None, format=None,
nohtml=False,
xml_with_decisions=False, # force décisions dans XML xml_with_decisions=False, # force décisions dans XML
force_publishing=False, # force publication meme si semestre non publié sur "portail" force_publishing=False, # force publication meme si semestre non publié sur "portail"
prefer_mail_perso=False, # mails envoyés sur adresse perso si non vide prefer_mail_perso=False, # mails envoyés sur adresse perso si non vide
@ -883,9 +884,9 @@ def do_formsemestre_bulletinetud(
return bul, "" return bul, ""
if formsemestre.formation.is_apc(): if formsemestre.formation.is_apc():
etud = Identite.query.get(etudid) etudiant = Identite.query.get(etudid)
r = bulletin_but.BulletinBUT(formsemestre) r = bulletin_but.BulletinBUT(formsemestre)
I = r.bulletin_etud_complet(etud, version=version) I = r.bulletin_etud_complet(etudiant, version=version)
else: else:
I = formsemestre_bulletinetud_dict(formsemestre.id, etudid) I = formsemestre_bulletinetud_dict(formsemestre.id, etudid)
etud = I["etud"] etud = I["etud"]
@ -917,13 +918,6 @@ def do_formsemestre_bulletinetud(
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"
) )
@ -931,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 = (
"<div class=\"boldredmsg\">%s n'a pas d'adresse e-mail !</div>"
% etud["nomprenom"]
) + htm
return h, I["filigranne"]
#
mail_bulletin(formsemestre.id, I, pdfdata, filename, recipient_addr) mail_bulletin(formsemestre.id, I, pdfdata, filename, recipient_addr)
emaillink = '<a class="stdlink" href="mailto:%s">%s</a>' % ( flash(f"mail envoyé à {recipient_addr}")
recipient_addr,
recipient_addr, return True, I["filigranne"]
)
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)

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
@ -72,6 +79,7 @@ class BulletinGenerator:
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 ? 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 scale_table_in_page = True # rescale la table sur 1 page
multi_pages = False
def __init__( def __init__(
self, self,
@ -154,30 +162,47 @@ class BulletinGenerator:
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: 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),
@ -190,7 +215,7 @@ class BulletinGenerator:
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
@ -241,6 +266,11 @@ def make_formsemestre_bulletinetud(
bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id) bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id)
gen_class = None 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"): if infos.get("type") == "BUT" and format.startswith("pdf"):
gen_class = bulletin_get_class(bul_class_name + "BUT") gen_class = bulletin_get_class(bul_class_name + "BUT")
if gen_class is None: if gen_class is None:

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

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

View File

@ -51,12 +51,11 @@ 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 BaseDocTemplate
from flask import g, request from flask import g, request
@ -74,17 +73,17 @@ import app.scodoc.sco_utils as scu
import sco_version 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 = (
@ -93,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,
@ -109,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
@ -121,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))
) )
@ -142,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()
@ -174,7 +177,7 @@ 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)
@ -183,20 +186,14 @@ def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"):
fragments = [] fragments = []
# Make each bulletin # Make each bulletin
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
bookmarks = {}
filigrannes = {}
i = 1
for etud in formsemestre.get_inscrits(include_demdef=True, order=True): for etud in formsemestre.get_inscrits(include_demdef=True, order=True):
frag, filigranne = sco_bulletins.do_formsemestre_bulletinetud( frag, _ = sco_bulletins.do_formsemestre_bulletinetud(
formsemestre, formsemestre,
etud.id, etud.id,
format="pdfpart", format="pdfpart",
version=version, version=version,
) )
fragments += frag fragments += frag
filigrannes[i] = filigranne
bookmarks[i] = etud.sex_nom(no_accents=True)
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:
@ -205,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,
formsemestre.titre_mois(), 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" % (formsemestre.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(
@ -255,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"],

View File

@ -49,6 +49,8 @@ Balises img: actuellement interdites.
from reportlab.platypus import KeepTogether, Paragraph, Spacer, Table from reportlab.platypus import KeepTogether, Paragraph, Spacer, Table
from reportlab.lib.units import cm, mm from reportlab.lib.units import cm, mm
from reportlab.lib.colors import Color, blue 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 SU from app.scodoc.sco_pdf import SU
@ -73,7 +75,7 @@ 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
""" """
@ -115,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">
@ -130,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."
@ -141,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,
@ -168,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,
@ -180,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,
@ -196,7 +198,12 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
# ----- # -----
if format == "pdf": if format == "pdf":
return [KeepTogether(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)
@ -265,7 +272,7 @@ 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: col_keys, P, pdf_style, col_widths Renvoie: col_keys, P, pdf_style, col_widths
- col_keys: 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 caractères - table: liste de dicts de chaines de caractères
@ -375,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,
@ -412,6 +419,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
for ue in I["ues"]: for ue in I["ues"]:
ue_type = None ue_type = None
coef_ue = ue["coef_ue_txt"] if prefs["bul_show_ue_coef"] else "" 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 #

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

View File

@ -49,7 +49,6 @@
# sco_cache.EvaluationCache.get(evaluation_id), set(evaluation_id, value), delete(evaluation_id), # sco_cache.EvaluationCache.get(evaluation_id), set(evaluation_id, value), delete(evaluation_id),
# #
import time
import traceback import traceback
from flask import g from flask import g
@ -198,6 +197,26 @@ class SemInscriptionsCache(ScoDocCache):
duration = 12 * 60 * 60 # ttl 12h duration = 12 * 60 * 60 # ttl 12h
class TableRecapCache(ScoDocCache):
"""Cache table recap (pour get_table_recap)
Clé: formsemestre_id
Valeur: le html (<div class="table_recap">...</div>)
"""
prefix = "RECAP"
duration = 12 * 60 * 60 # ttl 12h
class TableRecapWithEvalsCache(ScoDocCache):
"""Cache table recap (pour get_table_recap)
Clé: formsemestre_id
Valeur: le html (<div class="table_recap">...</div>)
"""
prefix = "RECAPWITHEVALS"
duration = 12 * 60 * 60 # ttl 12h
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
): ):
@ -209,7 +228,7 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa
if getattr(g, "defer_cache_invalidation", False): if getattr(g, "defer_cache_invalidation", False):
g.sem_to_invalidate.add(formsemestre_id) g.sem_to_invalidate.add(formsemestre_id)
return return
log("inval_cache, formsemestre_id=%s pdfonly=%s" % (formsemestre_id, pdfonly)) log("inval_cache, formsemestre_id={formsemestre_id} pdfonly={pdfonly}")
if formsemestre_id is None: if formsemestre_id is None:
# clear all caches # clear all caches
log("----- invalidate_formsemestre: clearing all caches -----") log("----- invalidate_formsemestre: clearing all caches -----")
@ -247,6 +266,9 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa
SemInscriptionsCache.delete_many(formsemestre_ids) SemInscriptionsCache.delete_many(formsemestre_ids)
ResultatsSemestreCache.delete_many(formsemestre_ids) ResultatsSemestreCache.delete_many(formsemestre_ids)
ValidationsSemestreCache.delete_many(formsemestre_ids) ValidationsSemestreCache.delete_many(formsemestre_ids)
TableRecapCache.delete_many(formsemestre_ids)
TableRecapWithEvalsCache.delete_many(formsemestre_ids)
SemBulletinsPDFCache.invalidate_sems(formsemestre_ids) SemBulletinsPDFCache.invalidate_sems(formsemestre_ids)

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

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

@ -33,7 +33,7 @@ from flask import url_for, g, 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
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb

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,14 +51,12 @@ import fcntl
import subprocess import subprocess
import requests import requests
from flask import flash 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
@ -66,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
@ -95,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
@ -125,8 +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.")
flash("Données envoyées au serveur d'assistance")
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):
@ -175,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:
@ -184,7 +161,9 @@ 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)}
@ -193,7 +172,9 @@ def _send_db(ano_db_name):
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"],

View File

@ -166,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 = (
@ -189,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

@ -92,7 +92,7 @@ def do_matiere_create(args):
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 {formation.acronyme}", text=f"Modification de la formation {formation.acronyme}",
max_frequency=3, max_frequency=3,
) )
formation.invalidate_cached_sems() formation.invalidate_cached_sems()
@ -200,7 +200,7 @@ def do_matiere_delete(oid):
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 {formation.acronyme}", text=f"Modification de la formation {formation.acronyme}",
max_frequency=3, max_frequency=3,
) )
formation.invalidate_cached_sems() formation.invalidate_cached_sems()

View File

@ -520,7 +520,7 @@ def module_edit(module_id=None):
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",
@ -528,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",

View File

@ -35,7 +35,7 @@ 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
@ -141,7 +141,7 @@ def do_ue_create(args):
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 {formation.acronyme}", text=f"Modification de la formation {formation.acronyme}",
max_frequency=3, max_frequency=3,
) )
formation.invalidate_cached_sems() formation.invalidate_cached_sems()
@ -347,7 +347,7 @@ 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 "allow_null": not is_apc, # ects requis en APC
}, },
), ),
@ -372,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 (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.", "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.""",
}, },
), ),
( (
@ -381,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,
}, },
), ),
( (
@ -724,13 +727,16 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
{formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long} {formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long}
</a>&nbsp;""" </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} <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> )
H.append(
f"""</li>
<li> <a class="stdlink" href="{ <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>

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

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

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

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

@ -30,7 +30,7 @@
from flask import url_for, g, request from flask import url_for, g, request
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.notesdb as ndb import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu

View File

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

@ -648,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"
# #
@ -1314,7 +1314,7 @@ def _reassociate_moduleimpls(cnx, formsemestre_id, ues_old2new, modules_old2new)
def formsemestre_delete(formsemestre_id): def formsemestre_delete(formsemestre_id):
"""Delete a formsemestre (affiche avertissements)""" """Delete a formsemestre (affiche avertissements)"""
sem = sco_formsemestre.get_formsemestre(formsemestre_id) sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True)
F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0] F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0]
H = [ H = [
html_sco_header.html_sem_header("Suppression du semestre"), html_sco_header.html_sem_header("Suppression du semestre"),

View File

@ -38,7 +38,7 @@ 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 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
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb

View File

@ -33,7 +33,7 @@ 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 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
from app import log from app import log

View File

@ -36,7 +36,7 @@ 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_common import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import Module from app.models import Module
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
@ -1170,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>'

View File

@ -37,7 +37,7 @@ import app.scodoc.sco_utils as scu
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.notes import etud_has_notes_attente from app.models.notes import etud_has_notes_attente
@ -591,6 +591,8 @@ def formsemestre_recap_parcours_table(
etud_ue_status = { etud_ue_status = {
ue["ue_id"]: nt.get_etud_ue_status(etudid, ue["ue_id"]) for ue in ues ue["ue_id"]: nt.get_etud_ue_status(etudid, ue["ue_id"]) for ue in ues
} }
if not nt.is_apc:
# formations classiques: filtre UE sur inscriptions (et garde UE capitalisées)
ues = [ ues = [
ue ue
for ue in ues for ue in ues

View File

@ -46,7 +46,7 @@ from flask import url_for, make_response
from app import db from app import db
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, formsemestre from app.models import FormSemestre, formsemestre
from app.models import GROUPNAME_STR_LEN, SHORT_STR_LEN from app.models import GROUPNAME_STR_LEN, SHORT_STR_LEN
from app.models.groups import Partition from app.models.groups import Partition
@ -343,7 +343,7 @@ def get_group_other_partitions(group):
return other_partitions return other_partitions
def get_etud_groups(etudid, sem, exclude_default=False): def get_etud_groups(etudid: int, formsemestre_id: int, exclude_default=False):
"""Infos sur groupes de l'etudiant dans ce semestre """Infos sur groupes de l'etudiant dans ce semestre
[ group + partition_name ] [ group + partition_name ]
""" """
@ -358,18 +358,18 @@ def get_etud_groups(etudid, sem, exclude_default=False):
req += " and p.partition_name is not NULL" req += " and p.partition_name is not NULL"
groups = ndb.SimpleDictFetch( groups = ndb.SimpleDictFetch(
req + " ORDER BY p.numero", req + " ORDER BY p.numero",
{"etudid": etudid, "formsemestre_id": sem["formsemestre_id"]}, {"etudid": etudid, "formsemestre_id": formsemestre_id},
) )
return _sortgroups(groups) return _sortgroups(groups)
def get_etud_main_group(etudid, sem): def get_etud_main_group(etudid: int, formsemestre_id: int):
"""Return main group (the first one) for etud, or default one if no groups""" """Return main group (the first one) for etud, or default one if no groups"""
groups = get_etud_groups(etudid, sem, exclude_default=True) groups = get_etud_groups(etudid, formsemestre_id, exclude_default=True)
if groups: if groups:
return groups[0] return groups[0]
else: else:
return get_group(get_default_group(sem["formsemestre_id"])) return get_group(get_default_group(formsemestre_id))
def formsemestre_get_main_partition(formsemestre_id): def formsemestre_get_main_partition(formsemestre_id):

View File

@ -154,9 +154,9 @@ def sco_import_generate_excel_sample(
with_codesemestre=True, with_codesemestre=True,
only_tables=None, only_tables=None,
with_groups=True, with_groups=True,
exclude_cols=[], exclude_cols=(),
extra_cols=[], extra_cols=(),
group_ids=[], group_ids=(),
): ):
"""Generates an excel document based on format fmt """Generates an excel document based on format fmt
(format is the result of sco_import_format()) (format is the result of sco_import_format())
@ -167,7 +167,7 @@ def sco_import_generate_excel_sample(
style = sco_excel.excel_make_style(bold=True) style = sco_excel.excel_make_style(bold=True)
style_required = sco_excel.excel_make_style(bold=True, color=COLORS.RED) style_required = sco_excel.excel_make_style(bold=True, color=COLORS.RED)
titles = [] titles = []
titlesStyles = [] titles_styles = []
for l in fmt: for l in fmt:
name = l[0].lower() name = l[0].lower()
if (not with_codesemestre) and name == "codesemestre": if (not with_codesemestre) and name == "codesemestre":
@ -177,15 +177,15 @@ def sco_import_generate_excel_sample(
if name in exclude_cols: if name in exclude_cols:
continue # colonne exclue continue # colonne exclue
if int(l[3]): if int(l[3]):
titlesStyles.append(style) titles_styles.append(style)
else: else:
titlesStyles.append(style_required) titles_styles.append(style_required)
titles.append(name) titles.append(name)
if with_groups and "groupes" not in titles: if with_groups and "groupes" not in titles:
titles.append("groupes") titles.append("groupes")
titlesStyles.append(style) titles_styles.append(style)
titles += extra_cols titles += extra_cols
titlesStyles += [style] * len(extra_cols) titles_styles += [style] * len(extra_cols)
if group_ids: if group_ids:
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
members = groups_infos.members members = groups_infos.members
@ -194,7 +194,7 @@ def sco_import_generate_excel_sample(
% (group_ids, len(members)) % (group_ids, len(members))
) )
titles = ["etudid"] + titles titles = ["etudid"] + titles
titlesStyles = [style] + titlesStyles titles_styles = [style] + titles_styles
# rempli table avec données actuelles # rempli table avec données actuelles
lines = [] lines = []
for i in members: for i in members:
@ -213,7 +213,7 @@ def sco_import_generate_excel_sample(
else: else:
lines = [[]] # empty content, titles only lines = [[]] # empty content, titles only
return sco_excel.excel_simple_table( return sco_excel.excel_simple_table(
titles=titles, titles_styles=titlesStyles, sheet_name="Etudiants", lines=lines titles=titles, titles_styles=titles_styles, sheet_name="Etudiants", lines=lines
) )
@ -256,7 +256,7 @@ def scolars_import_excel_file(
formsemestre_id=None, formsemestre_id=None,
check_homonyms=True, check_homonyms=True,
require_ine=False, require_ine=False,
exclude_cols=[], exclude_cols=(),
): ):
"""Importe etudiants depuis fichier Excel """Importe etudiants depuis fichier Excel
et les inscrit dans le semestre indiqué (et à TOUS ses modules) et les inscrit dans le semestre indiqué (et à TOUS ses modules)
@ -302,7 +302,8 @@ def scolars_import_excel_file(
else: else:
unknown.append(f) unknown.append(f)
raise ScoValueError( raise ScoValueError(
"Nombre de colonnes incorrect (devrait être %d, et non %d) <br/> (colonnes manquantes: %s, colonnes invalides: %s)" """Nombre de colonnes incorrect (devrait être %d, et non %d)<br/>
(colonnes manquantes: %s, colonnes invalides: %s)"""
% (len(titles), len(fs), list(missing.keys()), unknown) % (len(titles), len(fs), list(missing.keys()), unknown)
) )
titleslist = [] titleslist = []
@ -313,7 +314,7 @@ def scolars_import_excel_file(
# ok, same titles # ok, same titles
# Start inserting data, abort whole transaction in case of error # Start inserting data, abort whole transaction in case of error
created_etudids = [] created_etudids = []
NbImportedHomonyms = 0 np_imported_homonyms = 0
GroupIdInferers = {} GroupIdInferers = {}
try: # --- begin DB transaction try: # --- begin DB transaction
linenum = 0 linenum = 0
@ -377,10 +378,10 @@ def scolars_import_excel_file(
if val: if val:
try: try:
val = sco_excel.xldate_as_datetime(val) val = sco_excel.xldate_as_datetime(val)
except ValueError: except ValueError as exc:
raise ScoValueError( raise ScoValueError(
f"date invalide ({val}) sur ligne {linenum}, colonne {titleslist[i]}" f"date invalide ({val}) sur ligne {linenum}, colonne {titleslist[i]}"
) ) from exc
# INE # INE
if ( if (
titleslist[i].lower() == "code_ine" titleslist[i].lower() == "code_ine"
@ -404,15 +405,17 @@ def scolars_import_excel_file(
if values["code_ine"] and not is_new_ine: if values["code_ine"] and not is_new_ine:
raise ScoValueError("Code INE dupliqué (%s)" % values["code_ine"]) raise ScoValueError("Code INE dupliqué (%s)" % values["code_ine"])
# Check nom/prenom # Check nom/prenom
ok, NbHomonyms = sco_etud.check_nom_prenom( ok = False
if "nom" in values and "prenom" in values:
ok, nb_homonyms = sco_etud.check_nom_prenom(
cnx, nom=values["nom"], prenom=values["prenom"] cnx, nom=values["nom"], prenom=values["prenom"]
) )
if not ok: if not ok:
raise ScoValueError( raise ScoValueError(
"nom ou prénom invalide sur la ligne %d" % (linenum) "nom ou prénom invalide sur la ligne %d" % (linenum)
) )
if NbHomonyms: if nb_homonyms:
NbImportedHomonyms += 1 np_imported_homonyms += 1
# Insert in DB tables # Insert in DB tables
formsemestre_id_etud = _import_one_student( formsemestre_id_etud = _import_one_student(
cnx, cnx,
@ -425,11 +428,11 @@ def scolars_import_excel_file(
) )
# Verification proportion d'homonymes: si > 10%, abandonne # Verification proportion d'homonymes: si > 10%, abandonne
log("scolars_import_excel_file: detected %d homonyms" % NbImportedHomonyms) log("scolars_import_excel_file: detected %d homonyms" % np_imported_homonyms)
if check_homonyms and NbImportedHomonyms > len(created_etudids) / 10: if check_homonyms and np_imported_homonyms > len(created_etudids) / 10:
log("scolars_import_excel_file: too many homonyms") log("scolars_import_excel_file: too many homonyms")
raise ScoValueError( raise ScoValueError(
"Il y a trop d'homonymes (%d étudiants)" % NbImportedHomonyms "Il y a trop d'homonymes (%d étudiants)" % np_imported_homonyms
) )
except: except:
cnx.rollback() cnx.rollback()

View File

@ -27,6 +27,8 @@
"""Liste des notes d'une évaluation """Liste des notes d'une évaluation
""" """
from collections import defaultdict
import numpy as np
import flask import flask
from flask import url_for, g, request from flask import url_for, g, request
@ -36,22 +38,22 @@ from app import models
from app.comp import res_sem from app.comp import res_sem
from app.comp import moy_mod from app.comp import moy_mod
from app.comp.moy_mod import ModuleImplResults from app.comp.moy_mod import ModuleImplResults
from app.comp.res_common import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.comp.res_but import ResultatsSemestreBUT
from app.models import FormSemestre from app.models import FormSemestre
from app.models.etudiants import Identite
from app.models.evaluations import Evaluation from app.models.evaluations import Evaluation
from app.models.moduleimpls import ModuleImpl from app.models.moduleimpls import ModuleImpl
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.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc import sco_cache
from app.scodoc import sco_edit_module
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
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_moduleimpl from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_etud
from app.scodoc import sco_users from app.scodoc import sco_users
import sco_version import sco_version
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
@ -318,10 +320,12 @@ def _make_table_notes(
for etudid, etat in etudid_etats: for etudid, etat in etudid_etats:
css_row_class = None css_row_class = None
# infos identite etudiant # infos identite etudiant
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] etud = Identite.query.get(etudid)
if etud is None:
continue
if etat == "I": # si inscrit, indique groupe if etat == "I": # si inscrit, indique groupe
groups = sco_groups.get_etud_groups(etudid, sem) groups = sco_groups.get_etud_groups(etudid, modimpl_o["formsemestre_id"])
grc = sco_groups.listgroups_abbrev(groups) grc = sco_groups.listgroups_abbrev(groups)
else: else:
if etat == "D": if etat == "D":
@ -330,7 +334,7 @@ def _make_table_notes(
else: else:
grc = etat grc = etat
code = etud.get(anonymous_lst_key) code = getattr(etud, anonymous_lst_key)
if not code: # laisser le code vide n'aurait aucun sens, prenons l'etudid if not code: # laisser le code vide n'aurait aucun sens, prenons l'etudid
code = etudid code = etudid
@ -339,15 +343,20 @@ def _make_table_notes(
"code": str(code), # INE, NIP ou etudid "code": str(code), # INE, NIP ou etudid
"_code_td_attrs": 'style="padding-left: 1em; padding-right: 2em;"', "_code_td_attrs": 'style="padding-left: 1em; padding-right: 2em;"',
"etudid": etudid, "etudid": etudid,
"nom": etud["nom"].upper(), "nom": etud.nom.upper(),
"_nomprenom_target": "formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s" "_nomprenom_target": url_for(
% (modimpl_o["formsemestre_id"], etudid), "notes.formsemestre_bulletinetud",
"_nomprenom_td_attrs": 'id="%s" class="etudinfo"' % (etud["etudid"]), scodoc_dept=g.scodoc_dept,
"prenom": etud["prenom"].lower().capitalize(), formsemestre_id=modimpl_o["formsemestre_id"],
"nomprenom": etud["nomprenom"], etudid=etudid,
),
"_nomprenom_td_attrs": f"""id="{etudid}" class="etudinfo" data-sort="{etud.sort_key}" """,
"prenom": etud.prenom.lower().capitalize(),
"nomprenom": etud.nomprenom,
"group": grc, "group": grc,
"email": etud["email"], "_group_td_attrs": 'class="group"',
"emailperso": etud["emailperso"], "email": etud.get_first_email(),
"emailperso": etud.get_first_email("emailperso"),
"_css_row_class": css_row_class or "", "_css_row_class": css_row_class or "",
} }
) )
@ -384,7 +393,7 @@ def _make_table_notes(
"_css_row_class": "moyenne sortbottom", "_css_row_class": "moyenne sortbottom",
"_table_part": "foot", "_table_part": "foot",
#'_nomprenom_td_attrs' : 'colspan="2" ', #'_nomprenom_td_attrs' : 'colspan="2" ',
"nomprenom": "Moyenne (sans les absents) :", "nomprenom": "Moyenne :",
"comment": "", "comment": "",
} }
# Ajoute les notes de chaque évaluation: # Ajoute les notes de chaque évaluation:
@ -566,15 +575,13 @@ def _make_table_notes(
html_sortable=True, html_sortable=True,
base_url=base_url, base_url=base_url,
filename=filename, filename=filename,
origin="Généré par %s le " % sco_version.SCONAME origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
+ scu.timedate_human_repr()
+ "",
caption=caption, caption=caption,
html_next_section=html_next_section, html_next_section=html_next_section,
page_title="Notes de " + sem["titremois"], page_title="Notes de " + sem["titremois"],
html_title=html_title, html_title=html_title,
pdf_title=pdf_title, pdf_title=pdf_title,
html_class="table_leftalign notes_evaluation", html_class="notes_evaluation",
preferences=sco_preferences.SemPreferences(modimpl_o["formsemestre_id"]), preferences=sco_preferences.SemPreferences(modimpl_o["formsemestre_id"]),
# html_generate_cells=False # la derniere ligne (moyennes) est incomplete # html_generate_cells=False # la derniere ligne (moyennes) est incomplete
) )
@ -590,9 +597,15 @@ def _make_table_notes(
if not e["eval_state"]["evalcomplete"]: if not e["eval_state"]["evalcomplete"]:
all_complete = False all_complete = False
if all_complete: if all_complete:
eval_info = '<span class="eval_info eval_complete">Evaluations prises en compte dans les moyennes</span>' eval_info = '<span class="eval_info"><span class="eval_complete">Evaluations prises en compte dans les moyennes.</span>'
else: else:
eval_info = '<span class="eval_info help">Les évaluations en vert et orange sont prises en compte dans les moyennes. Celles en rouge n\'ont pas toutes leurs notes.</span>' eval_info = """<span class="eval_info help">
Les évaluations en vert et orange sont prises en compte dans les moyennes.
Celles en rouge n'ont pas toutes leurs notes."""
if is_apc:
eval_info += """ <span>La moyenne indicative est la moyenne des moyennes d'UE, et n'est pas utilisée en BUT.
Les moyennes sur le groupe sont estimées sans les absents (sauf pour les moyennes des moyennes d'UE) ni les démissionnaires.</span>"""
eval_info += """</span>"""
return html_form + eval_info + t + "<p></p>" return html_form + eval_info + t + "<p></p>"
else: else:
# Une seule evaluation: ajoute histogramme # Une seule evaluation: ajoute histogramme
@ -657,7 +670,23 @@ def _add_eval_columns(
notes = [] # liste des notes numeriques, pour calcul histogramme uniquement notes = [] # liste des notes numeriques, pour calcul histogramme uniquement
evaluation_id = e["evaluation_id"] evaluation_id = e["evaluation_id"]
e_o = Evaluation.query.get(evaluation_id) # XXX en attendant ré-écriture e_o = Evaluation.query.get(evaluation_id) # XXX en attendant ré-écriture
inscrits = e_o.moduleimpl.formsemestre.etudids_actifs # set d'etudids
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
if len(e["jour"]) > 0:
titles[evaluation_id] = "%(description)s (%(jour)s)" % e
else:
titles[evaluation_id] = "%(description)s " % e
if e["eval_state"]["evalcomplete"]:
klass = "eval_complete"
elif e["eval_state"]["evalattente"]:
klass = "eval_attente"
else:
klass = "eval_incomplete"
titles[evaluation_id] += " (non prise en compte)"
titles[f"_{evaluation_id}_td_attrs"] = f'class="{klass}"'
for row in rows: for row in rows:
etudid = row["etudid"] etudid = row["etudid"]
if etudid in notes_db: if etudid in notes_db:
@ -666,8 +695,13 @@ def _add_eval_columns(
nb_abs += 1 nb_abs += 1
if val == scu.NOTES_ATTENTE: if val == scu.NOTES_ATTENTE:
nb_att += 1 nb_att += 1
# calcul moyenne SANS LES ABSENTS # calcul moyenne SANS LES ABSENTS ni les DEMISSIONNAIRES
if val != None and val != scu.NOTES_NEUTRALISE and val != scu.NOTES_ATTENTE: if (
(etudid in inscrits)
and val != None
and val != scu.NOTES_NEUTRALISE
and val != scu.NOTES_ATTENTE
):
if e["note_max"] > 0: if e["note_max"] > 0:
valsur20 = val * 20.0 / e["note_max"] # remet sur 20 valsur20 = val * 20.0 / e["note_max"] # remet sur 20
else: else:
@ -692,9 +726,11 @@ def _add_eval_columns(
val = None val = None
if val is None: if val is None:
row["_" + str(evaluation_id) + "_td_attrs"] = 'class="etudabs" ' row[f"_{evaluation_id}_td_attrs"] = f'class="etudabs {klass}" '
if not row.get("_css_row_class", ""): if not row.get("_css_row_class", ""):
row["_css_row_class"] = "etudabs" row["_css_row_class"] = "etudabs"
else:
row[f"_{evaluation_id}_td_attrs"] = f'class="{klass}" '
# regroupe les commentaires # regroupe les commentaires
if explanation: if explanation:
if explanation in K: if explanation in K:
@ -747,18 +783,6 @@ def _add_eval_columns(
else: else:
row_moys[evaluation_id] = "" row_moys[evaluation_id] = ""
if len(e["jour"]) > 0:
titles[evaluation_id] = "%(description)s (%(jour)s)" % e
else:
titles[evaluation_id] = "%(description)s " % e
if e["eval_state"]["evalcomplete"]:
titles["_" + str(evaluation_id) + "_td_attrs"] = 'class="eval_complete"'
elif e["eval_state"]["evalattente"]:
titles["_" + str(evaluation_id) + "_td_attrs"] = 'class="eval_attente"'
else:
titles["_" + str(evaluation_id) + "_td_attrs"] = 'class="eval_incomplete"'
return notes, nb_abs, nb_att # pour histogramme return notes, nb_abs, nb_att # pour histogramme
@ -791,6 +815,7 @@ def _add_moymod_column(
col_id = "moymod" col_id = "moymod"
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)
inscrits = formsemestre.etudids_actifs
nb_notes = 0 nb_notes = 0
sum_notes = 0 sum_notes = 0
@ -800,7 +825,7 @@ def _add_moymod_column(
val = nt.get_etud_mod_moy(moduleimpl_id, etudid) # note sur 20, ou 'NA','NI' val = nt.get_etud_mod_moy(moduleimpl_id, etudid) # note sur 20, ou 'NA','NI'
row[col_id] = scu.fmt_note(val, keep_numeric=keep_numeric) row[col_id] = scu.fmt_note(val, keep_numeric=keep_numeric)
row["_" + col_id + "_td_attrs"] = ' class="moyenne" ' row["_" + col_id + "_td_attrs"] = ' class="moyenne" '
if not isinstance(val, str): if etudid in inscrits and not isinstance(val, str):
notes.append(val) notes.append(val)
nb_notes = nb_notes + 1 nb_notes = nb_notes + 1
sum_notes += val sum_notes += val
@ -840,18 +865,16 @@ def _add_apc_columns(
# => On recharge tout dans les nouveaux modèles # => On recharge tout dans les nouveaux modèles
# rows est une liste de dict avec une clé "etudid" # rows est une liste de dict avec une clé "etudid"
# on va y ajouter une clé par UE du semestre # on va y ajouter une clé par UE du semestre
nt: NotesTableCompat = res_sem.load_formsemestre_results(modimpl.formsemestre) nt: ResultatsSemestreBUT = res_sem.load_formsemestre_results(modimpl.formsemestre)
modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl.id] modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl.id]
inscrits = modimpl.formsemestre.etudids_actifs
# XXX A ENLEVER TODO # les UE dans lesquelles ce module a un coef non nul:
# modimpl = ModuleImpl.query.get(moduleimpl_id) ues_with_coef = nt.modimpl_coefs_df[modimpl.id][
nt.modimpl_coefs_df[modimpl.id] > 0
# evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( ].index
# moduleimpl_id ues = [ue for ue in ues if ue.id in ues_with_coef]
# ) sum_by_ue = defaultdict(float)
# etuds_moy_module = moy_mod.compute_module_moy( nb_notes_by_ue = defaultdict(int)
# evals_notes, evals_poids, evaluations, evaluations_completes
# )
if is_conforme: if is_conforme:
# valeur des moyennes vers les UEs: # valeur des moyennes vers les UEs:
for row in rows: for row in rows:
@ -859,6 +882,13 @@ def _add_apc_columns(
moy_ue = modimpl_results.etuds_moy_module[ue.id].get(row["etudid"], "?") moy_ue = modimpl_results.etuds_moy_module[ue.id].get(row["etudid"], "?")
row[f"moy_ue_{ue.id}"] = scu.fmt_note(moy_ue, keep_numeric=keep_numeric) row[f"moy_ue_{ue.id}"] = scu.fmt_note(moy_ue, keep_numeric=keep_numeric)
row[f"_moy_ue_{ue.id}_class"] = "moy_ue" row[f"_moy_ue_{ue.id}_class"] = "moy_ue"
if (
isinstance(moy_ue, float)
and not np.isnan(moy_ue)
and row["etudid"] in inscrits
):
sum_by_ue[ue.id] += moy_ue
nb_notes_by_ue[ue.id] += 1
# Nom et coefs des UE (lignes titres): # Nom et coefs des UE (lignes titres):
ue_coefs = modimpl.module.ue_coefs ue_coefs = modimpl.module.ue_coefs
if is_conforme: if is_conforme:
@ -873,3 +903,8 @@ def _add_apc_columns(
if coefs: if coefs:
row_coefs[f"moy_ue_{ue.id}"] = coefs[0].coef row_coefs[f"moy_ue_{ue.id}"] = coefs[0].coef
row_coefs[f"_moy_ue_{ue.id}_td_attrs"] = f' class="{coef_class}" ' row_coefs[f"_moy_ue_{ue.id}_td_attrs"] = f' class="{coef_class}" '
if nb_notes_by_ue[ue.id] > 0:
row_moys[col_id] = "%.3g" % (sum_by_ue[ue.id] / nb_notes_by_ue[ue.id])
row_moys["_" + col_id + "_help"] = "moyenne des moyennes"
else:
row_moys[col_id] = ""

View File

@ -34,7 +34,7 @@ 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 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.notesdb as ndb import app.scodoc.notesdb as ndb
@ -192,7 +192,7 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False):
) )
H.append("""</input></td>""") H.append("""</input></td>""")
groups = sco_groups.get_etud_groups(etud["etudid"], sem) groups = sco_groups.get_etud_groups(etud["etudid"], formsemestre_id)
for partition in partitions: for partition in partitions:
if partition["partition_name"]: if partition["partition_name"]:
gr_name = "" gr_name = ""
@ -303,13 +303,10 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
) )
for mod in options: for mod in options:
if can_change: if can_change:
c_link = ( c_link = '<a class="discretelink" href="moduleimpl_inscriptions_edit?moduleimpl_id=%s">%s</a>' % (
'<a class="discretelink" href="moduleimpl_inscriptions_edit?moduleimpl_id=%s">%s</a>'
% (
mod["moduleimpl_id"], mod["moduleimpl_id"],
mod["descri"] or "<i>(inscrire des étudiants)</i>", mod["descri"] or "<i>(inscrire des étudiants)</i>",
) )
)
else: else:
c_link = mod["descri"] c_link = mod["descri"]
H.append( H.append(

View File

@ -34,8 +34,7 @@ from flask_login import current_user
from app.auth.models import User from app.auth.models import User
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 ModuleImpl from app.models import ModuleImpl
from app.models.evaluations import Evaluation from app.models.evaluations import Evaluation
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu

View File

@ -235,7 +235,7 @@ def ficheEtud(etudid=None):
) )
grlink = '<span class="fontred">%s</span>' % descr["situation"] grlink = '<span class="fontred">%s</span>' % descr["situation"]
else: else:
group = sco_groups.get_etud_main_group(etudid, sem) group = sco_groups.get_etud_main_group(etudid, sem["formsemestre_id"])
if group["partition_name"]: if group["partition_name"]:
gr_name = group["group_name"] gr_name = group["group_name"]
else: else:
@ -584,7 +584,7 @@ def etud_info_html(etudid, with_photo="1", debug=False):
elif etud["cursem"]: # le semestre "en cours" pour l'étudiant elif etud["cursem"]: # le semestre "en cours" pour l'étudiant
sem = etud["cursem"] sem = etud["cursem"]
if sem: if sem:
groups = sco_groups.get_etud_groups(etudid, sem) groups = sco_groups.get_etud_groups(etudid, formsemestre_id)
grc = sco_groups.listgroups_abbrev(groups) grc = sco_groups.listgroups_abbrev(groups)
H += '<div class="eid_info">En <b>S%d</b>: %s</div>' % (sem["semestre_id"], grc) H += '<div class="eid_info">En <b>S%d</b>: %s</div>' % (sem["semestre_id"], grc)
H += "</div>" # fin partie gauche (eid_left) H += "</div>" # fin partie gauche (eid_left)

View File

@ -29,7 +29,7 @@
""" """
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, UniteEns from app.models import FormSemestre, UniteEns
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -1011,7 +1011,9 @@ def formsemestre_has_decisions(formsemestre_id):
def etud_est_inscrit_ue(cnx, etudid, formsemestre_id, ue_id): def etud_est_inscrit_ue(cnx, etudid, formsemestre_id, ue_id):
"""Vrai si l'étudiant est inscrit à au moins un module de cette UE dans ce semestre""" """Vrai si l'étudiant est inscrit à au moins un module de cette UE dans ce semestre.
Ne pas utiliser pour les formations APC !
"""
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute( cursor.execute(
"""SELECT mi.* """SELECT mi.*

View File

@ -44,22 +44,17 @@ import traceback
import unicodedata import unicodedata
import reportlab import reportlab
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak from reportlab.pdfgen import canvas
from reportlab.platypus import Table, TableStyle, Image, KeepInFrame from reportlab.platypus import Paragraph, Frame
from reportlab.platypus.flowables import Flowable from reportlab.platypus.flowables import Flowable
from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.rl_config import defaultPageSize # pylint: disable=no-name-in-module from reportlab.rl_config import defaultPageSize # pylint: disable=no-name-in-module
from reportlab.lib.units import inch, cm, mm from reportlab.lib.units import inch, cm, mm
from reportlab.lib.colors import pink, black, red, blue, green, magenta, red
from reportlab.lib.colors import Color
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY
from reportlab.lib import styles from reportlab.lib import styles
from reportlab.lib.pagesizes import letter, A4, landscape
from flask import g from flask import g
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import CONFIG from app.scodoc.sco_utils import CONFIG
from app import log from app import log
from app.scodoc.sco_exceptions import ScoGenError, ScoValueError from app.scodoc.sco_exceptions import ScoGenError, ScoValueError
@ -89,6 +84,12 @@ def SU(s):
return s return s
def get_available_font_names() -> list[str]:
"""List installed font names"""
can = canvas.Canvas(io.StringIO())
return can.getAvailableFonts()
def _splitPara(txt): def _splitPara(txt):
"split a string, returns a list of <para > ... </para>" "split a string, returns a list of <para > ... </para>"
L = [] L = []
@ -147,12 +148,26 @@ def makeParas(txt, style, suppress_empty=False):
except Exception as e: except Exception as e:
log(traceback.format_exc()) log(traceback.format_exc())
log("Invalid pdf para format: %s" % txt) log("Invalid pdf para format: %s" % txt)
try:
result = [ result = [
Paragraph( Paragraph(
SU('<font color="red"><i>Erreur: format invalide</i></font>'), SU('<font color="red"><i>Erreur: format invalide</i></font>'),
style, style,
) )
] ]
except ValueError as e: # probleme font ? essaye sans style
# recupere font en cause ?
m = re.match(r".*family/bold/italic for (.*)", e.args[0], re.DOTALL)
if m:
message = f"police non disponible: {m[1]}"
else:
message = "format invalide"
return [
Paragraph(
SU(f'<font color="red"><b>Erreur: {message}</b></font>'),
reportlab.lib.styles.ParagraphStyle({}),
)
]
return result return result
@ -166,7 +181,6 @@ def bold_paras(L, tag="b", close=None):
if hasattr(L, "keys"): if hasattr(L, "keys"):
# L is a dict # L is a dict
for k in L: for k in L:
x = L[k]
if k[0] != "_": if k[0] != "_":
L[k] = b + L[k] or "" + close L[k] = b + L[k] or "" + close
return L return L
@ -175,7 +189,29 @@ def bold_paras(L, tag="b", close=None):
return [b + (x or "") + close for x in L] return [b + (x or "") + close for x in L]
class ScolarsPageTemplate(PageTemplate): class BulMarker(Flowable):
"""Custom Flowables pour nos bulletins PDF: invisibles, juste pour se repérer"""
def wrap(self, *args):
return (0, 0)
def draw(self):
return
class DebutBulletin(BulMarker):
"""Début d'un bulletin.
Element vide utilisé pour générer les bookmarks
"""
def __init__(self, bookmark=None, filigranne=None, footer_content=None):
self.bookmark = bookmark
self.filigranne = filigranne
self.footer_content = footer_content
super().__init__()
class ScoDocPageTemplate(PageTemplate):
"""Our own page template.""" """Our own page template."""
def __init__( def __init__(
@ -192,17 +228,17 @@ class ScolarsPageTemplate(PageTemplate):
preferences=None, # dictionnary with preferences, required preferences=None, # dictionnary with preferences, required
): ):
"""Initialise our page template.""" """Initialise our page template."""
from app.scodoc.sco_logos import ( # defered import (solve circular dependency ->sco_logo ->scodoc, ->sco_pdf
find_logo, from app.scodoc.sco_logos import find_logo
) # defered import (solve circular dependency ->sco_logo ->scodoc, ->sco_pdf
self.preferences = preferences self.preferences = preferences
self.pagesbookmarks = pagesbookmarks self.pagesbookmarks = pagesbookmarks or {}
self.pdfmeta_author = author self.pdfmeta_author = author
self.pdfmeta_title = title self.pdfmeta_title = title
self.pdfmeta_subject = subject self.pdfmeta_subject = subject
self.server_name = server_name self.server_name = server_name
self.filigranne = filigranne self.filigranne = filigranne
self.page_number = 1
self.footer_template = footer_template self.footer_template = footer_template
if self.preferences: if self.preferences:
self.with_page_background = self.preferences["bul_pdf_with_background"] self.with_page_background = self.preferences["bul_pdf_with_background"]
@ -217,7 +253,7 @@ class ScolarsPageTemplate(PageTemplate):
document.pagesize[0] - 20.0 * mm - left * mm - right * mm, document.pagesize[0] - 20.0 * mm - left * mm - right * mm,
document.pagesize[1] - 18.0 * mm - top * mm - bottom * mm, document.pagesize[1] - 18.0 * mm - top * mm - bottom * mm,
) )
PageTemplate.__init__(self, "ScolarsPageTemplate", [content]) super().__init__("ScoDocPageTemplate", [content])
self.logo = None self.logo = None
logo = find_logo( logo = find_logo(
logoname="bul_pdf_background", dept_id=g.scodoc_dept_id logoname="bul_pdf_background", dept_id=g.scodoc_dept_id
@ -234,7 +270,7 @@ class ScolarsPageTemplate(PageTemplate):
if logo is not None: if logo is not None:
self.background_image_filename = logo.filepath self.background_image_filename = logo.filepath
def beforeDrawPage(self, canvas, doc): def beforeDrawPage(self, canv, doc):
"""Draws (optional) background, logo and contribution message on each page. """Draws (optional) background, logo and contribution message on each page.
day : Day of the month as a decimal number [01,31] day : Day of the month as a decimal number [01,31]
@ -249,10 +285,10 @@ class ScolarsPageTemplate(PageTemplate):
""" """
if not self.preferences: if not self.preferences:
return return
canvas.saveState() canv.saveState()
# ---- Background image # ---- Background image
if self.background_image_filename and self.with_page_background: if self.background_image_filename and self.with_page_background:
canvas.drawImage( canv.drawImage(
self.background_image_filename, 0, 0, doc.pagesize[0], doc.pagesize[1] self.background_image_filename, 0, 0, doc.pagesize[0], doc.pagesize[1]
) )
@ -263,50 +299,93 @@ class ScolarsPageTemplate(PageTemplate):
(width, height), (width, height),
image, image,
) = self.logo ) = self.logo
canvas.drawImage(image, inch, doc.pagesize[1] - inch, width, height) canv.drawImage(image, inch, doc.pagesize[1] - inch, width, height)
# ---- Add some meta data and bookmarks
if self.pdfmeta_author:
canv.setAuthor(SU(self.pdfmeta_author))
if self.pdfmeta_title:
canv.setTitle(SU(self.pdfmeta_title))
if self.pdfmeta_subject:
canv.setSubject(SU(self.pdfmeta_subject))
bookmark = self.pagesbookmarks.get(doc.page, None)
if bookmark:
canv.bookmarkPage(bookmark)
canv.addOutlineEntry(SU(bookmark), bookmark)
def draw_footer(self, canv, content):
"""Print the footer"""
canv.setFont(
self.preferences["SCOLAR_FONT"], self.preferences["SCOLAR_FONT_SIZE_FOOT"]
)
canv.drawString(
self.preferences["pdf_footer_x"] * mm,
self.preferences["pdf_footer_y"] * mm,
content,
)
canv.restoreState()
def footer_string(self) -> str:
"""String contenu du pied de page"""
d = _makeTimeDict()
d["scodoc_name"] = sco_version.SCONAME
d["server_url"] = self.server_name
return SU(self.footer_template % d)
def afterDrawPage(self, canv, doc):
if not self.preferences:
return
# ---- Footer
foot_content = None
if hasattr(doc, "current_footer"):
foot_content = doc.current_footer
self.draw_footer(canv, foot_content or self.footer_string())
# ---- Filigranne (texte en diagonal en haut a gauche de chaque page) # ---- Filigranne (texte en diagonal en haut a gauche de chaque page)
if self.filigranne: filigranne = None
if hasattr(doc, "filigranne"):
# filigranne crée par DebutBulletin
filigranne = doc.filigranne
if not filigranne and self.filigranne:
if isinstance(self.filigranne, str): if isinstance(self.filigranne, str):
filigranne = self.filigranne # same for all pages filigranne = self.filigranne # same for all pages
else: else:
filigranne = self.filigranne.get(doc.page, None) filigranne = self.filigranne.get(doc.page, None)
if filigranne: if filigranne:
canvas.saveState() canv.saveState()
canvas.translate(9 * cm, 27.6 * cm) canv.translate(9 * cm, 27.6 * cm)
canvas.rotate(30) canv.rotate(30)
canvas.scale(4.5, 4.5) canv.scale(4.5, 4.5)
canvas.setFillColorRGB(1.0, 0.65, 0.65) canv.setFillColorRGB(1.0, 0.65, 0.65, alpha=0.6)
canvas.drawRightString(0, 0, SU(filigranne)) canv.drawRightString(0, 0, SU(filigranne))
canvas.restoreState() canv.restoreState()
doc.filigranne = None
# ---- Add some meta data and bookmarks def afterPage(self):
if self.pdfmeta_author: """Called after all flowables have been drawn on a page.
canvas.setAuthor(SU(self.pdfmeta_author)) Increment pageNum since the page has been completed.
if self.pdfmeta_title: """
canvas.setTitle(SU(self.pdfmeta_title)) self.page_number += 1
if self.pdfmeta_subject:
canvas.setSubject(SU(self.pdfmeta_subject))
bm = self.pagesbookmarks.get(doc.page, None) class BulletinDocTemplate(BaseDocTemplate):
if bm != None: """Doc template pour les bulletins PDF
key = bm ajoute la gestion des bookmarks
txt = SU(bm) """
canvas.bookmarkPage(key)
canvas.addOutlineEntry(txt, bm) # inspired by https://www.reportlab.com/snippets/13/
# ---- Footer def afterFlowable(self, flowable):
canvas.setFont( """Called by Reportlab after each flowable"""
self.preferences["SCOLAR_FONT"], self.preferences["SCOLAR_FONT_SIZE_FOOT"] if isinstance(flowable, DebutBulletin):
self.current_footer = ""
if flowable.bookmark:
self.current_footer = flowable.footer_content
self.canv.bookmarkPage(flowable.bookmark)
self.canv.addOutlineEntry(
SU(flowable.bookmark), flowable.bookmark, level=0, closed=None
) )
d = _makeTimeDict() if flowable.filigranne:
d["scodoc_name"] = sco_version.SCONAME self.filigranne = flowable.filigranne
d["server_url"] = self.server_name
footer_str = SU(self.footer_template % d)
canvas.drawString(
self.preferences["pdf_footer_x"] * mm,
self.preferences["pdf_footer_y"] * mm,
footer_str,
)
canvas.restoreState()
def _makeTimeDict(): def _makeTimeDict():
@ -333,7 +412,7 @@ def pdf_basic_page(
report = io.BytesIO() # in-memory document, no disk file report = io.BytesIO() # in-memory document, no disk file
document = BaseDocTemplate(report) document = BaseDocTemplate(report)
document.addPageTemplates( document.addPageTemplates(
ScolarsPageTemplate( ScoDocPageTemplate(
document, document,
title=title, title=title,
author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION), author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION),
@ -378,8 +457,8 @@ class PDFLock(object):
return # deja lock pour ce thread return # deja lock pour ce thread
try: try:
self.Q.put(1, True, self.timeout) self.Q.put(1, True, self.timeout)
except queue.Full: except queue.Full as e:
raise ScoGenError(msg="Traitement PDF occupé: ré-essayez") raise ScoGenError(msg="Traitement PDF occupé: ré-essayez") from e
self.current_thread = threading.get_ident() self.current_thread = threading.get_ident()
self.nref = 1 self.nref = 1
log("PDFLock: granted to %s" % self.current_thread) log("PDFLock: granted to %s" % self.current_thread)
@ -406,7 +485,6 @@ class WatchLock:
def release(self): def release(self):
t = threading.current_thread() t = threading.current_thread()
assert (self.native_id == t.native_id) and (self.ident == t.ident) assert (self.native_id == t.native_id) and (self.ident == t.ident)
pass
class FakeLock: class FakeLock:

View File

@ -34,7 +34,7 @@ import collections
from flask import url_for, g, request from flask import url_for, g, request
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
from app.scodoc import sco_abs from app.scodoc import sco_abs

View File

@ -121,6 +121,7 @@ from app import log
from app.scodoc.sco_exceptions import ScoValueError, ScoException from app.scodoc.sco_exceptions import ScoValueError, ScoException
from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.TrivialFormulator import TrivialFormulator
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app.scodoc import sco_pdf
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -152,19 +153,18 @@ def get_preference(name, formsemestre_id=None):
def _convert_pref_type(p, pref_spec): def _convert_pref_type(p, pref_spec):
"""p est une ligne de la bd """p est une ligne de la bd
{'id': , 'dept_id': , 'name': '', 'value': '', 'formsemestre_id': } {'id': , 'dept_id': , 'name': '', 'value': '', 'formsemestre_id': }
converti la valeur chane en le type désiré spécifié par pref_spec converti la valeur chaine en le type désiré spécifié par pref_spec
""" """
if "type" in pref_spec: if "type" in pref_spec:
typ = pref_spec["type"] typ = pref_spec["type"]
if typ == "float": if typ == "float":
# special case for float values (where NULL means 0) # special case for float values (where NULL means 0)
if p["value"]: p["value"] = float(p["value"] or 0)
p["value"] = float(p["value"]) elif typ == "int":
p["value"] = int(p["value"] or 0)
else: else:
p["value"] = 0.0 raise ValueError("invalid preference type")
else:
func = eval(typ)
p["value"] = func(p["value"])
if pref_spec.get("input_type", None) == "boolcheckbox": if pref_spec.get("input_type", None) == "boolcheckbox":
# boolcheckbox: la valeur stockée en base est une chaine "0" ou "1" # boolcheckbox: la valeur stockée en base est une chaine "0" ou "1"
# que l'on ressort en True|False # que l'on ressort en True|False
@ -195,6 +195,8 @@ def _get_pref_default_value_from_config(name, pref_spec):
return value return value
_INSTALLED_FONTS = ", ".join(sco_pdf.get_available_font_names())
PREF_CATEGORIES = ( PREF_CATEGORIES = (
# sur page "Paramètres" # sur page "Paramètres"
("general", {}), ("general", {}),
@ -358,8 +360,22 @@ class BasePreferences(object):
"use_ue_coefs", "use_ue_coefs",
{ {
"initvalue": 0, "initvalue": 0,
"title": "Utiliser les coefficients d'UE pour calculer la moyenne générale", "title": "Utiliser les coefficients d'UE pour calculer la moyenne générale (hors BUT)",
"explanation": """Calcule les moyennes dans chaque UE, puis pondère ces résultats pour obtenir la moyenne générale. Par défaut, le coefficient d'une UE est simplement la somme des coefficients des modules dans lesquels l'étudiant a des notes. <b>Attention: changer ce réglage va modifier toutes les moyennes du semestre !</b>""", "explanation": """Calcule les moyennes dans chaque UE, puis pondère ces résultats pour obtenir la moyenne générale. Par défaut, le coefficient d'une UE est simplement la somme des coefficients des modules dans lesquels l'étudiant a des notes. <b>Attention: changer ce réglage va modifier toutes les moyennes du semestre !</b>. Aucun effet en BUT.""",
"input_type": "boolcheckbox",
"category": "misc",
"labels": ["non", "oui"],
"only_global": False,
},
),
(
"but_moy_skip_empty_ues",
{
"initvalue": 0,
"title": "BUT: moyenne générale sans les UE sans notes",
"explanation": """La moyenne générale indicative BUT est basée sur les moyennes d'UE pondérées par leurs ECTS.
Si cette option est cochée, ne prend pas en compte les UEs sans notes. <b>Attention: changer ce réglage va modifier toutes
les moyennes du semestre !</b>. Aucun effet dans les formations non BUT.""",
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"category": "misc", "category": "misc",
"labels": ["non", "oui"], "labels": ["non", "oui"],
@ -764,7 +780,7 @@ class BasePreferences(object):
{ {
"initvalue": "Helvetica", "initvalue": "Helvetica",
"title": "Police de caractère principale", "title": "Police de caractère principale",
"explanation": "pour les pdf (Helvetica est recommandée)", "explanation": f"pour les pdf (Helvetica est recommandée, parmi {_INSTALLED_FONTS})",
"size": 25, "size": 25,
"category": "pdf", "category": "pdf",
}, },
@ -1127,7 +1143,7 @@ class BasePreferences(object):
{ {
"initvalue": "Times-Roman", "initvalue": "Times-Roman",
"title": "Police de caractère pour les PV", "title": "Police de caractère pour les PV",
"explanation": "pour les pdf", "explanation": f"pour les pdf ({_INSTALLED_FONTS})",
"size": 25, "size": 25,
"category": "pvpdf", "category": "pvpdf",
}, },
@ -1159,7 +1175,7 @@ class BasePreferences(object):
"bul_show_abs", # ex "gestion_absence" "bul_show_abs", # ex "gestion_absence"
{ {
"initvalue": 1, "initvalue": 1,
"title": "Indiquer les absences sous les bulletins", "title": "Indiquer les absences dans les bulletins",
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"category": "bul", "category": "bul",
"labels": ["non", "oui"], "labels": ["non", "oui"],
@ -1221,7 +1237,7 @@ class BasePreferences(object):
{ {
"initvalue": 0, "initvalue": 0,
"title": "Afficher toutes les évaluations sur les bulletins", "title": "Afficher toutes les évaluations sur les bulletins",
"explanation": "y compris incomplètes ou futures", "explanation": "y compris incomplètes ou futures (déconseillé, risque de publier des notes non définitives)",
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"category": "bul", "category": "bul",
"labels": ["non", "oui"], "labels": ["non", "oui"],
@ -1529,7 +1545,7 @@ class BasePreferences(object):
{ {
"initvalue": "Times-Roman", "initvalue": "Times-Roman",
"title": "Police titres bulletins", "title": "Police titres bulletins",
"explanation": "pour les pdf", "explanation": f"pour les pdf ({_INSTALLED_FONTS})",
"size": 25, "size": 25,
"category": "bul", "category": "bul",
}, },

View File

@ -36,7 +36,7 @@ from flask import request
from flask_login import current_user from flask_login import current_user
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, Identite from app.models import FormSemestre, Identite
from app.scodoc import sco_abs from app.scodoc import sco_abs
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours

View File

@ -55,7 +55,7 @@ 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 import res_sem
from app.comp.res_common import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, UniteEns from app.models import FormSemestre, UniteEns
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu

View File

@ -28,15 +28,13 @@
"""Edition des PV de jury """Edition des PV de jury
""" """
import io import io
import os
import re import re
import reportlab import reportlab
from reportlab.lib.units import cm, mm from reportlab.lib.units import cm, mm
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY from reportlab.lib.enums import TA_RIGHT, TA_JUSTIFY
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak from reportlab.platypus import Paragraph, Spacer, Frame, PageBreak
from reportlab.platypus import Table, TableStyle, Image, KeepInFrame from reportlab.platypus import Table, TableStyle, Image
from reportlab.platypus.flowables import Flowable
from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate
from reportlab.lib.pagesizes import A4, landscape from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib import styles from reportlab.lib import styles
@ -53,7 +51,6 @@ from app.scodoc import sco_preferences
from app.scodoc import sco_etud from app.scodoc import sco_etud
import sco_version import sco_version
from app.scodoc.sco_logos import find_logo from app.scodoc.sco_logos import find_logo
from app.scodoc.sco_pdf import PDFLOCK
from app.scodoc.sco_pdf import SU from app.scodoc.sco_pdf import SU
LOGO_FOOTER_ASPECT = scu.CONFIG.LOGO_FOOTER_ASPECT # XXX A AUTOMATISER LOGO_FOOTER_ASPECT = scu.CONFIG.LOGO_FOOTER_ASPECT # XXX A AUTOMATISER
@ -317,14 +314,14 @@ class PVTemplate(CourrierIndividuelTemplate):
self.with_footer = self.preferences["PV_WITH_FOOTER"] self.with_footer = self.preferences["PV_WITH_FOOTER"]
self.with_page_background = self.preferences["PV_WITH_BACKGROUND"] self.with_page_background = self.preferences["PV_WITH_BACKGROUND"]
def afterDrawPage(self, canvas, doc): def afterDrawPage(self, canv, doc):
"""Called after all flowables have been drawn on a page""" """Called after all flowables have been drawn on a page"""
pass pass
def beforeDrawPage(self, canvas, doc): def beforeDrawPage(self, canv, doc):
"""Called before any flowables are drawn on a page""" """Called before any flowables are drawn on a page"""
# If the page number is even, force a page break # If the page number is even, force a page break
CourrierIndividuelTemplate.beforeDrawPage(self, canvas, doc) CourrierIndividuelTemplate.beforeDrawPage(self, canv, doc)
# Note: on cherche un moyen de generer un saut de page double # Note: on cherche un moyen de generer un saut de page double
# (redémarrer sur page impaire, nouvelle feuille en recto/verso). Pas trouvé en Platypus. # (redémarrer sur page impaire, nouvelle feuille en recto/verso). Pas trouvé en Platypus.
# #

View File

@ -32,23 +32,23 @@ import json
import time import time
from xml.etree import ElementTree from xml.etree import ElementTree
from flask import request from flask import g, request
from flask import make_response from flask import make_response, url_for
from app import log from app import log
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 import FormSemestre from app.models import FormSemestre
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.evaluations import Evaluation from app.models.evaluations import Evaluation
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_bac
from app.scodoc import sco_bulletins_json from app.scodoc import sco_bulletins_json
from app.scodoc import sco_bulletins_xml from app.scodoc import sco_bulletins_xml
from app.scodoc import sco_bulletins, sco_excel from app.scodoc import sco_bulletins, sco_excel
from app.scodoc import sco_cache
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
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
@ -108,30 +108,33 @@ def formsemestre_recapcomplet(
page_title="Récapitulatif", page_title="Récapitulatif",
no_side_bar=True, no_side_bar=True,
init_qtip=True, init_qtip=True,
javascripts=["libjs/sorttable.js", "js/etud_info.js"], javascripts=["js/etud_info.js", "js/table_recap.js"],
), ),
sco_formsemestre_status.formsemestre_status_head( sco_formsemestre_status.formsemestre_status_head(
formsemestre_id=formsemestre_id formsemestre_id=formsemestre_id
), ),
]
if len(formsemestre.inscriptions) > 0:
H += [
'<form name="f" method="get" action="%s">' % request.base_url, '<form name="f" method="get" action="%s">' % request.base_url,
'<input type="hidden" name="formsemestre_id" value="%s"></input>' '<input type="hidden" name="formsemestre_id" value="%s"></input>'
% formsemestre_id, % formsemestre_id,
'<input type="hidden" name="pref_override" value="0"></input>', '<input type="hidden" name="pref_override" value="0"></input>',
] ]
if modejury: if modejury:
H.append( H.append(
'<input type="hidden" name="modejury" value="%s"></input>' % modejury '<input type="hidden" name="modejury" value="%s"></input>'
% modejury
) )
H.append( H.append(
'<select name="tabformat" onchange="document.f.submit()" class="noprint">' '<select name="tabformat" onchange="document.f.submit()" class="noprint">'
) )
for (format, label) in ( for (format, label) in (
("html", "HTML"), ("html", "Tableau"),
("xls", "Fichier tableur (Excel)"), ("evals", "Avec toutes les évaluations"),
("xlsall", "Fichier tableur avec toutes les évals"), ("xml", "Bulletins XML (obsolète)"),
("csv", "Fichier tableur (CSV)"), ("json", "Bulletins JSON"),
("xml", "Fichier XML"),
("json", "JSON"),
): ):
if format == tabformat: if format == tabformat:
selected = " selected" selected = " selected"
@ -141,22 +144,13 @@ def formsemestre_recapcomplet(
H.append("</select>") H.append("</select>")
H.append( H.append(
"""(cliquer sur un nom pour afficher son bulletin ou <a class="stdlink" href="%s/Notes/formsemestre_bulletins_pdf?formsemestre_id=%s">ici avoir le classeur papier</a>)""" f"""&nbsp;(cliquer sur un nom pour afficher son bulletin ou <a class="stdlink"
% (scu.ScoURL(), formsemestre_id) href="{url_for('notes.formsemestre_bulletins_pdf',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)}">
ici avoir le classeur papier</a>)
"""
) )
if not parcours.UE_IS_MODULE:
H.append(
"""<input type="checkbox" name="hidemodules" value="1" onchange="document.f.submit()" """
)
if hidemodules:
H.append("checked")
H.append(""" >cacher les modules</input>""")
H.append(
"""<input type="checkbox" name="hidebac" value="1" onchange="document.f.submit()" """
)
if hidebac:
H.append("checked")
H.append(""" >cacher bac</input>""")
data = do_formsemestre_recapcomplet( data = do_formsemestre_recapcomplet(
formsemestre_id, formsemestre_id,
format=tabformat, format=tabformat,
@ -175,6 +169,7 @@ def formsemestre_recapcomplet(
H.append(data) H.append(data)
if not isFile: if not isFile:
if len(formsemestre.inscriptions) > 0:
H.append("</form>") H.append("</form>")
H.append( H.append(
"""<p><a class="stdlink" href="formsemestre_pvjury?formsemestre_id=%s">Voir les décisions du jury</a></p>""" """<p><a class="stdlink" href="formsemestre_pvjury?formsemestre_id=%s">Voir les décisions du jury</a></p>"""
@ -223,6 +218,13 @@ def do_formsemestre_recapcomplet(
force_publishing=True, force_publishing=True,
): ):
"""Calcule et renvoie le tableau récapitulatif.""" """Calcule et renvoie le tableau récapitulatif."""
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
if (format == "html" or format == "evals") and not modejury:
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
data, filename = gen_formsemestre_recapcomplet_html(
formsemestre, res, include_evaluations=(format == "evals")
)
else:
data, filename, format = make_formsemestre_recapcomplet( data, filename, format = make_formsemestre_recapcomplet(
formsemestre_id=formsemestre_id, formsemestre_id=formsemestre_id,
format=format, format=format,
@ -236,7 +238,8 @@ def do_formsemestre_recapcomplet(
rank_partition_id=rank_partition_id, rank_partition_id=rank_partition_id,
force_publishing=force_publishing, force_publishing=force_publishing,
) )
if format == "xml" or format == "html": # ---
if format == "xml" or format == "html" or format == "evals":
return data return data
elif format == "csv": elif format == "csv":
return scu.send_file(data, filename=filename, mime=scu.CSV_MIMETYPE) return scu.send_file(data, filename=filename, mime=scu.CSV_MIMETYPE)
@ -248,12 +251,12 @@ def do_formsemestre_recapcomplet(
js, filename=filename, suffix=scu.JSON_SUFFIX, mime=scu.JSON_MIMETYPE js, filename=filename, suffix=scu.JSON_SUFFIX, mime=scu.JSON_MIMETYPE
) )
else: else:
raise ValueError("unknown format %s" % format) raise ValueError(f"unknown format {format}")
def make_formsemestre_recapcomplet( def make_formsemestre_recapcomplet(
formsemestre_id=None, formsemestre_id=None,
format="html", # html, xml, xls, xlsall, json format="html", # html, evals, xml, json
hidemodules=False, # ne pas montrer les modules (ignoré en XML) hidemodules=False, # ne pas montrer les modules (ignoré en XML)
hidebac=False, # pas de colonne Bac (ignoré en XML) hidebac=False, # pas de colonne Bac (ignoré en XML)
xml_nodate=False, # format XML sans dates (sert pour debug cache: comparaison de XML) xml_nodate=False, # format XML sans dates (sert pour debug cache: comparaison de XML)
@ -404,7 +407,7 @@ def make_formsemestre_recapcomplet(
gr_name = "Déf." gr_name = "Déf."
is_dem[etudid] = False is_dem[etudid] = False
else: else:
group = sco_groups.get_etud_main_group(etudid, sem) group = sco_groups.get_etud_main_group(etudid, formsemestre_id)
gr_name = group["group_name"] or "" gr_name = group["group_name"] or ""
is_dem[etudid] = False is_dem[etudid] = False
if rank_partition_id: if rank_partition_id:
@ -593,66 +596,29 @@ def make_formsemestre_recapcomplet(
<script type="text/javascript"> <script type="text/javascript">
function va_saisir(formsemestre_id, etudid) { function va_saisir(formsemestre_id, etudid) {
loc = 'formsemestre_validation_etud_form?formsemestre_id='+formsemestre_id+'&etudid='+etudid; loc = 'formsemestre_validation_etud_form?formsemestre_id='+formsemestre_id+'&etudid='+etudid;
if (SORT_COLUMN_INDEX) {
loc += '&sortcol=' + SORT_COLUMN_INDEX;
}
loc += '#etudid' + etudid; loc += '#etudid' + etudid;
document.location=loc; document.location=loc;
} }
</script> </script>
<table class="notes_recapcomplet sortable" id="recapcomplet"> <table class="notes_recapcomplet gt_table_searchable compact" id="recapcomplet">
""" """
] ]
if sortcol: # sort table using JS sorttable if sortcol: # sort table using JS sorttable
H.append( H.append(
"""<script type="text/javascript"> """<script type="text/javascript">
function resort_recap() {
var clid = %d;
// element <a place par sorttable (ligne de titre)
lnk = document.getElementById("recap_trtit").childNodes[clid].childNodes[0];
ts_resortTable(lnk,clid);
// Scroll window:
eid = document.location.hash;
if (eid) {
var eid = eid.substring(1); // remove #
var e = document.getElementById(eid);
if (e) {
var y = e.offsetTop + e.offsetParent.offsetTop;
window.scrollTo(0,y);
}
}
}
addEvent(window, "load", resort_recap);
</script> </script>
""" """
% (int(sortcol)) % (int(sortcol))
) )
cells = '<tr class="recap_row_tit sortbottom" id="recap_trtit">'
for i in range(len(F[0]) - 2): ligne_titres_head = _ligne_titres(
if i in ue_index: ue_index, F, cod2mod, modejury, with_modules_links=False
cls = "recap_tit_ue"
else:
cls = "recap_tit"
if (
i == 0 or F[0][i] == "classement"
): # Rang: force tri numerique pour sortable
cls = cls + " sortnumeric"
if F[0][i] in cod2mod: # lien vers etat module
modimpl = cod2mod[F[0][i]]
cells += '<td class="%s"><a href="moduleimpl_status?moduleimpl_id=%s" title="%s (%s)">%s</a></td>' % (
cls,
modimpl.id,
modimpl.module.titre,
sco_users.user_info(modimpl.responsable_id)["nomcomplet"],
F[0][i],
) )
else: ligne_titres_foot = _ligne_titres(
cells += '<td class="%s">%s</td>' % (cls, F[0][i]) ue_index, F, cod2mod, modejury, with_modules_links=True
if modejury: )
cells += '<td class="recap_tit">Décision</td>'
ligne_titres = cells + "</tr>" H.append("<thead>\n" + ligne_titres_head + "\n</thead>\n<tbody>\n")
H.append(ligne_titres) # titres
if disable_etudlink: if disable_etudlink:
etudlink = "%(name)s" etudlink = "%(name)s"
else: else:
@ -663,6 +629,9 @@ def make_formsemestre_recapcomplet(
nblines = len(F) - 1 nblines = len(F) - 1
for l in F[1:]: for l in F[1:]:
etudid = l[-1] etudid = l[-1]
if ir == nblines - 6:
H.append("</tbody>")
H.append("<tfoot>")
if ir >= nblines - 6: if ir >= nblines - 6:
# dernieres lignes: # dernieres lignes:
el = l[1] el = l[1]
@ -674,7 +643,7 @@ def make_formsemestre_recapcomplet(
"recap_row_nbeval", "recap_row_nbeval",
"recap_row_ects", "recap_row_ects",
)[ir - nblines + 6] )[ir - nblines + 6]
cells = '<tr class="%s sortbottom">' % styl cells = f'<tr class="{styl} sortbottom">'
else: else:
el = etudlink % { el = etudlink % {
"formsemestre_id": formsemestre_id, "formsemestre_id": formsemestre_id,
@ -682,17 +651,23 @@ def make_formsemestre_recapcomplet(
"name": l[1], "name": l[1],
} }
if ir % 2 == 0: if ir % 2 == 0:
cells = '<tr class="recap_row_even" id="etudid%s">' % etudid cells = f'<tr class="recap_row_even" id="etudid{etudid}">'
else: else:
cells = '<tr class="recap_row_odd" id="etudid%s">' % etudid cells = f'<tr class="recap_row_odd" id="etudid{etudid}">'
ir += 1 ir += 1
# XXX nsn = [ x.replace('NA', '-') for x in l[:-2] ] # XXX nsn = [ x.replace('NA', '-') for x in l[:-2] ]
# notes sans le NA: # notes sans le NA:
nsn = l[:-2] # copy nsn = l[:-2] # copy
for i in range(len(nsn)): for i, _ in enumerate(nsn):
if nsn[i] == "NA": if nsn[i] == "NA":
nsn[i] = "-" nsn[i] = "-"
cells += '<td class="recap_col">%s</td>' % nsn[0] # rang try:
order = int(nsn[0].split()[0])
except:
order = 99999
cells += (
f'<td class="recap_col" data-order="{order:05d}">{nsn[0]}</td>' # rang
)
cells += '<td class="recap_col">%s</td>' % el # nom etud (lien) cells += '<td class="recap_col">%s</td>' % el # nom etud (lien)
if not hidebac: if not hidebac:
cells += '<td class="recap_col_bac">%s</td>' % nsn[2] # bac cells += '<td class="recap_col_bac">%s</td>' % nsn[2] # bac
@ -760,7 +735,8 @@ def make_formsemestre_recapcomplet(
cells += "</td>" cells += "</td>"
H.append(cells + "</tr>") H.append(cells + "</tr>")
H.append(ligne_titres) H.append(ligne_titres_foot)
H.append("</tfoot>")
H.append("</table>") H.append("</table>")
# Form pour choisir partition de classement: # Form pour choisir partition de classement:
@ -828,6 +804,40 @@ def make_formsemestre_recapcomplet(
raise ValueError("unknown format %s" % format) raise ValueError("unknown format %s" % format)
def _ligne_titres(ue_index, F, cod2mod, modejury, with_modules_links=True):
"""Cellules de la ligne de titre (haut ou bas)"""
cells = '<tr class="recap_row_tit sortbottom" id="recap_trtit">'
for i in range(len(F[0]) - 2):
if i in ue_index:
cls = "recap_tit_ue"
else:
cls = "recap_tit"
attr = f'class="{cls}"'
if i == 0 or F[0][i] == "classement": # Rang: force tri numerique
try:
order = int(F[0][i].split()[0])
except:
order = 99999
attr += f' data-order="{order:05d}"'
if F[0][i] in cod2mod: # lien vers etat module
modimpl = cod2mod[F[0][i]]
if with_modules_links:
href = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
else:
href = ""
cells += f"""<td {attr}><a href="{href}" title="{modimpl.module.titre} ({
sco_users.user_info(modimpl.responsable_id)["nomcomplet"]})">{F[0][i]}</a></td>"""
else:
cells += f"<td {attr}>{F[0][i]}</td>"
if modejury:
cells += '<td class="recap_tit">Décision</td>'
return cells + "</tr>"
def _list_notes_evals(evals: list[Evaluation], etudid: int) -> list[str]: def _list_notes_evals(evals: list[Evaluation], etudid: int) -> list[str]:
"""Liste des notes des evaluations completes de ce module """Liste des notes des evaluations completes de ce module
(pour table xls avec evals) (pour table xls avec evals)
@ -994,3 +1004,99 @@ def formsemestres_bulletins(annee_scolaire):
jslist.append(J) jslist.append(J)
return scu.sendJSON(jslist) return scu.sendJSON(jslist)
def _gen_cell(key: str, row: dict, elt="td"):
"html table cell"
klass = row.get(f"_{key}_class")
attrs = f'class="{klass}"' if klass else ""
order = row.get(f"_{key}_order")
if order:
attrs += f' data-order="{order}"'
content = row.get(key, "")
target = row.get(f"_{key}_target")
target_attrs = row.get(f"_{key}_target_attrs", "")
if target or target_attrs: # avec lien
href = f'href="{target}"' if target else ""
content = f"<a {href} {target_attrs}>{content}</a>"
return f"<{elt} {attrs}>{content}</{elt}>"
def _gen_row(keys: list[str], row, elt="td"):
klass = row.get("_tr_class")
tr_class = f'class="{klass}"' if klass else ""
return f'<tr {tr_class}>{"".join([_gen_cell(key, row, elt) for key in keys])}</tr>'
def gen_formsemestre_recapcomplet_html(
formsemestre: FormSemestre, res: NotesTableCompat, include_evaluations=False
):
"""Construit table recap pour le BUT
Cache le résultat pour le semestre.
Return: data, filename
"""
filename = scu.sanitize_filename(
f"""recap-{formsemestre.titre_num()}-{time.strftime("%Y-%m-%d")}"""
)
if include_evaluations:
table_html = sco_cache.TableRecapWithEvalsCache.get(formsemestre.id)
else:
table_html = sco_cache.TableRecapCache.get(formsemestre.id)
if table_html is None:
table_html = _gen_formsemestre_recapcomplet_html(
formsemestre, res, include_evaluations, filename
)
if include_evaluations:
sco_cache.TableRecapWithEvalsCache.set(formsemestre.id, table_html)
else:
sco_cache.TableRecapCache.set(formsemestre.id, table_html)
return table_html, filename
def _gen_formsemestre_recapcomplet_html(
formsemestre: FormSemestre,
res: NotesTableCompat,
include_evaluations=False,
filename: str = "",
) -> str:
"""Génère le html"""
rows, footer_rows, titles, column_ids = res.get_table_recap(
convert_values=True, include_evaluations=include_evaluations
)
if not rows:
return (
'<div class="table_recap"><div class="message">aucun étudiant !</div></div>',
"",
)
H = [
f"""<div class="table_recap"><table class="table_recap {
'apc' if formsemestre.formation.is_apc() else 'classic'}"
data-filename="{filename}">"""
]
# header
H.append(
f"""
<thead>
{_gen_row(column_ids, titles, "th")}
</thead>
"""
)
# body
H.append("<tbody>")
for row in rows:
H.append(f"{_gen_row(column_ids, row)}\n")
H.append("</tbody>\n")
# footer
H.append("<tfoot>")
idx_last = len(footer_rows) - 1
for i, row in enumerate(footer_rows):
H.append(f'{_gen_row(column_ids, row, "th" if i == idx_last else "td")}\n')
H.append(
"""
</tfoot>
</table>
</div>
"""
)
return "".join(H)

View File

@ -40,7 +40,7 @@ from flask import url_for, g, request
import pydot import pydot
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

View File

@ -37,7 +37,7 @@ from flask import g, url_for, request
from flask_login import current_user from flask_login import current_user
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
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
@ -272,6 +272,7 @@ def do_evaluation_upload_xls():
"notes.moduleimpl_status", "notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
moduleimpl_id=mod["moduleimpl_id"], moduleimpl_id=mod["moduleimpl_id"],
_external=True,
) )
sco_news.add( sco_news.add(
typ=sco_news.NEWS_NOTE, typ=sco_news.NEWS_NOTE,
@ -846,7 +847,7 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]):
etuds = _get_sorted_etuds(E, etudids, formsemestre_id) etuds = _get_sorted_etuds(E, etudids, formsemestre_id)
for e in etuds: for e in etuds:
etudid = e["etudid"] etudid = e["etudid"]
groups = sco_groups.get_etud_groups(etudid, sem) groups = sco_groups.get_etud_groups(etudid, formsemestre_id)
grc = sco_groups.listgroups_abbrev(groups) grc = sco_groups.listgroups_abbrev(groups)
L.append( L.append(
@ -1019,7 +1020,7 @@ def _get_sorted_etuds(E, etudids, formsemestre_id):
{"etudid": etudid, "formsemestre_id": formsemestre_id} {"etudid": etudid, "formsemestre_id": formsemestre_id}
)[0] )[0]
# Groupes auxquels appartient cet étudiant: # Groupes auxquels appartient cet étudiant:
e["groups"] = sco_groups.get_etud_groups(etudid, sem) e["groups"] = sco_groups.get_etud_groups(etudid, formsemestre_id)
# Information sur absence (tenant compte de la demi-journée) # Information sur absence (tenant compte de la demi-journée)
jour_iso = ndb.DateDMYtoISO(E["jour"]) jour_iso = ndb.DateDMYtoISO(E["jour"])
@ -1270,6 +1271,7 @@ def save_note(etudid=None, evaluation_id=None, value=None, comment=""):
"notes.moduleimpl_status", "notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
moduleimpl_id=M["moduleimpl_id"], moduleimpl_id=M["moduleimpl_id"],
_external=True,
) )
result = {"nbchanged": 0} # JSON result = {"nbchanged": 0} # JSON
# Check access: admin, respformation, or responsable_id # Check access: admin, respformation, or responsable_id

View File

@ -43,7 +43,7 @@ import flask
from flask import g from flask import g
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 import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_cache from app.scodoc import sco_cache
@ -89,7 +89,7 @@ class SemSet(dict):
if semset_id: # read existing set if semset_id: # read existing set
L = semset_list(cnx, args={"semset_id": semset_id}) L = semset_list(cnx, args={"semset_id": semset_id})
if not L: if not L:
raise ValueError("invalid semset_id %s" % semset_id) raise ScoValueError(f"Ensemble inexistant ! (semset {semset_id})")
self["title"] = L[0]["title"] self["title"] = L[0]["title"]
self["annee_scolaire"] = L[0]["annee_scolaire"] self["annee_scolaire"] = L[0]["annee_scolaire"]
self["sem_id"] = L[0]["sem_id"] self["sem_id"] = L[0]["sem_id"]

View File

@ -38,7 +38,7 @@ import http
from flask import g, url_for from flask import g, url_for
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
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb

View File

@ -378,7 +378,7 @@ def _trombino_pdf(groups_infos):
# Build document # Build document
document = BaseDocTemplate(report) document = BaseDocTemplate(report)
document.addPageTemplates( document.addPageTemplates(
sco_pdf.ScolarsPageTemplate( sco_pdf.ScoDocPageTemplate(
document, document,
preferences=sco_preferences.SemPreferences(sem["formsemestre_id"]), preferences=sco_preferences.SemPreferences(sem["formsemestre_id"]),
) )
@ -458,7 +458,7 @@ def _listeappel_photos_pdf(groups_infos):
# Build document # Build document
document = BaseDocTemplate(report) document = BaseDocTemplate(report)
document.addPageTemplates( document.addPageTemplates(
sco_pdf.ScolarsPageTemplate( sco_pdf.ScoDocPageTemplate(
document, document,
preferences=sco_preferences.SemPreferences(sem["formsemestre_id"]), preferences=sco_preferences.SemPreferences(sem["formsemestre_id"]),
) )

View File

@ -33,20 +33,23 @@
import io import io
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib import pagesizes from reportlab.lib.colors import black
from reportlab.lib.pagesizes import A4, A3 from reportlab.lib.pagesizes import A4, A3
from reportlab.lib import styles
from reportlab.lib.pagesizes import landscape
from reportlab.lib.units import cm
from reportlab.platypus import KeepInFrame, Paragraph, Table, TableStyle
from reportlab.platypus.doctemplate import BaseDocTemplate
import app.scodoc.sco_utils as scu
from app import log
from app.scodoc import sco_abs from app.scodoc import sco_abs
from app.scodoc import sco_etud
from app.scodoc.sco_exceptions import ScoPDFFormatError
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_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_trombino from app.scodoc import sco_trombino
from app.scodoc import sco_etud import app.scodoc.sco_utils as scu
from app.scodoc.sco_exceptions import ScoPDFFormatError from app.scodoc.sco_pdf import SU, ScoDocPageTemplate
from app.scodoc.sco_pdf import *
# Paramétrage de l'aspect graphique: # Paramétrage de l'aspect graphique:
PHOTOWIDTH = 2.8 * cm PHOTOWIDTH = 2.8 * cm
@ -55,7 +58,7 @@ N_PER_ROW = 5
def pdf_trombino_tours( def pdf_trombino_tours(
group_ids=[], # liste des groupes à afficher group_ids=(), # liste des groupes à afficher
formsemestre_id=None, # utilisé si pas de groupes selectionné formsemestre_id=None, # utilisé si pas de groupes selectionné
): ):
"""Generation du trombinoscope en fichier PDF""" """Generation du trombinoscope en fichier PDF"""
@ -66,7 +69,6 @@ def pdf_trombino_tours(
DeptName = sco_preferences.get_preference("DeptName") DeptName = sco_preferences.get_preference("DeptName")
DeptFullName = sco_preferences.get_preference("DeptFullName") DeptFullName = sco_preferences.get_preference("DeptFullName")
UnivName = sco_preferences.get_preference("UnivName")
InstituteName = sco_preferences.get_preference("InstituteName") InstituteName = sco_preferences.get_preference("InstituteName")
# Generate PDF page # Generate PDF page
StyleSheet = styles.getSampleStyleSheet() StyleSheet = styles.getSampleStyleSheet()
@ -74,7 +76,11 @@ def pdf_trombino_tours(
T = Table( T = Table(
[ [
[Paragraph(SU(InstituteName), StyleSheet["Heading3"])], [Paragraph(SU(InstituteName), StyleSheet["Heading3"])],
[Paragraph(SU("Département " + DeptFullName), StyleSheet["Heading3"])], [
Paragraph(
SU("Département " + DeptFullName or "(?)"), StyleSheet["Heading3"]
)
],
[ [
Paragraph( Paragraph(
SU("Date ............ / ............ / ......................"), SU("Date ............ / ............ / ......................"),
@ -139,9 +145,7 @@ def pdf_trombino_tours(
for group_id in groups_infos.group_ids: for group_id in groups_infos.group_ids:
if group_id != "None": if group_id != "None":
members, group, group_tit, sem, nbdem = sco_groups.get_group_infos( members, _, group_tit, sem, _ = sco_groups.get_group_infos(group_id, "I")
group_id, "I"
)
groups += " %s" % group_tit groups += " %s" % group_tit
L = [] L = []
currow = [] currow = []
@ -176,7 +180,9 @@ def pdf_trombino_tours(
n = 1 n = 1
for m in members: for m in members:
img = sco_trombino._get_etud_platypus_image(m, image_width=PHOTOWIDTH) img = sco_trombino._get_etud_platypus_image(m, image_width=PHOTOWIDTH)
etud_main_group = sco_groups.get_etud_main_group(m["etudid"], sem) etud_main_group = sco_groups.get_etud_main_group(
m["etudid"], sem["formsemestre_id"]
)
if group_id != etud_main_group["group_id"]: if group_id != etud_main_group["group_id"]:
text_group = " (" + etud_main_group["group_name"] + ")" text_group = " (" + etud_main_group["group_name"] + ")"
else: else:
@ -264,7 +270,7 @@ def pdf_trombino_tours(
filename = "trombino-%s-%s.pdf" % (DeptName, groups_infos.groups_filename) filename = "trombino-%s-%s.pdf" % (DeptName, groups_infos.groups_filename)
document = BaseDocTemplate(report) document = BaseDocTemplate(report)
document.addPageTemplates( document.addPageTemplates(
ScolarsPageTemplate( ScoDocPageTemplate(
document, document,
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
) )
@ -282,14 +288,14 @@ def pdf_trombino_tours(
def pdf_feuille_releve_absences( def pdf_feuille_releve_absences(
group_ids=[], # liste des groupes à afficher group_ids=(), # liste des groupes à afficher
formsemestre_id=None, # utilisé si pas de groupes selectionné formsemestre_id=None, # utilisé si pas de groupes selectionné
): ):
"""Generation de la feuille d'absence en fichier PDF, avec photos""" """Generation de la feuille d'absence en fichier PDF, avec photos"""
NB_CELL_AM = sco_preferences.get_preference("feuille_releve_abs_AM") NB_CELL_AM = sco_preferences.get_preference("feuille_releve_abs_AM")
NB_CELL_PM = sco_preferences.get_preference("feuille_releve_abs_PM") NB_CELL_PM = sco_preferences.get_preference("feuille_releve_abs_PM")
COLWIDTH = 0.85 * cm col_width = 0.85 * cm
if sco_preferences.get_preference("feuille_releve_abs_samedi"): if sco_preferences.get_preference("feuille_releve_abs_samedi"):
days = sco_abs.DAYNAMES[:6] # Lundi, ..., Samedi days = sco_abs.DAYNAMES[:6] # Lundi, ..., Samedi
else: else:
@ -303,7 +309,6 @@ def pdf_feuille_releve_absences(
DeptName = sco_preferences.get_preference("DeptName") DeptName = sco_preferences.get_preference("DeptName")
DeptFullName = sco_preferences.get_preference("DeptFullName") DeptFullName = sco_preferences.get_preference("DeptFullName")
UnivName = sco_preferences.get_preference("UnivName")
InstituteName = sco_preferences.get_preference("InstituteName") InstituteName = sco_preferences.get_preference("InstituteName")
# Generate PDF page # Generate PDF page
StyleSheet = styles.getSampleStyleSheet() StyleSheet = styles.getSampleStyleSheet()
@ -321,7 +326,8 @@ def pdf_feuille_releve_absences(
], ],
[ [
Paragraph( Paragraph(
SU("Département " + DeptFullName), StyleSheet["Heading3"] SU("Département " + (DeptFullName or "(?)")),
StyleSheet["Heading3"],
), ),
"", "",
], ],
@ -335,7 +341,7 @@ def pdf_feuille_releve_absences(
currow = [""] * (NB_CELL_AM + 1 + NB_CELL_PM + 1) currow = [""] * (NB_CELL_AM + 1 + NB_CELL_PM + 1)
elem_day = Table( elem_day = Table(
[currow], [currow],
colWidths=([COLWIDTH] * (NB_CELL_AM + 1 + NB_CELL_PM + 1)), colWidths=([col_width] * (NB_CELL_AM + 1 + NB_CELL_PM + 1)),
style=TableStyle( style=TableStyle(
[ [
("GRID", (0, 0), (NB_CELL_AM - 1, 0), 0.25, black), ("GRID", (0, 0), (NB_CELL_AM - 1, 0), 0.25, black),
@ -357,7 +363,7 @@ def pdf_feuille_releve_absences(
elem_week = Table( elem_week = Table(
W, W,
colWidths=([COLWIDTH * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days), colWidths=([col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days),
style=TableStyle( style=TableStyle(
[ [
("LEFTPADDING", (0, 0), (-1, -1), 0), ("LEFTPADDING", (0, 0), (-1, -1), 0),
@ -373,7 +379,7 @@ def pdf_feuille_releve_absences(
elem_day_name = Table( elem_day_name = Table(
[currow], [currow],
colWidths=([COLWIDTH * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days), colWidths=([col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days),
style=TableStyle( style=TableStyle(
[ [
("LEFTPADDING", (0, 0), (-1, -1), 0), ("LEFTPADDING", (0, 0), (-1, -1), 0),
@ -385,9 +391,7 @@ def pdf_feuille_releve_absences(
) )
for group_id in groups_infos.group_ids: for group_id in groups_infos.group_ids:
members, group, group_tit, sem, nbdem = sco_groups.get_group_infos( members, _, group_tit, _, _ = sco_groups.get_group_infos(group_id, "I")
group_id, "I"
)
L = [] L = []
currow = [ currow = [
@ -424,7 +428,10 @@ def pdf_feuille_releve_absences(
T = Table( T = Table(
L, L,
colWidths=( colWidths=(
[5.0 * cm, (COLWIDTH * (NB_CELL_AM + 1 + NB_CELL_PM + 1) * nb_days)] [
5.0 * cm,
(col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1) * nb_days),
]
), ),
style=TableStyle( style=TableStyle(
[ [
@ -460,7 +467,7 @@ def pdf_feuille_releve_absences(
else: else:
document = BaseDocTemplate(report, pagesize=taille) document = BaseDocTemplate(report, pagesize=taille)
document.addPageTemplates( document.addPageTemplates(
ScolarsPageTemplate( ScoDocPageTemplate(
document, document,
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
) )

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