Merge branch 'master' into prepajury9

This commit is contained in:
Jean-Marie Place 2024-06-22 08:58:02 +02:00
commit 351cbbb5f6
170 changed files with 6319 additions and 3633 deletions

View File

@ -637,14 +637,12 @@ def critical_error(msg):
import app.scodoc.sco_utils as scu
log(f"\n*** CRITICAL ERROR: {msg}")
send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
subject = f"CRITICAL ERROR: {msg}".strip()[:68]
send_scodoc_alarm(subject, msg)
clear_scodoc_cache()
raise ScoValueError(
f"""
Une erreur est survenue.
Si le problème persiste, merci de contacter le support ScoDoc via
{scu.SCO_DISCORD_ASSISTANCE}
Une erreur est survenue, veuillez -essayer.
{msg}
"""

View File

@ -161,8 +161,17 @@ def count_assiduites(
query?est_just=f
query?est_just=t
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
formsemestre_id:<int:formsemestre_id>
metric:<array[string]:metric>
split:<bool:split>
"""
@ -253,6 +262,15 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False)
query?est_just=f
query?est_just=t
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
formsemestre_id:<int:formsemestre_id>
"""
@ -329,6 +347,16 @@ def assiduites_group(with_query: bool = False):
query?est_just=f
query?est_just=t
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
etudids:<array[int]:etudids
formsemestre_id:<int:formsemestre_id>
"""
@ -388,7 +416,16 @@ def assiduites_group(with_query: bool = False):
@as_json
@permission_required(Permission.ScoView)
def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne toutes les assiduités du formsemestre"""
"""Retourne toutes les assiduités du formsemestre
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
"""
# Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None
@ -438,7 +475,20 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
def count_assiduites_formsemestre(
formsemestre_id: int = None, with_query: bool = False
):
"""Comptage des assiduités du formsemestre"""
"""Comptage des assiduités du formsemestre
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
moduleimpl_id:<int:moduleimpl_id>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
formsemestre_id:<int:formsemestre_id>
metric:<array[string]:metric>
split:<bool:split>
"""
# Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None

View File

@ -15,12 +15,14 @@ from flask_login import login_required
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.models import APO_CODE_STR_LEN
from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required
from app.models import (
ApcNiveau,
ApcParcours,
Formation,
Module,
UniteEns,
)
from app.scodoc import sco_formations
@ -336,3 +338,166 @@ def desassoc_ue_niveau(ue_id: int):
# "usage web"
flash(f"UE {ue.acronyme} dé-associée")
return {"status": 0}
@bp.route("/ue/<int:ue_id>", methods=["GET"])
@api_web_bp.route("/ue/<int:ue_id>", methods=["GET"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
def get_ue(ue_id: int):
"""Renvoie l'UE"""
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
return ue.to_dict(convert_objects=True)
@bp.route("/module/<int:module_id>", methods=["GET"])
@api_web_bp.route("/module/<int:module_id>", methods=["GET"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
def get_module(module_id: int):
"""Renvoie le module"""
query = Module.query.filter_by(id=module_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
module: Module = query.first_or_404()
return module.to_dict(convert_objects=True)
@bp.route("/ue/<int:ue_id>/set_code_apogee/<string:code_apogee>", methods=["POST"])
@api_web_bp.route(
"/ue/<int:ue_id>/set_code_apogee/<string:code_apogee>", methods=["POST"]
)
@bp.route(
"/ue/<int:ue_id>/set_code_apogee", defaults={"code_apogee": ""}, methods=["POST"]
)
@api_web_bp.route(
"/ue/<int:ue_id>/set_code_apogee", defaults={"code_apogee": ""}, methods=["POST"]
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
def ue_set_code_apogee(ue_id: int, code_apogee: str = ""):
"""Change le code Apogée de l'UE.
Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
par des virgules.
(Ce changement peut être fait sur formation verrouillée)
Si code_apogee n'est pas spécifié ou vide,
utilise l'argument value du POST (utilisé par jinplace.js)
Le retour est une chaîne (le code enregistré), pas json.
"""
if not code_apogee:
code_apogee = request.form.get("value", "")
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
code_apogee = code_apogee.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque
log(f"API ue_set_code_apogee: ue_id={ue.id} code_apogee={code_apogee}")
ue.code_apogee = code_apogee
db.session.add(ue)
db.session.commit()
return code_apogee or ""
@bp.route("/ue/<int:ue_id>/set_code_apogee_rcue/<string:code_apogee>", methods=["POST"])
@api_web_bp.route(
"/ue/<int:ue_id>/set_code_apogee_rcue/<string:code_apogee>", methods=["POST"]
)
@bp.route(
"/ue/<int:ue_id>/set_code_apogee_rcue",
defaults={"code_apogee": ""},
methods=["POST"],
)
@api_web_bp.route(
"/ue/<int:ue_id>/set_code_apogee_rcue",
defaults={"code_apogee": ""},
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
def ue_set_code_apogee_rcue(ue_id: int, code_apogee: str = ""):
"""Change le code Apogée du RCUE de l'UE.
Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
par des virgules.
(Ce changement peut être fait sur formation verrouillée)
Si code_apogee n'est pas spécifié ou vide,
utilise l'argument value du POST (utilisé par jinplace.js)
Le retour est une chaîne (le code enregistré), pas json.
"""
if not code_apogee:
code_apogee = request.form.get("value", "")
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
code_apogee = code_apogee.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque
log(f"API ue_set_code_apogee_rcue: ue_id={ue.id} code_apogee={code_apogee}")
ue.code_apogee_rcue = code_apogee
db.session.add(ue)
db.session.commit()
return code_apogee or ""
@bp.route(
"/module/<int:module_id>/set_code_apogee/<string:code_apogee>",
methods=["POST"],
)
@api_web_bp.route(
"/module/<int:module_id>/set_code_apogee/<string:code_apogee>",
methods=["POST"],
)
@bp.route(
"/module/<int:module_id>/set_code_apogee",
defaults={"code_apogee": ""},
methods=["POST"],
)
@api_web_bp.route(
"/module/<int:module_id>/set_code_apogee",
defaults={"code_apogee": ""},
methods=["POST"],
)
@login_required
@scodoc
@permission_required(Permission.EditFormation)
def module_set_code_apogee(module_id: int, code_apogee: str = ""):
"""Change le code Apogée du module.
Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
par des virgules.
(Ce changement peut être fait sur formation verrouillée)
Si code_apogee n'est pas spécifié ou vide,
utilise l'argument value du POST (utilisé par jinplace.js)
Le retour est une chaîne (le code enregistré), pas json.
"""
if not code_apogee:
code_apogee = request.form.get("value", "")
query = Module.query.filter_by(id=module_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
module: Module = query.first_or_404()
code_apogee = code_apogee.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque
log(f"API module_set_code_apogee: module_id={module.id} code_apogee={code_apogee}")
module.code_apogee = code_apogee
db.session.add(module)
db.session.commit()
return code_apogee or ""

View File

@ -3,8 +3,8 @@
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Justificatifs
"""
"""ScoDoc 9 API : Justificatifs"""
from datetime import datetime
from flask_json import as_json
@ -113,6 +113,16 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
user_id (l'id de l'auteur du justificatif)
query?user_id=[int]
ex query?user_id=3
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
order:<bool:order>
courant:<bool:courant>
group_id:<int:group_id>
"""
# Récupération de l'étudiant
etud: Identite = tools.get_etud(etudid, nip, ine)
@ -154,6 +164,17 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False):
"""
Renvoie tous les justificatifs d'un département
(en ajoutant un champ "formsemestre" si possible)
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
order:<bool:order>
courant:<bool:courant>
group_id:<int:group_id>
"""
# Récupération du département et des étudiants du département
@ -225,7 +246,19 @@ def _set_sems(justi: Justificatif, restrict: bool) -> dict:
@as_json
@permission_required(Permission.ScoView)
def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne tous les justificatifs du formsemestre"""
"""Retourne tous les justificatifs du formsemestre
QUERY
-----
user_id:<int:user_id>
est_just:<bool:est_just>
date_debut:<string:date_debut_iso>
date_fin:<string:date_fin_iso>
etat:<array[string]:etat>
order:<bool:order>
courant:<bool:courant>
group_id:<int:group_id>
"""
# Récupération du formsemestre
formsemestre: FormSemestre = None

View File

@ -14,6 +14,15 @@ import cracklib # pylint: disable=import-error
from flask import current_app, g
from flask_login import UserMixin, AnonymousUserMixin
from sqlalchemy.exc import (
IntegrityError,
DataError,
DatabaseError,
OperationalError,
ProgrammingError,
StatementError,
InterfaceError,
)
from werkzeug.security import generate_password_hash, check_password_hash
@ -48,13 +57,13 @@ def is_valid_password(cleartxt) -> bool:
return False
def invalid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is invalid"
def is_valid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is valid"
return (
not user_name
or (len(user_name) < 2)
or (len(user_name) >= USERNAME_STR_LEN)
or not VALID_LOGIN_EXP.match(user_name)
user_name
and (len(user_name) >= 2)
and (len(user_name) < USERNAME_STR_LEN)
and VALID_LOGIN_EXP.match(user_name)
)
@ -123,7 +132,7 @@ class User(UserMixin, ScoDocModel):
# check login:
if not "user_name" in kwargs:
raise ValueError("missing user_name argument")
if invalid_user_name(kwargs["user_name"]):
if not is_valid_user_name(kwargs["user_name"]):
raise ValueError(f"invalid user_name: {kwargs['user_name']}")
kwargs["nom"] = kwargs.get("nom", "") or ""
kwargs["prenom"] = kwargs.get("prenom", "") or ""
@ -329,7 +338,8 @@ class User(UserMixin, ScoDocModel):
if new_user:
if "user_name" in data:
# never change name of existing users
if invalid_user_name(data["user_name"]):
# (see change_user_name method to do that)
if not is_valid_user_name(data["user_name"]):
raise ValueError(f"invalid user_name: {data['user_name']}")
self.user_name = data["user_name"]
if "password" in data:
@ -522,6 +532,64 @@ class User(UserMixin, ScoDocModel):
# nomnoacc était le nom en minuscules sans accents (inutile)
def change_user_name(self, new_user_name: str):
"""Modify user name, update all relevant tables.
commit session.
"""
# Safety check
new_user_name = new_user_name.strip()
if (
not is_valid_user_name(new_user_name)
or User.query.filter_by(user_name=new_user_name).count() > 0
):
raise ValueError("invalid user_name")
# Le user_name est utilisé dans d'autres tables (sans être une clé)
# BulAppreciations.author
# EntrepriseHistorique.authenticated_user
# EtudAnnotation.author
# ScolarNews.authenticated_user
# Scolog.authenticated_user
from app.models import (
BulAppreciations,
EtudAnnotation,
ScolarNews,
Scolog,
)
from app.entreprises.models import EntrepriseHistorique
try:
# Update all instances of EtudAnnotation
db.session.query(BulAppreciations).filter(
BulAppreciations.author == self.user_name
).update({BulAppreciations.author: new_user_name})
db.session.query(EntrepriseHistorique).filter(
EntrepriseHistorique.authenticated_user == self.user_name
).update({EntrepriseHistorique.authenticated_user: new_user_name})
db.session.query(EtudAnnotation).filter(
EtudAnnotation.author == self.user_name
).update({EtudAnnotation.author: new_user_name})
db.session.query(ScolarNews).filter(
ScolarNews.authenticated_user == self.user_name
).update({ScolarNews.authenticated_user: new_user_name})
db.session.query(Scolog).filter(
Scolog.authenticated_user == self.user_name
).update({Scolog.authenticated_user: new_user_name})
# And update ourself:
self.user_name = new_user_name
db.session.add(self)
db.session.commit()
except (
IntegrityError,
DataError,
DatabaseError,
OperationalError,
ProgrammingError,
StatementError,
InterfaceError,
) as exc:
db.session.rollback()
raise exc
class AnonymousUser(AnonymousUserMixin):
"Notre utilisateur anonyme"

View File

@ -18,7 +18,7 @@ from app.auth.forms import (
ResetPasswordRequestForm,
UserCreationForm,
)
from app.auth.models import Role, User, invalid_user_name
from app.auth.models import Role, User, is_valid_user_name
from app.auth.email import send_password_reset_email
from app.decorators import admin_required
from app.forms.generic import SimpleConfirmationForm
@ -35,10 +35,12 @@ def _login_form():
form = LoginForm()
if form.validate_on_submit():
# note: ceci est la première requête SQL déclenchée par un utilisateur arrivant
if invalid_user_name(form.user_name.data):
user = None
else:
user = User.query.filter_by(user_name=form.user_name.data).first()
user = (
User.query.filter_by(user_name=form.user_name.data).first()
if is_valid_user_name(form.user_name.data)
else None
)
if user is None or not user.check_password(form.password.data):
current_app.logger.info("login: invalid (%s)", form.user_name.data)
flash(_("Nom ou mot de passe invalide"))

View File

@ -124,7 +124,9 @@ def _build_bulletin_but_infos(
formsemestre, bulletins_sem.res
)
if warn_html:
raise ScoValueError("<b>Formation mal configurée pour le BUT</b>" + warn_html)
raise ScoValueError(
"<b>Formation mal configurée pour le BUT</b>" + warn_html, safe=True
)
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
refcomp, etud
)

View File

@ -73,6 +73,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
html_class="notes_bulletin",
html_class_ignore_default=True,
html_with_td_classes=True,
table_id="bul-table",
)
table_objects = table.gen(fmt=fmt)
objects += table_objects

View File

@ -29,7 +29,7 @@ from app.models.but_refcomp import (
ApcReferentielCompetences,
)
from app.models.ues import UEParcours
from app.models.but_validations import ApcValidationRCUE
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
@ -42,9 +42,9 @@ from app.scodoc import sco_cursus_dut
class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
"""Pour compat ScoDoc 7: à revoir pour le BUT"""
"""Pour compat ScoDoc 7"""
def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT):
def __init__(self, etud: Identite, formsemestre_id: int, res: ResultatsSemestreBUT):
super().__init__(etud, formsemestre_id, res)
# Ajustements pour le BUT
self.can_compensate_with_prev = False # jamais de compensation à la mode DUT
@ -54,8 +54,22 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
return False
def parcours_validated(self):
"True si le parcours est validé"
return False # XXX TODO
"True si le parcours (ici diplôme BUT) est validé"
return but_parcours_validated(
self.etud.id, self.cur_sem.formation.referentiel_competence_id
)
def but_parcours_validated(etudid: int, referentiel_competence_id: int) -> bool:
"""Détermine si le parcours BUT est validé:
ne regarde que si une validation BUT3 est enregistrée
"""
return any(
sco_codes.code_annee_validant(v.code)
for v in ApcValidationAnnee.query.filter_by(
etudid=etudid, ordre=3, referentiel_competence_id=referentiel_competence_id
)
)
class EtudCursusBUT:
@ -287,81 +301,81 @@ class FormSemestreCursusBUT:
)
return niveaux_by_annee
def get_etud_validation_par_competence_et_annee(self, etud: Identite):
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
validation_par_competence_et_annee = {}
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# On s'assurer qu'elle concerne notre cursus !
ue = validation_rcue.ue2
if ue.id not in self.ue_ids:
if (
ue.formation.referentiel_competences_id
== self.referentiel_competences_id
):
self.ue_ids = ue.id
else:
continue # skip this validation
niveau = validation_rcue.niveau()
if not niveau.competence.id in validation_par_competence_et_annee:
validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = validation_par_competence_et_annee.get(
niveau.competence.id
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
] = validation_rcue
return validation_par_competence_et_annee
# def get_etud_validation_par_competence_et_annee(self, etud: Identite):
# """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
# validation_par_competence_et_annee = {}
# for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# # On s'assurer qu'elle concerne notre cursus !
# ue = validation_rcue.ue2
# if ue.id not in self.ue_ids:
# if (
# ue.formation.referentiel_competences_id
# == self.referentiel_competences_id
# ):
# self.ue_ids = ue.id
# else:
# continue # skip this validation
# niveau = validation_rcue.niveau()
# if not niveau.competence.id in validation_par_competence_et_annee:
# validation_par_competence_et_annee[niveau.competence.id] = {}
# previous_validation = validation_par_competence_et_annee.get(
# niveau.competence.id
# ).get(validation_rcue.annee())
# # prend la "meilleure" validation
# if (not previous_validation) or (
# sco_codes.BUT_CODES_ORDER[validation_rcue.code]
# > sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
# ):
# self.validation_par_competence_et_annee[niveau.competence.id][
# niveau.annee
# ] = validation_rcue
# return validation_par_competence_et_annee
def list_etud_inscriptions(self, etud: Identite):
"Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
self.niveaux_by_annee = {}
"{ annee : liste des niveaux à valider }"
self.niveaux: dict[int, ApcNiveau] = {}
"cache les niveaux"
for annee in (1, 2, 3):
niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
annee, [self.parcour] if self.parcour else None # XXX WIP
)[1]
# groupe les niveaux de tronc commun et ceux spécifiques au parcour
self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
niveaux_d[self.parcour.id] if self.parcour else []
)
self.niveaux.update(
{niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
)
# def list_etud_inscriptions(self, etud: Identite):
# "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
# self.niveaux_by_annee = {}
# "{ annee : liste des niveaux à valider }"
# self.niveaux: dict[int, ApcNiveau] = {}
# "cache les niveaux"
# for annee in (1, 2, 3):
# niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
# annee, [self.parcour] if self.parcour else None # XXX WIP
# )[1]
# # groupe les niveaux de tronc commun et ceux spécifiques au parcour
# self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
# niveaux_d[self.parcour.id] if self.parcour else []
# )
# self.niveaux.update(
# {niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
# )
self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau()
if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get(
niveau.competence.id
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDER[validation_rcue.code]
> sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
] = validation_rcue
# self.validation_par_competence_et_annee = {}
# """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
# for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# niveau = validation_rcue.niveau()
# if not niveau.competence.id in self.validation_par_competence_et_annee:
# self.validation_par_competence_et_annee[niveau.competence.id] = {}
# previous_validation = self.validation_par_competence_et_annee.get(
# niveau.competence.id
# ).get(validation_rcue.annee())
# # prend la "meilleure" validation
# if (not previous_validation) or (
# sco_codes.BUT_CODES_ORDER[validation_rcue.code]
# > sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
# ):
# self.validation_par_competence_et_annee[niveau.competence.id][
# niveau.annee
# ] = validation_rcue
self.competences = {
competence.id: competence
for competence in (
self.parcour.query_competences()
if self.parcour
else self.formation.referentiel_competence.get_competences_tronc_commun()
)
}
"cache { competence_id : competence }"
# self.competences = {
# competence.id: competence
# for competence in (
# self.parcour.query_competences()
# if self.parcour
# else self.formation.referentiel_competence.get_competences_tronc_commun()
# )
# }
# "cache { competence_id : competence }"
def but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float:

View File

@ -1034,8 +1034,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
return messages
def valide_diplome(self) -> bool:
"Vrai si l'étudiant à validé son diplôme"
return False # TODO XXX
"Vrai si l'étudiant a validé son diplôme (décision enregistrée)"
return self.annee_but == 3 and sco_codes.code_annee_validant(self.code_valide)
def list_ue_parcour_etud(

View File

@ -155,6 +155,7 @@ def pvjury_table_but(
deca = None
ects_but_valides = but_ects_valides(etud, referentiel_competence_id)
has_diplome = deca.valide_diplome()
row = {
"nom_pv": (
etud.code_ine or etud.code_nip or etud.id
@ -181,10 +182,15 @@ def pvjury_table_but(
),
"decision_but": deca.code_valide if deca else "",
"devenir": (
", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else ""
"Diplôme obtenu"
if has_diplome
else (
", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else ""
)
),
"diplome": "ADM" if has_diplome else "",
# pour exports excel seulement:
"civilite": etud.civilite_etat_civil_str,
"nom": etud.nom,

View File

@ -27,6 +27,7 @@
"""caches pour tables APC
"""
from flask import g
from app.scodoc import sco_cache
@ -47,3 +48,27 @@ class EvaluationsPoidsCache(sco_cache.ScoDocCache):
"""
prefix = "EPC"
@classmethod
def invalidate_all(cls):
"delete all cached evaluations poids (in current dept)"
from app.models.formsemestre import FormSemestre
from app.models.moduleimpls import ModuleImpl
moduleimpl_ids = [
mi.id
for mi in ModuleImpl.query.join(FormSemestre).filter_by(
dept_id=g.scodoc_dept_id
)
]
cls.delete_many(moduleimpl_ids)
@classmethod
def invalidate_sem(cls, formsemestre_id):
"delete cached evaluations poids for this formsemestre from cache"
from app.models.moduleimpls import ModuleImpl
moduleimpl_ids = [
mi.id for mi in ModuleImpl.query.filter_by(formsemestre_id=formsemestre_id)
]
cls.delete_many(moduleimpl_ids)

View File

@ -45,7 +45,6 @@ from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoBugCatcher
from app.scodoc.sco_utils import ModuleType
@ -113,6 +112,8 @@ class ModuleImplResults:
"""
self.evals_etudids_sans_note = {}
"""dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
self.evals_type = {}
"""Type de chaque eval { evaluation.id : evaluation.evaluation_type }"""
self.load_notes(etudids, etudids_actifs)
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module vient de la session2"""
@ -164,7 +165,10 @@ class ModuleImplResults:
self.evaluations_completes = []
self.evaluations_completes_dict = {}
self.etudids_attente = set() # empty
self.evals_type = {}
evaluation: Evaluation
for evaluation in moduleimpl.evaluations:
self.evals_type[evaluation.id] = evaluation.evaluation_type
eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi
# tous les inscrits (non dem) au module ont une note
@ -270,6 +274,24 @@ class ModuleImplResults:
* self.evaluations_completes
).reshape(-1, 1)
def get_evaluations_special_coefs(
self, modimpl: ModuleImpl, evaluation_type=Evaluation.EVALUATION_SESSION2
) -> np.array:
"""Coefficients des évaluations de session 2 ou rattrapage.
Les évals de session 2 et rattrapage sont réputées "complètes": elles sont toujours
prises en compte mais seules les notes numériques et ABS sont utilisées.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[
(e.coefficient if e.evaluation_type == evaluation_type else 0.0)
for e in modimpl.evaluations
],
dtype=float,
)
).reshape(-1, 1)
# was _list_notes_evals_titles
def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"Liste des évaluations complètes"
@ -296,32 +318,26 @@ class ModuleImplResults:
for (etudid, x) in self.evals_notes[evaluation_id].items()
}
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl) -> Evaluation | None:
"""L'évaluation de rattrapage de ce module, ou None s'il n'en a pas.
def get_evaluations_rattrapage(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations de rattrapage de ce module.
Rattrapage: la moyenne du module est la meilleure note entre moyenne
des autres évals et la note eval rattrapage.
des autres évals et la moyenne des notes de rattrapage.
"""
eval_list = [
return [
e
for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE
]
if eval_list:
return eval_list[0]
return None
def get_evaluation_session2(self, moduleimpl: ModuleImpl) -> Evaluation | None:
"""L'évaluation de deuxième session de ce module, ou None s'il n'en a pas.
Session 2: remplace la note de moyenne des autres évals.
def get_evaluations_session2(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations de deuxième session de ce module, ou None s'il n'en a pas.
La moyenne des notes de Session 2 remplace la note de moyenne des autres évals.
"""
eval_list = [
return [
e
for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_SESSION2
]
if eval_list:
return eval_list[0]
return None
def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations bonus de ce module, ou liste vide s'il n'en a pas."""
@ -344,12 +360,13 @@ class ModuleImplResultsAPC(ModuleImplResults):
"Calcul des moyennes de modules à la mode BUT"
def compute_module_moy(
self,
evals_poids_df: pd.DataFrame,
self, evals_poids_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> pd.DataFrame:
"""Calcule les moyennes des étudiants dans ce module
Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs
Argument:
evals_poids: DataFrame, colonnes: UEs, lignes: EVALs
modimpl_coefs_df: DataFrame, colonnes: modimpl_id, lignes: ue_id
Résultat: DataFrame, colonnes UE, lignes etud
= la note de l'étudiant dans chaque UE pour ce module.
@ -370,6 +387,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
return pd.DataFrame(index=[], columns=evals_poids_df.columns)
if nb_ues == 0:
return pd.DataFrame(index=self.evals_notes.index, columns=[])
# coefs des évals complètes normales (pas rattr., session 2 ni bonus):
evals_coefs = self.get_evaluations_coefs(modimpl)
evals_poids = evals_poids_df.values * evals_coefs
# -> evals_poids shape : (nb_evals, nb_ues)
@ -398,6 +416,47 @@ class ModuleImplResultsAPC(ModuleImplResults):
) / np.sum(evals_poids_etuds, axis=1)
# etuds_moy_module shape: nb_etuds x nb_ues
evals_session2 = self.get_evaluations_session2(modimpl)
evals_rat = self.get_evaluations_rattrapage(modimpl)
if evals_session2:
# Session2 : quand elle existe, remplace la note de module
# Calcul moyenne notes session2 et remplace (si la note session 2 existe)
etuds_moy_module_s2 = self._compute_moy_special(
modimpl,
evals_notes_stacked,
evals_poids_df,
Evaluation.EVALUATION_SESSION2,
)
# Vrai si toutes les UEs avec coef non nul ont bien une note de session 2 calculée:
mod_coefs = modimpl_coefs_df[modimpl.id]
etuds_use_session2 = np.all(
np.isfinite(etuds_moy_module_s2[:, mod_coefs != 0]), axis=1
)
etuds_moy_module = np.where(
etuds_use_session2[:, np.newaxis],
etuds_moy_module_s2,
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
elif evals_rat:
etuds_moy_module_rat = self._compute_moy_special(
modimpl,
evals_notes_stacked,
evals_poids_df,
Evaluation.EVALUATION_RATTRAPAGE,
)
etuds_ue_use_rattrapage = (
etuds_moy_module_rat > etuds_moy_module
) # etud x UE
etuds_moy_module = np.where(
etuds_ue_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
np.any(etuds_ue_use_rattrapage, axis=1), index=self.evals_notes.index
)
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
@ -405,47 +464,6 @@ class ModuleImplResultsAPC(ModuleImplResults):
evals_poids_df,
evals_notes_stacked,
)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
notes_session2 = self.evals_notes[eval_session2.id].values
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
etuds_moy_module = np.where(
etuds_use_session2[:, np.newaxis],
np.tile(
(notes_session2 / (eval_session2.note_max / 20.0))[:, np.newaxis],
nb_ues,
),
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
else:
# Rattrapage: remplace la note de module ssi elle est supérieure
eval_rat = self.get_evaluation_rattrapage(modimpl)
if eval_rat:
notes_rat = self.evals_notes[eval_rat.id].values
# remplace les notes invalides (ATT, EXC...) par des NaN
notes_rat = np.where(
notes_rat > scu.NOTES_ABSENCE,
notes_rat / (eval_rat.note_max / 20.0),
np.nan,
)
# "Étend" le rattrapage sur les UE: la note de rattrapage est la même
# pour toutes les UE mais ne remplace que là où elle est supérieure
notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1)
# prend le max
etuds_use_rattrapage = notes_rat_ues > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
)
# Serie indiquant que l'étudiant utilise une note de rattrapage sur l'une des UE:
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
)
self.etuds_moy_module = pd.DataFrame(
etuds_moy_module,
index=self.evals_notes.index,
@ -453,6 +471,34 @@ class ModuleImplResultsAPC(ModuleImplResults):
)
return self.etuds_moy_module
def _compute_moy_special(
self,
modimpl: ModuleImpl,
evals_notes_stacked: np.array,
evals_poids_df: pd.DataFrame,
evaluation_type: int,
) -> np.array:
"""Calcul moyenne APC sur évals rattrapage ou session2"""
nb_etuds = self.evals_notes.shape[0]
nb_ues = evals_poids_df.shape[1]
evals_coefs_s2 = self.get_evaluations_special_coefs(
modimpl, evaluation_type=evaluation_type
)
evals_poids_s2 = evals_poids_df.values * evals_coefs_s2
poids_stacked_s2 = np.stack(
[evals_poids_s2] * nb_etuds
) # nb_etuds, nb_evals, nb_ues
evals_poids_etuds_s2 = np.where(
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
poids_stacked_s2,
0,
)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module_s2 = np.sum(
evals_poids_etuds_s2 * evals_notes_stacked, axis=1
) / np.sum(evals_poids_etuds_s2, axis=1)
return etuds_moy_module_s2
def apply_bonus(
self,
etuds_moy_module: pd.DataFrame,
@ -525,6 +571,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
return evals_poids, ues
# appelé par ModuleImpl.check_apc_conformity()
def moduleimpl_is_conforme(
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> bool:
@ -546,12 +593,12 @@ def moduleimpl_is_conforme(
if len(modimpl_coefs_df) != nb_ues:
# il arrive (#bug) que le cache ne soit pas à jour...
sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent")
return app.critical_error("moduleimpl_is_conforme: err 1")
if moduleimpl.id not in modimpl_coefs_df:
# soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
return app.critical_error("moduleimpl_is_conforme: err 2")
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
@ -593,46 +640,43 @@ class ModuleImplResultsClassic(ModuleImplResults):
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1)
evals_session2 = self.get_evaluations_session2(modimpl)
evals_rat = self.get_evaluations_rattrapage(modimpl)
if evals_session2:
# Session2 : quand elle existe, remplace la note de module
# Calcule la moyenne des évaluations de session2
etuds_moy_module_s2 = self._compute_moy_special(
modimpl, evals_notes_20, Evaluation.EVALUATION_SESSION2
)
etuds_use_session2 = np.isfinite(etuds_moy_module_s2)
etuds_moy_module = np.where(
etuds_use_session2,
etuds_moy_module_s2,
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
elif evals_rat:
# Rattrapage: remplace la note de module ssi elle est supérieure
# Calcule la moyenne des évaluations de rattrapage
etuds_moy_module_rat = self._compute_moy_special(
modimpl, evals_notes_20, Evaluation.EVALUATION_RATTRAPAGE
)
etuds_use_rattrapage = etuds_moy_module_rat > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage, index=self.evals_notes.index
)
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_notes_20,
)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
notes_session2 = self.evals_notes[eval_session2.id].values
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
etuds_moy_module = np.where(
etuds_use_session2,
notes_session2 / (eval_session2.note_max / 20.0),
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
else:
# Rattrapage: remplace la note de module ssi elle est supérieure
eval_rat = self.get_evaluation_rattrapage(modimpl)
if eval_rat:
notes_rat = self.evals_notes[eval_rat.id].values
# remplace les notes invalides (ATT, EXC...) par des NaN
notes_rat = np.where(
notes_rat > scu.NOTES_ABSENCE,
notes_rat / (eval_rat.note_max / 20.0),
np.nan,
)
# prend le max
etuds_use_rattrapage = notes_rat > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage, index=self.evals_notes.index
)
self.etuds_moy_module = pd.Series(
etuds_moy_module,
index=self.evals_notes.index,
@ -640,6 +684,28 @@ class ModuleImplResultsClassic(ModuleImplResults):
return self.etuds_moy_module
def _compute_moy_special(
self, modimpl: ModuleImpl, evals_notes_20: np.array, evaluation_type: int
) -> np.array:
"""Calcul moyenne sur évals rattrapage ou session2"""
# n'utilise que les notes valides et ABS (0).
# Même calcul que pour les évals normales, mais avec seulement les
# coefs des évals de session 2 ou rattrapage:
nb_etuds = self.evals_notes.shape[0]
evals_coefs = self.get_evaluations_special_coefs(
modimpl, evaluation_type=evaluation_type
).reshape(-1)
coefs_stacked = np.stack([evals_coefs] * nb_etuds)
# zéro partout sauf si une note ou ABS:
evals_coefs_etuds = np.where(
self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked, 0
)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module = np.sum(
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1)
return etuds_moy_module # array 1d (nb_etuds)
def apply_bonus(
self,
etuds_moy_module: np.ndarray,

View File

@ -183,7 +183,9 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray:
return modimpls_notes.swapaxes(0, 1)
def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
def notes_sem_load_cube(
formsemestre: FormSemestre, modimpl_coefs_df: pd.DataFrame
) -> tuple:
"""Construit le "cube" (tenseur) des notes du semestre.
Charge toutes les notes (sql), calcule les moyennes des modules
et assemble le cube.
@ -207,8 +209,8 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
etudids, etudids_actifs = formsemestre.etudids_actifs()
for modimpl in formsemestre.modimpls_sorted:
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs)
evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
etuds_moy_module = mod_results.compute_module_moy(evals_poids)
evals_poids = modimpl.get_evaluations_poids()
etuds_moy_module = mod_results.compute_module_moy(evals_poids, modimpl_coefs_df)
modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids
modimpls_notes.append(etuds_moy_module)

View File

@ -59,16 +59,17 @@ class ResultatsSemestreBUT(NotesTableCompat):
def compute(self):
"Charge les notes et inscriptions et calcule les moyennes d'UE et gen."
self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs(
self.formsemestre, modimpls=self.formsemestre.modimpls_sorted
)
(
self.sem_cube,
self.modimpls_evals_poids,
self.modimpls_results,
) = moy_ue.notes_sem_load_cube(self.formsemestre)
) = moy_ue.notes_sem_load_cube(self.formsemestre, self.modimpl_coefs_df)
self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre)
self.ues_inscr_parcours_df = self.load_ues_inscr_parcours()
self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs(
self.formsemestre, modimpls=self.formsemestre.modimpls_sorted
)
# l'idx de la colonne du mod modimpl.id est
# modimpl_coefs_df.columns.get_loc(modimpl.id)
# idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id)

View File

@ -242,7 +242,8 @@ class ResultatsSemestreClassic(NotesTableCompat):
)
}">saisir le coefficient de cette UE avant de continuer</a></p>
</div>
"""
""",
safe=True,
)

View File

@ -518,7 +518,8 @@ class ResultatsSemestre(ResultatsCache):
Corrigez ou faite corriger le programme
<a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept,
formation_id=ue_capitalized.formation_id)}">via cette page</a>.
"""
""",
safe=True,
)
else:
# Coefs de l'UE capitalisée en formation classique:

View File

@ -9,9 +9,9 @@ import datetime
from threading import Thread
from flask import current_app, g
from flask_mail import Message
from flask_mail import BadHeaderError, Message
from app import mail
from app import log, mail
from app.models.departements import Departement
from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_preferences
@ -20,7 +20,15 @@ from app.scodoc import sco_preferences
def send_async_email(app, msg):
"Send an email, async"
with app.app_context():
mail.send(msg)
try:
mail.send(msg)
except BadHeaderError:
log(
f"""send_async_email: BadHeaderError
msg={msg}
"""
)
raise
def send_email(

View File

@ -151,7 +151,7 @@ class EntrepriseHistorique(db.Model):
__tablename__ = "are_historique"
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
authenticated_user = db.Column(db.Text)
authenticated_user = db.Column(db.Text) # user_name login sans contrainte
entreprise_id = db.Column(db.Integer)
object = db.Column(db.Text)
object_id = db.Column(db.Integer)

View File

@ -338,9 +338,11 @@ def add_entreprise():
if form.validate_on_submit():
entreprise = Entreprise(
nom=form.nom_entreprise.data.strip(),
siret=form.siret.data.strip()
if form.siret.data.strip()
else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}", # siret provisoire
siret=(
form.siret.data.strip()
if form.siret.data.strip()
else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}"
), # siret provisoire
siret_provisoire=False if form.siret.data.strip() else True,
association=form.association.data,
adresse=form.adresse.data.strip(),
@ -352,7 +354,7 @@ def add_entreprise():
db.session.add(entreprise)
db.session.commit()
db.session.refresh(entreprise)
except:
except Exception:
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
@ -804,9 +806,9 @@ def add_offre(entreprise_id):
missions=form.missions.data.strip(),
duree=form.duree.data.strip(),
expiration_date=form.expiration_date.data,
correspondant_id=form.correspondant.data
if form.correspondant.data != ""
else None,
correspondant_id=(
form.correspondant.data if form.correspondant.data != "" else None
),
)
db.session.add(offre)
db.session.commit()
@ -1328,9 +1330,11 @@ def add_contact(entreprise_id):
).first_or_404(description=f"entreprise {entreprise_id} inconnue")
form = ContactCreationForm(
date=f"{datetime.now().strftime('%Y-%m-%dT%H:%M')}",
utilisateur=f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
if current_user.nom and current_user.prenom
else "",
utilisateur=(
f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
if current_user.nom and current_user.prenom
else ""
),
)
if request.method == "POST" and form.cancel.data:
return redirect(url_for("entreprises.contacts", entreprise_id=entreprise_id))
@ -1496,9 +1500,9 @@ def add_stage_apprentissage(entreprise_id):
date_debut=form.date_debut.data,
date_fin=form.date_fin.data,
formation_text=formation.formsemestre.titre if formation else None,
formation_scodoc=formation.formsemestre.formsemestre_id
if formation
else None,
formation_scodoc=(
formation.formsemestre.formsemestre_id if formation else None
),
notes=form.notes.data.strip(),
)
db.session.add(stage_apprentissage)
@ -1802,7 +1806,7 @@ def import_donnees():
db.session.add(entreprise)
db.session.commit()
db.session.refresh(entreprise)
except:
except Exception:
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(

View File

@ -62,6 +62,11 @@ class AjoutAssiOrJustForm(FlaskForm):
if field:
field.errors.append(err_msg)
def disable_all(self):
"Disable all fields"
for field in self:
field.render_kw = {"disabled": True}
date_debut = StringField(
"Date de début",
validators=[validators.Length(max=10)],
@ -175,36 +180,3 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
validators=[DataRequired(message="This field is required.")],
)
fichiers = MultipleFileField(label="Ajouter des fichiers")
class ChoixDateForm(FlaskForm):
"""
Formulaire de choix de date
(utilisé par la page de choix de date
si la date courante n'est pas dans le semestre)
"""
def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs)
self.ok = True
self.error_messages: list[str] = [] # used to report our errors
def set_error(self, err_msg, field=None):
"Set error message both in form and field"
self.ok = False
self.error_messages.append(err_msg)
if field:
field.errors.append(err_msg)
date = StringField(
"Date",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "date",
},
)
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -0,0 +1,122 @@
""" """
from flask_wtf import FlaskForm
from wtforms import (
StringField,
SelectField,
RadioField,
TextAreaField,
validators,
SubmitField,
)
from app.scodoc.sco_utils import EtatAssiduite
class EditAssiForm(FlaskForm):
"""
Formulaire de modification d'une assiduité
"""
def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs)
self.ok = True
self.error_messages: list[str] = [] # used to report our errors
def set_error(self, err_msg, field=None):
"Set error message both in form and field"
self.ok = False
self.error_messages.append(err_msg)
if field:
field.errors.append(err_msg)
def disable_all(self):
"Disable all fields"
for field in self:
field.render_kw = {"disabled": True}
assi_etat = RadioField(
"État:",
choices=[
(EtatAssiduite.ABSENT.value, EtatAssiduite.ABSENT.version_lisible()),
(EtatAssiduite.RETARD.value, EtatAssiduite.RETARD.version_lisible()),
(EtatAssiduite.PRESENT.value, EtatAssiduite.PRESENT.version_lisible()),
],
default="absent",
validators=[
validators.DataRequired("spécifiez le type d'évènement à signaler"),
],
)
modimpl = SelectField(
"Module",
choices={}, # will be populated dynamically
)
description = TextAreaField(
"Description",
render_kw={
"id": "description",
"cols": 75,
"rows": 4,
"maxlength": 500,
},
)
date_debut = StringField(
"Date de début",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "assi_date_debut",
},
)
heure_debut = StringField(
"Heure début",
default="",
validators=[validators.Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_heure_debut",
},
)
heure_fin = StringField(
"Heure fin",
default="",
validators=[validators.Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_heure_fin",
},
)
date_fin = StringField(
"Date de fin",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "assi_date_fin",
},
)
entry_date = StringField(
"Date de dépôt ou saisie",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "entry_date",
},
)
entry_time = StringField(
"Heure dépôt",
default="",
validators=[validators.Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_heure_fin",
},
)
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -0,0 +1,66 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire création de ticket de bug
"""
from flask_wtf import FlaskForm
from wtforms import SubmitField, validators
from wtforms.fields.simple import StringField, TextAreaField, BooleanField
from app.scodoc import sco_preferences
class CreateBugReport(FlaskForm):
"""Formulaire permettant la création d'un ticket de bug"""
title = StringField(
label="Titre du ticket",
validators=[
validators.DataRequired("titre du ticket requis"),
],
)
message = TextAreaField(
label="Message",
id="ticket_message",
validators=[
validators.DataRequired("message du ticket requis"),
],
)
etab = StringField(label="Etablissement")
include_dump = BooleanField(
"""Inclure une copie anonymisée de la base de données ?
Ces données faciliteront le traitement du problème et resteront strictement confidentielles.
""",
default=False,
)
submit = SubmitField("Envoyer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
def __init__(self, *args, **kwargs):
super(CreateBugReport, self).__init__(*args, **kwargs)
self.etab.data = sco_preferences.get_preference("InstituteName") or ""

View File

@ -353,12 +353,22 @@ class Assiduite(ScoDocModel):
elif self.external_data is not None and "module" in self.external_data:
return (
"Tout module"
"Autre module (pas dans la liste)"
if self.external_data["module"] == "Autre"
else self.external_data["module"]
)
return "Non spécifié" if traduire else None
return "Module non spécifié" if traduire else None
def get_moduleimpl_id(self) -> int | str | None:
"""
Retourne le ModuleImpl associé à l'assiduité
"""
if self.moduleimpl_id is not None:
return self.moduleimpl_id
if self.external_data is not None and "module" in self.external_data:
return self.external_data["module"]
return None
def get_saisie(self) -> str:
"""
@ -395,6 +405,14 @@ class Assiduite(ScoDocModel):
if force:
raise ScoValueError("Module non renseigné")
@classmethod
def get_assiduite(cls, assiduite_id: int) -> "Assiduite":
"""Assiduité ou 404, cherche uniquement dans le département courant"""
query = Assiduite.query.filter_by(id=assiduite_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
return query.first_or_404()
class Justificatif(ScoDocModel):
"""
@ -685,10 +703,14 @@ def is_period_conflicting(
date_fin: datetime,
collection: Query,
collection_cls: Assiduite | Justificatif,
obj_id: int = -1,
) -> bool:
"""
Vérifie si une date n'entre pas en collision
avec les justificatifs ou assiduites déjà présentes
On peut donner un objet_id pour exclure un objet de la vérification
(utile pour les modifications)
"""
# On s'assure que les dates soient avec TimeZone
@ -696,7 +718,9 @@ def is_period_conflicting(
date_fin = localize_datetime(date_fin)
count: int = collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut
collection_cls.date_debut < date_fin,
collection_cls.date_fin > date_debut,
collection_cls.id != obj_id,
).count()
return count > 0

View File

@ -274,6 +274,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
return "type_departement mismatch"
# Table d'équivalences entre refs:
equiv = self._load_config_equivalences()
# Même specialité (ou alias) ?
if self.specialite != other.specialite and other.specialite not in equiv.get(
"alias", []
):
return "specialite mismatch"
# mêmes parcours ?
eq_parcours = equiv.get("parcours", {})
parcours_by_code_1 = {eq_parcours.get(p.code, p.code): p for p in self.parcours}
@ -317,6 +322,9 @@ class ApcReferentielCompetences(db.Model, XMLModel):
def _load_config_equivalences(self) -> dict:
"""Load config file ressources/referentiels/equivalences.yaml
used to define equivalences between distinct referentiels
return a dict, with optional keys:
alias: list of equivalent names for speciality (eg SD == STID)
parcours: dict with equivalent parcours acronyms
"""
try:
with open(REFCOMP_EQUIVALENCE_FILENAME, encoding="utf-8") as f:

View File

@ -113,6 +113,12 @@ class ApcValidationRCUE(db.Model):
"formsemestre_id": self.formsemestre_id,
}
def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée associés à cette validation RCUE.
Prend les codes des deux UEs
"""
return self.ue1.get_codes_apogee_rcue() | self.ue2.get_codes_apogee_rcue()
class ApcValidationAnnee(db.Model):
"""Validation des années du BUT"""
@ -213,6 +219,7 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
dec_rcue["code"]}"""
)
decisions["descr_decisions_rcue"] = ", ".join(titres_rcues)
decisions["descr_decisions_rcue_list"] = titres_rcues
decisions["descr_decisions_niveaux"] = (
"Niveaux de compétences: " + decisions["descr_decisions_rcue"]
)

View File

@ -199,6 +199,11 @@ class Identite(models.ScoDocModel):
@classmethod
def get_etud(cls, etudid: int) -> "Identite":
"""Etudiant ou 404, cherche uniquement dans le département courant"""
if not isinstance(etudid, int):
try:
etudid = int(etudid)
except (TypeError, ValueError):
abort(404, "etudid invalide")
if g.scodoc_dept:
return cls.query.filter_by(
id=etudid, dept_id=g.scodoc_dept_id
@ -299,9 +304,10 @@ class Identite(models.ScoDocModel):
@property
def nomprenom(self, reverse=False) -> str:
"""Civilité/nom/prenom pour affichages: "M. Pierre Dupont"
"""DEPRECATED
Civilité/prénom/nom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
Prend l'identité courant et non celle de l'état civile si elles diffèrent.
Prend l'identité courante et non celle de l'état civil si elles diffèrent.
"""
nom = self.nom_usuel or self.nom
prenom = self.prenom_str
@ -309,6 +315,12 @@ class Identite(models.ScoDocModel):
return f"{nom} {prenom}".strip()
return f"{self.civilite_str} {prenom} {nom}".strip()
def nom_prenom(self) -> str:
"""Civilite NOM Prénom
Prend l'identité courante et non celle de l'état civil si elles diffèrent.
"""
return f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()} {self.prenom_str}"
@property
def prenom_str(self):
"""Prénom à afficher. Par exemple: "Jean-Christophe" """
@ -347,14 +359,15 @@ class Identite(models.ScoDocModel):
"Le mail associé à la première adresse de l'étudiant, ou None"
return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None
def get_formsemestres(self) -> list:
def get_formsemestres(self, recent_first=True) -> list:
"""Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit,
triée par date_debut
triée par date_debut, le plus récent d'abord (comme "sems" de scodoc7)
(si recent_first=False, le plus ancien en tête)
"""
return sorted(
[ins.formsemestre for ins in self.formsemestre_inscriptions],
key=attrgetter("date_debut"),
reverse=True,
reverse=recent_first,
)
def get_modimpls_by_formsemestre(
@ -393,6 +406,18 @@ class Identite(models.ScoDocModel):
modimpls_by_formsemestre[formsemestre.id] = modimpls_sem
return modimpls_by_formsemestre
def get_modimpls_from_formsemestre(
self, formsemestre: "FormSemestre"
) -> list["ModuleImpl"]:
"""
Liste des ModuleImpl auxquels l'étudiant est inscrit dans le formsemestre.
"""
modimpls = ModuleImpl.query.join(ModuleImplInscription).filter(
ModuleImplInscription.etudid == self.id,
ModuleImpl.formsemestre_id == formsemestre.id,
)
return modimpls.all()
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect.
@ -551,7 +576,7 @@ class Identite(models.ScoDocModel):
.all()
)
def inscription_courante(self):
def inscription_courante(self) -> "FormSemestreInscription | None":
"""La première inscription à un formsemestre _actuellement_ en cours.
None s'il n'y en a pas (ou plus, ou pas encore).
"""

View File

@ -71,6 +71,15 @@ class Evaluation(models.ScoDocModel):
EVALUATION_BONUS,
}
def type_abbrev(self) -> str:
"Le nom abrégé du type de cette éval."
return {
self.EVALUATION_NORMALE: "std",
self.EVALUATION_RATTRAPAGE: "rattrapage",
self.EVALUATION_SESSION2: "session 2",
self.EVALUATION_BONUS: "bonus",
}.get(self.evaluation_type, "?")
def __repr__(self):
return f"""<Evaluation {self.id} {
self.date_debut.isoformat() if self.date_debut else ''} "{
@ -417,12 +426,13 @@ class Evaluation(models.ScoDocModel):
return modified
def set_ue_poids(self, ue, poids: float) -> None:
"""Set poids évaluation vers cette UE"""
"""Set poids évaluation vers cette UE. Commit."""
self.update_ue_poids_dict({ue.id: poids})
def set_ue_poids_dict(self, ue_poids_dict: dict) -> None:
"""set poids vers les UE (remplace existants)
ue_poids_dict = { ue_id : poids }
Commit session.
"""
from app.models.ues import UniteEns
@ -432,9 +442,12 @@ class Evaluation(models.ScoDocModel):
if ue is None:
raise ScoValueError("poids vers une UE inexistante")
ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids)
L.append(ue_poids)
db.session.add(ue_poids)
L.append(ue_poids)
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
db.session.commit()
self.moduleimpl.invalidate_evaluations_poids() # inval cache
def update_ue_poids_dict(self, ue_poids_dict: dict) -> None:

View File

@ -27,7 +27,7 @@ class Scolog(db.Model):
method = db.Column(db.Text)
msg = db.Column(db.Text)
etudid = db.Column(db.Integer) # sans contrainte pour garder logs après suppression
authenticated_user = db.Column(db.Text) # login, sans contrainte
authenticated_user = db.Column(db.Text) # user_name login, sans contrainte
# zope_remote_addr suppressed
@classmethod
@ -76,7 +76,9 @@ class ScolarNews(db.Model):
date = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), index=True
)
authenticated_user = db.Column(db.Text, index=True) # login, sans contrainte
authenticated_user = db.Column(
db.Text, index=True
) # user_name login, sans contrainte
# type in 'INSCR', 'NOTES', 'FORM', 'SEM', 'MISC'
type = db.Column(db.String(SHORT_STR_LEN), index=True)
object = db.Column(

View File

@ -36,6 +36,7 @@ from app.models.config import ScoDocSiteConfig
from app.models.departements import Departement
from app.models.etudiants import Identite
from app.models.evaluations import Evaluation
from app.models.events import ScolarNews
from app.models.formations import Formation
from app.models.groups import GroupDescr, Partition
from app.models.moduleimpls import (
@ -207,6 +208,70 @@ class FormSemestre(models.ScoDocModel):
).first_or_404()
return cls.query.filter_by(id=formsemestre_id).first_or_404()
@classmethod
def create_formsemestre(cls, args: dict, silent=False) -> "FormSemestre":
"""Création d'un formsemestre, avec toutes les valeurs par défaut
et notification (sauf si silent).
Crée la partition par défaut.
"""
# was sco_formsemestre.do_formsemestre_create
if "dept_id" not in args:
args["dept_id"] = g.scodoc_dept_id
formsemestre: "FormSemestre" = cls.create_from_dict(args)
db.session.flush()
for etape in args["etapes"]:
formsemestre.add_etape(etape)
db.session.commit()
for u in args["responsables"]:
formsemestre.responsables.append(u)
# create default partition
partition = Partition(
formsemestre=formsemestre, partition_name=None, numero=1000000
)
db.session.add(partition)
partition.create_group(default=True)
db.session.commit()
if not silent:
url = url_for(
"notes.formsemestre_status",
scodoc_dept=formsemestre.departement.acronym,
formsemestre_id=formsemestre.id,
)
ScolarNews.add(
typ=ScolarNews.NEWS_SEM,
text=f"""Création du semestre <a href="{url}">{formsemestre.titre}</a>""",
url=url,
max_frequency=0,
)
return formsemestre
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict.
args: dict with args in application.
returns: dict to store in model's db.
"""
if "date_debut" in args:
args["date_debut"] = scu.convert_fr_date(args["date_debut"])
if "date_fin" in args:
args["date_fin"] = scu.convert_fr_date(args["date_debut"])
if "etat" in args:
args["etat"] = bool(args["etat"])
if "bul_bgcolor" in args:
args["bul_bgcolor"] = args.get("bul_bgcolor") or "white"
if "titre" in args:
args["titre"] = args.get("titre") or "sans titre"
return args
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
Add 'etapes' to excluded."""
# on ne peut pas affecter directement etapes
return super().filter_model_attributes(data, (excluded or set()) | {"etapes"})
def sort_key(self) -> tuple:
"""clé pour tris par ordre de date_debut, le plus ancien en tête
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
@ -610,6 +675,41 @@ class FormSemestre(models.ScoDocModel):
)
)
@classmethod
def est_in_semestre_scolaire(
cls,
date_debut: datetime.date,
year=False,
periode=None,
mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
) -> bool:
"""Vrai si la date_debut est dans la période indiquée (1,2,0)
du semestre `periode` de l'année scolaire indiquée
(ou, à défaut, de celle en cours).
La période utilise les même conventions que semset["sem_id"];
* 1 : première période
* 2 : deuxième période
* 0 ou période non précisée: annualisé (donc inclut toutes les périodes)
)
"""
if not year:
year = scu.annee_scolaire()
# n'utilise pas le jour pivot
jour_pivot_annee = jour_pivot_periode = 1
# calcule l'année universitaire et la période
sem_annee, sem_periode = cls.comp_periode(
date_debut,
mois_pivot_annee,
mois_pivot_periode,
jour_pivot_annee,
jour_pivot_periode,
)
if periode is None or periode == 0:
return sem_annee == year
return sem_annee == year and sem_periode == periode
def est_terminal(self) -> bool:
"Vrai si dernier semestre de son cursus (ou formation mono-semestre)"
return (self.semestre_id < 0) or (
@ -694,7 +794,7 @@ class FormSemestre(models.ScoDocModel):
FormSemestre.titre,
)
def etapes_apo_vdi(self) -> list[ApoEtapeVDI]:
def etapes_apo_vdi(self) -> list["ApoEtapeVDI"]:
"Liste des vdis"
# was read_formsemestre_etapes
return [e.as_apovdi() for e in self.etapes if e.etape_apo]
@ -707,9 +807,9 @@ class FormSemestre(models.ScoDocModel):
return ""
return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
def add_etape(self, etape_apo: str):
def add_etape(self, etape_apo: str | ApoEtapeVDI):
"Ajoute une étape"
etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=etape_apo)
etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=str(etape_apo))
db.session.add(etape)
def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
@ -938,7 +1038,7 @@ class FormSemestre(models.ScoDocModel):
def etudids_actifs(self) -> tuple[list[int], set[int]]:
"""Liste les etudids inscrits (incluant DEM et DEF),
qui ser al'index des dataframes de notes
qui sera l'index des dataframes de notes
et donne l'ensemble des inscrits non DEM ni DEF.
"""
return [inscr.etudid for inscr in self.inscriptions], {
@ -1225,10 +1325,18 @@ class FormSemestreEtape(db.Model):
"Etape False if code empty"
return self.etape_apo is not None and (len(self.etape_apo) > 0)
def __eq__(self, other):
if isinstance(other, ApoEtapeVDI):
return self.as_apovdi() == other
return str(self) == str(other)
def __repr__(self):
return f"<Etape {self.id} apo={self.etape_apo!r}>"
def as_apovdi(self) -> ApoEtapeVDI:
def __str__(self):
return self.etape_apo or ""
def as_apovdi(self) -> "ApoEtapeVDI":
return ApoEtapeVDI(self.etape_apo)
@ -1381,8 +1489,9 @@ class FormSemestreInscription(db.Model):
def __repr__(self):
return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={
self.formsemestre_id} etat={self.etat} {
('parcours='+str(self.parcour)) if self.parcour else ''}>"""
self.formsemestre_id} (S{self.formsemestre.semestre_id}) etat={self.etat} {
('parcours="'+str(self.parcour.code)+'"') if self.parcour else ''
} {('etape="'+self.etape+'"') if self.etape else ''}>"""
class NotesSemSet(db.Model):

View File

@ -93,6 +93,10 @@ class Partition(ScoDocModel):
):
group.remove_etud(etud)
def is_default(self) -> bool:
"vrai si partition par défault (tous les étudiants)"
return not self.partition_name
def is_parcours(self) -> bool:
"Vrai s'il s'agit de la partition de parcours"
return self.partition_name == scu.PARTITION_PARCOURS

View File

@ -6,6 +6,7 @@ from flask import abort, g
from flask_login import current_user
from flask_sqlalchemy.query import Query
import app
from app import db
from app.auth.models import User
from app.comp import df_cache
@ -78,7 +79,9 @@ class ModuleImpl(ScoDocModel):
] or self.module.get_edt_ids()
def get_evaluations_poids(self) -> pd.DataFrame:
"""Les poids des évaluations vers les UE (accès via cache)"""
"""Les poids des évaluations vers les UEs (accès via cache redis).
Toutes les évaluations sont considérées (normales, bonus, rattr., etc.)
"""
evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id)
if evaluations_poids is None:
from app.comp import moy_mod
@ -108,20 +111,37 @@ class ModuleImpl(ScoDocModel):
"""Invalide poids cachés"""
df_cache.EvaluationsPoidsCache.delete(self.id)
def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool:
"""true si les poids des évaluations du module permettent de satisfaire
les coefficients du PN.
def check_apc_conformity(
self, res: "ResultatsSemestreBUT", evaluation_type=Evaluation.EVALUATION_NORMALE
) -> bool:
"""true si les poids des évaluations du type indiqué (normales par défaut)
du module permettent de satisfaire les coefficients du PN.
"""
# appelé par formsemestre_status, liste notes, et moduleimpl_status
if not self.module.formation.get_cursus().APC_SAE or (
self.module.module_type != scu.ModuleType.RESSOURCE
and self.module.module_type != scu.ModuleType.SAE
self.module.module_type
not in {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE}
):
return True # Non BUT, toujours conforme
from app.comp import moy_mod
mod_results = res.modimpls_results.get(self.id)
if mod_results is None:
app.critical_error("check_apc_conformity: err 1")
selected_evaluations_ids = [
eval_id
for eval_id, eval_type in mod_results.evals_type.items()
if eval_type == evaluation_type
]
if not selected_evaluations_ids:
return True # conforme si pas d'évaluations
selected_evaluations_poids = self.get_evaluations_poids().loc[
selected_evaluations_ids
]
return moy_mod.moduleimpl_is_conforme(
self,
self.get_evaluations_poids(),
selected_evaluations_poids,
res.modimpl_coefs_df,
)
@ -233,6 +253,27 @@ class ModuleImpl(ScoDocModel):
return False
return True
def can_change_inscriptions(self, user: User | None = None, raise_exc=True) -> bool:
"""check si user peut inscrire/désinsincrire des étudiants à ce module.
Autorise ScoEtudInscrit ou responsables semestre.
"""
user = current_user if user is None else user
if not self.formsemestre.etat:
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")
return False
# -- check access
# resp. module ou ou perm. EtudInscrit ou resp. semestre
if (
user.id != self.responsable_id
and not user.has_permission(Permission.EtudInscrit)
and user.id not in (u.id for u in self.formsemestre.responsables)
):
if raise_exc:
raise AccessDenied(f"Modification impossible pour {user}")
return False
return True
def est_inscrit(self, etud: Identite) -> bool:
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre).

View File

@ -340,6 +340,21 @@ class Module(models.ScoDocModel):
# Liste seulement les coefs définis:
return [(c.ue, c.coef) for c in self.get_ue_coefs_sorted()]
def get_ue_coefs_descr(self) -> str:
"""Description des coefficients vers les UEs (APC)"""
coefs_descr = ", ".join(
[
f"{ue.acronyme}: {co}"
for ue, co in self.ue_coefs_list()
if isinstance(co, float) and co > 0
]
)
if coefs_descr:
descr = "Coefs: " + coefs_descr
else:
descr = "(pas de coefficients) "
return descr
def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée (codés en base comme "VRT1,VRT2")"""
if self.code_apogee:

View File

@ -46,6 +46,8 @@ class UniteEns(models.ScoDocModel):
# coef UE, utilise seulement si l'option use_ue_coefs est activée:
coefficient = db.Column(db.Float)
# id de l'élément Apogée du RCUE (utilisé pour les UEs de sem. pair du BUT)
code_apogee_rcue = db.Column(db.String(APO_CODE_STR_LEN))
# coef. pour le calcul de moyennes de RCUE. Par défaut, 1.
coef_rcue = db.Column(db.Float, nullable=False, default=1.0, server_default="1.0")
@ -274,6 +276,12 @@ class UniteEns(models.ScoDocModel):
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
def get_codes_apogee_rcue(self) -> set[str]:
"""Les codes Apogée RCUE (codés en base comme "VRT1,VRT2")"""
if self.code_apogee_rcue:
return {x.strip() for x in self.code_apogee_rcue.split(",") if x}
return set()
def _parcours_niveaux_ids(self, parcours=list[ApcParcours]) -> set[int]:
"""set des ids de niveaux communs à tous les parcours listés"""
return set.intersection(

View File

@ -176,6 +176,7 @@ class GenTable:
self.xml_link = xml_link
# HTML parameters:
if not table_id: # random id
log("Warning: GenTable() called without table_id")
self.table_id = "gt_" + str(random.randint(0, 1000000))
else:
self.table_id = table_id
@ -312,9 +313,12 @@ class GenTable:
T.append(l + [self.bottom_titles.get(cid, "") for cid in self.columns_ids])
return T
def get_titles_list(self):
def get_titles_list(self, with_lines_titles=True):
"list of titles"
return [self.titles.get(cid, "") for cid in self.columns_ids]
titles = [self.titles.get(cid, "") for cid in self.columns_ids]
if with_lines_titles:
titles.insert(0, "")
return titles
def gen(self, fmt="html", columns_ids=None):
"""Build representation of the table in the specified format.

View File

@ -25,8 +25,7 @@
#
##############################################################################
"""HTML Header/Footer for ScoDoc pages
"""
"""HTML Header/Footer for ScoDoc pages"""
import html
@ -101,7 +100,7 @@ _HTML_BEGIN = f"""<!DOCTYPE html>
<script src="{scu.STATIC_DIR}/libjs/menu.js"></script>
<script src="{scu.STATIC_DIR}/libjs/bubble.js"></script>
<script>
window.onload=function(){{enableTooltips("gtrcontent")}};
window.onload=function(){{enableTooltips("gtrcontent"); enableTooltips("sidebar");}};
</script>
<script src="{scu.STATIC_DIR}/jQuery/jquery.js"></script>
@ -218,7 +217,7 @@ def sco_header(
<script src="{scu.STATIC_DIR}/libjs/menu.js"></script>
<script src="{scu.STATIC_DIR}/libjs/bubble.js"></script>
<script>
window.onload=function(){{enableTooltips("gtrcontent")}};
window.onload=function(){{enableTooltips("gtrcontent"); enableTooltips("sidebar");}};
const SCO_URL="{url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)}";
const SCO_TIMEZONE="{scu.TIME_ZONE}";

View File

@ -28,6 +28,7 @@
"""
Génération de la "sidebar" (marge gauche des pages HTML)
"""
from flask import render_template, url_for
from flask import g, request
from flask_login import current_user
@ -151,7 +152,7 @@ def sidebar(etudid: int = None):
H = [
f"""
<!-- sidebar py -->
<div class="sidebar">
<div class="sidebar" id="sidebar">
{ sidebar_common() }
<div class="box-chercheetud">Chercher étudiant:<br>
<form method="get" id="form-chercheetud"
@ -193,7 +194,7 @@ def sidebar(etudid: int = None):
formsemestre.date_debut.strftime(scu.DATE_FMT)
} au {
formsemestre.date_fin.strftime(scu.DATE_FMT)
}">({
}" data-tooltip>({
sco_preferences.get_preference("assi_metrique", None)})
<br>{nbabsjust:1g} J., {nbabsnj:1g} N.J.</span>"""
)
@ -227,12 +228,9 @@ def sidebar(etudid: int = None):
<li><a href="{ url_for('assiduites.calendrier_assi_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Calendrier</a></li>
<li><a href="{ url_for('assiduites.liste_assiduites_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Liste</a></li>
<li><a href="{ url_for('assiduites.bilan_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Bilan</a></li>
}" title="Les pages bilan et liste ont été fusionnées">Liste/Bilan</a></li>
</ul>
"""
)

View File

@ -157,5 +157,6 @@ def table_billets(
rows=rows,
html_sortable=True,
html_class="table_leftalign",
table_id="table_billets",
)
return tab

View File

@ -31,6 +31,7 @@
Il suffit d'appeler abs_notify() après chaque ajout d'absence.
"""
import datetime
from typing import Optional

View File

@ -288,6 +288,7 @@ def apo_table_compare_etud_results(A, B):
html_class="table_leftalign",
html_with_td_classes=True,
preferences=sco_preferences.SemPreferences(),
table_id="apo_table_compare_etud_results",
)
return T

View File

@ -43,14 +43,13 @@ import re
import time
from zipfile import ZipFile
from flask import send_file
from flask import g, send_file
import numpy as np
from app import log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.comp.res_but import ResultatsSemestreBUT
from app.models import (
ApcValidationAnnee,
ApcValidationRCUE,
@ -79,7 +78,6 @@ from app.scodoc.codes_cursus import (
)
from app.scodoc import sco_cursus
from app.scodoc import sco_formsemestre
from app.scodoc import sco_etud
def _apo_fmt_note(note, fmt="%3.2f"):
@ -99,7 +97,7 @@ class EtuCol:
"""Valeurs colonnes d'un element pour un etudiant"""
def __init__(self, nip, apo_elt, init_vals):
pass # XXX
pass
ETUD_OK = "ok"
@ -132,7 +130,7 @@ class ApoEtud(dict):
"Vrai si BUT"
self.col_elts = {}
"{'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}}"
self.etud: Identite = None
self.etud: Identite | None = None
"etudiant ScoDoc associé"
self.etat = None # ETUD_OK, ...
self.is_nar = False
@ -150,9 +148,9 @@ class ApoEtud(dict):
_apo_fmt_note, fmt=ScoDocSiteConfig.get_code_apo("NOTES_FMT") or "3.2f"
)
# Initialisés par associate_sco:
self.autre_sem: dict = None
self.autre_formsemestre: FormSemestre = None
self.autre_res: NotesTableCompat = None
self.cur_sem: dict = None
self.cur_formsemestre: FormSemestre = None
self.cur_res: NotesTableCompat = None
self.new_cols = {}
"{ col_id : value to record in csv }"
@ -171,24 +169,18 @@ class ApoEtud(dict):
Sinon, cherche le semestre, et met l'état à ETUD_OK ou ETUD_NON_INSCRIT.
"""
# futur: #WIP
# etud: Identite = Identite.query.filter_by(code_nip=self["nip"], dept_id=g.scodoc_dept_id).first()
# self.etud = etud
etuds = sco_etud.get_etud_info(code_nip=self["nip"], filled=True)
if not etuds:
self.etud = Identite.query.filter_by(
code_nip=self["nip"], dept_id=g.scodoc_dept_id
).first()
if not self.etud:
# pas dans ScoDoc
self.etud = None
self.log.append("non inscrit dans ScoDoc")
self.etat = ETUD_ORPHELIN
else:
# futur: #WIP
# formsemestre_ids = {
# ins.formsemestre_id for ins in etud.formsemestre_inscriptions
# }
# in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids)
self.etud = etuds[0]
# cherche le semestre ScoDoc correspondant à l'un de ceux de l'etape:
formsemestre_ids = {s["formsemestre_id"] for s in self.etud["sems"]}
formsemestre_ids = {
ins.formsemestre_id for ins in self.etud.formsemestre_inscriptions
}
in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids)
if not in_formsemestre_ids:
self.log.append(
@ -228,7 +220,8 @@ class ApoEtud(dict):
self.new_cols[col_id] = self.cols[col_id]
except KeyError as exc:
raise ScoFormatError(
f"""Fichier Apogee invalide : ligne mal formatée ? <br>colonne <tt>{col_id}</tt> non déclarée ?"""
f"""Fichier Apogee invalide : ligne mal formatée ? <br>colonne <tt>{
col_id}</tt> non déclarée ?"""
) from exc
else:
try:
@ -254,7 +247,7 @@ class ApoEtud(dict):
# codes = set([apo_data.apo_csv.cols[col_id].code for col_id in apo_data.apo_csv.col_ids])
# return codes - set(sco_elts)
def search_elt_in_sem(self, code, sem) -> dict:
def search_elt_in_sem(self, code: str, sem: dict) -> dict:
"""
VET code jury etape (en BUT, le code annuel)
ELP élément pédagogique: UE, module
@ -267,13 +260,17 @@ class ApoEtud(dict):
Args:
code (str): code apo de l'element cherché
sem (dict): semestre dans lequel on cherche l'élément
cur_sem (dict): semestre "courant" pour résultats annuels (VET)
autre_sem (dict): autre semestre utilisé pour calculer les résultats annuels (VET)
Utilise notamment:
cur_formsemestre : semestre "courant" pour résultats annuels (VET)
autre_formsemestre : autre formsemestre utilisé pour les résultats annuels (VET)
Returns:
dict: with N, B, J, R keys, ou None si elt non trouvé
"""
etudid = self.etud["etudid"]
if not self.etud:
return None
etudid = self.etud.id
if not self.cur_res:
log("search_elt_in_sem: no cur_res !")
return None
@ -316,10 +313,10 @@ class ApoEtud(dict):
code in {x.strip() for x in sem["elt_annee_apo"].split(",")}
):
export_res_etape = self.export_res_etape
if (not export_res_etape) and self.cur_sem:
if (not export_res_etape) and self.cur_formsemestre:
# exporte toujours le résultat de l'étape si l'étudiant est diplômé
Se = sco_cursus.get_situation_etud_cursus(
self.etud, self.cur_sem["formsemestre_id"]
self.etud, self.cur_formsemestre.id
)
export_res_etape = Se.all_other_validated()
@ -377,6 +374,20 @@ class ApoEtud(dict):
if module_code_found:
return VOID_APO_RES
# RCUE du BUT (validations enregistrées seulement, pas avant jury)
if res.is_apc:
for val_rcue in ApcValidationRCUE.query.filter_by(
etudid=etudid, formsemestre_id=sem["formsemestre_id"]
):
if code in val_rcue.get_codes_apogee():
return dict(
N="", # n'exporte pas de moyenne RCUE
B=20,
J="",
R=ScoDocSiteConfig.get_code_apo(val_rcue.code),
M="",
)
#
return None # element Apogee non trouvé dans ce semestre
@ -418,11 +429,10 @@ class ApoEtud(dict):
#
# XXX cette règle est discutable, à valider
# log('comp_elt_annuel cur_sem=%s autre_sem=%s' % (cur_sem['formsemestre_id'], autre_sem['formsemestre_id']))
if not self.cur_sem:
if not self.cur_formsemestre:
# l'étudiant n'a pas de semestre courant ?!
self.log.append("pas de semestre courant")
log(f"comp_elt_annuel: etudid {etudid} has no cur_sem")
log(f"comp_elt_annuel: etudid {etudid} has no cur_formsemestre")
return VOID_APO_RES
if self.is_apc:
@ -438,7 +448,7 @@ class ApoEtud(dict):
# ne touche pas aux RATs
return VOID_APO_RES
if not self.autre_sem:
if not self.autre_formsemestre:
# formations monosemestre, ou code VET semestriel,
# ou jury intermediaire et etudiant non redoublant...
return self.comp_elt_semestre(self.cur_res, cur_decision, etudid)
@ -518,7 +528,7 @@ class ApoEtud(dict):
self.validation_annee_but: ApcValidationAnnee = (
ApcValidationAnnee.query.filter_by(
formsemestre_id=formsemestre.id,
etudid=self.etud["etudid"],
etudid=self.etud.id,
referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id,
).first()
)
@ -527,7 +537,7 @@ class ApoEtud(dict):
)
def etud_set_semestres_de_etape(self, apo_data: "ApoData"):
"""Set .cur_sem and .autre_sem et charge les résultats.
"""Set .cur_formsemestre and .autre_formsemestre et charge les résultats.
Lorsqu'on a une formation semestrialisée mais avec un code étape annuel,
il faut considérer les deux semestres ((S1,S2) ou (S3,S4)) pour calculer
le code annuel (VET ou VRT1A (voir elt_annee_apo)).
@ -535,52 +545,48 @@ class ApoEtud(dict):
Pour les jurys intermediaires (janvier, S1 ou S3): (S2 ou S4) de la même
étape lors d'une année précédente ?
Set cur_sem: le semestre "courant" et autre_sem, ou None s'il n'y en a pas.
Set cur_formsemestre: le formsemestre "courant"
et autre_formsemestre, ou None s'il n'y en a pas.
"""
# Cherche le semestre "courant":
cur_sems = [
sem
for sem in self.etud["sems"]
# Cherche le formsemestre "courant":
cur_formsemestres = [
formsemestre
for formsemestre in self.etud.get_formsemestres()
if (
(sem["semestre_id"] == apo_data.cur_semestre_id)
and (apo_data.etape in sem["etapes"])
(formsemestre.semestre_id == apo_data.cur_semestre_id)
and (apo_data.etape in formsemestre.etapes)
and (
sco_formsemestre.sem_in_semestre_scolaire(
sem,
FormSemestre.est_in_semestre_scolaire(
formsemestre.date_debut,
apo_data.annee_scolaire,
0, # annee complete
)
)
)
]
if not cur_sems:
cur_sem = None
else:
# prend le plus recent avec decision
cur_sem = None
for sem in cur_sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
cur_formsemestre = None
if cur_formsemestres:
# prend le plus récent avec décision
for formsemestre in cur_formsemestres:
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
has_decision = res.etud_has_decision(self.etud["etudid"])
if has_decision:
cur_sem = sem
if apo_data.export_res_sdj or res.etud_has_decision(self.etud.id):
cur_formsemestre = formsemestre
self.cur_res = res
break
if cur_sem is None:
cur_sem = cur_sems[0] # aucun avec décision, prend le plus recent
if res.formsemestre.id == cur_sem["formsemestre_id"]:
if cur_formsemestres is None:
cur_formsemestre = cur_formsemestres[
0
] # aucun avec décision, prend le plus recent
if res.formsemestre.id == cur_formsemestre.id:
self.cur_res = res
else:
formsemestre = FormSemestre.query.get_or_404(
cur_sem["formsemestre_id"]
)
self.cur_res = res_sem.load_formsemestre_results(formsemestre)
self.cur_res = res_sem.load_formsemestre_results(cur_formsemestre)
self.cur_sem = cur_sem
self.cur_formsemestre = cur_formsemestre
if apo_data.cur_semestre_id <= 0:
# "autre_sem" non pertinent pour sessions sans semestres:
self.autre_sem = None
# autre_formsemestre non pertinent pour sessions sans semestres:
self.autre_formsemestre = None
self.autre_res = None
return
@ -601,52 +607,49 @@ class ApoEtud(dict):
courant_mois_debut = 1 # ou 2 (fev-jul)
else:
raise ValueError("invalid periode value !") # bug ?
courant_date_debut = "%d-%02d-01" % (
courant_annee_debut,
courant_mois_debut,
courant_date_debut = datetime.date(
day=1, month=courant_mois_debut, year=courant_annee_debut
)
else:
courant_date_debut = "9999-99-99"
courant_date_debut = datetime.date(day=31, month=12, year=9999)
# etud['sems'] est la liste des semestres de l'étudiant, triés par date,
# le plus récemment effectué en tête.
# Cherche les semestres (antérieurs) de l'indice autre de la même étape apogée
# s'il y en a plusieurs, choisit le plus récent ayant une décision
autres_sems = []
for sem in self.etud["sems"]:
for formsemestre in self.etud.get_formsemestres():
if (
sem["semestre_id"] == autre_semestre_id
and apo_data.etape_apogee in sem["etapes"]
formsemestre.semestre_id == autre_semestre_id
and apo_data.etape_apogee in formsemestre.etapes
):
if (
sem["date_debut_iso"] < courant_date_debut
formsemestre.date_debut < courant_date_debut
): # on demande juste qu'il ait démarré avant
autres_sems.append(sem)
autres_sems.append(formsemestre)
if not autres_sems:
autre_sem = None
autre_formsemestre = None
elif len(autres_sems) == 1:
autre_sem = autres_sems[0]
autre_formsemestre = autres_sems[0]
else:
autre_sem = None
for sem in autres_sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
autre_formsemestre = None
for formsemestre in autres_sems:
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if res.is_apc:
has_decision = res.etud_has_decision(self.etud["etudid"])
has_decision = res.etud_has_decision(self.etud.id)
else:
has_decision = res.get_etud_decision_sem(self.etud["etudid"])
if has_decision:
autre_sem = sem
has_decision = res.get_etud_decision_sem(self.etud.id)
if has_decision or apo_data.export_res_sdj:
autre_formsemestre = formsemestre
break
if autre_sem is None:
autre_sem = autres_sems[0] # aucun avec decision, prend le plus recent
if autre_formsemestre is None:
autre_formsemestre = autres_sems[
0
] # aucun avec decision, prend le plus recent
self.autre_sem = autre_sem
self.autre_formsemestre = autre_formsemestre
# Charge les résultats:
if autre_sem:
formsemestre = FormSemestre.query.get_or_404(autre_sem["formsemestre_id"])
self.autre_res = res_sem.load_formsemestre_results(formsemestre)
if autre_formsemestre:
self.autre_res = res_sem.load_formsemestre_results(self.autre_formsemestre)
else:
self.autre_res = None
@ -873,6 +876,16 @@ class ApoData:
codes_ues = set().union(
*[ue.get_codes_apogee() for ue in formsemestre.get_ues(with_sport=True)]
)
codes_rcues = (
set().union(
*[
ue.get_codes_apogee_rcue()
for ue in formsemestre.get_ues(with_sport=True)
]
)
if self.is_apc
else set()
)
s = set()
codes_by_sem[sem["formsemestre_id"]] = s
for col_id in self.apo_csv.col_ids[4:]:
@ -885,13 +898,18 @@ class ApoData:
if code in codes_ues:
s.add(code)
continue
# associé à un RCUE BUT
if code in codes_rcues:
s.add(code)
continue
# associé à un module:
if code in codes_modules:
s.add(code)
# log('codes_by_sem=%s' % pprint.pformat(codes_by_sem))
return codes_by_sem
def build_cr_table(self):
def build_cr_table(self) -> GenTable:
"""Table compte rendu des décisions"""
rows = [] # tableau compte rendu des decisions
for apo_etud in self.etuds:
@ -913,13 +931,14 @@ class ApoData:
columns_ids = ["NIP", "nom", "prenom"]
columns_ids.extend(("etape", "etape_note", "est_NAR", "commentaire"))
T = GenTable(
table = GenTable(
columns_ids=columns_ids,
titles=dict(zip(columns_ids, columns_ids)),
rows=rows,
table_id="build_cr_table",
xls_sheet_name="Decisions ScoDoc",
)
return T
return table
def build_adsup_table(self):
"""Construit une table listant les ADSUP émis depuis les formsemestres
@ -969,6 +988,7 @@ class ApoData:
"rcue": "RCUE",
},
rows=rows,
table_id="adsup_table",
xls_sheet_name="ADSUPs",
)
@ -1054,6 +1074,7 @@ def nar_etuds_table(apo_data, nar_etuds):
columns_ids=columns_ids,
titles=dict(zip(columns_ids, columns_ids)),
rows=rows,
table_id="nar_etuds_table",
xls_sheet_name="NAR ScoDoc",
)
return table.excel()

View File

@ -139,7 +139,7 @@ class BaseArchiver:
dirs = glob.glob(base + "*")
return [os.path.split(x)[1] for x in dirs]
def list_obj_archives(self, oid: int, dept_id: int = None):
def list_obj_archives(self, oid: int, dept_id: int = None) -> list[str]:
"""Returns
:return: list of archive identifiers for this object (paths to non empty dirs)
"""

View File

@ -751,7 +751,7 @@ def formsemestre_get_assiduites_count(
) -> tuple[int, int, int]:
"""Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
Utilise un cache.
Utilise un cache (si moduleimpl_id n'est pas spécifié).
"""
metrique = sco_preferences.get_preference("assi_metrique", formsemestre.id)
return get_assiduites_count_in_interval(
@ -779,7 +779,7 @@ def get_assiduites_count_in_interval(
"""Les comptes d'absences de cet étudiant entre ces deux dates, incluses:
tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
On peut spécifier les dates comme datetime ou iso.
Utilise un cache.
Utilise un cache (si moduleimpl_id n'est pas spécifié).
"""
date_debut_iso = date_debut_iso or date_debut.strftime("%Y-%m-%d")
date_fin_iso = date_fin_iso or date_fin.strftime("%Y-%m-%d")

View File

@ -0,0 +1,102 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 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
#
##############################################################################
"""Rapport de bug ScoDoc
Permet de créer un rapport de bug (ticket) sur la plateforme git scodoc.org.
Le principe est le suivant:
1- Si l'utilisateur le demande, on dump la base de données et on l'envoie
2- ScoDoc envoie une requête POST à scodoc.org pour qu'un ticket git soit créé avec les
informations fournies par l'utilisateur + quelques métadonnées.
"""
from flask import g
from flask_login import current_user
import requests
import app.scodoc.sco_utils as scu
import sco_version
from app import log
from app.scodoc.sco_dump_db import sco_dump_and_send_db
from app.scodoc.sco_exceptions import ScoValueError
def sco_bug_report(
title: str = "", message: str = "", etab: str = "", include_dump: bool = False
) -> requests.Response:
"""Envoi d'un bug report (ticket)"""
dump_id = None
if include_dump:
dump = sco_dump_and_send_db()
try:
dump_id = dump.json()["dump_id"]
except (requests.exceptions.JSONDecodeError, KeyError):
dump_id = "inconnu (erreur)"
log(f"sco_bug_report: {scu.SCO_BUG_REPORT_URL} by {current_user.user_name}")
try:
r = requests.post(
scu.SCO_BUG_REPORT_URL,
json={
"ticket": {
"title": title,
"message": message,
"etab": etab,
"dept": getattr(g, "scodoc_dept", "-"),
},
"user": {
"name": current_user.get_nomcomplet(),
"email": current_user.email,
},
"dump": {
"included": include_dump,
"id": dump_id,
},
"scodoc": {
"version": sco_version.SCOVERSION,
},
},
timeout=scu.SCO_ORG_TIMEOUT,
)
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
log("ConnectionError: Impossible de joindre le serveur d'assistance")
raise ScoValueError(
"""
Impossible de joindre le serveur d'assistance (scodoc.org).
Veuillez contacter le service informatique de votre établissement pour
corriger la configuration de ScoDoc. Dans la plupart des cas, il
s'agit d'un proxy mal configuré.
"""
) from exc
return r

View File

@ -226,6 +226,7 @@ class BulletinGenerator:
server_name=self.server_name,
filigranne=self.filigranne,
preferences=sco_preferences.SemPreferences(formsemestre_id),
with_page_numbers=self.multi_pages,
)
)
try:

View File

@ -106,6 +106,7 @@ def assemble_bulletins_pdf(
pagesbookmarks=pagesbookmarks,
filigranne=filigranne,
preferences=sco_preferences.SemPreferences(formsemestre_id),
with_page_numbers=False, # on ne veut pas de no de pages sur les bulletins imprimés en masse
)
)
document.multiBuild(story)
@ -122,7 +123,8 @@ def replacement_function(match) -> str:
return r'<img %s src="%s"%s/>' % (match.group(2), logo.filepath, match.group(4))
raise ScoValueError(
'balise "%s": logo "%s" introuvable'
% (pydoc.html.escape(balise), pydoc.html.escape(name))
% (pydoc.html.escape(balise), pydoc.html.escape(name)),
safe=True,
)

View File

@ -114,6 +114,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
html_class="notes_bulletin",
html_class_ignore_default=True,
html_with_td_classes=True,
table_id="std_bul_table",
)
return T.gen(fmt=fmt)

View File

@ -274,6 +274,7 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa
"""expire cache pour un semestre (ou tous ceux du département si formsemestre_id non spécifié).
Si pdfonly, n'expire que les bulletins pdf cachés.
"""
from app.comp import df_cache
from app.models.formsemestre import FormSemestre
from app.scodoc import sco_cursus
@ -315,12 +316,14 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa
and fid in g.formsemestre_results_cache
):
del g.formsemestre_results_cache[fid]
df_cache.EvaluationsPoidsCache.invalidate_sem(formsemestre_id)
else:
# optimization when we invalidate all evaluations:
EvaluationCache.invalidate_all_sems()
df_cache.EvaluationsPoidsCache.invalidate_all()
if hasattr(g, "formsemestre_results_cache"):
del g.formsemestre_results_cache
SemInscriptionsCache.delete_many(formsemestre_ids)
ResultatsSemestreCache.delete_many(formsemestre_ids)
ValidationsSemestreCache.delete_many(formsemestre_ids)

View File

@ -141,6 +141,7 @@ def formsemestre_table_estim_cost(
""",
origin=f"""Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}""",
filename=f"EstimCout-S{formsemestre.semestre_id}",
table_id="formsemestre_table_estim_cost",
)
return tab

View File

@ -34,13 +34,13 @@ from app.scodoc import sco_cursus_dut
from app.comp.res_compat import NotesTableCompat
from app.comp import res_sem
from app.models import FormSemestre
from app.models import FormSemestre, Identite
import app.scodoc.notesdb as ndb
# SituationEtudParcours -> get_situation_etud_cursus
def get_situation_etud_cursus(
etud: dict, formsemestre_id: int
etud: Identite, formsemestre_id: int
) -> sco_cursus_dut.SituationEtudCursus:
"""renvoie une instance de SituationEtudCursus (ou sous-classe spécialisée)"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)

View File

@ -31,7 +31,7 @@
from app import db
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, UniteEns, ScolarAutorisationInscription
from app.models import FormSemestre, Identite, ScolarAutorisationInscription, UniteEns
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
@ -115,14 +115,22 @@ class SituationEtudCursus:
class SituationEtudCursusClassic(SituationEtudCursus):
"Semestre dans un parcours"
def __init__(self, etud: dict, formsemestre_id: int, nt: NotesTableCompat):
def __init__(self, etud: Identite, formsemestre_id: int, nt: NotesTableCompat):
"""
etud: dict filled by fill_etuds_info()
"""
assert formsemestre_id == nt.formsemestre.id
self.etud = etud
self.etudid = etud["etudid"]
self.etudid = etud.id
self.formsemestre_id = formsemestre_id
self.sem = sco_formsemestre.get_formsemestre(formsemestre_id)
self.formsemestres: list[FormSemestre] = []
"les semestres parcourus, le plus ancien en tête"
self.sem = sco_formsemestre.get_formsemestre(
formsemestre_id
) # TODO utiliser formsemestres
self.cur_sem: FormSemestre = nt.formsemestre
self.can_compensate: set[int] = set()
"les formsemestre_id qui peuvent compenser le courant"
self.nt: NotesTableCompat = nt
self.formation = self.nt.formsemestre.formation
self.parcours = self.nt.parcours
@ -130,18 +138,20 @@ class SituationEtudCursusClassic(SituationEtudCursus):
# pour le DUT, le dernier est toujours S4.
# Ici: terminal si semestre == NB_SEM ou bien semestre_id==-1
# (licences et autres formations en 1 seule session))
self.semestre_non_terminal = self.sem["semestre_id"] != self.parcours.NB_SEM
if self.sem["semestre_id"] == NO_SEMESTRE_ID:
self.semestre_non_terminal = self.cur_sem.semestre_id != self.parcours.NB_SEM
if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
self.semestre_non_terminal = False
# Liste des semestres du parcours de cet étudiant:
self._comp_semestres()
# Determine le semestre "precedent"
self.prev_formsemestre_id = self._search_prev()
self._search_prev()
# Verifie barres
self._comp_barres()
# Verifie compensation
if self.prev and self.sem["gestion_compensation"]:
self.can_compensate_with_prev = self.prev["can_compensate"]
if self.prev_formsemestre and self.cur_sem.gestion_compensation:
self.can_compensate_with_prev = (
self.prev_formsemestre.id in self.can_compensate
)
else:
self.can_compensate_with_prev = False
@ -170,14 +180,14 @@ class SituationEtudCursusClassic(SituationEtudCursus):
if rule.conclusion[0] in self.parcours.UNUSED_CODES:
continue
# Saute regles REDOSEM si pas de semestres decales:
if (not self.sem["gestion_semestrielle"]) and rule.conclusion[
if (not self.cur_sem.gestion_semestrielle) and rule.conclusion[
3
] == "REDOSEM":
continue
if rule.match(state):
if rule.conclusion[0] == ADC:
# dans les regles on ne peut compenser qu'avec le PRECEDENT:
fiduc = self.prev_formsemestre_id
fiduc = self.prev_formsemestre.id
assert fiduc
else:
fiduc = None
@ -203,8 +213,8 @@ class SituationEtudCursusClassic(SituationEtudCursus):
"Phrase d'explication pour le code devenir"
if not devenir:
return ""
s = self.sem["semestre_id"] # numero semestre courant
if s < 0: # formation sans semestres (eg licence)
s_idx = self.cur_sem.semestre_id # numero semestre courant
if s_idx < 0: # formation sans semestres (eg licence)
next_s = 1
else:
next_s = self._get_next_semestre_id()
@ -219,27 +229,27 @@ class SituationEtudCursusClassic(SituationEtudCursus):
elif devenir == REO:
return "Réorienté"
elif devenir == REDOANNEE:
return "Redouble année (recommence %s%s)" % (SA, (s - 1))
return "Redouble année (recommence %s%s)" % (SA, (s_idx - 1))
elif devenir == REDOSEM:
return "Redouble semestre (recommence en %s%s)" % (SA, s)
return "Redouble semestre (recommence en %s%s)" % (SA, s_idx)
elif devenir == RA_OR_NEXT:
return passage + ", ou redouble année (en %s%s)" % (SA, (s - 1))
return passage + ", ou redouble année (en %s%s)" % (SA, (s_idx - 1))
elif devenir == RA_OR_RS:
return "Redouble semestre %s%s, ou redouble année (en %s%s)" % (
SA,
s,
s_idx,
SA,
s - 1,
s_idx - 1,
)
elif devenir == RS_OR_NEXT:
return passage + ", ou semestre %s%s" % (SA, s)
return passage + ", ou semestre %s%s" % (SA, s_idx)
elif devenir == NEXT_OR_NEXT2:
return passage + ", ou en semestre %s%s" % (
SA,
s + 2,
s_idx + 2,
) # coherent avec get_next_semestre_ids
elif devenir == NEXT2:
return "Passe en %s%s" % (SA, s + 2)
return "Passe en %s%s" % (SA, s_idx + 2)
else:
log("explique_devenir: code devenir inconnu: %s" % devenir)
return "Code devenir inconnu !"
@ -258,7 +268,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
def _sems_validated(self, exclude_current=False):
"True si semestres du parcours validés"
if self.sem["semestre_id"] == NO_SEMESTRE_ID:
if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
# mono-semestre: juste celui ci
decision = self.nt.get_etud_decision_sem(self.etudid)
return decision and code_semestre_validant(decision["code"])
@ -266,8 +276,8 @@ class SituationEtudCursusClassic(SituationEtudCursus):
to_validate = set(
range(1, self.parcours.NB_SEM + 1)
) # ensemble des indices à valider
if exclude_current and self.sem["semestre_id"] in to_validate:
to_validate.remove(self.sem["semestre_id"])
if exclude_current and self.cur_sem.semestre_id in to_validate:
to_validate.remove(self.cur_sem.semestre_id)
return self._sem_list_validated(to_validate)
def can_jump_to_next2(self):
@ -275,20 +285,20 @@ class SituationEtudCursusClassic(SituationEtudCursus):
Il faut donc que tous les semestres 1...n-1 soient validés et que n+1 soit en attente.
(et que le sem courant n soit validé, ce qui n'est pas testé ici)
"""
n = self.sem["semestre_id"]
if not self.sem["gestion_semestrielle"]:
s_idx = self.cur_sem.semestre_id
if not self.cur_sem.gestion_semestrielle:
return False # pas de semestre décalés
if n == NO_SEMESTRE_ID or n > self.parcours.NB_SEM - 2:
if s_idx == NO_SEMESTRE_ID or s_idx > self.parcours.NB_SEM - 2:
return False # n+2 en dehors du parcours
if self._sem_list_validated(set(range(1, n))):
# antérieurs validé, teste suivant
n1 = n + 1
for sem in self.get_semestres():
if self._sem_list_validated(set(range(1, s_idx))):
# antérieurs validés, teste suivant
n1 = s_idx + 1
for formsemestre in self.formsemestres:
if (
sem["semestre_id"] == n1
and sem["formation_code"] == self.formation.formation_code
formsemestre.semestre_id == n1
and formsemestre.formation.formation_code
== self.formation.formation_code
):
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre
)
@ -315,19 +325,17 @@ class SituationEtudCursusClassic(SituationEtudCursus):
return not sem_idx_set
def _comp_semestres(self):
# etud['sems'] est trie par date decroissante (voir fill_etuds_info)
if not "sems" in self.etud:
self.etud["sems"] = sco_etud.etud_inscriptions_infos(
self.etud["etudid"], self.etud["ne"]
)["sems"]
sems = self.etud["sems"][:] # copy
sems.reverse()
# plus ancien en tête:
self.formsemestres = self.etud.get_formsemestres(recent_first=False)
# Nb max d'UE et acronymes
ue_acros = {} # acronyme ue : 1
nb_max_ue = 0
for sem in sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
sems = []
for formsemestre in self.formsemestres: # plus ancien en tête
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
sem = formsemestre.to_dict()
sems.append(sem)
ues = nt.get_ues_stat_dict(filter_sport=True)
for ue in ues:
ue_acros[ue["acronyme"]] = 1
@ -338,37 +346,48 @@ class SituationEtudCursusClassic(SituationEtudCursus):
sem["formation_code"] = formsemestre.formation.formation_code
# si sem peut servir à compenser le semestre courant, positionne
# can_compensate
sem["can_compensate"] = self.check_compensation_dut(sem, nt)
if self.check_compensation_dut(sem, nt):
self.can_compensate.add(formsemestre.id)
self.ue_acros = list(ue_acros.keys())
self.ue_acros.sort()
self.nb_max_ue = nb_max_ue
self.sems = sems
def get_semestres(self):
def get_semestres(self) -> list[dict]:
"""Liste des semestres dans lesquels a été inscrit
l'étudiant (quelle que soit la formation), le plus ancien en tête"""
return self.sems
def get_cursus_descr(self, filter_futur=False):
def get_cursus_descr(self, filter_futur=False, filter_formation_code=False):
"""Description brève du parcours: "S1, S2, ..."
Si filter_futur, ne mentionne pas les semestres qui sont après le semestre courant.
Si filter_formation_code, restreint aux semestres de même code formation que le courant.
"""
cur_begin_date = self.sem["dateord"]
cur_begin_date = self.cur_sem.date_debut
cur_formation_code = self.cur_sem.formation.formation_code
p = []
for s in self.sems:
if s["ins"]["etat"] == scu.DEMISSION:
for formsemestre in self.formsemestres:
inscription = formsemestre.etuds_inscriptions.get(self.etud.id)
if inscription is None:
raise ValueError("Etudiant non inscrit au semestre") # bug
if inscription.etat == scu.DEMISSION:
dem = " (dem.)"
else:
dem = ""
if filter_futur and s["dateord"] > cur_begin_date:
if filter_futur and formsemestre.date_debut > cur_begin_date:
continue # skip semestres demarrant apres le courant
SA = self.parcours.SESSION_ABBRV # 'S' ou 'A'
if s["semestre_id"] < 0:
SA = "A" # force, cas des DUT annuels par exemple
p.append("%s%d%s" % (SA, -s["semestre_id"], dem))
if (
filter_formation_code
and formsemestre.formation.formation_code != cur_formation_code
):
continue # restreint aux semestres de la formation courante (pour les PV)
session_abbrv = self.parcours.SESSION_ABBRV # 'S' ou 'A'
if formsemestre.semestre_id < 0:
session_abbrv = "A" # force, cas des DUT annuels par exemple
p.append("%s%d%s" % (session_abbrv, -formsemestre.semestre_id, dem))
else:
p.append("%s%d%s" % (SA, s["semestre_id"], dem))
p.append("%s%d%s" % (session_abbrv, formsemestre.semestre_id, dem))
return ", ".join(p)
def get_parcours_decisions(self):
@ -377,7 +396,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
Returns: { semestre_id : code }
"""
r = {}
if self.sem["semestre_id"] == NO_SEMESTRE_ID:
if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
indices = [NO_SEMESTRE_ID]
else:
indices = list(range(1, self.parcours.NB_SEM + 1))
@ -420,22 +439,22 @@ class SituationEtudCursusClassic(SituationEtudCursus):
"true si ce semestre pourrait etre compensé par un autre (e.g. barres UE > 8)"
return self.barres_ue_ok
def _search_prev(self):
def _search_prev(self) -> FormSemestre | None:
"""Recherche semestre 'precedent'.
return prev_formsemestre_id
positionne .prev_decision
"""
self.prev = None
self.prev_formsemestre = None
self.prev_decision = None
if len(self.sems) < 2:
if len(self.formsemestres) < 2:
return None
# Cherche sem courant dans la liste triee par date_debut
cur = None
icur = -1
for cur in self.sems:
for cur in self.formsemestres:
icur += 1
if cur["formsemestre_id"] == self.formsemestre_id:
if cur.id == self.formsemestre_id:
break
if not cur or cur["formsemestre_id"] != self.formsemestre_id:
if not cur or cur.id != self.formsemestre_id:
log(
f"*** SituationEtudCursus: search_prev: cur not found (formsemestre_id={self.formsemestre_id}, etudid={self.etudid})"
)
@ -443,60 +462,59 @@ class SituationEtudCursusClassic(SituationEtudCursus):
# Cherche semestre antérieur de même formation (code) et semestre_id precedent
#
# i = icur - 1 # part du courant, remonte vers le passé
i = len(self.sems) - 1 # par du dernier, remonte vers le passé
prev = None
i = len(self.formsemestres) - 1 # par du dernier, remonte vers le passé
prev_formsemestre = None
while i >= 0:
if (
self.sems[i]["formation_code"] == self.formation.formation_code
and self.sems[i]["semestre_id"] == cur["semestre_id"] - 1
self.formsemestres[i].formation.formation_code
== self.formation.formation_code
and self.formsemestres[i].semestre_id == cur.semestre_id - 1
):
prev = self.sems[i]
prev_formsemestre = self.formsemestres[i]
break
i -= 1
if not prev:
if not prev_formsemestre:
return None # pas de precedent trouvé
self.prev = prev
self.prev_formsemestre = prev_formsemestre
# Verifications basiques:
# ?
# Code etat du semestre precedent:
formsemestre = FormSemestre.query.get_or_404(prev["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_formsemestre)
self.prev_decision = nt.get_etud_decision_sem(self.etudid)
self.prev_moy_gen = nt.get_etud_moy_gen(self.etudid)
self.prev_barres_ue_ok = nt.etud_check_conditions_ues(self.etudid)[0]
return self.prev["formsemestre_id"]
def get_next_semestre_ids(self, devenir):
def get_next_semestre_ids(self, devenir: str) -> list[int]:
"""Liste des numeros de semestres autorises avec ce devenir
Ne vérifie pas que le devenir est possible (doit être fait avant),
juste que le rang du semestre est dans le parcours [1..NB_SEM]
"""
s = self.sem["semestre_id"]
s_idx = self.cur_sem.semestre_id
if devenir == NEXT:
ids = [self._get_next_semestre_id()]
elif devenir == REDOANNEE:
ids = [s - 1]
ids = [s_idx - 1]
elif devenir == REDOSEM:
ids = [s]
ids = [s_idx]
elif devenir == RA_OR_NEXT:
ids = [s - 1, self._get_next_semestre_id()]
ids = [s_idx - 1, self._get_next_semestre_id()]
elif devenir == RA_OR_RS:
ids = [s - 1, s]
ids = [s_idx - 1, s_idx]
elif devenir == RS_OR_NEXT:
ids = [s, self._get_next_semestre_id()]
ids = [s_idx, self._get_next_semestre_id()]
elif devenir == NEXT_OR_NEXT2:
ids = [
self._get_next_semestre_id(),
s + 2,
s_idx + 2,
] # cohérent avec explique_devenir()
elif devenir == NEXT2:
ids = [s + 2]
ids = [s_idx + 2]
else:
ids = [] # reoriente ou autre: pas de next !
# clip [1..NB_SEM]
r = []
for idx in ids:
if idx > 0 and idx <= self.parcours.NB_SEM:
if 0 < idx <= self.parcours.NB_SEM:
r.append(idx)
return r
@ -504,27 +522,27 @@ class SituationEtudCursusClassic(SituationEtudCursus):
"""Indice du semestre suivant non validé.
S'il n'y en a pas, ramène NB_SEM+1
"""
s = self.sem["semestre_id"]
if s >= self.parcours.NB_SEM:
s_idx = self.cur_sem.semestre_id
if s_idx >= self.parcours.NB_SEM:
return self.parcours.NB_SEM + 1
validated = True
while validated and (s < self.parcours.NB_SEM):
s = s + 1
while validated and (s_idx < self.parcours.NB_SEM):
s_idx = s_idx + 1
# semestre s validé ?
validated = False
for sem in self.sems:
for formsemestre in self.formsemestres:
if (
sem["formation_code"] == self.formation.formation_code
and sem["semestre_id"] == s
formsemestre.formation.formation_code
== self.formation.formation_code
and formsemestre.semestre_id == s_idx
):
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre
)
decision = nt.get_etud_decision_sem(self.etudid)
if decision and code_semestre_validant(decision["code"]):
validated = True
return s
return s_idx
def valide_decision(self, decision):
"""Enregistre la decision (instance de DecisionSem)
@ -539,8 +557,11 @@ class SituationEtudCursusClassic(SituationEtudCursus):
fsid = decision.formsemestre_id_utilise_pour_compenser
if fsid:
ok = False
for sem in self.sems:
if sem["formsemestre_id"] == fsid and sem["can_compensate"]:
for formsemestre in self.formsemestres:
if (
formsemestre.id == fsid
and formsemestre.id in self.can_compensate
):
ok = True
break
if not ok:
@ -581,7 +602,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
decision.assiduite,
)
# -- modification du code du semestre precedent
if self.prev and decision.new_code_prev:
if self.prev_formsemestre and decision.new_code_prev:
if decision.new_code_prev == ADC:
# ne compense le prec. qu'avec le sem. courant
fsid = self.formsemestre_id
@ -589,7 +610,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
fsid = None
to_invalidate += formsemestre_update_validation_sem(
cnx,
self.prev["formsemestre_id"],
self.prev_formsemestre.id,
self.etudid,
decision.new_code_prev,
assidu=True,
@ -601,18 +622,18 @@ class SituationEtudCursusClassic(SituationEtudCursus):
etudid=self.etudid,
commit=False,
msg="formsemestre_id=%s code=%s"
% (self.prev["formsemestre_id"], decision.new_code_prev),
% (self.prev_formsemestre.id, decision.new_code_prev),
)
# modifs des codes d'UE (pourraient passer de ADM a CMP, meme sans modif des notes)
formsemestre_validate_ues(
self.prev["formsemestre_id"],
self.prev_formsemestre.id,
self.etudid,
decision.new_code_prev,
decision.assiduite, # attention: en toute rigueur il faudrait utiliser une indication de l'assiduite au sem. precedent, que nous n'avons pas...
)
sco_cache.invalidate_formsemestre(
formsemestre_id=self.prev["formsemestre_id"]
formsemestre_id=self.prev_formsemestre.id
) # > modif decisions jury (sem, UE)
try:
@ -694,7 +715,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
class SituationEtudCursusECTS(SituationEtudCursusClassic):
"""Gestion parcours basés sur ECTS"""
def __init__(self, etud, formsemestre_id, nt):
def __init__(self, etud: Identite, formsemestre_id: int, nt):
SituationEtudCursusClassic.__init__(self, etud, formsemestre_id, nt)
def could_be_compensated(self):

View File

@ -222,6 +222,7 @@ def table_debouche_etudids(etudids, keep_numeric=True):
html_sortable=True,
html_class="table_leftalign table_listegroupe",
preferences=sco_preferences.SemPreferences(),
table_id="table_debouche_etudids",
)
return tab

View File

@ -198,6 +198,18 @@ def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable
if current_user.has_permission(Permission.EditApogee):
html_class += " apo_editable"
tab = GenTable(
columns_ids=columns_ids,
html_class_ignore_default=True,
html_class=html_class,
html_sortable=True,
html_table_attrs=f"""
data-apo_save_url="{url_for('notes.formsemestre_set_apo_etapes', scodoc_dept=g.scodoc_dept)}"
data-elt_annee_apo_save_url="{url_for('notes.formsemestre_set_elt_annee_apo', scodoc_dept=g.scodoc_dept)}"
data-elt_sem_apo_save_url="{url_for('notes.formsemestre_set_elt_sem_apo', scodoc_dept=g.scodoc_dept)}"
""",
html_with_td_classes=True,
preferences=sco_preferences.SemPreferences(),
rows=sems,
titles={
"formsemestre_id": "id",
"semestre_id_n": "S#",
@ -211,19 +223,7 @@ def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable
"elt_sem_apo": "Elt. sem. Apo.",
"formation": "Formation",
},
columns_ids=columns_ids,
rows=sems,
table_id="semlist",
html_class_ignore_default=True,
html_class=html_class,
html_sortable=True,
html_table_attrs=f"""
data-apo_save_url="{url_for('notes.formsemestre_set_apo_etapes', scodoc_dept=g.scodoc_dept)}"
data-elt_annee_apo_save_url="{url_for('notes.formsemestre_set_elt_annee_apo', scodoc_dept=g.scodoc_dept)}"
data-elt_sem_apo_save_url="{url_for('notes.formsemestre_set_elt_sem_apo', scodoc_dept=g.scodoc_dept)}"
""",
html_with_td_classes=True,
preferences=sco_preferences.SemPreferences(),
)
return tab

View File

@ -67,7 +67,7 @@ SCO_DUMP_LOCK = "/tmp/scodump.lock"
def sco_dump_and_send_db(
message: str = "", request_url: str = "", traceback_str_base64: str = ""
):
) -> requests.Response:
"""Dump base de données et l'envoie anonymisée pour debug"""
traceback_str = base64.urlsafe_b64decode(traceback_str_base64).decode(
scu.SCO_ENCODING
@ -97,7 +97,6 @@ def sco_dump_and_send_db(
# Send
r = _send_db(ano_db_name, message, request_url, traceback_str=traceback_str)
code = r.status_code
finally:
# Drop anonymized database
@ -107,7 +106,7 @@ def sco_dump_and_send_db(
log("sco_dump_and_send_db: done.")
return code
return r
def _duplicate_db(db_name, ano_db_name):
@ -216,11 +215,11 @@ def _drop_ano_db(ano_db_name):
log("_drop_ano_db: no temp db, nothing to drop")
return
cmd = ["dropdb", ano_db_name]
log("sco_dump_and_send_db: {}".format(cmd))
log(f"sco_dump_and_send_db: {cmd}")
try:
_ = subprocess.check_output(cmd)
except subprocess.CalledProcessError as e:
log("sco_dump_and_send_db: exception dropdb {}".format(e))
except subprocess.CalledProcessError as exc:
log(f"sco_dump_and_send_db: exception dropdb {exc}")
raise ScoValueError(
"erreur lors de la suppression de la base {}".format(ano_db_name)
)
f"erreur lors de la suppression de la base {ano_db_name}"
) from exc

View File

@ -326,6 +326,7 @@ def do_formation_create(args: dict) -> Formation:
scodoc_dept=g.scodoc_dept,
formation_id=formation.id,
),
safe=True,
) from exc
ScolarNews.add(

View File

@ -890,23 +890,6 @@ def module_edit(
)
# Edition en ligne du code Apogee
def edit_module_set_code_apogee(id=None, value=None):
"Set UE code apogee"
module_id = id
value = str(value).strip("-_ \t")
log("edit_module_set_code_apogee: module_id=%s code_apogee=%s" % (module_id, value))
modules = module_list(args={"module_id": module_id})
if not modules:
return "module invalide" # should not occur
do_module_edit({"module_id": module_id, "code_apogee": value})
if not value:
value = scu.APO_MISSING_CODE_STR
return value
def module_table(formation_id):
"""Liste des modules de la formation
(XXX inutile ou a revoir)

View File

@ -84,6 +84,7 @@ _ueEditor = ndb.EditableTable(
"ects",
"is_external",
"code_apogee",
"code_apogee_rcue",
"coefficient",
"coef_rcue",
"color",
@ -425,6 +426,20 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"max_length": APO_CODE_STR_LEN,
},
),
]
if is_apc:
form_descr += [
(
"code_apogee_rcue",
{
"title": "Code Apogée du RCUE",
"size": 25,
"explanation": "(optionnel) code(s) élément pédagogique Apogée du RCUE",
"max_length": APO_CODE_STR_LEN,
},
),
]
form_descr += [
(
"is_external",
{
@ -1041,10 +1056,10 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
if current_user.has_permission(Permission.EditFormSemestre):
H.append(
f"""<ul>
<li><a class="stdlink" href="{
<li><b><a class="stdlink" href="{
url_for('notes.formsemestre_createwithmodules', scodoc_dept=g.scodoc_dept,
formation_id=formation_id, semestre_id=1)
}">Mettre en place un nouveau semestre de formation {formation.acronyme}</a>
}">Mettre en place un nouveau semestre de formation {formation.acronyme}</a></b>
</li>
</ul>"""
)
@ -1109,12 +1124,18 @@ def _ue_table_ues(
klass = "span_apo_edit"
else:
klass = ""
ue["code_apogee_str"] = (
""", Apo: <span class="%s" data-url="edit_ue_set_code_apogee" id="%s" data-placeholder="%s">"""
% (klass, ue["ue_id"], scu.APO_MISSING_CODE_STR)
+ (ue["code_apogee"] or "")
+ "</span>"
edit_url = url_for(
"apiweb.ue_set_code_apogee",
scodoc_dept=g.scodoc_dept,
ue_id=ue["ue_id"],
)
ue[
"code_apogee_str"
] = f""", Apo: <span
class="{klass}" data-url="{edit_url}" id="{ue['ue_id']}"
data-placeholder="{scu.APO_MISSING_CODE_STR}">{
ue["code_apogee"] or ""
}</span>"""
if cur_ue_semestre_id != ue["semestre_id"]:
cur_ue_semestre_id = ue["semestre_id"]
@ -1348,16 +1369,17 @@ def _ue_table_modules(
heurescoef = (
"%(heures_cours)s/%(heures_td)s/%(heures_tp)s, coef. %(coefficient)s" % mod
)
if mod_editable:
klass = "span_apo_edit"
else:
klass = ""
heurescoef += (
', Apo: <span class="%s" data-url="edit_module_set_code_apogee" id="%s" data-placeholder="%s">'
% (klass, mod["module_id"], scu.APO_MISSING_CODE_STR)
+ (mod["code_apogee"] or "")
+ "</span>"
edit_url = url_for(
"apiweb.module_set_code_apogee",
scodoc_dept=g.scodoc_dept,
module_id=mod["module_id"],
)
heurescoef += f""", Apo: <span
class="{'span_apo_edit' if editable else ''}"
data-url="{edit_url}" id="{mod["module_id"]}"
data-placeholder="{scu.APO_MISSING_CODE_STR}">{
mod["code_apogee"] or ""
}</span>"""
if tag_editable:
tag_cls = "module_tag_editor"
else:
@ -1493,28 +1515,6 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
formation.invalidate_module_coefs()
# essai edition en ligne:
def edit_ue_set_code_apogee(id=None, value=None):
"set UE code apogee"
ue_id = id
value = value.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque
log("edit_ue_set_code_apogee: ue_id=%s code_apogee=%s" % (ue_id, value))
ues = ue_list(args={"ue_id": ue_id})
if not ues:
return "ue invalide"
do_ue_edit(
{"ue_id": ue_id, "code_apogee": value},
bypass_lock=True,
dont_invalidate_cache=False,
)
if not value:
value = scu.APO_MISSING_CODE_STR
return value
def ue_is_locked(ue_id):
"""True if UE should not be modified
(contains modules used in a locked formsemestre)

View File

@ -30,6 +30,7 @@
Lecture et conversion des ics.
"""
from datetime import timezone
import glob
import os
@ -229,7 +230,7 @@ def translate_calendar(
heure_deb=event["heure_deb"],
heure_fin=event["heure_fin"],
moduleimpl_id=modimpl.id,
jour=event["jour"],
day=event["jour"],
)
if modimpl and group
else None

View File

@ -247,9 +247,7 @@ def apo_csv_check_etape(semset, set_nips, etape_apo):
return nips_ok, apo_nips, nips_no_apo, nips_no_sco, maq_elems, sem_elems
def apo_csv_semset_check(
semset, allow_missing_apo=False, allow_missing_csv=False
): # was apo_csv_check
def apo_csv_semset_check(semset, allow_missing_apo=False, allow_missing_csv=False):
"""
check students in stored maqs vs students in semset
Cas à détecter:
@ -346,120 +344,3 @@ def apo_csv_retreive_etuds_by_nip(semset, nips):
etuds[nip] = apo_etuds_by_nips.get(nip, {"nip": nip, "etape_apo": "?"})
return etuds
"""
Tests:
from debug import *
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_formsemestre
from app.scodoc.sco_etape_apogee import *
from app.scodoc.sco_apogee_csv import *
from app.scodoc.sco_semset import *
app.set_sco_dept('RT')
csv_data = open('/opt/misc/VDTRT_V1RT.TXT').read()
annee_scolaire=2015
sem_id=1
apo_data = sco_apogee_csv.ApoData(csv_data, periode=sem_id)
print apo_data.etape_apogee
apo_data.setup()
e = apo_data.etuds[0]
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco( apo_data)
print apo_csv_list_stored_archives()
# apo_csv_store(csv_data, annee_scolaire, sem_id)
groups_infos = sco_groups_view.DisplayedGroupsInfos( [sco_groups.get_default_group(formsemestre_id)], formsemestre_id=formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
#
s = SemSet('NSS29902')
apo_data = sco_apogee_csv.ApoData(open('/opt/scodoc/var/scodoc/archives/apo_csv/RT/2015-2/2016-07-10-11-26-15/V1RT.csv').read(), periode=1)
# cas Tiziri K. (inscrite en S1, démission en fin de S1, pas inscrite en S2)
# => pas de décision, ce qui est voulu (?)
#
apo_data.setup()
e = [ e for e in apo_data.etuds if e['nom'] == 'XYZ' ][0]
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco(apo_data)
self=e
col_id='apoL_c0129'
# --
from app.scodoc import sco_portal_apogee
_ = go_dept(app, 'GEA').Notes
#csv_data = sco_portal_apogee.get_maquette_apogee(etape='V1GE', annee_scolaire=2015)
csv_data = open('/tmp/V1GE.txt').read()
apo_data = sco_apogee_csv.ApoData(csv_data, periode=1)
# ------
# les elements inconnus:
from debug import *
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_formsemestre
from app.scodoc.sco_etape_apogee import *
from app.scodoc.sco_apogee_csv import *
from app.scodoc.sco_semset import *
_ = go_dept(app, 'RT').Notes
csv_data = open('/opt/misc/V2RT.csv').read()
annee_scolaire=2015
sem_id=1
apo_data = sco_apogee_csv.ApoData(csv_data, periode=1)
print apo_data.etape_apogee
apo_data.setup()
for e in apo_data.etuds:
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco(apo_data)
# ------
# test export jury intermediaire
from debug import *
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_formsemestre
from app.scodoc.sco_etape_apogee import *
from app.scodoc.sco_apogee_csv import *
from app.scodoc.sco_semset import *
_ = go_dept(app, 'CJ').Notes
csv_data = open('/opt/scodoc/var/scodoc/archives/apo_csv/CJ/2016-1/2017-03-06-21-46-32/V1CJ.csv').read()
annee_scolaire=2016
sem_id=1
apo_data = sco_apogee_csv.ApoData(csv_data, periode=1)
print apo_data.etape_apogee
apo_data.setup()
e = [ e for e in apo_data.etuds if e['nom'] == 'XYZ' ][0] #
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco(apo_data)
self=e
sco_elts = {}
col_id='apoL_c0001'
code = apo_data.cols[col_id]['Code'] # 'V1RT'
sem = apo_data.sems_periode[0] # le S1
"""

View File

@ -125,14 +125,19 @@ def apo_semset_maq_status(
H.append("""<p><em>Aucune maquette chargée</em></p>""")
# Upload fichier:
H.append(
"""<form id="apo_csv_add" action="view_apo_csv_store" method="post" enctype="multipart/form-data">
Charger votre fichier maquette Apogée:
f"""<form id="apo_csv_add" action="view_apo_csv_store"
method="post" enctype="multipart/form-data"
style="margin-bottom: 8px;"
>
<div style="margin-top: 12px; margin-bottom: 8px;">
{'Charger votre fichier' if tab_archives.is_empty() else 'Ajouter un autre fichier'}
maquette Apogée:
</div>
<input type="file" size="30" name="csvfile"/>
<input type="hidden" name="semset_id" value="%s"/>
<input type="hidden" name="semset_id" value="{semset_id}"/>
<input type="submit" value="Ajouter ce fichier"/>
<input type="checkbox" name="autodetect" checked/>autodétecter encodage</input>
</form>"""
% (semset_id,)
)
# Récupération sur portail:
maquette_url = sco_portal_apogee.get_maquette_url()
@ -335,7 +340,7 @@ def apo_semset_maq_status(
missing = maq_elems - sem_elems
H.append('<div id="apo_elements">')
H.append(
'<p>Elements Apogée: <span class="apo_elems">%s</span></p>'
'<p>Élements Apogée: <span class="apo_elems">%s</span></p>'
% ", ".join(
[
e if not e in missing else '<span class="missing">' + e + "</span>"
@ -351,7 +356,7 @@ def apo_semset_maq_status(
]
H.append(
f"""<div class="apo_csv_status_missing_elems">
<span class="fontred">Elements Apogée absents dans ScoDoc: </span>
<span class="fontred">Élements Apogée absents dans ScoDoc: </span>
<span class="apo_elems fontred">{
", ".join(sorted(missing))
}</span>
@ -442,11 +447,11 @@ def table_apo_csv_list(semset):
annee_scolaire = semset["annee_scolaire"]
sem_id = semset["sem_id"]
T = sco_etape_apogee.apo_csv_list_stored_archives(
rows = sco_etape_apogee.apo_csv_list_stored_archives(
annee_scolaire, sem_id, etapes=semset.list_etapes()
)
for t in T:
for t in rows:
# Ajoute qq infos pour affichage:
csv_data = sco_etape_apogee.apo_csv_get(t["etape_apo"], annee_scolaire, sem_id)
apo_data = sco_apogee_csv.ApoData(csv_data, periode=semset["sem_id"])
@ -484,12 +489,13 @@ def table_apo_csv_list(semset):
"date_str": "Enregistré le",
},
columns_ids=columns_ids,
rows=T,
rows=rows,
html_class="table_leftalign apo_maq_list",
html_sortable=True,
# base_url = '%s?formsemestre_id=%s' % (request.base_url, formsemestre_id),
# caption='Maquettes enregistrées',
preferences=sco_preferences.SemPreferences(),
table_id="apo_csv_list",
)
return tab
@ -582,6 +588,7 @@ def _view_etuds_page(
html_class="table_leftalign",
filename="students_apo",
preferences=sco_preferences.SemPreferences(),
table_id="view_etuds_page",
)
if fmt != "html":
return tab.make_page(fmt=fmt)
@ -798,6 +805,7 @@ def view_apo_csv(etape_apo="", semset_id="", fmt="html"):
filename="students_" + etape_apo,
caption="Étudiants Apogée en " + etape_apo,
preferences=sco_preferences.SemPreferences(),
table_id="view_apo_csv",
)
if fmt != "html":

View File

@ -93,7 +93,7 @@ import json
from flask import url_for, g
from app.scodoc.sco_portal_apogee import get_inscrits_etape
from app.scodoc import sco_portal_apogee
from app import log
from app.scodoc.sco_utils import annee_scolaire_debut
from app.scodoc.gen_tables import GenTable
@ -136,11 +136,16 @@ class DataEtudiant(object):
self.etudid = etudid
self.data_apogee = None
self.data_scodoc = None
self.etapes = set() # l'ensemble des étapes où il est inscrit
self.semestres = set() # l'ensemble des formsemestre_id où il est inscrit
self.tags = set() # les anomalies relevées
self.ind_row = "-" # là où il compte dans les effectifs (ligne et colonne)
self.etapes = set()
"l'ensemble des étapes où il est inscrit"
self.semestres = set()
"l'ensemble des formsemestre_id où il est inscrit"
self.tags = set()
"les anomalies relevées"
self.ind_row = "-"
"ligne où il compte dans les effectifs"
self.ind_col = "-"
"colonne où il compte dans les effectifs"
def add_etape(self, etape):
self.etapes.add(etape)
@ -163,9 +168,9 @@ class DataEtudiant(object):
def set_ind_col(self, indicatif):
self.ind_col = indicatif
def get_identity(self):
def get_identity(self) -> str:
"""
Calcul le nom/prénom de l'étudiant (données ScoDoc en priorité, sinon données Apogée)
Calcule le nom/prénom de l'étudiant (données ScoDoc en priorité, sinon données Apogée)
:return: L'identité calculée
"""
if self.data_scodoc is not None:
@ -176,9 +181,12 @@ class DataEtudiant(object):
def _help() -> str:
return """
<div id="export_help" class="pas_help"> <span>Explications sur les tableaux des effectifs et liste des
étudiants</span>
<div> <p>Le tableau des effectifs présente le nombre d'étudiants selon deux critères:</p>
<div id="export_help" class="pas_help">
<span>Explications sur les tableaux des effectifs
et liste des étudiants</span>
<div>
<p>Le tableau des effectifs présente le nombre d'étudiants selon deux critères:
</p>
<ul>
<li>En colonne le statut de l'étudiant par rapport à Apogée:
<ul>
@ -406,7 +414,8 @@ class EtapeBilan:
for key_etape in self.etapes:
annee_apogee, etapestr = key_to_values(key_etape)
self.etu_etapes[key_etape] = set()
for etud in get_inscrits_etape(etapestr, annee_apogee):
# get_inscrits_etape interroge portail Apo:
for etud in sco_portal_apogee.get_inscrits_etape(etapestr, annee_apogee):
key_etu = self.register_etud_apogee(etud, key_etape)
self.etu_etapes[key_etape].add(key_etu)
@ -444,7 +453,6 @@ class EtapeBilan:
data_etu = self.etudiants[key_etu]
ind_col = "-"
ind_row = "-"
# calcul de la colonne
if len(data_etu.etapes) == 1:
ind_col = self.indicatifs[list(data_etu.etapes)[0]]
@ -478,32 +486,34 @@ class EtapeBilan:
affichage de l'html
:return: Le code html à afficher
"""
if not sco_portal_apogee.has_portal():
return """<div id="synthese" class="semset_description">
<em>Pas de portail Apogée configuré</em>
</div>"""
self.load_listes() # chargement des données
self.dispatch() # analyse et répartition
# calcul de la liste des colonnes et des lignes de la table des effectifs
self.all_rows_str = "'" + ",".join(["." + r for r in self.all_rows_ind]) + "'"
self.all_cols_str = "'" + ",".join(["." + c for c in self.all_cols_ind]) + "'"
H = [
"""<div id="synthese" class="semset_description">
return f"""
<div id="synthese" class="semset_description">
<details open="true">
<summary><b>Tableau des effectifs</b>
</summary>
""",
self._diagtable(),
"""</details>""",
self.display_tags(),
"""<details open="true">
<summary><b id="effectifs">Liste des étudiants <span id="compte"></span></b>
</summary>
""",
entete_liste_etudiant(),
self.table_effectifs(),
"""</details>""",
_help(),
]
return "\n".join(H)
<summary><b>Tableau des effectifs</b>
</summary>
{self._diagtable()}
</details>
{self.display_tags()}
<details open="true">
<summary>
<b id="effectifs">Liste des étudiants <span id="compte"></span></b>
</summary>
{entete_liste_etudiant()}
{self.table_effectifs()}
</details>
{_help()}
</div>
"""
def _inc_count(self, ind_row, ind_col):
if (ind_row, ind_col) not in self.repartition:
@ -666,7 +676,9 @@ class EtapeBilan:
col_ids,
self.titres,
html_class="repartition",
html_sortable=True,
html_with_td_classes=True,
table_id="apo-repartition",
).gen(fmt="html")
)
return "\n".join(H)
@ -690,26 +702,34 @@ class EtapeBilan:
return "\n".join(H)
@staticmethod
def link_etu(etudid, nom):
return '<a class="stdlink" href="%s">%s</a>' % (
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid),
nom,
)
def link_etu(etudid, nom) -> str:
"Lien html vers fiche de l'étudiant"
return f"""<a class="stdlink" href="{
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}">{nom}</a>"""
def link_semestre(self, semestre, short=False):
if short:
return (
'<a class="stdlink" href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%('
"formsemestre_id)s</a> " % self.semestres[semestre]
)
else:
return (
'<a class="stdlink" href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titre_num)s'
" %(mois_debut)s - %(mois_fin)s)</a>" % self.semestres[semestre]
)
def link_semestre(self, semestre, short=False) -> str:
"Lien html vers tableau de bord semestre"
key = "session_id" if short else "titremois"
sem = self.semestres[semestre]
return f"""<a class="stdlink" href="{
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
formsemestre_id=sem['formsemestre_id']
)}">{sem[key]}</a>
"""
def table_effectifs(self):
H = []
def table_effectifs(self) -> str:
"Table html donnant les étudiants dans chaque semestre"
H = [
"""
<style>
table#apo-detail td.semestre {
white-space: nowrap;
word-break: normal;
}
</style>
"""
]
col_ids = ["tag", "etudiant", "prenom", "nip", "semestre", "apogee", "annee"]
titles = {
@ -762,9 +782,10 @@ class EtapeBilan:
rows,
col_ids,
titles,
table_id="detail",
html_class="table_leftalign",
html_sortable=True,
html_with_td_classes=True,
table_id="apo-detail",
).gen(fmt="html")
)
return "\n".join(H)

View File

@ -367,6 +367,9 @@ def evaluation_create_form(
+ "\n".join(H)
+ "\n"
+ tf[1]
+ render_template(
"scodoc/forms/evaluation_edit.j2",
)
+ render_template(
"scodoc/help/evaluations.j2", is_apc=is_apc, modimpl=modimpl
)

View File

@ -70,8 +70,8 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
Colonnes:
- code (UE ou module),
- titre
- type évaluation
- complete
- publiée
- inscrits (non dem. ni def.)
- nb notes manquantes
- nb ATT
@ -81,9 +81,10 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
rows = []
titles = {
"type": "",
"code": "Code",
"code": "Module",
"titre": "",
"date": "Date",
"type_evaluation": "Type",
"complete": "Comptée",
"inscrits": "Inscrits",
"manquantes": "Manquantes", # notes eval non entrées
@ -114,7 +115,9 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
rows.append(row)
line_idx += 1
for evaluation_id in modimpl_results.evals_notes:
e = db.session.get(Evaluation, evaluation_id)
e: Evaluation = db.session.get(Evaluation, evaluation_id)
if e is None:
continue # ignore errors (rare race conditions?)
eval_etat = modimpl_results.evaluations_etat[evaluation_id]
row = {
"type": "",
@ -128,6 +131,7 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
"_titre_target_attrs": 'class="discretelink"',
"date": e.date_debut.strftime(scu.DATE_FMT) if e.date_debut else "",
"_date_order": e.date_debut.isoformat() if e.date_debut else "",
"type_evaluation": e.type_abbrev(),
"complete": "oui" if eval_etat.is_complete else "non",
"_complete_target": "#",
"_complete_target_attrs": (

View File

@ -427,7 +427,9 @@ class JourEval(sco_gen_cal.Jour):
)
heure_fin_txt = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else ""
title = f"{e.description or e.moduleimpl.module.titre_str()}"
title = f"{e.moduleimpl.module.titre_str()}"
if e.description:
title += f" : {e.description}"
if heure_debut_txt:
title += f" de {heure_debut_txt} à {heure_fin_txt}"
@ -633,6 +635,7 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, fmt="html"):
base_url="%s?formsemestre_id=%s" % (request.base_url, formsemestre_id),
origin=f"""Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}""",
filename=scu.make_filename("evaluations_delais_" + formsemestre.titre_annee()),
table_id="formsemestre_evaluations_delai_correction",
)
return tab.make_page(fmt=fmt)

View File

@ -45,13 +45,17 @@ class ScoInvalidCSRF(ScoException):
class ScoValueError(ScoException):
"Exception avec page d'erreur utilisateur, et qui stoque dest_url"
"""Exception avec page d'erreur utilisateur
- dest_url : url aller après la page d'erreur
- safe (default False): si vrai, affiche le message non html quoté.
"""
# mal nommée: super classe de toutes les exceptions avec page
# d'erreur gentille.
def __init__(self, msg, dest_url=None):
def __init__(self, msg, dest_url=None, safe=False):
super().__init__(msg)
self.dest_url = dest_url
self.safe = safe # utilisé par template sco_value_error.j2
class ScoPermissionDenied(ScoValueError):

View File

@ -106,6 +106,7 @@ def _build_results_table(start_date=None, end_date=None, types_parcours=[]):
html_class="table_leftalign",
html_sortable=True,
preferences=sco_preferences.SemPreferences(),
table_id="export_result_table",
)
return tab, semlist

View File

@ -32,8 +32,7 @@ from flask import url_for, g, request
from flask_login import current_user
import app
from app.models import Departement
import app.scodoc.sco_utils as scu
from app.models import Departement, Identite
import app.scodoc.notesdb as ndb
from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header
@ -55,7 +54,9 @@ def form_search_etud(
"form recherche par nom"
H = []
H.append(
f"""<form action="{ url_for("scolar.search_etud_in_dept", scodoc_dept=g.scodoc_dept) }" method="POST">
f"""<form action="{
url_for("scolar.search_etud_in_dept", scodoc_dept=g.scodoc_dept)
}" method="POST">
<b>{title}</b>
<input type="text" name="expnom" class="in-expnom" width="12" spellcheck="false" value="">
<input type="submit" value="Chercher">
@ -100,9 +101,9 @@ def form_search_etud(
return "\n".join(H)
def search_etuds_infos_from_exp(expnom: str = "") -> list[dict]:
def search_etuds_infos_from_exp(expnom: str = "") -> list[Identite]:
"""Cherche étudiants, expnom peut être, dans cet ordre:
un etudid (int), un code NIP, ou le début d'un nom.
un etudid (int), un code NIP, ou une partie d'un nom (case insensitive).
"""
if not isinstance(expnom, int) and len(expnom) <= 1:
return [] # si expnom est trop court, n'affiche rien
@ -111,13 +112,22 @@ def search_etuds_infos_from_exp(expnom: str = "") -> list[dict]:
except ValueError:
etudid = None
if etudid is not None:
etuds = sco_etud.get_etud_info(filled=True, etudid=expnom)
if len(etuds) == 1:
return etuds
etud = Identite.query.filter_by(dept_id=g.scodoc_dept_id, id=etudid).first()
if etud:
return [etud]
expnom_str = str(expnom)
if scu.is_valid_code_nip(expnom_str):
return search_etuds_infos(code_nip=expnom_str)
return search_etuds_infos(expnom=expnom_str)
etuds = Identite.query.filter_by(
dept_id=g.scodoc_dept_id, code_nip=expnom_str
).all()
if etuds:
return etuds
return (
Identite.query.filter_by(dept_id=g.scodoc_dept_id)
.filter(Identite.nom.op("~*")(expnom_str))
.all()
)
def search_etud_in_dept(expnom=""):
@ -152,7 +162,7 @@ def search_etud_in_dept(expnom=""):
if len(etuds) == 1:
# va directement a la fiche
url_args["etudid"] = etuds[0]["etudid"]
url_args["etudid"] = etuds[0].id
return flask.redirect(url_for(endpoint, **url_args))
H = [
@ -179,14 +189,39 @@ def search_etud_in_dept(expnom=""):
)
if len(etuds) > 0:
# Choix dans la liste des résultats:
rows = []
e: Identite
for e in etuds:
url_args["etudid"] = e["etudid"]
url_args["etudid"] = e.id
target = url_for(endpoint, **url_args)
e["_nomprenom_target"] = target
e["inscription_target"] = target
e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"])
sco_groups.etud_add_group_infos(
e, e["cursem"]["formsemestre_id"] if e["cursem"] else None
cur_inscription = e.inscription_courante()
inscription = (
e.inscription_descr().get("inscription_str", "")
if cur_inscription
else ""
)
groupes = (
", ".join(
gr.group_name
for gr in sco_groups.get_etud_formsemestre_groups(
e, cur_inscription.formsemestre
)
)
if cur_inscription
else ""
)
rows.append(
{
"code_nip": e.code_nip or "",
"etudid": e.id,
"inscription": inscription,
"inscription_target": target,
"groupes": groupes,
"nomprenom": e.nomprenom,
"_nomprenom_target": target,
"_nomprenom_td_attrs": f'id="{e.id}" class="etudinfo"',
}
)
tab = GenTable(
@ -197,10 +232,11 @@ def search_etud_in_dept(expnom=""):
"inscription": "Inscription",
"groupes": "Groupes",
},
rows=etuds,
rows=rows,
html_sortable=True,
html_class="table_leftalign",
preferences=sco_preferences.SemPreferences(),
table_id="search_etud_in_dept",
)
H.append(tab.html())
if len(etuds) > 20: # si la page est grande
@ -213,15 +249,16 @@ def search_etud_in_dept(expnom=""):
)
)
else:
H.append('<h2 style="color: red;">Aucun résultat pour "%s".</h2>' % expnom)
H.append(f'<h2 style="color: red;">Aucun résultat pour "{expnom}".</h2>')
H.append(
"""<p class="help">La recherche porte sur tout ou partie du NOM ou du NIP de l'étudiant. Saisir au moins deux caractères.</p>"""
"""<p class="help">La recherche porte sur tout ou partie du NOM ou du NIP
de l'étudiant. Saisir au moins deux caractères.</p>"""
)
return "\n".join(H) + html_sco_header.sco_footer()
# Was chercheEtudsInfo()
def search_etuds_infos(expnom=None, code_nip=None):
def search_etuds_infos(expnom=None, code_nip=None) -> list[dict]:
"""recherche les étudiants correspondants à expnom ou au code_nip
et ramene liste de mappings utilisables en DTML.
"""
@ -264,7 +301,7 @@ def search_etud_by_name(term: str) -> list:
FROM identite
WHERE
dept_id = %(dept_id)s
AND code_nip LIKE %(beginning)s
AND code_nip ILIKE %(beginning)s
ORDER BY nom
""",
{"beginning": term + "%", "dept_id": g.scodoc_dept_id},
@ -283,7 +320,7 @@ def search_etud_by_name(term: str) -> list:
FROM identite
WHERE
dept_id = %(dept_id)s
AND nom LIKE %(beginning)s
AND nom ILIKE %(beginning)s
ORDER BY nom
""",
{"beginning": term + "%", "dept_id": g.scodoc_dept_id},
@ -348,6 +385,7 @@ def table_etud_in_accessible_depts(expnom=None):
rows=etuds,
html_sortable=True,
html_class="table_leftalign",
table_id="etud_in_accessible_depts",
)
H.append('<div class="table_etud_in_dept">')
@ -383,13 +421,13 @@ def search_inscr_etud_by_nip(code_nip, fmt="json"):
"""
result, _ = search_etud_in_accessible_depts(code_nip=code_nip)
T = []
rows = []
for etuds in result:
if etuds:
dept_id = etuds[0]["dept"]
for e in etuds:
for sem in e["sems"]:
T.append(
rows.append(
{
"dept": dept_id,
"etudid": e["etudid"],
@ -414,6 +452,6 @@ def search_inscr_etud_by_nip(code_nip, fmt="json"):
"date_debut_iso",
"date_fin_iso",
)
tab = GenTable(columns_ids=columns_ids, rows=T)
tab = GenTable(columns_ids=columns_ids, rows=rows, table_id="inscr_etud_by_nip")
return tab.make_page(fmt=fmt, with_html_headers=False, publish=True)

View File

@ -143,6 +143,7 @@ def formation_export_dict(
if not export_codes_apo:
ue_dict.pop("code_apogee", None)
ue_dict.pop("code_apogee_rcue", None)
if ue_dict.get("ects") is None:
ue_dict.pop("ects", None)
mats = sco_edit_matiere.matiere_list({"ue_id": ue.id})
@ -649,20 +650,20 @@ def formation_list_table(detail: bool) -> GenTable:
"semestres_ues": "Semestres avec UEs",
}
return GenTable(
columns_ids=columns_ids,
rows=rows,
titles=titles,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
caption=title,
html_caption=title,
table_id="formation_list_table",
html_class="formation_list_table table_leftalign",
html_with_td_classes=True,
html_sortable=True,
base_url=f"{request.base_url}" + ("?detail=on" if detail else ""),
caption=title,
columns_ids=columns_ids,
html_caption=title,
html_class="formation_list_table table_leftalign",
html_sortable=True,
html_with_td_classes=True,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
page_title=title,
pdf_title=title,
preferences=sco_preferences.SemPreferences(),
rows=rows,
table_id="formation_list_table",
titles=titles,
)

View File

@ -229,11 +229,14 @@ def etapes_apo_str(etapes):
return ", ".join([str(x) for x in etapes])
def do_formsemestre_create(args, silent=False):
def do_formsemestre_create( # DEPRECATED, use FormSemestre.create_formsemestre()
args, silent=False
):
"create a formsemestre"
from app.models import ScolarNews
from app.scodoc import sco_groups
log("Warning: do_formsemestre_create is deprecated")
cnx = ndb.GetDBConnexion()
formsemestre_id = _formsemestreEditor.create(cnx, args)
if args["etapes"]:
@ -419,49 +422,23 @@ def sem_set_responsable_name(sem):
)
def sem_in_semestre_scolaire(
sem,
year=False,
periode=None,
mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
) -> bool:
"""Vrai si la date du début du semestre est dans la période indiquée (1,2,0)
du semestre `periode` de l'année scolaire indiquée
(ou, à défaut, de celle en cours).
La période utilise les même conventions que semset["sem_id"];
* 1 : première période
* 2 : deuxième période
* 0 ou période non précisée: annualisé (donc inclut toutes les périodes)
)
"""
if not year:
year = scu.annee_scolaire()
# n'utilise pas le jour pivot
jour_pivot_annee = jour_pivot_periode = 1
# calcule l'année universitaire et la période
sem_annee, sem_periode = FormSemestre.comp_periode(
datetime.datetime.fromisoformat(sem["date_debut_iso"]),
mois_pivot_annee,
mois_pivot_periode,
jour_pivot_annee,
jour_pivot_periode,
)
if periode is None or periode == 0:
return sem_annee == year
return sem_annee == year and sem_periode == periode
def sem_in_annee_scolaire(sem, year=False):
def sem_in_annee_scolaire(sem: dict, year=False): # OBSOLETE
"""Test si sem appartient à l'année scolaire year (int).
N'utilise que la date de début, pivot au 1er août.
Si année non specifiée, année scolaire courante
"""
return sem_in_semestre_scolaire(sem, year, periode=0)
return FormSemestre.est_in_semestre_scolaire(
datetime.date.fromisoformat(sem["date_debut_iso"]), year, periode=0
)
def sem_est_courant(sem): # -> FormSemestre.est_courant
def sem_in_semestre_scolaire(sem, year=False, periode=None): # OBSOLETE
return FormSemestre.est_in_semestre_scolaire(
datetime.date.fromisoformat(sem["date_debut_iso"]), year, periode=periode
)
def sem_est_courant(sem: dict): # -> FormSemestre.est_courant
"""Vrai si la date actuelle (now) est dans le semestre (les dates de début et fin sont incluses)"""
now = time.strftime("%Y-%m-%d")
debut = ndb.DateDMYtoISO(sem["date_debut"])
@ -527,15 +504,16 @@ def table_formsemestres(
preferences = sco_preferences.SemPreferences()
tab = GenTable(
columns_ids=columns_ids,
rows=sems,
titles=titles,
html_class="table_leftalign",
html_empty_element="<p><em>aucun résultat</em></p>",
html_next_section=html_next_section,
html_sortable=True,
html_title=html_title,
html_next_section=html_next_section,
html_empty_element="<p><em>aucun résultat</em></p>",
page_title="Semestres",
preferences=preferences,
rows=sems,
table_id="table_formsemestres",
titles=titles,
)
return tab

View File

@ -37,16 +37,17 @@ from app import db
from app.auth.models import User
from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN
from app.models import (
Module,
ModuleImpl,
Evaluation,
UniteEns,
ScoDocSiteConfig,
ScolarFormSemestreValidation,
ScolarAutorisationInscription,
ApcValidationAnnee,
ApcValidationRCUE,
Evaluation,
FormSemestreUECoef,
Module,
ModuleImpl,
ScoDocSiteConfig,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
ScolarNews,
UniteEns,
)
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
@ -438,12 +439,14 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
"elt_sem_apo",
{
"size": 32,
"title": "Element(s) Apogée:",
"explanation": "associé(s) au résultat du semestre (ex: VRTW1). Inutile en BUT. Séparés par des virgules.",
"allow_null": not sco_preferences.get_preference(
"always_require_apo_sem_codes"
)
or (formsemestre and formsemestre.modalite == "EXT"),
"title": "Element(s) Apogée sem.:",
"explanation": """associé(s) au résultat du semestre (ex: VRTW1).
Inutile en BUT. Séparés par des virgules.""",
"allow_null": (
not sco_preferences.get_preference("always_require_apo_sem_codes")
or (formsemestre and formsemestre.modalite == "EXT")
or (formsemestre and formsemestre.formation.is_apc())
),
},
)
)
@ -452,7 +455,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
"elt_annee_apo",
{
"size": 32,
"title": "Element(s) Apogée:",
"title": "Element(s) Apogée année:",
"explanation": "associé(s) au résultat de l'année (ex: VRT1A). Séparés par des virgules.",
"allow_null": not sco_preferences.get_preference(
"always_require_apo_sem_codes"
@ -1249,7 +1252,7 @@ def formsemestre_clone(formsemestre_id):
raise ScoValueError("id responsable invalide")
new_formsemestre_id = do_formsemestre_clone(
formsemestre_id,
resp.id,
resp,
tf[2]["date_debut"],
tf[2]["date_fin"],
clone_evaluations=tf[2]["clone_evaluations"],
@ -1267,7 +1270,7 @@ def formsemestre_clone(formsemestre_id):
def do_formsemestre_clone(
orig_formsemestre_id,
responsable_id, # new resp.
responsable: User, # new resp.
date_debut,
date_fin, # 'dd/mm/yyyy'
clone_evaluations=False,
@ -1280,49 +1283,63 @@ def do_formsemestre_clone(
formsemestre_orig: FormSemestre = FormSemestre.query.get_or_404(
orig_formsemestre_id
)
orig_sem = sco_formsemestre.get_formsemestre(orig_formsemestre_id)
cnx = ndb.GetDBConnexion()
# 1- create sem
args = orig_sem.copy()
args = formsemestre_orig.to_dict()
del args["formsemestre_id"]
args["responsables"] = [responsable_id]
del args["id"]
del args["parcours"] # copiés ensuite
args["responsables"] = [responsable]
args["date_debut"] = date_debut
args["date_fin"] = date_fin
args["etat"] = 1 # non verrouillé
formsemestre_id = sco_formsemestre.do_formsemestre_create(args)
log(f"created formsemestre {formsemestre_id}")
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
formsemestre = FormSemestre.create_formsemestre(args)
log(f"created formsemestre {formsemestre}")
# 2- create moduleimpls
modimpl_orig: ModuleImpl
for modimpl_orig in formsemestre_orig.modimpls:
assert isinstance(modimpl_orig, ModuleImpl)
assert isinstance(modimpl_orig.id, int)
log(f"cloning {modimpl_orig}")
args = modimpl_orig.to_dict(with_module=False)
args["formsemestre_id"] = formsemestre_id
args["formsemestre_id"] = formsemestre.id
modimpl_new = ModuleImpl.create_from_dict(args)
log(f"created ModuleImpl from {args}")
db.session.flush()
# copy enseignants
for ens in modimpl_orig.enseignants:
modimpl_new.enseignants.append(ens)
db.session.add(modimpl_new)
db.session.flush()
log(f"new moduleimpl.id = {modimpl_new.id}")
# optionally, copy evaluations
if clone_evaluations:
e: Evaluation
for e in Evaluation.query.filter_by(moduleimpl_id=modimpl_orig.id):
log(f"cloning evaluation {e.id}")
# copie en enlevant la date
new_eval = e.clone(
not_copying=("date_debut", "date_fin", "moduleimpl_id")
)
new_eval.moduleimpl_id = modimpl_new.id
args = dict(e.__dict__)
args.pop("_sa_instance_state")
args.pop("id")
args["moduleimpl_id"] = modimpl_new.id
new_eval = Evaluation(**args)
db.session.add(new_eval)
db.session.commit()
# Copie les poids APC de l'évaluation
new_eval.set_ue_poids_dict(e.get_ue_poids_dict())
db.session.commit()
# 3- copy uecoefs
objs = sco_formsemestre.formsemestre_uecoef_list(
cnx, args={"formsemestre_id": orig_formsemestre_id}
)
for obj in objs:
args = obj.copy()
args["formsemestre_id"] = formsemestre_id
_ = sco_formsemestre.formsemestre_uecoef_create(cnx, args)
for ue_coef in FormSemestreUECoef.query.filter_by(
formsemestre_id=formsemestre_orig.id
):
new_ue_coef = FormSemestreUECoef(
formsemestre_id=formsemestre.id,
ue_id=ue_coef.ue_id,
coefficient=ue_coef.coefficient,
)
db.session.add(new_ue_coef)
db.session.flush()
# NB: don't copy notes_formsemestre_custommenu (usually specific)
@ -1334,11 +1351,11 @@ def do_formsemestre_clone(
if not prefs.is_global(pname):
pvalue = prefs[pname]
try:
prefs.base_prefs.set(formsemestre_id, pname, pvalue)
prefs.base_prefs.set(formsemestre.id, pname, pvalue)
except ValueError:
log(
"do_formsemestre_clone: ignoring old preference %s=%s for %s"
% (pname, pvalue, formsemestre_id)
f"""do_formsemestre_clone: ignoring old preference {
pname}={pvalue} for {formsemestre}"""
)
# 5- Copie les parcours
@ -1349,10 +1366,10 @@ def do_formsemestre_clone(
# 6- Copy partitions and groups
if clone_partitions:
sco_groups_copy.clone_partitions_and_groups(
orig_formsemestre_id, formsemestre_id
orig_formsemestre_id, formsemestre.id
)
return formsemestre_id
return formsemestre.id
def formsemestre_delete(formsemestre_id: int) -> str | flask.Response:

View File

@ -731,20 +731,21 @@ def formsemestre_description_table(
rows.append(sums)
return GenTable(
columns_ids=columns_ids,
rows=rows,
titles=titles,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}&with_evals={with_evals}",
caption=title,
columns_ids=columns_ids,
html_caption=title,
html_class="table_leftalign formsemestre_description",
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}&with_evals={with_evals}",
page_title=title,
html_title=html_sco_header.html_sem_header(
"Description du semestre", with_page_header=False
),
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
page_title=title,
pdf_title=title,
preferences=sco_preferences.SemPreferences(formsemestre_id),
rows=rows,
table_id="formsemestre_description_table",
titles=titles,
)
@ -798,7 +799,7 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
<div class="sem-groups-partition-titre">{
'Groupes de ' + partition.partition_name
if partition.partition_name else
'Tous les étudiants'}
('aucun étudiant inscrit' if partition_is_empty else 'Tous les étudiants')}
</div>
<div class="sem-groups-partition-titre">{
"Assiduité" if not partition_is_empty else ""
@ -826,6 +827,55 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
</div>
</div>
<div class="sem-groups-assi">
"""
)
if can_edit_abs:
H.append(
f"""
<div>
<a class="stdlink" href="{
url_for("assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
day=datetime.date.today().isoformat(),
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Saisir l'assiduité</a>
</div>
"""
)
# YYYY-Www (ISO 8601) :
current_week: str = datetime.datetime.now().strftime("%G-W%V")
H.append(
f"""
<div>
<a class="stdlink" href="{
url_for("assiduites.signal_assiduites_hebdo",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
week=current_week,
)}">Saisie hebdomadaire</a>
</div>
"""
)
if can_edit_abs:
H.append(
f"""
<div>
<a class="stdlink" href="{
url_for("assiduites.bilan_dept",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Justificatifs en attente</a>
</div>
"""
)
H.append(
f"""
<div>
<a class="stdlink" href="{
url_for("assiduites.visu_assi_group",
@ -838,52 +888,9 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
</div>
"""
)
if can_edit_abs:
H.append(
f"""
<div>
<a class="stdlink" href="{
url_for("assiduites.visu_assiduites_group",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
jour = datetime.date.today().isoformat(),
group_ids=group.id,
)}">
Visualiser</a>
</div>
<div>
<a class="stdlink" href="{
url_for("assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
jour=datetime.date.today().isoformat(),
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Saisie journalière</a>
</div>
<div>
<a class="stdlink" href="{
url_for("assiduites.signal_assiduites_diff",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Saisie différée</a>
</div>
<div>
<a class="stdlink" href="{
url_for("assiduites.bilan_dept",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Justificatifs en attente</a>
</div>
"""
)
H.append("</div>") # /sem-groups-assi
if partition_is_empty:
if partition_is_empty and not partition.is_default():
H.append(
'<div class="help sem-groups-none">Aucun groupe peuplé dans cette partition'
)
@ -1191,17 +1198,7 @@ def formsemestre_tableau_modules(
mod_descr = "Module " + (mod.titre or "")
is_apc = mod.is_apc() # SAE ou ressource
if is_apc:
coef_descr = ", ".join(
[
f"{ue.acronyme}: {co}"
for ue, co in mod.ue_coefs_list()
if isinstance(co, float) and co > 0
]
)
if coef_descr:
mod_descr += " Coefs: " + coef_descr
else:
mod_descr += " (pas de coefficients) "
mod_descr += " " + mod.get_ue_coefs_descr()
else:
mod_descr += ", coef. " + str(mod.coefficient)
mod_ens = sco_users.user_info(modimpl.responsable_id)["nomcomplet"]

View File

@ -116,7 +116,7 @@ def formsemestre_validation_etud_form(
check = True
etud_d = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
Se = sco_cursus.get_situation_etud_cursus(etud_d, formsemestre_id)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
if not Se.sem["etat"]:
raise ScoValueError("validation: semestre verrouille")
@ -262,8 +262,8 @@ def formsemestre_validation_etud_form(
return "\n".join(H + footer)
# Infos si pas de semestre précédent
if not Se.prev:
if Se.sem["semestre_id"] == 1:
if not Se.prev_formsemestre:
if Se.cur_sem.semestre_id == 1:
H.append("<p>Premier semestre (pas de précédent)</p>")
else:
H.append("<p>Pas de semestre précédent !</p>")
@ -274,7 +274,7 @@ def formsemestre_validation_etud_form(
f"""Le jury n'a pas statué sur le semestre précédent ! (<a href="{
url_for("notes.formsemestre_validation_etud_form",
scodoc_dept=g.scodoc_dept,
formsemestre_id=Se.prev["formsemestre_id"],
formsemestre_id=Se.prev_formsemestre.id,
etudid=etudid)
}">le faire maintenant</a>)
"""
@ -310,9 +310,9 @@ def formsemestre_validation_etud_form(
H.append("</p>")
# Cas particulier pour ATJ: corriger precedent avant de continuer
if Se.prev_decision and Se.prev_decision["code"] == ATJ:
if Se.prev_formsemestre and Se.prev_decision and Se.prev_decision["code"] == ATJ:
H.append(
"""<div class="sfv_warning"><p>La décision du semestre précédent est en
f"""<div class="sfv_warning"><p>La décision du semestre précédent est en
<b>attente</b> à cause d\'un <b>problème d\'assiduité<b>.</p>
<p>Vous devez la corriger avant de continuer ce jury. Soit vous considérez que le
problème d'assiduité n'est pas réglé et choisissez de ne pas valider le semestre
@ -320,14 +320,16 @@ def formsemestre_validation_etud_form(
l'assiduité.</p>
<form method="get" action="formsemestre_validation_etud_form">
<input type="submit" value="Statuer sur le semestre précédent"/>
<input type="hidden" name="formsemestre_id" value="%s"/>
<input type="hidden" name="etudid" value="%s"/>
<input type="hidden" name="desturl" value="formsemestre_validation_etud_form?etudid=%s&formsemestre_id=%s"/>
<input type="hidden" name="formsemestre_id" value="{Se.prev_formsemestre.id}"/>
<input type="hidden" name="etudid" value="{etudid}"/>
<input type="hidden" name="desturl" value="{
url_for("notes.formsemestre_validation_etud_form",
etudid=etudid, formsemestre_id=formsemestre_id, scodoc_dept=g.scodoc_dept
)}"/>
"""
% (Se.prev["formsemestre_id"], etudid, etudid, formsemestre_id)
)
if sortcol:
H.append('<input type="hidden" name="sortcol" value="%s"/>' % sortcol)
H.append(f"""<input type="hidden" name="sortcol" value="{sortcol}"/>""")
H.append("</form></div>")
H.append(html_sco_header.sco_footer())
@ -405,7 +407,7 @@ def formsemestre_validation_etud(
sortcol=None,
):
"""Enregistre validation"""
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
# retrouve la decision correspondant au code:
choices = Se.get_possible_choices(assiduite=True)
@ -438,7 +440,7 @@ def formsemestre_validation_etud_manu(
"""Enregistre validation"""
if assidu:
assidu = True
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
if code_etat in Se.parcours.UNUSED_CODES:
raise ScoValueError("code decision invalide dans ce parcours")
@ -494,32 +496,35 @@ def decisions_possible_rows(Se, assiduite, subtitle="", trclass=""):
choices = Se.get_possible_choices(assiduite=assiduite)
if not choices:
return ""
TitlePrev = ""
if Se.prev:
if Se.prev["semestre_id"] >= 0:
TitlePrev = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.prev["semestre_id"])
prev_title = ""
if Se.prev_formsemestre:
if Se.prev_formsemestre.semestre_id >= 0:
prev_title = "%s%d" % (
Se.parcours.SESSION_ABBRV,
Se.prev_formsemestre.semestre_id,
)
else:
TitlePrev = "Prec."
prev_title = "Prec."
if Se.sem["semestre_id"] >= 0:
TitleCur = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.sem["semestre_id"])
if Se.cur_sem.semestre_id >= 0:
cur_title = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.cur_sem.semestre_id)
else:
TitleCur = Se.parcours.SESSION_NAME
cur_title = Se.parcours.SESSION_NAME
H = [
'<tr class="%s titles"><th class="sfv_subtitle">%s</em></th>'
% (trclass, subtitle)
]
if Se.prev:
H.append("<th>Code %s</th>" % TitlePrev)
H.append("<th>Code %s</th><th>Devenir</th></tr>" % TitleCur)
if Se.prev_formsemestre:
H.append(f"<th>Code {prev_title}</th>")
H.append(f"<th>Code {cur_title}</th><th>Devenir</th></tr>")
for ch in choices:
H.append(
"""<tr class="%s"><td title="règle %s"><input type="radio" name="codechoice" value="%s" onClick="document.getElementById('subut').disabled=false;">"""
% (trclass, ch.rule_id, ch.codechoice)
)
H.append("%s </input></td>" % ch.explication)
if Se.prev:
if Se.prev_formsemestre:
H.append('<td class="centercell">%s</td>' % _dispcode(ch.new_code_prev))
H.append(
'<td class="centercell">%s</td><td>%s</td>'
@ -535,7 +540,6 @@ def formsemestre_recap_parcours_table(
etudid,
with_links=False,
with_all_columns=True,
a_url="",
sem_info=None,
show_details=False,
):
@ -576,14 +580,14 @@ def formsemestre_recap_parcours_table(
H.append("<th></th></tr>")
num_sem = 0
for sem in situation_etud_cursus.get_semestres():
is_prev = situation_etud_cursus.prev and (
situation_etud_cursus.prev["formsemestre_id"] == sem["formsemestre_id"]
for formsemestre in situation_etud_cursus.formsemestres:
is_prev = situation_etud_cursus.prev_formsemestre and (
situation_etud_cursus.prev_formsemestre.id == formsemestre.id
)
is_cur = situation_etud_cursus.formsemestre_id == sem["formsemestre_id"]
is_cur = situation_etud_cursus.formsemestre_id == formsemestre.id
num_sem += 1
dpv = sco_pv_dict.dict_pvjury(sem["formsemestre_id"], etudids=[etudid])
dpv = sco_pv_dict.dict_pvjury(formsemestre.id, etudids=[etudid])
pv = dpv["decisions"][0]
decision_sem = pv["decision_sem"]
decisions_ue = pv["decisions_ue"]
@ -592,7 +596,6 @@ def formsemestre_recap_parcours_table(
else:
ass = ""
formsemestre = db.session.get(FormSemestre, sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if is_cur:
type_sem = "*" # now unused
@ -603,20 +606,24 @@ def formsemestre_recap_parcours_table(
else:
type_sem = ""
class_sem = "sem_autre"
if sem["formation_code"] != situation_etud_cursus.formation.formation_code:
if (
formsemestre.formation.formation_code
!= situation_etud_cursus.formation.formation_code
):
class_sem += " sem_autre_formation"
if sem["bul_bgcolor"]:
bgcolor = sem["bul_bgcolor"]
else:
bgcolor = "background-color: rgb(255,255,240)"
bgcolor = (
formsemestre.bul_bgcolor
if formsemestre.bul_bgcolor
else "background-color: rgb(255,255,240)"
)
# 1ere ligne: titre sem, decision, acronymes UE
H.append('<tr class="%s rcp_l1 sem_%s">' % (class_sem, sem["formsemestre_id"]))
H.append('<tr class="%s rcp_l1 sem_%s">' % (class_sem, formsemestre.id))
if is_cur:
pm = ""
elif is_prev:
pm = minuslink % sem["formsemestre_id"]
pm = minuslink % formsemestre.id
else:
pm = plusminus % sem["formsemestre_id"]
pm = plusminus % formsemestre.id
inscr = formsemestre.etuds_inscriptions.get(etudid)
parcours_name = ""
@ -638,9 +645,12 @@ def formsemestre_recap_parcours_table(
H.append(
f"""
<td class="rcp_type_sem" style="background-color:{bgcolor};">{num_sem}{pm}</td>
<td class="datedebut">{sem['mois_debut']}</td>
<td class="datedebut">{formsemestre.mois_debut()}</td>
<td class="rcp_titre_sem"><a class="formsemestre_status_link"
href="{a_url}formsemestre_bulletinetud?formsemestre_id={formsemestre.id}&etudid={etudid}"
href="{
url_for("notes.formsemestre_bulletinetud", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id, etudid=etudid
)}"
title="Bulletin de notes">{formsemestre.titre_annee()}{parcours_name}</a>
"""
)
@ -675,7 +685,7 @@ def formsemestre_recap_parcours_table(
ues = [
ue
for ue in ues
if etud_est_inscrit_ue(cnx, etudid, sem["formsemestre_id"], ue.id)
if etud_est_inscrit_ue(cnx, etudid, formsemestre.id, ue.id)
or etud_ue_status[ue.id]["is_capitalized"]
]
@ -697,7 +707,7 @@ def formsemestre_recap_parcours_table(
H.append("<td></td>")
H.append("</tr>")
# 2eme ligne: notes
H.append(f"""<tr class="{class_sem} rcp_l2 sem_{sem["formsemestre_id"]}">""")
H.append(f"""<tr class="{class_sem} rcp_l2 sem_{formsemestre.id}">""")
H.append(
f"""<td class="rcp_type_sem"
style="background-color:{bgcolor};">&nbsp;</td>"""
@ -706,21 +716,28 @@ def formsemestre_recap_parcours_table(
default_sem_info = '<span class="fontred">[sem. précédent]</span>'
else:
default_sem_info = ""
if not sem["etat"]: # locked
if not formsemestre.etat: # locked
lockicon = scu.icontag("lock32_img", title="verrouillé", border="0")
default_sem_info += lockicon
if sem["formation_code"] != situation_etud_cursus.formation.formation_code:
default_sem_info += f"""Autre formation: {sem["formation_code"]}"""
if (
formsemestre.formation.formation_code
!= situation_etud_cursus.formation.formation_code
):
default_sem_info += (
f"""Autre formation: {formsemestre.formation.formation_code}"""
)
H.append(
'<td class="datefin">%s</td><td class="sem_info">%s</td>'
% (sem["mois_fin"], sem_info.get(sem["formsemestre_id"], default_sem_info))
% (formsemestre.mois_fin(), sem_info.get(formsemestre.id, default_sem_info))
)
# Moy Gen (sous le code decision)
H.append(
f"""<td class="rcp_moy">{scu.fmt_note(nt.get_etud_moy_gen(etudid))}</td>"""
)
# Absences (nb d'abs non just. dans ce semestre)
nbabsnj = sco_assiduites.get_assiduites_count(etudid, sem)[0]
nbabsnj = sco_assiduites.formsemestre_get_assiduites_count(
etudid, formsemestre
)[0]
H.append(f"""<td class="rcp_abs">{nbabsnj}</td>""")
# UEs
@ -767,26 +784,30 @@ def formsemestre_recap_parcours_table(
H.append("<td></td>")
if with_links:
H.append(
'<td><a href="%sformsemestre_validation_etud_form?formsemestre_id=%s&etudid=%s">modifier</a></td>'
% (a_url, sem["formsemestre_id"], etudid)
f"""<td><a class="stdlink" href="{
url_for("notes.formsemestre_validation_etud_form", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id, etudid=etudid
)}">modifier</a></td>"""
)
H.append("</tr>")
# 3eme ligne: ECTS
if (
sco_preferences.get_preference("bul_show_ects", sem["formsemestre_id"])
sco_preferences.get_preference("bul_show_ects", formsemestre.id)
or nt.parcours.ECTS_ONLY
):
etud_ects_infos = nt.get_etud_ects_pot(etudid) # ECTS potentiels
H.append(
f"""<tr class="{class_sem} rcp_l2 sem_{sem["formsemestre_id"]}">
f"""<tr class="{class_sem} rcp_l2 sem_{formsemestre.id}">
<td class="rcp_type_sem" style="background-color:{bgcolor};">&nbsp;</td>
<td></td>"""
)
# Total ECTS (affiché sous la moyenne générale)
H.append(
f"""<td class="sem_ects_tit"><a title="crédit acquis">ECTS:</a></td>
<td class="sem_ects">{pv.get("sum_ects",0):2.2g} / {etud_ects_infos["ects_total"]:2.2g}</td>
<td class="sem_ects">{
pv.get("sum_ects",0):2.2g} / {etud_ects_infos["ects_total"]:2.2g}
</td>
<td class="rcp_abs"></td>
"""
)
@ -865,7 +886,7 @@ def form_decision_manuelle(Se, formsemestre_id, etudid, desturl="", sortcol=None
# précédent n'est pas géré dans ScoDoc (code ADC_)
# log(str(Se.sems))
for sem in Se.sems:
if sem["can_compensate"]:
if sem["formsemestre_id"] in Se.can_compensate:
H.append(
'<option value="%s_%s">Admis par compensation avec S%s (%s)</option>'
% (
@ -882,7 +903,7 @@ def form_decision_manuelle(Se, formsemestre_id, etudid, desturl="", sortcol=None
H.append("</select></td></tr>")
# Choix code semestre precedent:
if Se.prev:
if Se.prev_formsemestre:
H.append(
'<tr><td>Code semestre précédent: </td><td><select name="new_code_prev"><option value="">Choisir une décision...</option>'
)
@ -975,7 +996,7 @@ def do_formsemestre_validation_auto(formsemestre_id):
conflicts = [] # liste des etudiants avec decision differente déjà saisie
with sco_cache.DeferredSemCacheManager():
for etudid in etudids:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
{"etudid": etudid, "formsemestre_id": formsemestre_id}
@ -984,7 +1005,7 @@ def do_formsemestre_validation_auto(formsemestre_id):
# Conditions pour validation automatique:
if ins["etat"] == scu.INSCRIT and (
(
(not Se.prev)
(not Se.prev_formsemestre)
or (
Se.prev_decision and Se.prev_decision["code"] in (ADM, ADC, ADJ)
)
@ -1055,8 +1076,8 @@ def do_formsemestre_validation_auto(formsemestre_id):
f"""<li><a href="{
url_for('notes.formsemestre_validation_etud_form',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id,
etudid=etud["etudid"], check=1)
}">{etud["nomprenom"]}</li>"""
etudid=etud.id, check=1)
}">{etud_d["nomprenom"]}</li>"""
)
H.append("</ul>")
H.append(

View File

@ -54,11 +54,11 @@ class Jour:
"""
return self.date.isocalendar()[0:2] == datetime.date.today().isocalendar()[0:2]
def get_date(self) -> str:
def get_date(self, fmt=scu.DATE_FMT) -> str:
"""
Renvoie la date du jour au format "dd/mm/yyyy"
Renvoie la date du jour au format fmt ou "dd/mm/yyyy" par défaut
"""
return self.date.strftime(scu.DATE_FMT)
return self.date.strftime(fmt)
def get_html(self):
"""
@ -93,12 +93,22 @@ class Calendrier:
Représente un calendrier
Permet d'obtenir les informations sur les jours
et générer une représentation html
highlight: str
-> ["jour", "semaine", "mois"]
permet de mettre en valeur lors du passage de la souris
"""
def __init__(self, date_debut: datetime.date, date_fin: datetime.date):
def __init__(
self,
date_debut: datetime.date,
date_fin: datetime.date,
highlight: str = None,
):
self.date_debut = date_debut
self.date_fin = date_fin
self.jours: dict[str, list[Jour]] = {}
self.highlight: str = highlight
def _get_dates_between(self) -> list[datetime.date]:
"""
@ -130,11 +140,13 @@ class Calendrier:
month = scu.MONTH_NAMES_ABBREV[date.month - 1]
# Ajouter le jour à la liste correspondante au mois
if month not in organized:
organized[month] = []
organized[month] = {} # semaine {22: []}
jour: Jour = self.instanciate_jour(date)
organized[month].append(jour)
semaine = date.strftime("%G-W%V")
if semaine not in organized[month]:
organized[month][semaine] = []
organized[month][semaine].append(jour)
self.jours = organized
@ -150,4 +162,53 @@ class Calendrier:
get_html Renvoie le code html du calendrier
"""
self.organize_by_month()
return render_template("calendrier.j2", calendrier=self.jours)
return render_template(
"calendrier.j2", calendrier=self.jours, highlight=self.highlight
)
class JourChoix(Jour):
"""
Représente un jour dans le calendrier pour choisir une date
"""
def get_html(self):
return ""
class CalendrierChoix(Calendrier):
"""
Représente un calendrier pour choisir une date
"""
def instanciate_jour(self, date: datetime.date) -> Jour:
return JourChoix(date)
def calendrier_choix_date(
date_debut: datetime.date,
date_fin: datetime.date,
url: str,
mode: str = "jour",
titre: str = "Choisir une date",
):
"""
Permet d'afficher un calendrier pour choisir une date et renvoyer sur une url.
mode : str
- "jour" -> ajoutera "&day=yyyy-mm-dd" à l'url (ex: 2024-05-30)
- "semaine" -> ajoutera "&week=yyyy-Www" à l'url (ex : 2024-W22)
titre : str
- texte à afficher au dessus du calendrier
"""
calendrier: CalendrierChoix = CalendrierChoix(date_debut, date_fin, highlight=mode)
return render_template(
"choix_date.j2",
calendrier=calendrier.get_html(),
url=url,
titre=titre,
mode=mode,
)

View File

@ -466,9 +466,9 @@ def etud_add_group_infos(
etud['groupes'] = "TDB, Gr2, TPB1"
etud['partitionsgroupes'] = "Groupes TD:TDB, Groupes TP:Gr2 (...)"
"""
etud[
"partitions"
] = collections.OrderedDict() # partition_id : group + partition_name
etud["partitions"] = (
collections.OrderedDict()
) # partition_id : group + partition_name
if not formsemestre_id:
etud["groupes"] = ""
return etud
@ -1409,21 +1409,17 @@ def groups_auto_repartition(partition: Partition):
return flask.redirect(dest_url)
def _get_prev_moy(etudid, formsemestre_id):
def _get_prev_moy(etudid: int, formsemestre_id: int) -> float | str:
"""Donne la derniere moyenne generale calculee pour cette étudiant,
ou 0 si on n'en trouve pas (nouvel inscrit,...).
"""
info = sco_etud.get_etud_info(etudid=etudid, filled=True)
if not info:
raise ScoValueError("etudiant invalide: etudid=%s" % etudid)
etud = info[0]
etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
if Se.prev:
prev_sem = db.session.get(FormSemestre, Se.prev["formsemestre_id"])
if Se.prev_formsemestre:
prev_sem = db.session.get(FormSemestre, Se.prev_formsemestre.id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_sem)
return nt.get_etud_moy_gen(etudid)
else:
return 0.0
return nt.get_etud_moy_gen(etud.id)
return 0.0
def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"):

View File

@ -27,10 +27,8 @@
"""Exports groupes
"""
from flask import request
from app.scodoc import notesdb as ndb
from app.scodoc import sco_excel
from app.scodoc import sco_groups_view
from app.scodoc import sco_preferences
from app.scodoc.gen_tables import GenTable
@ -83,14 +81,13 @@ def groups_export_annotations(group_ids, formsemestre_id=None, fmt="html"):
"date_str": "Date",
"comment": "Annotation",
},
origin="Généré par %s le " % sco_version.SCONAME
+ scu.timedate_human_repr()
+ "",
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
page_title=f"Annotations sur les étudiants de {groups_infos.groups_titles}",
caption="Annotations",
base_url=groups_infos.base_url,
html_sortable=True,
html_class="table_leftalign",
preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="groups_export_annotations",
)
return table.make_page(fmt=fmt)

View File

@ -26,7 +26,7 @@
##############################################################################
"""Affichage étudiants d'un ou plusieurs groupes
sous forme: de liste html (table exportable), de trombinoscope (exportable en pdf)
sous forme: de liste html (table exportable), de trombinoscope (exportable en pdf)
"""
# Re-ecriture en 2014 (re-organisation de l'interface, modernisation du code)
@ -39,7 +39,7 @@ from flask import url_for, g, request
from flask_login import current_user
from app import db
from app.models import FormSemestre
from app.models import FormSemestre, Identite
import app.scodoc.sco_utils as scu
from app.scodoc import html_sco_header
from app.scodoc import sco_excel
@ -661,6 +661,7 @@ def groups_table(
text_fields_separator=prefs["moodle_csv_separator"],
text_with_titles=prefs["moodle_csv_with_headerline"],
preferences=prefs,
table_id="groups_table",
)
#
if fmt == "html":
@ -861,21 +862,25 @@ def groups_table(
# et ajoute infos inscription
for m in groups_infos.members:
etud_info = sco_etud.get_etud_info(m["etudid"], filled=True)[0]
# TODO utiliser Identite
etud = Identite.get_etud(m["etudid"])
m.update(etud_info)
sco_etud.etud_add_lycee_infos(etud_info)
# et ajoute le parcours
Se = sco_cursus.get_situation_etud_cursus(
etud_info, groups_infos.formsemestre_id
etud, groups_infos.formsemestre_id
)
m["parcours"] = Se.get_cursus_descr()
m["code_cursus"], _ = sco_report.get_code_cursus_etud(
etud_info["etudid"], sems=etud_info["sems"]
etud.id, formsemestres=etud.get_formsemestres()
)
# TODO utiliser Identite:
rows = [[m.get(k, "") for k in keys] for m in groups_infos.members]
title = "etudiants_%s" % groups_infos.groups_filename
title = f"etudiants_{groups_infos.groups_filename}"
xls = sco_excel.excel_simple_table(titles=titles, lines=rows, sheet_name=title)
filename = title
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE)
return scu.send_file(
xls, filename=title, suffix=scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE
)
else:
raise ScoValueError("unsupported format")
@ -977,16 +982,16 @@ def form_choix_jour_saisie_hebdo(groups_infos, moduleimpl_id=None):
if not authuser.has_permission(Permission.AbsChange):
return ""
return f"""
<button onclick="window.location='{
<a class="stdlink" href="{
url_for(
"assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
group_ids=",".join(map(str,groups_infos.group_ids)),
jour=datetime.date.today().isoformat(),
day=datetime.date.today().isoformat(),
formsemestre_id=groups_infos.formsemestre_id,
moduleimpl_id="" if moduleimpl_id is None else moduleimpl_id
)
}';">Saisie du jour ({datetime.date.today().strftime('%d/%m/%Y')})</button>
}">Saisie du jour ({datetime.date.today().strftime('%d/%m/%Y')})</a>
"""
@ -997,16 +1002,16 @@ def form_choix_saisie_semaine(groups_infos):
return ""
query_args = parse_qs(request.query_string)
moduleimpl_id = query_args.get("moduleimpl_id", [None])[0]
semaine = datetime.date.today().isocalendar().week
semaine = datetime.datetime.now().strftime("%G-W%V")
return f"""
<button onclick="window.location='{url_for(
"assiduites.signal_assiduites_diff",
<a class="stdlink" href="{url_for(
"assiduites.signal_assiduites_hebdo",
group_ids=",".join(map(str,groups_infos.group_ids)),
semaine=semaine,
week=semaine,
scodoc_dept=g.scodoc_dept,
formsemestre_id=groups_infos.formsemestre_id,
moduleimpl_id=moduleimpl_id
)}';">Saisie à la semaine</button>
)}">Saisie à la semaine (semaine {''.join(semaine[-2:])})</a>
"""
@ -1028,10 +1033,9 @@ def export_groups_as_moodle_csv(formsemestre_id=None):
moodle_sem_name = sem["session_id"]
columns_ids = ("email", "semestre_groupe")
T = []
for partition_id in partitions_etud_groups:
rows = []
for partition_id, members in partitions_etud_groups.items():
partition = sco_groups.get_partition(partition_id)
members = partitions_etud_groups[partition_id]
for etudid in members:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
group_name = members[etudid]["group_name"]
@ -1040,16 +1044,17 @@ def export_groups_as_moodle_csv(formsemestre_id=None):
elts.append(partition["partition_name"])
if group_name:
elts.append(group_name)
T.append({"email": etud["email"], "semestre_groupe": "-".join(elts)})
rows.append({"email": etud["email"], "semestre_groupe": "-".join(elts)})
# Make table
prefs = sco_preferences.SemPreferences(formsemestre_id)
tab = GenTable(
rows=T,
columns_ids=("email", "semestre_groupe"),
filename=moodle_sem_name + "-moodle",
titles={x: x for x in columns_ids},
preferences=prefs,
rows=rows,
text_fields_separator=prefs["moodle_csv_separator"],
text_with_titles=prefs["moodle_csv_with_headerline"],
preferences=prefs,
table_id="export_groups_as_moodle_csv",
titles={x: x for x in columns_ids},
)
return tab.make_page(fmt="csv")

View File

@ -28,7 +28,6 @@
""" Importation des étudiants à partir de fichiers CSV
"""
import collections
import io
import os
import re
@ -64,6 +63,7 @@ import app.scodoc.sco_utils as scu
FORMAT_FILE = "format_import_etudiants.txt"
# Champs modifiables via "Import données admission"
# (nom/prénom modifiables en mode "avec etudid")
ADMISSION_MODIFIABLE_FIELDS = (
"code_nip",
"code_ine",
@ -132,19 +132,27 @@ def sco_import_format(with_codesemestre=True):
return r
def sco_import_format_dict(with_codesemestre=True):
def sco_import_format_dict(with_codesemestre=True, use_etudid: bool = False):
"""Attribut: { 'type': , 'table', 'allow_nulls' , 'description' }"""
fmt = sco_import_format(with_codesemestre=with_codesemestre)
R = collections.OrderedDict()
formats = {}
for l in fmt:
R[l[0]] = {
formats[l[0]] = {
"type": l[1],
"table": l[2],
"allow_nulls": l[3],
"description": l[4],
"aliases": l[5],
}
return R
if use_etudid:
formats["etudid"] = {
"type": "int",
"table": "identite",
"allow_nulls": False,
"description": "",
"aliases": ["etudid", "id"],
}
return formats
def sco_import_generate_excel_sample(
@ -188,8 +196,7 @@ def sco_import_generate_excel_sample(
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
members = groups_infos.members
log(
"sco_import_generate_excel_sample: group_ids=%s %d members"
% (group_ids, len(members))
f"sco_import_generate_excel_sample: group_ids={group_ids}, {len(members)} members"
)
titles = ["etudid"] + titles
titles_styles = [style] + titles_styles
@ -234,21 +241,26 @@ def students_import_excel(
exclude_cols=["photo_filename"],
)
if return_html:
if formsemestre_id:
dest = url_for(
dest_url = (
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
else:
dest = url_for("notes.index_html", scodoc_dept=g.scodoc_dept)
if formsemestre_id
else url_for("notes.index_html", scodoc_dept=g.scodoc_dept)
)
H = [html_sco_header.sco_header(page_title="Import etudiants")]
H.append("<ul>")
for d in diag:
H.append("<li>%s</li>" % d)
H.append("</ul>")
H.append("<p>Import terminé !</p>")
H.append('<p><a class="stdlink" href="%s">Continuer</a></p>' % dest)
H.append(f"<li>{d}</li>")
H.append(
f"""
</ul>)
<p>Import terminé !</p>
<p><a class="stdlink" href="{dest_url}">Continuer</a></p>
"""
)
return "\n".join(H) + html_sco_header.sco_footer()
@ -308,13 +320,13 @@ def scolars_import_excel_file(
titleslist = []
for t in fs:
if t not in titles:
raise ScoValueError('Colonne invalide: "%s"' % t)
raise ScoValueError(f'Colonne invalide: "{t}"')
titleslist.append(t) #
# ok, same titles
# Start inserting data, abort whole transaction in case of error
created_etudids = []
np_imported_homonyms = 0
GroupIdInferers = {}
group_id_inferer = {}
try: # --- begin DB transaction
linenum = 0
for line in data[1:]:
@ -429,7 +441,7 @@ def scolars_import_excel_file(
_import_one_student(
formsemestre_id,
values,
GroupIdInferers,
group_id_inferer,
annee_courante,
created_etudids,
linenum,
@ -496,13 +508,14 @@ def scolars_import_excel_file(
def students_import_admission(
csvfile, type_admission="", formsemestre_id=None, return_html=True
):
csvfile, type_admission="", formsemestre_id=None, return_html=True, use_etudid=False
) -> str:
"import donnees admission from Excel file (v2016)"
diag = scolars_import_admission(
csvfile,
formsemestre_id=formsemestre_id,
type_admission=type_admission,
use_etudid=use_etudid,
)
if return_html:
H = [html_sco_header.sco_header(page_title="Import données admissions")]
@ -524,6 +537,7 @@ def students_import_admission(
)
return "\n".join(H) + html_sco_header.sco_footer()
return ""
def _import_one_student(
@ -599,13 +613,15 @@ def _is_new_ine(cnx, code_ine):
# ------ Fonction ré-écrite en nov 2016 pour lire des fichiers sans etudid (fichiers APB)
def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None):
def scolars_import_admission(
datafile, formsemestre_id=None, type_admission=None, use_etudid=False
):
"""Importe données admission depuis un fichier Excel quelconque
par exemple ceux utilisés avec APB
par exemple ceux utilisés avec APB, avec ou sans etudid
Cherche dans ce fichier les étudiants qui correspondent à des inscrits du
semestre formsemestre_id.
Le fichier n'a pas l'INE ni le NIP ni l'etudid, la correspondance se fait
Si le fichier n'a pas d'etudid (use_etudid faux), la correspondance se fait
via les noms/prénoms qui doivent être égaux (la casse, les accents et caractères spéciaux
étant ignorés).
@ -617,23 +633,24 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
dans le fichier importé) du champ type_admission.
Si une valeur existe ou est présente dans le fichier importé, ce paramètre est ignoré.
TODO:
- choix onglet du classeur
"""
log(f"scolars_import_admission: formsemestre_id={formsemestre_id}")
diag: list[str] = []
members = sco_groups.get_group_members(
sco_groups.get_default_group(formsemestre_id)
)
etuds_by_nomprenom = {} # { nomprenom : etud }
diag = []
for m in members:
np = (adm_normalize_string(m["nom"]), adm_normalize_string(m["prenom"]))
if np in etuds_by_nomprenom:
msg = "Attention: hononymie pour %s %s" % (m["nom"], m["prenom"])
log(msg)
diag.append(msg)
etuds_by_nomprenom[np] = m
etuds_by_etudid = {} # { etudid : etud }
if use_etudid:
etuds_by_etudid = {m["etudid"]: m for m in members}
else:
for m in members:
np = (adm_normalize_string(m["nom"]), adm_normalize_string(m["prenom"]))
if np in etuds_by_nomprenom:
msg = f"""Attention: hononymie pour {m["nom"]} {m["prenom"]}"""
log(msg)
diag.append(msg)
etuds_by_nomprenom[np] = m
exceldata = datafile.read()
diag2, data = sco_excel.excel_bytes_to_list(exceldata)
@ -644,19 +661,29 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
titles = data[0]
# idx -> ('field', convertor)
fields = adm_get_fields(titles, formsemestre_id)
idx_nom = None
idx_prenom = None
fields = adm_get_fields(titles, formsemestre_id, use_etudid=use_etudid)
idx_nom = idx_prenom = idx_etudid = None
for idx, field in fields.items():
if field[0] == "nom":
idx_nom = idx
if field[0] == "prenom":
idx_prenom = idx
if (idx_nom is None) or (idx_prenom is None):
match field[0]:
case "nom":
idx_nom = idx
case "prenom":
idx_prenom = idx
case "etudid":
idx_etudid = idx
if (not use_etudid and ((idx_nom is None) or (idx_prenom is None))) or (
use_etudid and idx_etudid is None
):
log("fields indices=" + ", ".join([str(x) for x in fields]))
log("fields titles =" + ", ".join([fields[x][0] for x in fields]))
log("fields titles =" + ", ".join([x[0] for x in fields.values()]))
raise ScoFormatError(
"scolars_import_admission: colonnes nom et prenom requises",
(
"""colonne etudid requise
(si l'option "Utiliser l'identifiant d'étudiant ScoDoc" est cochée)"""
if use_etudid
else "colonnes nom et prenom requises"
),
dest_url=url_for(
"scolar.form_students_import_infos_admissions",
scodoc_dept=g.scodoc_dept,
@ -665,18 +692,31 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
)
modifiable_fields = set(ADMISSION_MODIFIABLE_FIELDS)
if use_etudid:
modifiable_fields |= {"nom", "prenom"}
nline = 2 # la premiere ligne de donnees du fichier excel est 2
n_import = 0
for line in data[1:]:
# Retrouve l'étudiant parmi ceux du semestre par (nom, prenom)
nom = adm_normalize_string(line[idx_nom])
prenom = adm_normalize_string(line[idx_prenom])
if (nom, prenom) not in etuds_by_nomprenom:
msg = f"""Étudiant <b>{line[idx_nom]} {line[idx_prenom]} inexistant</b>"""
diag.append(msg)
if use_etudid:
try:
etud = etuds_by_etudid.get(int(line[idx_etudid]))
except ValueError:
etud = None
if not etud:
msg = f"""Étudiant avec code etudid=<b>{line[idx_etudid]}</b> inexistant"""
diag.append(msg)
else:
etud = etuds_by_nomprenom[(nom, prenom)]
# Retrouve l'étudiant parmi ceux du semestre par (nom, prenom)
nom = adm_normalize_string(line[idx_nom])
prenom = adm_normalize_string(line[idx_prenom])
etud = etuds_by_nomprenom.get((nom, prenom))
if not etud:
msg = (
f"""Étudiant <b>{line[idx_nom]} {line[idx_prenom]}</b> inexistant"""
)
diag.append(msg)
if etud:
cur_adm = sco_etud.admission_list(cnx, args={"id": etud["admission_id"]})[0]
# peuple les champs presents dans le tableau
args = {}
@ -758,19 +798,19 @@ def adm_normalize_string(s):
)
def adm_get_fields(titles, formsemestre_id):
def adm_get_fields(titles, formsemestre_id: int, use_etudid: bool = False):
"""Cherche les colonnes importables dans les titres (ligne 1) du fichier excel
return: { idx : (field_name, convertor) }
"""
format_dict = sco_import_format_dict()
format_dict = sco_import_format_dict(use_etudid=use_etudid)
fields = {}
idx = 0
for title in titles:
title_n = adm_normalize_string(title)
for k in format_dict:
for v in format_dict[k]["aliases"]:
for k, fmt in format_dict.items():
for v in fmt["aliases"]:
if adm_normalize_string(v) == title_n:
typ = format_dict[k]["type"]
typ = fmt["type"]
if typ == "real":
convertor = adm_convert_real
elif typ == "integer" or typ == "int":
@ -834,11 +874,12 @@ def adm_table_description_format():
columns_ids = ("attribute", "type", "writable", "description", "aliases_str")
tab = GenTable(
titles=titles,
columns_ids=columns_ids,
rows=list(Fmt.values()),
html_sortable=True,
html_class="table_leftalign",
html_sortable=True,
preferences=sco_preferences.SemPreferences(),
rows=list(Fmt.values()),
table_id="adm_table_description_format",
titles=titles,
)
return tab

View File

@ -140,7 +140,7 @@ def read_users_excel_file(datafile, titles=TITLES) -> list[dict]:
for line in data[1:]:
d = {}
for i, field in enumerate(xls_titles):
d[field] = line[i]
d[field] = (line[i] or "").strip()
users.append(d)
return users

View File

@ -747,10 +747,11 @@ def etuds_select_box_xls(src_cat):
else:
e["paiementinscription_str"] = "-"
tab = GenTable(
titles=titles,
columns_ids=columns_ids,
rows=etuds,
caption="%(title)s. %(help)s" % src_cat["infos"],
columns_ids=columns_ids,
preferences=sco_preferences.SemPreferences(),
rows=etuds,
table_id="etuds_select_box_xls",
titles=titles,
)
return tab.excel() # tab.make_page(filename=src_cat["infos"]["filename"])

View File

@ -599,20 +599,21 @@ def _make_table_notes(
)
# display
tab = GenTable(
titles=titles,
columns_ids=columns_ids,
rows=rows,
html_sortable=True,
base_url=base_url,
filename=filename,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
caption=caption,
html_next_section=html_next_section,
page_title="Notes de " + formsemestre.titre_mois(),
html_title=html_title,
pdf_title=pdf_title,
columns_ids=columns_ids,
filename=filename,
html_class="notes_evaluation",
html_next_section=html_next_section,
html_sortable=True,
html_title=html_title,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
page_title="Notes de " + formsemestre.titre_mois(),
pdf_title=pdf_title,
preferences=sco_preferences.SemPreferences(formsemestre.id),
rows=rows,
table_id="table-liste-notes",
titles=titles,
# html_generate_cells=False # la derniere ligne (moyennes) est incomplete
)
if fmt == "bordereau":

View File

@ -180,6 +180,7 @@ def _table_etuds_lycees(etuds, group_lycees, title, preferences, no_links=False)
html_class="table_leftalign table_listegroupe",
bottom_titles=bottom_titles,
preferences=preferences,
table_id="table_etuds_lycees",
)
return tab, etuds_by_lycee

View File

@ -30,9 +30,6 @@
import psycopg2
from app import db
from app.models import Formation
from app.scodoc import scolog
from app.scodoc import sco_cache
import app.scodoc.notesdb as ndb
@ -56,7 +53,8 @@ _moduleimplEditor = ndb.EditableTable(
def do_moduleimpl_create(args):
"create a moduleimpl"
# TODO remplacer par une methode de ModuleImpl qui appelle super().create_from_dict() puis invalide le formsemestre
# TODO remplacer par une methode de ModuleImpl qui appelle
# super().create_from_dict() puis invalide le formsemestre
cnx = ndb.GetDBConnexion()
r = _moduleimplEditor.create(cnx, args)
sco_cache.invalidate_formsemestre(

View File

@ -79,9 +79,9 @@ def moduleimpl_inscriptions_edit(
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
module = modimpl.module
formsemestre = modimpl.formsemestre
# -- check lock
if not formsemestre.etat:
raise ScoValueError("opération impossible: semestre verrouille")
# -- check permission (and lock)
if not modimpl.can_change_inscriptions():
return # can_change_inscriptions raises exception
header = html_sco_header.sco_header(
page_title="Inscription au module",
init_qtip=True,

View File

@ -64,19 +64,27 @@ def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str:
can_edit_notes_ens = modimpl.can_edit_notes(current_user)
if can_edit_notes and nbnotes != 0:
sup_label = "Supprimer évaluation impossible (il y a des notes)"
sup_label = "Suppression évaluation impossible (il y a des notes)"
else:
sup_label = "Supprimer évaluation"
menu_eval = [
{
"title": "Saisir notes",
"title": "Saisir les notes",
"endpoint": "notes.saisie_notes",
"args": {
"evaluation_id": evaluation_id,
},
"enabled": can_edit_notes_ens,
},
{
"title": "Saisir par fichier tableur",
"id": "menu_saisie_tableur",
"endpoint": "notes.saisie_notes_tableur",
"args": {
"evaluation_id": evaluation.id,
},
},
{
"title": "Modifier évaluation",
"endpoint": "notes.evaluation_edit",
@ -146,29 +154,48 @@ def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str:
return htmlutils.make_menu("actions", menu_eval, alone=True)
def _ue_coefs_html(coefs_lst) -> str:
def _ue_coefs_html(modimpl: ModuleImpl) -> str:
""" """
max_coef = max([x[1] for x in coefs_lst]) if coefs_lst else 1.0
H = """
coefs_lst = modimpl.module.ue_coefs_list()
max_coef = max(x[1] for x in coefs_lst) if coefs_lst else 1.0
H = f"""
<div id="modimpl_coefs">
<div>Coefficients vers les UE</div>
"""
if coefs_lst:
H += (
f"""
<div class="coefs_histo" style="--max:{max_coef}">
"""
+ "\n".join(
[
f"""<div style="--coef:{coef};
{'background-color: ' + ue.color + ';' if ue.color else ''}
"><div>{coef}</div>{ue.acronyme}</div>"""
for ue, coef in coefs_lst
if coef > 0
]
<div>Coefficients vers les UEs
<span><a class="stdlink" href="{
url_for(
"notes.edit_modules_ue_coefs",
scodoc_dept=g.scodoc_dept,
formation_id=modimpl.module.formation.id,
semestre_idx=modimpl.formsemestre.semestre_id,
)
+ "</div>"
}">détail</a>
</span>
</div>
"""
if coefs_lst:
H += _html_hinton_map(
colors=(uc[0].color for uc in coefs_lst),
max_val=max_coef,
size=36,
title=modimpl.module.get_ue_coefs_descr(),
values=(uc[1] for uc in coefs_lst),
)
# (
# f"""
# <div class="coefs_histo" style="--max:{max_coef}">
# """
# + "\n".join(
# [
# f"""<div style="--coef:{coef};
# {'background-color: ' + ue.color + ';' if ue.color else ''}
# "><div>{coef}</div>{ue.acronyme}</div>"""
# for ue, coef in coefs_lst
# if coef > 0
# ]
# )
# + "</div>"
# )
else:
H += """<div class="missing_value">non définis</div>"""
H += "</div>"
@ -177,9 +204,7 @@ def _ue_coefs_html(coefs_lst) -> str:
def moduleimpl_status(moduleimpl_id=None, partition_id=None):
"""Tableau de bord module (liste des evaluations etc)"""
if not isinstance(moduleimpl_id, int):
raise ScoInvalidIdType("moduleimpl_id must be an integer !")
modimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id)
modimpl: ModuleImpl = ModuleImpl.get_modimpl(moduleimpl_id)
g.current_moduleimpl_id = modimpl.id
module: Module = modimpl.module
formsemestre_id = modimpl.formsemestre_id
@ -195,13 +220,22 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
Evaluation.date_debut.desc(),
).all()
nb_evaluations = len(evaluations)
max_poids = max(
[
max([p.poids for p in e.ue_poids] or [0]) * (e.coefficient or 0.0)
for e in evaluations
]
or [0]
)
# Le poids max pour chaque catégorie d'évaluation
max_poids_by_type: dict[int, float] = {}
for eval_type in (
Evaluation.EVALUATION_NORMALE,
Evaluation.EVALUATION_RATTRAPAGE,
Evaluation.EVALUATION_SESSION2,
Evaluation.EVALUATION_BONUS,
):
max_poids_by_type[eval_type] = max(
[
max([p.poids for p in e.ue_poids] or [0]) * (e.coefficient or 0.0)
for e in evaluations
if e.evaluation_type == eval_type
]
or [0.0]
)
#
sem_locked = not formsemestre.etat
can_edit_evals = (
@ -265,7 +299,7 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
H.append(scu.icontag("lock32_img", title="verrouillé"))
H.append("""</td><td class="fichetitre2">""")
if modimpl.module.is_apc():
H.append(_ue_coefs_html(modimpl.module.ue_coefs_list()))
H.append(_ue_coefs_html(modimpl))
else:
H.append(
f"""Coef. dans le semestre: {
@ -284,10 +318,13 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
H.append(
f"""<tr><td class="fichetitre2">Inscrits: </td><td> {len(mod_inscrits)} étudiants"""
)
if current_user.has_permission(Permission.EtudInscrit):
if modimpl.can_change_inscriptions(raise_exc=False):
H.append(
f"""<a class="stdlink" style="margin-left:2em;"
href="moduleimpl_inscriptions_edit?moduleimpl_id={modimpl.id}">modifier</a>"""
href="{
url_for("notes.moduleimpl_inscriptions_edit",
scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id
)}">modifier</a>"""
)
H.append("</td></tr>")
# Ligne: règle de calcul
@ -318,35 +355,62 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
f"""
<span class="moduleimpl_abs_link"><a class="stdlink" href="{
url_for("assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept)
}?group_ids={group_id}&jour={
}?group_ids={group_id}&day={
datetime.date.today().isoformat()
}&formsemestre_id={formsemestre.id}
&moduleimpl_id={moduleimpl_id}
"
>Saisie Absences journée</a></span>
>Saisie Absences</a></span>
"""
)
current_week: str = datetime.datetime.now().strftime("%G-W%V")
H.append(
f"""
<span class="moduleimpl_abs_link"><a class="stdlink" href="{
url_for(
"assiduites.signal_assiduites_diff",
scodoc_dept=g.scodoc_dept,
group_ids=group_id,
formsemestre_id=formsemestre.id,
moduleimpl_id="" if moduleimpl_id is None else moduleimpl_id
)}"
>Saisie Absences Différée</a></span>
url_for("assiduites.signal_assiduites_hebdo",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group_id,
week=current_week,
moduleimpl_id=moduleimpl_id
)
}
"
>Saisie Absences (Hebdo)</a></span>
"""
)
H.append("</td></tr></table>")
#
if not modimpl.check_apc_conformity(nt):
H.append(
"""<div class="warning conformite">Les poids des évaluations de ce module ne sont
pas encore conformes au PN.
Ses notes ne peuvent pas être prises en compte dans les moyennes d'UE.
"""<div class="warning conformite">Les poids des évaluations de ce
module ne permettent pas d'évaluer toutes les UEs (compétences)
prévues par les coefficients du programme.
<b>Ses notes ne peuvent pas être prises en compte dans les moyennes d'UE.</b>
Vérifiez les poids des évaluations.
</div>"""
)
if not modimpl.check_apc_conformity(
nt, evaluation_type=Evaluation.EVALUATION_SESSION2
):
H.append(
"""<div class="warning conformite">
Il y a des évaluations de <b>deuxième session</b>
mais leurs poids ne permettent pas d'évaluer toutes les UEs (compétences)
prévues par les coefficients du programme.
La deuxième session ne sera donc <b>pas prise en compte</b>.
Vérifiez les poids de ces évaluations.
</div>"""
)
if not modimpl.check_apc_conformity(
nt, evaluation_type=Evaluation.EVALUATION_RATTRAPAGE
):
H.append(
"""<div class="warning conformite">
Il y a des évaluations de <b>rattrapage</b>
mais leurs poids n'évaluent pas toutes les UEs (compétences)
prévues par les coefficients du programme.
Vérifiez les poids de ces évaluations.
</div>"""
)
@ -437,7 +501,7 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
eval_index=eval_index,
nb_evals=nb_evaluations,
is_apc=nt.is_apc,
max_poids=max_poids,
max_poids=max_poids_by_type.get(evaluation.evaluation_type, 10000.0),
)
)
eval_index -= 1
@ -756,27 +820,27 @@ def _ligne_evaluation(
#
if etat["nb_notes"] == 0:
H.append(f"""<tr class="{tr_class}"><td></td>""")
if modimpl.module.is_apc():
H.append(
f"""<td colspan="8" class="eval_poids">{
evaluation.get_ue_poids_str()}</td>"""
)
else:
H.append('<td colspan="8"></td>')
# if modimpl.module.is_apc():
# H.append(
# f"""<td colspan="8" class="eval_poids">{
# evaluation.get_ue_poids_str()}</td>"""
# )
# else:
# H.append('<td colspan="8"></td>')
H.append("""</tr>""")
else: # il y a deja des notes saisies
gr_moyennes = etat["gr_moyennes"]
first_group = True
# first_group = True
for gr_moyenne in gr_moyennes:
H.append(f"""<tr class="{tr_class}"><td>&nbsp;</td>""")
if first_group and modimpl.module.is_apc():
H.append(
f"""<td class="eval_poids" colspan="4">{
evaluation.get_ue_poids_str()}</td>"""
)
else:
H.append("""<td colspan="4"></td>""")
first_group = False
# if first_group and modimpl.module.is_apc():
# H.append(
# f"""<td class="eval_poids" colspan="4">{
# evaluation.get_ue_poids_str()}</td>"""
# )
# else:
H.append("""<td colspan="4"></td>""")
# first_group = False
if gr_moyenne["group_name"] is None:
name = "Tous" # tous
else:
@ -832,26 +896,47 @@ def _evaluation_poids_html(evaluation: Evaluation, max_poids: float = 0.0) -> st
ue_poids = evaluation.get_ue_poids_dict(sort=True) # { ue_id : poids }
if not ue_poids:
return ""
if max_poids < scu.NOTES_PRECISION:
values = [poids * (evaluation.coefficient) for poids in ue_poids.values()]
colors = [db.session.get(UniteEns, ue_id).color for ue_id in ue_poids]
return _html_hinton_map(
classes=("evaluation_poids",),
colors=colors,
max_val=max_poids,
title=f"Poids de l'évaluation vers les UEs: {evaluation.get_ue_poids_str()}",
values=values,
)
def _html_hinton_map(
classes=(),
colors=(),
max_val: float | None = None,
size=12,
title: str = "",
values=(),
) -> str:
"""Représente une liste de nombres sous forme de carrés"""
if max_val is None:
max_val = max(values)
if max_val < scu.NOTES_PRECISION:
return ""
H = (
"""<div class="evaluation_poids">"""
return (
f"""<div class="hinton_map {" ".join(classes)}"
style="--size:{size}px;"
title="{title}"
data-tooltip>"""
+ "\n".join(
[
f"""<div title="poids vers {ue.acronyme}: {poids:g}">
<div style="--size:{math.sqrt(poids*(evaluation.coefficient)/max_poids*144)}px;
{'background-color: ' + ue.color + ';' if ue.color else ''}
f"""<div>
<div style="--boxsize:{size*math.sqrt(value/max_val)}px;
{'background-color: ' + color + ';' if color else ''}
"></div>
</div>"""
for ue, poids in (
(db.session.get(UniteEns, ue_id), poids)
for ue_id, poids in ue_poids.items()
)
for value, color in zip(values, colors)
]
)
+ "</div>"
)
return H
def _html_modimpl_etuds_attente(res: ResultatsSemestre, modimpl: ModuleImpl) -> str:

View File

@ -264,14 +264,13 @@ def fiche_etud(etudid=None):
sem_info[formsemestre.id] = grlink
if inscriptions:
Se = sco_cursus.get_situation_etud_cursus(info, info["last_formsemestre_id"])
Se = sco_cursus.get_situation_etud_cursus(etud, info["last_formsemestre_id"])
info["liste_inscriptions"] = formsemestre_recap_parcours_table(
Se,
etudid,
with_links=False,
sem_info=sem_info,
with_all_columns=False,
a_url="Notes/",
)
info["link_bul_pdf"] = (
"""<span class="link_bul_pdf fontred">PDF interdits par l'admin.</span>"""

View File

@ -247,6 +247,7 @@ class ScoDocPageTemplate(PageTemplate):
footer_template=DEFAULT_PDF_FOOTER_TEMPLATE,
filigranne=None,
preferences=None, # dictionnary with preferences, required
with_page_numbers=False,
):
"""Initialise our page template."""
# defered import (solve circular dependency ->sco_logo ->scodoc, ->sco_pdf
@ -259,8 +260,9 @@ class ScoDocPageTemplate(PageTemplate):
self.pdfmeta_subject = subject
self.server_name = server_name
self.filigranne = filigranne
self.page_number = 1
self.footer_template = footer_template
self.with_page_numbers = with_page_numbers
self.page_number = 1
if self.preferences:
self.with_page_background = self.preferences["bul_pdf_with_background"]
else:
@ -337,6 +339,7 @@ class ScoDocPageTemplate(PageTemplate):
def draw_footer(self, canv, content):
"""Print the footer"""
# called 1/page
try:
canv.setFont(
self.preferences["SCOLAR_FONT"],
@ -351,8 +354,11 @@ class ScoDocPageTemplate(PageTemplate):
canv.drawString(
self.preferences["pdf_footer_x"] * mm,
self.preferences["pdf_footer_y"] * mm,
content,
content + " " + (self.preferences["pdf_footer_extra"] or ""),
)
if self.with_page_numbers:
canv.drawString(190.0 * mm, 6 * mm, f"Page {self.page_number}")
canv.restoreState()
def footer_string(self) -> str:
@ -382,18 +388,14 @@ class ScoDocPageTemplate(PageTemplate):
filigranne = self.filigranne.get(doc.page, None)
if filigranne:
canv.saveState()
canv.translate(9 * cm, 27.6 * cm)
canv.rotate(30)
canv.scale(4.5, 4.5)
canv.translate(10 * cm, 21.0 * cm)
canv.rotate(36)
canv.scale(7, 7)
canv.setFillColorRGB(1.0, 0.65, 0.65, alpha=0.6)
canv.drawRightString(0, 0, SU(filigranne))
canv.drawCentredString(0, 0, SU(filigranne))
canv.restoreState()
doc.filigranne = None
def afterPage(self):
"""Called after all flowables have been drawn on a page.
Increment pageNum since the page has been completed.
"""
# Increment page number
self.page_number += 1

View File

@ -96,13 +96,16 @@ def photo_portal_url(code_nip: str):
return None
def get_etud_photo_url(etudid, size="small"):
def get_etud_photo_url(etudid, size="small", seed=None):
"L'URL scodoc vers la photo de l'étudiant"
kwargs = {"seed": seed} if seed else {}
return (
url_for(
"scolar.get_photo_image",
scodoc_dept=g.scodoc_dept,
etudid=etudid,
size=size,
**kwargs,
)
if has_request_context()
else ""
@ -114,9 +117,11 @@ def etud_photo_url(etud: dict, size="small", fast=False) -> str:
If ScoDoc doesn't have an image and a portal is configured, link to it.
"""
photo_url = get_etud_photo_url(etud["etudid"], size=size)
if fast:
return photo_url
return get_etud_photo_url(etud["etudid"], size=size)
photo_url = get_etud_photo_url(
etud["etudid"], size=size, seed=hash(etud.get("photo_filename"))
)
path = photo_pathname(etud["photo_filename"], size=size)
if not path:
# Portail ?
@ -374,7 +379,15 @@ def copy_portal_photo_to_fs(etudid: int):
portal_timeout = sco_preferences.get_preference("portal_timeout")
error_message = None
try:
r = requests.get(url, timeout=portal_timeout)
r = requests.get(
url,
timeout=portal_timeout,
params={
"nom": etud.nom or "",
"prenom": etud.prenom or "",
"civilite": etud.civilite,
},
)
except requests.ConnectionError:
error_message = "ConnectionError"
except requests.Timeout:

View File

@ -378,6 +378,7 @@ class PlacementRunner:
preferences=sco_preferences.SemPreferences(
self.moduleimpl_data["formsemestre_id"]
),
table_id="placement_pdf",
)
return tab.make_page(fmt="pdf", with_html_headers=False)

View File

@ -221,6 +221,7 @@ def formsemestre_poursuite_report(formsemestre_id, fmt="html"):
html_class="table_leftalign table_listegroupe",
pdf_link=False, # pas d'export pdf
preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="formsemestre_poursuite_report",
)
tab.filename = scu.make_filename("poursuite " + sem["titreannee"])

View File

@ -110,6 +110,7 @@ get_base_preferences(formsemestre_id)
Return base preferences for current scodoc_dept (instance BasePreferences)
"""
import flask
from flask import current_app, flash, g, request, url_for
@ -592,7 +593,9 @@ class BasePreferences:
PS: Au dela de %(abs_notify_abs_threshold)s, un email automatique est adressé toutes les %(abs_notify_abs_increment)s absences. Ces valeurs sont modifiables dans les préférences de ScoDoc.
""",
"title": """Message notification e-mail""",
"explanation": """Balises remplacées, voir la documentation""",
"explanation": """Balises remplacées, voir <a class="stdlink" target="_blank"
href="https://scodoc.org/GestionAbsences/#contenu-du-mail-de-notification"
>la documentation</a>""",
"input_type": "textarea",
"rows": 15,
"cols": 64,
@ -622,18 +625,6 @@ class BasePreferences:
"explanation": "Désactive la saisie et l'affichage des présences",
},
),
(
"periode_defaut",
{
"initvalue": 2.0,
"size": 10,
"title": "Durée par défaut d'un créneau",
"type": "float",
"category": "assi",
"only_global": True,
"explanation": "Durée d'un créneau en heure. Utilisé dans les pages de saisie",
},
),
(
"nb_heures_par_jour",
{
@ -963,6 +954,16 @@ class BasePreferences:
"category": "pdf",
},
),
(
"pdf_footer_extra",
{
"initvalue": "",
"title": "Texte à ajouter en pied de page",
"explanation": "sur tous les documents, par exemple vos coordonnées, ...",
"size": 78,
"category": "pdf",
},
),
(
"pdf_footer_x",
{

View File

@ -81,14 +81,11 @@ def feuille_preparation_jury(formsemestre_id):
nbabs = {}
nbabsjust = {}
for etud in etuds:
Se = sco_cursus.get_situation_etud_cursus(
etud.to_dict_scodoc7(), formsemestre_id
)
if Se.prev:
formsemestre_prev = FormSemestre.query.get_or_404(
Se.prev["formsemestre_id"]
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
if Se.prev_formsemestre:
ntp: NotesTableCompat = res_sem.load_formsemestre_results(
Se.prev_formsemestre
)
ntp: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre_prev)
for ue in ntp.get_ues_stat_dict(filter_sport=True):
ue_status = ntp.get_etud_ue_status(etud.id, ue["ue_id"])
ue_code_s = (
@ -110,7 +107,7 @@ def feuille_preparation_jury(formsemestre_id):
moy_ue[ue_code_s][etud.id] = ue_status["moy"] if ue_status else ""
ue_acro[ue_code_s] = (ue["numero"], ue["acronyme"], ue["titre"])
if Se.prev:
if Se.prev_formsemestre:
try:
moy_inter[etud.id] = (moy[etud.id] + prev_moy[etud.id]) / 2.0
except (KeyError, TypeError):
@ -132,12 +129,7 @@ def feuille_preparation_jury(formsemestre_id):
# parcours:
parcours[etud.id] = Se.get_cursus_descr()
# groupe principal (td)
groupestd[etud.id] = ""
for s in Se.etud["sems"]:
if s["formsemestre_id"] == formsemestre_id:
groupestd[etud.id] = etud_groups.get(etud.id, {}).get(
main_partition_id, ""
)
groupestd[etud.id] = etud_groups.get(etud.id, {}).get(main_partition_id, "")
# absences:
_, nbabsjust[etud.id], nbabs[etud.id] = sco_assiduites.get_assiduites_count(
etud.id, sem

View File

@ -42,7 +42,6 @@ from app.models import (
but_validations,
)
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_ue
from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre
from app.scodoc import sco_cursus
@ -81,6 +80,7 @@ def dict_pvjury(
},
'autorisations' : [ { 'semestre_id' : { ... } } ],
'validation_parcours' : True si parcours validé (diplome obtenu)
'parcours' : 'S1, S2, S3, S4, A1',
'prev_code' : code (calculé slt si with_prev),
'mention' : mention (en fct moy gen),
'sum_ects' : total ECTS acquis dans ce semestre (incluant les UE capitalisées)
@ -107,10 +107,10 @@ def dict_pvjury(
D = {} # même chose que decisions, mais { etudid : dec }
for etudid in etudids:
etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(
etud.to_dict_scodoc7(), formsemestre_id
situation_etud = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
semestre_non_terminal = (
semestre_non_terminal or situation_etud.semestre_non_terminal
)
semestre_non_terminal = semestre_non_terminal or Se.semestre_non_terminal
d = {}
d["identite"] = nt.identdict[etudid]
d["etat"] = nt.get_etud_etat(
@ -120,9 +120,8 @@ def dict_pvjury(
d["decisions_ue"] = nt.get_etud_decisions_ue(etudid)
if formsemestre.formation.is_apc():
d.update(but_validations.dict_decision_jury(etud, formsemestre))
d["last_formsemestre_id"] = Se.get_semestres()[
-1
] # id du dernier semestre (chronologiquement) dans lequel il a été inscrit
# id du dernier semestre (chronologiquement) dans lequel il a été inscrit:
d["last_formsemestre_id"] = situation_etud.get_semestres()[-1]
ects_capitalises_by_ue_code = _comp_ects_capitalises_by_ue_code(nt, etudid)
d["sum_ects_capitalises"] = sum(ects_capitalises_by_ue_code.values())
@ -162,10 +161,13 @@ def dict_pvjury(
d["autorisations"] = [a.to_dict() for a in autorisations]
d["autorisations_descr"] = descr_autorisations(autorisations)
d["validation_parcours"] = Se.parcours_validated()
d["parcours"] = Se.get_cursus_descr(filter_futur=True)
d["validation_parcours"] = situation_etud.parcours_validated()
d["parcours"] = situation_etud.get_cursus_descr(filter_futur=True)
d["parcours_in_cur_formation"] = situation_etud.get_cursus_descr(
filter_futur=True, filter_formation_code=True
)
if with_parcours_decisions:
d["parcours_decisions"] = Se.get_parcours_decisions()
d["parcours_decisions"] = situation_etud.get_parcours_decisions()
# Observations sur les compensations:
compensators = sco_cursus_dut.scolar_formsemestre_validation_list(
cnx, args={"compense_formsemestre_id": formsemestre_id, "etudid": etudid}
@ -206,19 +208,19 @@ def dict_pvjury(
if not info:
continue # should not occur
etud = info[0]
if Se.prev and Se.prev_decision:
d["prev_decision_sem"] = Se.prev_decision
d["prev_code"] = Se.prev_decision["code"]
if situation_etud.prev_formsemestre and situation_etud.prev_decision:
d["prev_decision_sem"] = situation_etud.prev_decision
d["prev_code"] = situation_etud.prev_decision["code"]
d["prev_code_descr"] = _descr_decision_sem(
scu.INSCRIT, Se.prev_decision
scu.INSCRIT, situation_etud.prev_decision
)
d["prev"] = Se.prev
d["prev"] = situation_etud.prev_formsemestre.to_dict()
has_prev = True
else:
d["prev_decision_sem"] = None
d["prev_code"] = ""
d["prev_code_descr"] = ""
d["Se"] = Se
d["Se"] = situation_etud
decisions.append(d)
D[etudid] = d

View File

@ -149,7 +149,7 @@ def pvjury_table(
etudid=e["identite"]["etudid"],
),
"_nomprenom_td_attrs": f"""id="{e['identite']['etudid']}" class="etudinfo" """,
"parcours": e["parcours"],
"parcours": e["parcours_in_cur_formation"],
"decision": _descr_decision_sem_abbrev(e["etat"], e["decision_sem"]),
"ue_cap": e["decisions_ue_descr"],
"validation_parcours_code": "ADM" if e["validation_parcours"] else "",
@ -252,6 +252,7 @@ def formsemestre_pvjury(formsemestre_id, fmt="html", publish=True):
html_class="table_leftalign",
html_sortable=True,
preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="formsemestre_pvjury",
)
if fmt != "html":
return tab.make_page(
@ -312,6 +313,7 @@ def formsemestre_pvjury(formsemestre_id, fmt="html", publish=True):
html_sortable=True,
html_with_td_classes=True,
preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="formsemestre_pvjury_counts",
).html()
)
H.append(

View File

@ -257,7 +257,9 @@ def pdf_lettre_individuelle(sem, decision, etud: Identite, params, signature=Non
else:
params["autorisations_txt"] = ""
if decision["decision_sem"] and situation_etud.parcours_validated():
if (
formsemestre.formation.is_apc() or decision["decision_sem"]
) and situation_etud.parcours_validated():
params["diplome_txt"] = (
"""Vous avez donc obtenu le diplôme : <b>%(titre_formation)s</b>""" % params
)
@ -357,5 +359,8 @@ def add_apc_infos(formsemestre: FormSemestre, params: dict, decision: dict):
params[
"decision_ue_txt"
] = f"""{params["decision_ue_txt"]}<br/>
<b>Niveaux de compétences:</b><br/> {decision.get("descr_decisions_rcue") or ""}
<b>Niveaux de compétences:</b>
<br/>&nbsp;&nbsp;&nbsp;&nbsp;- {
'<br/>&nbsp;&nbsp;&nbsp;&nbsp;- '.join( decision.get("descr_decisions_rcue_list", []) )
}
"""

View File

@ -236,6 +236,7 @@ def _results_by_category(
html_col_width="4em",
html_sortable=True,
preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id=f"results_by_category-{category_name}",
)
@ -247,8 +248,6 @@ def formsemestre_report(
result="codedecision",
category_name="",
result_name="",
title="Statistiques",
only_primo=None,
):
"""
Tableau sur résultats (result) par type de category bac
@ -276,9 +275,6 @@ def formsemestre_report(
f"Répartition des résultats par {category_name}, semestre {sem['titreannee']}"
)
tab.html_caption = f"Répartition des résultats par {category_name}."
tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id)
if only_primo:
tab.base_url += "&only_primo=on"
return tab
@ -325,8 +321,15 @@ def formsemestre_report_counts(
category=category,
result=result,
category_name=category_name,
title=title,
only_primo=only_primo,
)
tab.base_url = url_for(
"notes.formsemestre_report_counts",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
category=category,
only_primo=int(bool(only_primo)),
result=result,
group_ids=group_ids,
)
if len(formsemestre.inscriptions) == 0:
F = ["""<p><em>Aucun étudiant</em></p>"""]
@ -350,6 +353,7 @@ def formsemestre_report_counts(
"statut",
"annee_admission",
"type_admission",
"boursier",
"boursier_prec",
]
if jury_but_mode:
@ -695,19 +699,18 @@ def table_suivi_cohorte(
if statut:
dbac += " statut: %s" % statut
tab = GenTable(
titles=titles,
caption="Suivi cohorte " + pp + sem["titreannee"] + dbac,
columns_ids=columns_ids,
rows=L,
filename=scu.make_filename("cohorte " + sem["titreannee"]),
html_class="table_cohorte",
html_col_width="4em",
html_sortable=True,
filename=scu.make_filename("cohorte " + sem["titreannee"]),
origin="Généré par %s le " % sco_version.SCONAME
+ scu.timedate_human_repr()
+ "",
caption="Suivi cohorte " + pp + sem["titreannee"] + dbac,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
page_title="Suivi cohorte " + sem["titreannee"],
html_class="table_cohorte",
preferences=sco_preferences.SemPreferences(formsemestre.id),
rows=L,
table_id="table_suivi_cohorte",
titles=titles,
)
# Explication: liste des semestres associés à chaque date
if not P:
@ -1304,6 +1307,7 @@ def table_suivi_cursus(formsemestre_id, only_primo=False, grouped_parcours=True)
"code_cursus": len(etuds),
},
preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="table_suivi_cursus",
)
return tab

View File

@ -87,15 +87,16 @@ def formsemestre_but_indicateurs(formsemestre_id: int, fmt="html"):
bacs.append("Total")
tab = GenTable(
titles={bac: bac for bac in bacs},
columns_ids=["titre_indicateur"] + bacs,
rows=rows,
html_sortable=False,
preferences=sco_preferences.SemPreferences(formsemestre_id),
filename=scu.make_filename(f"Indicateurs_BUT_{formsemestre.titre_annee()}"),
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
html_caption="Indicateurs BUT annuels.",
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}",
columns_ids=["titre_indicateur"] + bacs,
filename=scu.make_filename(f"Indicateurs_BUT_{formsemestre.titre_annee()}"),
html_caption="Indicateurs BUT annuels.",
html_sortable=False,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
preferences=sco_preferences.SemPreferences(formsemestre_id),
rows=rows,
titles={bac: bac for bac in bacs},
table_id="formsemestre_but_indicateurs",
)
title = "Indicateurs suivi annuel BUT"
t = tab.make_page(

View File

@ -29,12 +29,15 @@
Formulaire revu en juillet 2016
"""
import html
import time
import psycopg2
import flask
from flask import g, url_for, request
from flask_login import current_user
from flask_sqlalchemy.query import Query
import psycopg2
from app import db, log
from app.auth.models import User
@ -75,8 +78,6 @@ import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import json_error
from app.scodoc.sco_utils import ModuleType
from flask_sqlalchemy.query import Query
def convert_note_from_string(
note: str,
@ -115,7 +116,7 @@ def convert_note_from_string(
return note_value, invalid
def _displayNote(val):
def _display_note(val):
"""Convert note from DB to viewable string.
Utilisé seulement pour I/O vers formulaires (sans perte de precision)
(Utiliser fmt_note pour les affichages)
@ -272,7 +273,7 @@ def do_evaluation_upload_xls():
diag.append("Notes invalides pour: " + ", ".join(etudsnames))
raise InvalidNoteValue()
else:
etudids_changed, nb_suppress, etudids_with_decisions = notes_add(
etudids_changed, nb_suppress, etudids_with_decisions, messages = notes_add(
current_user, evaluation_id, valid_notes, comment
)
# news
@ -292,9 +293,19 @@ def do_evaluation_upload_xls():
max_frequency=30 * 60, # 30 minutes
)
msg = f"""<p>{len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes, {
len(absents)} absents, {nb_suppress} note supprimées)
msg = f"""<p>
{len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes,
{len(absents)} absents, {nb_suppress} note supprimées)
</p>"""
if messages:
msg += f"""<div class="warning">Attention&nbsp;:
<ul>
<li>{
'</li><li>'.join(messages)
}
</li>
</ul>
</div>"""
if etudids_with_decisions:
msg += """<p class="warning">Important: il y avait déjà des décisions de jury
enregistrées, qui sont peut-être à revoir suite à cette modification !</p>
@ -322,7 +333,7 @@ def do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) -
# Convert and check value
L, invalids, _, _, _ = _check_notes([(etud.id, value)], evaluation)
if len(invalids) == 0:
etudids_changed, _, _ = notes_add(
etudids_changed, _, _, _ = notes_add(
current_user, evaluation.id, L, "Initialisation notes"
)
if len(etudids_changed) == 1:
@ -398,7 +409,9 @@ def do_evaluation_set_missing(
)
# ok
comment = "Initialisation notes manquantes"
etudids_changed, _, _ = notes_add(current_user, evaluation_id, valid_notes, comment)
etudids_changed, _, _, _ = notes_add(
current_user, evaluation_id, valid_notes, comment
)
# news
url = url_for(
"notes.moduleimpl_status",
@ -456,7 +469,7 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
)
if not dialog_confirmed:
etudids_changed, nb_suppress, existing_decisions = notes_add(
etudids_changed, nb_suppress, existing_decisions, _ = notes_add(
current_user, evaluation_id, notes, do_it=False, check_inscription=False
)
msg = f"""<p>Confirmer la suppression des {nb_suppress} notes ?
@ -477,7 +490,7 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
)
# modif
etudids_changed, nb_suppress, existing_decisions = notes_add(
etudids_changed, nb_suppress, existing_decisions, _ = notes_add(
current_user,
evaluation_id,
notes,
@ -519,7 +532,7 @@ def notes_add(
comment=None,
do_it=True,
check_inscription=True,
) -> tuple[list[int], int, list[int]]:
) -> tuple[list[int], int, list[int], list[str]]:
"""
Insert or update notes
notes is a list of tuples (etudid,value)
@ -528,30 +541,48 @@ def notes_add(
Nota:
- si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log)
Return: tuple (etudids_changed, nb_suppress, etudids_with_decision)
Return: tuple (etudids_changed, nb_suppress, etudids_with_decision, messages)
messages = list de messages d'avertissement/information pour l'utilisateur
"""
evaluation = Evaluation.get_evaluation(evaluation_id)
now = psycopg2.Timestamp(*time.localtime()[:6])
# Vérifie inscription et valeur note
inscrits = {
messages = []
# Vérifie inscription au module (même DEM/DEF)
etudids_inscrits_mod = {
x[0]
for x in sco_groups.do_evaluation_listeetuds_groups(
evaluation_id, getallstudents=True, include_demdef=True
)
}
# Les étudiants inscrits au semestre ni DEM ni DEF
_, etudids_actifs = evaluation.moduleimpl.formsemestre.etudids_actifs()
# Les étudiants inscrits au semestre et ceux "actifs" (ni DEM ni DEF)
etudids_inscrits_sem, etudids_actifs = (
evaluation.moduleimpl.formsemestre.etudids_actifs()
)
for etudid, value in notes:
if check_inscription and (
(etudid not in inscrits) or (etudid not in etudids_actifs)
):
log(f"notes_add: {etudid} non inscrit ou DEM/DEF: aborting")
raise NoteProcessError(f"étudiant {etudid} non inscrit dans ce module")
if check_inscription:
msg_err, msg_warn = "", ""
if etudid not in etudids_inscrits_sem:
msg_err = "non inscrit au semestre"
elif etudid not in etudids_inscrits_mod:
msg_err = "non inscrit au module"
elif etudid not in etudids_actifs:
# DEM ou DEF
msg_warn = "démissionnaire ou défaillant (note enregistrée)"
if msg_err or msg_warn:
etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
msg = f"étudiant {etud.nomprenom if etud else etudid} {msg_err or msg_warn}"
if msg_err:
log(f"notes_add: {etudid} non inscrit ou DEM/DEF: aborting")
raise NoteProcessError(msg)
if msg_warn:
messages.append(msg)
if (value is not None) and not isinstance(value, float):
log(f"notes_add: {etudid} valeur de note invalide ({value}): aborting")
etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
raise NoteProcessError(
f"etudiant {etudid}: valeur de note invalide ({value})"
f"etudiant {etud.nomprenom if etud else etudid}: valeur de note invalide ({value})"
)
# Recherche notes existantes
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
@ -566,102 +597,20 @@ def notes_add(
etudids_with_decision = []
try:
for etudid, value in notes:
changed = False
if etudid not in notes_db:
# nouvelle note
if value != scu.NOTES_SUPPRESS:
if do_it:
args = {
"etudid": etudid,
"evaluation_id": evaluation_id,
"value": value,
"comment": comment,
"uid": user.id,
"date": now,
}
ndb.quote_dict(args)
# Note: le conflit ci-dessous peut arriver si un autre thread
# a modifié la base après qu'on ait lu notes_db
cursor.execute(
"""INSERT INTO notes_notes
(etudid, evaluation_id, value, comment, date, uid)
VALUES
(%(etudid)s,%(evaluation_id)s,%(value)s,
%(comment)s,%(date)s,%(uid)s)
ON CONFLICT ON CONSTRAINT notes_notes_etudid_evaluation_id_key
DO UPDATE SET etudid=%(etudid)s, evaluation_id=%(evaluation_id)s,
value=%(value)s, comment=%(comment)s, date=%(date)s, uid=%(uid)s
""",
args,
)
changed = True
else:
# il y a deja une note
oldval = notes_db[etudid]["value"]
if type(value) != type(oldval):
changed = True
elif isinstance(value, float) and (
abs(value - oldval) > scu.NOTES_PRECISION
):
changed = True
elif value != oldval:
changed = True
if changed:
# recopie l'ancienne note dans notes_notes_log, puis update
if do_it:
cursor.execute(
"""INSERT INTO notes_notes_log
(etudid,evaluation_id,value,comment,date,uid)
SELECT etudid, evaluation_id, value, comment, date, uid
FROM notes_notes
WHERE etudid=%(etudid)s
and evaluation_id=%(evaluation_id)s
""",
{"etudid": etudid, "evaluation_id": evaluation_id},
)
args = {
"etudid": etudid,
"evaluation_id": evaluation_id,
"value": value,
"date": now,
"comment": comment,
"uid": user.id,
}
ndb.quote_dict(args)
if value != scu.NOTES_SUPPRESS:
if do_it:
cursor.execute(
"""UPDATE notes_notes
SET value=%(value)s, comment=%(comment)s, date=%(date)s, uid=%(uid)s
WHERE etudid = %(etudid)s
and evaluation_id = %(evaluation_id)s
""",
args,
)
else: # suppression ancienne note
if do_it:
log(
f"""notes_add, suppress, evaluation_id={evaluation_id}, etudid={
etudid}, oldval={oldval}"""
)
cursor.execute(
"""DELETE FROM notes_notes
WHERE etudid = %(etudid)s
AND evaluation_id = %(evaluation_id)s
""",
args,
)
# garde trace de la suppression dans l'historique:
args["value"] = scu.NOTES_SUPPRESS
cursor.execute(
"""INSERT INTO notes_notes_log
(etudid,evaluation_id,value,comment,date,uid)
VALUES
(%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s)
""",
args,
)
nb_suppress += 1
changed, suppressed = _record_note(
cursor,
notes_db,
etudid,
evaluation_id,
value,
comment=comment,
user=user,
date=now,
do_it=do_it,
)
if suppressed:
nb_suppress += 1
if changed:
etudids_changed.append(etudid)
if res.etud_has_decision(etudid, include_rcues=False):
@ -678,7 +627,108 @@ def notes_add(
cnx.commit()
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
sco_cache.EvaluationCache.delete(evaluation_id)
return etudids_changed, nb_suppress, etudids_with_decision
return etudids_changed, nb_suppress, etudids_with_decision, messages
def _record_note(
cursor,
notes_db,
etudid: int,
evaluation_id: int,
value: float,
comment: str = "",
user: User = None,
date=None,
do_it=False,
):
"Enregistrement de la note en base"
changed = False
suppressed = False
args = {
"etudid": etudid,
"evaluation_id": evaluation_id,
"value": value,
# convention scodoc7 quote comment:
"comment": (html.escape(comment) if isinstance(comment, str) else comment),
"uid": user.id,
"date": date,
}
if etudid not in notes_db:
# nouvelle note
if value != scu.NOTES_SUPPRESS:
if do_it:
# Note: le conflit ci-dessous peut arriver si un autre thread
# a modifié la base après qu'on ait lu notes_db
cursor.execute(
"""INSERT INTO notes_notes
(etudid, evaluation_id, value, comment, date, uid)
VALUES
(%(etudid)s,%(evaluation_id)s,%(value)s,
%(comment)s,%(date)s,%(uid)s)
ON CONFLICT ON CONSTRAINT notes_notes_etudid_evaluation_id_key
DO UPDATE SET etudid=%(etudid)s, evaluation_id=%(evaluation_id)s,
value=%(value)s, comment=%(comment)s, date=%(date)s, uid=%(uid)s
""",
args,
)
changed = True
else:
# il y a deja une note
oldval = notes_db[etudid]["value"]
if type(value) != type(oldval):
changed = True
elif isinstance(value, float) and (abs(value - oldval) > scu.NOTES_PRECISION):
changed = True
elif value != oldval:
changed = True
if changed:
# recopie l'ancienne note dans notes_notes_log, puis update
if do_it:
cursor.execute(
"""INSERT INTO notes_notes_log
(etudid,evaluation_id,value,comment,date,uid)
SELECT etudid, evaluation_id, value, comment, date, uid
FROM notes_notes
WHERE etudid=%(etudid)s
and evaluation_id=%(evaluation_id)s
""",
args,
)
if value != scu.NOTES_SUPPRESS:
if do_it:
cursor.execute(
"""UPDATE notes_notes
SET value=%(value)s, comment=%(comment)s, date=%(date)s, uid=%(uid)s
WHERE etudid = %(etudid)s
and evaluation_id = %(evaluation_id)s
""",
args,
)
else: # suppression ancienne note
if do_it:
log(
f"""notes_add, suppress, evaluation_id={evaluation_id}, etudid={
etudid}, oldval={oldval}"""
)
cursor.execute(
"""DELETE FROM notes_notes
WHERE etudid = %(etudid)s
AND evaluation_id = %(evaluation_id)s
""",
args,
)
# garde trace de la suppression dans l'historique:
args["value"] = scu.NOTES_SUPPRESS
cursor.execute(
"""INSERT INTO notes_notes_log
(etudid,evaluation_id,value,comment,date,uid)
VALUES
(%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s)
""",
args,
)
suppressed = True
return changed, suppressed
def saisie_notes_tableur(evaluation_id, group_ids=()):
@ -703,7 +753,7 @@ def saisie_notes_tableur(evaluation_id, group_ids=()):
)
page_title = "Saisie des notes" + (
f"""de {evaluation.description}""" if evaluation.description else ""
f""" de {evaluation.description}""" if evaluation.description else ""
)
# Informations sur les groupes à afficher:
@ -797,9 +847,13 @@ def saisie_notes_tableur(evaluation_id, group_ids=()):
}">
Revenir au tableau de bord du module</a>
&nbsp;&nbsp;&nbsp;
<a class="stdlink" href="{url_for("notes.saisie_notes_tableur",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}">Charger un autre fichier de notes</a>
&nbsp;&nbsp;&nbsp;
<a class="stdlink" href="{url_for("notes.saisie_notes",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}">Charger d'autres notes dans cette évaluation</a>
}">Formulaire de saisie des notes</a>
</p>"""
)
else:
@ -1015,7 +1069,7 @@ def saisie_notes(evaluation_id: int, group_ids: list = None):
"Autres opérations",
[
{
"title": "Saisie par fichier tableur",
"title": "Saisir par fichier tableur",
"id": "menu_saisie_tableur",
"endpoint": "notes.saisie_notes_tableur",
"args": {
@ -1126,7 +1180,7 @@ def _get_sorted_etuds(evaluation: Evaluation, etudids: list, formsemestre_id: in
# Note actuelle de l'étudiant:
if etudid in notes_db:
e["val"] = _displayNote(notes_db[etudid]["value"])
e["val"] = _display_note(notes_db[etudid]["value"])
comment = notes_db[etudid]["comment"]
if comment is None:
comment = ""
@ -1368,7 +1422,7 @@ def save_notes(
#
valid_notes, _, _, _, _ = _check_notes(notes, evaluation)
if valid_notes:
etudids_changed, _, etudids_with_decision = notes_add(
etudids_changed, _, etudids_with_decision, messages = notes_add(
current_user, evaluation.id, valid_notes, comment=comment, do_it=True
)
ScolarNews.add(
@ -1386,12 +1440,14 @@ def save_notes(
etudid: get_note_history_menu(evaluation.id, etudid)
for etudid in etudids_changed
},
"messages": messages,
}
else:
result = {
"etudids_changed": [],
"etudids_with_decision": [],
"history_menu": [],
"messages": [],
}
return result
@ -1420,7 +1476,7 @@ def get_note_history_menu(evaluation_id: int, etudid: int) -> str:
first = True
for i in history:
jt = i["date"].strftime("le %d/%m/%Y à %H:%M") + " (%s)" % i["user_name"]
dispnote = _displayNote(i["value"])
dispnote = _display_note(i["value"])
if first:
nv = "" # ne repete pas la valeur de la note courante
else:

View File

@ -378,11 +378,9 @@ class SemSet(dict):
def html_diagnostic(self):
"""Affichage de la partie Effectifs et Liste des étudiants
(actif seulement si un portail est configuré) XXX pourquoi ??
(actif seulement si un portail est configuré)
"""
if sco_portal_apogee.has_portal():
return self.bilan.html_diagnostic()
return ""
return self.bilan.html_diagnostic()
def get_semsets_list():
@ -482,10 +480,9 @@ def semset_page(fmt="html"):
# (remplacé par n liens vers chacun des semestres)
# s['_semtitles_str_target'] = s['_export_link_target']
# Experimental:
s[
"_title_td_attrs"
] = 'class="inplace_edit" data-url="edit_semset_set_title" id="%s"' % (
s["semset_id"]
s["_title_td_attrs"] = (
'class="inplace_edit" data-url="edit_semset_set_title" id="%s"'
% (s["semset_id"])
)
tab = GenTable(
@ -513,6 +510,7 @@ def semset_page(fmt="html"):
html_class="table_leftalign",
filename="semsets",
preferences=sco_preferences.SemPreferences(),
table_id="table-semsets",
)
if fmt != "html":
return tab.make_page(fmt=fmt)

View File

@ -115,7 +115,8 @@ def formsemestre_synchro_etuds(
url_for('notes.formsemestre_editwithmodules',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
}">Modifier ce semestre</a>)
"""
""",
safe=True,
)
footer = html_sco_header.sco_footer()
base_url = url_for(

View File

@ -180,7 +180,7 @@ def external_ue_inscrit_et_note(
description="note externe",
)
# Saisie des notes
_, _, _ = sco_saisie_notes.notes_add(
_, _, _, _ = sco_saisie_notes.notes_add(
current_user,
evaluation.id,
list(notes_etuds.items()),

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