Compare commits

...

22 Commits

Author SHA1 Message Date
Emmanuel Viennet a200be586a ue.titre peut être null + formattage code 2024-02-13 13:55:16 +01:00
Emmanuel Viennet 607604f91e Assiduite: retire arg inutile qui faisait planter sur sems vides 2024-02-12 10:21:05 +01:00
Emmanuel Viennet 8eedac0f03 orthographe 2024-02-12 10:12:46 +01:00
Emmanuel Viennet aea2204d9e PE: fix moy promo (max -> moy) 2024-02-12 09:26:23 +01:00
Emmanuel Viennet 9c15cbe647 PE: Fix moys (thx @jmpulille). Ajoute log au zip. 2024-02-11 22:06:37 +01:00
Emmanuel Viennet 6761f5a620 Ajoute une vérification sur les semestres BUT: association aux parcours 2024-02-11 21:19:45 +01:00
Emmanuel Viennet 69a53adb55 Migration pour clés etudiant/annotations et modif clé ModuleImpl/responsable 2024-02-11 12:21:48 +01:00
Emmanuel Viennet b30ea5f5fd Annotations étudiants: API et tests 2024-02-11 12:05:43 +01:00
Emmanuel Viennet 052fb3c7b9 Merge pull request 'Ajout des annotations dans l'API' (#857) from lyanis/ScoDoc:api-annot into master
Reviewed-on: ScoDoc/ScoDoc#857
2024-02-11 10:09:50 +01:00
Lyanis Souidi dbd0124c2c Retrait des annotations inutiles sur certaines routes API 2024-02-10 19:32:43 +01:00
Lyanis Souidi e989a4ffa8 Restreint l'accès aux annotations via l'API à la permission ViewEtudData 2024-02-10 15:53:57 +01:00
Lyanis Souidi 6ae2b0eb5f Merge branch 'master' into api-annot 2024-02-10 15:14:14 +01:00
Emmanuel Viennet d7f3376103 fiche_etud: restreint l'accès aux annotations à la permission ViewEtudData 2024-02-10 15:02:18 +01:00
Lyanis Souidi 677415fbfc Ajout des annotations dans l'API 2024-02-10 14:11:34 +01:00
Emmanuel Viennet 6cbeeedb1c Ajout colonne module sur page recap inscriptions 2024-02-09 15:36:37 +01:00
Emmanuel Viennet 39e7ad3ad6 clarifie titre option publication bulletins 2024-02-09 15:32:48 +01:00
Emmanuel Viennet 177d38428e Fix: jury BUT si une UE a été déassociée depuis la validation 2024-02-09 13:47:48 +01:00
Emmanuel Viennet f4c1d00046 PE: reformattage code, small bug fix. 2024-02-08 22:31:46 +01:00
Emmanuel Viennet 86c12dee08 complete previous commit 2024-02-08 16:26:50 +01:00
Emmanuel Viennet 8cf85f78a8 Assiduite: filtrage par formsemestre: intersection dates 2024-02-08 16:23:14 +01:00
Emmanuel Viennet 9ec0ef27ba Assiduite: visu_assi_group avec restriction aux modules du semestre. 2024-02-08 15:56:58 +01:00
Emmanuel Viennet c8ac796347 Assiduite: fix format cell. nombre dans exports excel 2024-02-08 11:05:57 +01:00
50 changed files with 1057 additions and 560 deletions

View File

@ -337,7 +337,7 @@ def assiduites_group(with_query: bool = False):
try:
etuds = [int(etu) for etu in etuds]
except ValueError:
return json_error(404, "Le champs etudids n'est pas correctement formé")
return json_error(404, "Le champ etudids n'est pas correctement formé")
# Vérification que tous les étudiants sont du même département
query = Identite.query.filter(Identite.id.in_(etuds))

View File

@ -10,7 +10,7 @@
from datetime import datetime
from operator import attrgetter
from flask import g, request
from flask import g, request, Response
from flask_json import as_json
from flask_login import current_user
from flask_login import login_required
@ -18,7 +18,7 @@ from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import VARCHAR
import app
from app import db
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.api import tools
from app.but import bulletin_but_court
@ -26,6 +26,7 @@ from app.decorators import scodoc, permission_required
from app.models import (
Admission,
Departement,
EtudAnnotation,
FormSemestreInscription,
FormSemestre,
Identite,
@ -54,6 +55,32 @@ import app.scodoc.sco_utils as scu
#
def _get_etud_by_code(
code_type: str, code: str, dept: Departement
) -> tuple[bool, Response | Identite]:
"""Get etud, using etudid, NIP or INE
Returns True, etud if ok, or False, error response.
"""
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return False, json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code)
else:
return False, json_error(404, "invalid code_type")
if dept:
query = query.filter_by(dept_id=dept.id)
etud = query.first()
if etud is None:
return False, json_error(404, message="etudiant inexistant")
return True, etud
@bp.route("/etudiants/courants", defaults={"long": False})
@bp.route("/etudiants/courants/long", defaults={"long": True})
@api_web_bp.route("/etudiants/courants", defaults={"long": False})
@ -105,7 +132,9 @@ def etudiants_courants(long=False):
)
if long:
restrict = not current_user.has_permission(Permission.ViewEtudData)
data = [etud.to_dict_api(restrict=restrict) for etud in etuds]
data = [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in etuds
]
else:
data = [etud.to_dict_short() for etud in etuds]
return data
@ -140,7 +169,7 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
message="étudiant inconnu",
)
restrict = not current_user.has_permission(Permission.ViewEtudData)
return etud.to_dict_api(restrict=restrict)
return etud.to_dict_api(restrict=restrict, with_annotations=True)
@bp.route("/etudiant/etudid/<int:etudid>/photo")
@ -253,7 +282,9 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
restrict = not current_user.has_permission(Permission.ViewEtudData)
return [etud.to_dict_api(restrict=restrict) for etud in query]
return [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in query
]
@bp.route("/etudiants/name/<string:start>")
@ -385,28 +416,15 @@ def bulletin(
pdf = True
if version not in scu.BULLETINS_VERSIONS_BUT:
return json_error(404, "version invalide")
# return f"{code_type}={code}, version={version}, pdf={pdf}"
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
if g.scodoc_dept and dept.acronym != g.scodoc_dept:
return json_error(404, "formsemestre inexistant")
app.set_sco_dept(dept.acronym)
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code, dept_id=dept.id)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code, dept_id=dept.id)
else:
return json_error(404, "invalid code_type")
etud = query.first()
if etud is None:
return json_error(404, message="etudiant inexistant")
ok, etud = _get_etud_by_code(code_type, code, dept)
if not ok:
return etud # json error
if version == "butcourt":
if pdf:
@ -558,26 +576,15 @@ def etudiant_create(force=False):
@api_web_bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def etudiant_edit(
code_type: str = "etudid",
code: str = None,
):
"""Edition des données étudiant (identité, admission, adresses)"""
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code)
else:
return json_error(404, "invalid code_type")
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first()
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
#
args = request.get_json(force=True) # may raise 400 Bad Request
etud.from_dict(args)
@ -600,3 +607,67 @@ def etudiant_edit(
restrict = not current_user.has_permission(Permission.ViewEtudData)
r = etud.to_dict_api(restrict=restrict)
return r
@bp.route("/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"])
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"]
)
@scodoc
@permission_required(Permission.EtudInscrit) # il faut en plus ViewEtudData
@as_json
def etudiant_annotation(
code_type: str = "etudid",
code: str = None,
):
"""Ajout d'une annotation sur un étudiant"""
if not current_user.has_permission(Permission.ViewEtudData):
return json_error(403, "non autorisé (manque ViewEtudData)")
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
#
args = request.get_json(force=True) # may raise 400 Bad Request
comment = args.get("comment", None)
if not isinstance(comment, str):
return json_error(404, "invalid comment (expected string)")
if len(comment) > scu.MAX_TEXT_LEN:
return json_error(404, "invalid comment (too large)")
annotation = EtudAnnotation(comment=comment, author=current_user.user_name)
etud.annotations.append(annotation)
db.session.add(etud)
db.session.commit()
log(f"etudiant_annotation/{etud.id}/{annotation.id}")
return annotation.to_dict()
@bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@as_json
@permission_required(Permission.EtudInscrit)
def etudiant_annotation_delete(
code_type: str = "etudid", code: str = None, annotation_id: int = None
):
"""
Suppression d'une annotation
"""
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
annotation = EtudAnnotation.query.filter_by(
etudid=etud.id, id=annotation_id
).first()
if annotation is None:
return json_error(404, "annotation not found")
log(f"etudiant_annotation_delete/{etud.id}/{annotation.id}")
db.session.delete(annotation)
db.session.commit()
return "ok"

View File

@ -119,9 +119,13 @@ class EtudCursusBUT:
self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
validation_rcue: ApcValidationRCUE
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:
if (
niveau is None
or 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
@ -443,8 +447,24 @@ def formsemestre_warning_apc_setup(
}">formation n'est pas associée à un référentiel de compétence.</a>
</div>
"""
# Vérifie les niveaux de chaque parcours
H = []
# Le semestre n'a pas de parcours, mais les UE ont des parcours ?
if not formsemestre.parcours:
nb_ues_sans_parcours = len(
formsemestre.formation.query_ues_parcour(None)
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
.all()
)
nb_ues_tot = (
UniteEns.query.filter_by(formation=formsemestre.formation, type=UE_STANDARD)
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
.count()
)
if nb_ues_sans_parcours != nb_ues_tot:
H.append(
f"""Le semestre n'est associé à aucun parcours, mais les UEs de la formation ont des parcours"""
)
# Vérifie les niveaux de chaque parcours
for parcour in formsemestre.parcours or [None]:
annee = (formsemestre.semestre_id + 1) // 2
niveaux_ids = {

View File

@ -256,7 +256,7 @@ def _gen_but_niveau_ue(
return f"""<div class="but_niveau_ue {ue_class}
{'annee_prec' if annee_prec else ''}
">
<div title="{ue.titre}">{ue.acronyme}</div>
<div title="{ue.titre or ''}">{ue.acronyme}</div>
<div class="but_note with_scoplement">
<div>{moy_ue_str}</div>
{scoplement}

View File

@ -17,7 +17,7 @@ def UEParcoursECTSForm(ue: UniteEns) -> FlaskForm:
pass
parcours: list[ApcParcours] = ue.formation.referentiel_competence.parcours
# Initialise un champs de saisie par parcours
# Initialise un champ de saisie par parcours
for parcour in parcours:
ects = ue.get_ects(parcour, only_parcours=True)
setattr(

View File

@ -82,7 +82,7 @@ class ConfigCASForm(FlaskForm):
cas_attribute_id = StringField(
label="Attribut CAS utilisé comme id (laissez vide pour prendre l'id par défaut)",
description="""Le champs CAS qui sera considéré comme l'id unique des
description="""Le champ CAS qui sera considéré comme l'id unique des
comptes utilisateurs.""",
)

View File

@ -77,10 +77,12 @@ class ApcValidationRCUE(db.Model):
niveau = self.niveau()
return niveau.annee if niveau else None
def niveau(self) -> ApcNiveau:
def niveau(self) -> ApcNiveau | None:
"""Le niveau de compétence associé à cet RCUE."""
# Par convention, il est donné par la seconde UE
return self.ue2.niveau_competence
# à défaut (si l'UE a été désacciée entre temps), la première
# et à défaut, renvoie None
return self.ue2.niveau_competence or self.ue1.niveau_competence
def to_dict(self):
"as a dict"

View File

@ -297,7 +297,7 @@ class ScoDocSiteConfig(db.Model):
@classmethod
def _get_int_field(cls, name: str, default=None) -> int:
"""Valeur d'un champs integer"""
"""Valeur d'un champ integer"""
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if (cfg is None) or cfg.value is None:
return default
@ -311,7 +311,7 @@ class ScoDocSiteConfig(db.Model):
default=None,
range_values: tuple = (),
) -> bool:
"""Set champs integer. True si changement."""
"""Set champ integer. True si changement."""
if value != cls._get_int_field(name, default=default):
if not isinstance(value, int) or (
range_values and (value < range_values[0]) or (value > range_values[1])

View File

@ -101,7 +101,12 @@ class Identite(models.ScoDocModel):
adresses = db.relationship(
"Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic"
)
annotations = db.relationship(
"EtudAnnotation",
backref="etudiant",
cascade="all, delete-orphan",
lazy="dynamic",
)
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
#
dispense_ues = db.relationship(
@ -477,9 +482,9 @@ class Identite(models.ScoDocModel):
"civilite": self.civilite,
"code_ine": self.code_ine or "",
"code_nip": self.code_nip or "",
"date_naissance": self.date_naissance.strftime("%d/%m/%Y")
if self.date_naissance
else "",
"date_naissance": (
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""
),
"dept_acronym": self.departement.acronym,
"dept_id": self.dept_id,
"dept_naissance": self.dept_naissance or "",
@ -506,7 +511,7 @@ class Identite(models.ScoDocModel):
d["id"] = self.id # a été écrasé par l'id de adresse
return d
def to_dict_api(self, restrict=False) -> dict:
def to_dict_api(self, restrict=False, with_annotations=False) -> dict:
"""Représentation dictionnaire pour export API, avec adresses et admission.
Si restrict, supprime les infos "personnelles" (boursier)
"""
@ -518,6 +523,17 @@ class Identite(models.ScoDocModel):
e["dept_acronym"] = self.departement.acronym
e.pop("departement", None)
e["sort_key"] = self.sort_key
if with_annotations:
e["annotations"] = (
[
annot.to_dict()
for annot in EtudAnnotation.query.filter_by(
etudid=self.id
).order_by(desc(EtudAnnotation.date))
]
if not restrict
else []
)
if restrict:
# Met à None les attributs protégés:
for attr in self.protected_attrs:
@ -1072,10 +1088,16 @@ class EtudAnnotation(db.Model):
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
etudid = db.Column(db.Integer) # sans contrainte (compat ScoDoc 7))
etudid = db.Column(db.Integer, db.ForeignKey(Identite.id))
author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user
comment = db.Column(db.Text)
def to_dict(self):
"""Représentation dictionnaire."""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
return e
from app.models.formsemestre import FormSemestre
from app.models.modules import Module

View File

@ -68,7 +68,7 @@ class FormSemestre(db.Model):
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
titre = db.Column(db.Text(), nullable=False)
date_debut = db.Column(db.Date(), nullable=False)
date_fin = db.Column(db.Date(), nullable=False)
date_fin = db.Column(db.Date(), nullable=False) # jour inclus
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")

View File

@ -1,19 +1,44 @@
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""Affichages, debug
"""
from flask import g
from app import log
PE_DEBUG = 0
PE_DEBUG = False
if not PE_DEBUG:
# log to notes.log
def pe_print(*a, **kw):
# kw is ignored. log always add a newline
log(" ".join(a))
else:
pe_print = print # print function
# On stocke les logs PE dans g.scodoc_pe_log
# pour ne pas modifier les nombreux appels à pe_print.
def pe_start_log() -> list[str]:
"Initialize log"
g.scodoc_pe_log = []
return g.scodoc_pe_log
def pe_print(*a):
"Log (or print in PE_DEBUG mode) and store in g"
lines = getattr(g, "scodoc_pe_log")
if lines is None:
lines = pe_start_log()
msg = " ".join(a)
lines.append(msg)
if PE_DEBUG:
print(msg)
else:
log(msg)
def pe_get_log() -> str:
"Renvoie une chaîne avec tous les messages loggués"
return "\n".join(getattr(g, "scodoc_pe_log", []))
# Affichage dans le tableur pe en cas d'absence de notes
SANS_NOTE = "-"
NOM_STAT_GROUPE = "statistiques du groupe"
NOM_STAT_PROMO = "statistiques de la promo"

View File

@ -52,7 +52,6 @@ from app.scodoc import sco_formsemestre
from app.scodoc.sco_logos import find_logo
# Generated LaTeX files are encoded as:
PE_LATEX_ENCODING = "utf-8"
@ -98,7 +97,8 @@ def calcul_age(born: datetime.date) -> int:
return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
def remove_accents(input_unicode_str):
# Nota: scu.suppress_accents fait la même chose mais renvoie un str et non un bytes
def remove_accents(input_unicode_str: str) -> bytes:
"""Supprime les accents d'une chaine unicode"""
nfkd_form = unicodedata.normalize("NFKD", input_unicode_str)
only_ascii = nfkd_form.encode("ASCII", "ignore")
@ -133,15 +133,15 @@ def escape_for_latex(s):
# ----------------------------------------------------------------------------------------
def list_directory_filenames(path):
"""List of regular filenames in a directory (recursive)
def list_directory_filenames(path: str) -> list[str]:
"""List of regular filenames (paths) in a directory (recursive)
Excludes files and directories begining with .
"""
R = []
paths = []
for root, dirs, files in os.walk(path, topdown=True):
dirs[:] = [d for d in dirs if d[0] != "."]
R += [os.path.join(root, fn) for fn in files if fn[0] != "."]
return R
paths += [os.path.join(root, fn) for fn in files if fn[0] != "."]
return paths
def add_local_file_to_zip(zipfile, ziproot, pathname, path_in_zip):
@ -195,13 +195,15 @@ def add_pe_stuff_to_zip(zipfile, ziproot):
def get_annee_diplome_semestre(
sem_base: FormSemestre | dict, nbre_sem_formation: int = 6
) -> int:
"""Pour un semestre ``sem_base`` donné (supposé être un semestre d'une formation BUT à 6 semestres)
et connaissant le numéro du semestre, ses dates de début et de fin du semestre, prédit l'année à laquelle
sera remis le diplôme BUT des étudiants qui y sont scolarisés
(en supposant qu'il n'y ait pas de redoublement à venir).
"""Pour un semestre ``sem_base`` donné (supposé être un semestre d'une formation BUT
à 6 semestres) et connaissant le numéro du semestre, ses dates de début et de fin du
semestre, prédit l'année à laquelle sera remis le diplôme BUT des étudiants qui y
sont scolarisés (en supposant qu'il n'y ait pas de redoublement à venir).
**Remarque sur le calcul** : Les semestres de 1ère partie d'année (S1, S3, S5 ou S4, S6 pour des semestres décalés)
s'étalent sur deux années civiles ; contrairement au semestre de seconde partie d'année universitaire.
**Remarque sur le calcul** : Les semestres de 1ère partie d'année (S1, S3, S5 ou S4,
S6 pour des semestres décalés)
s'étalent sur deux années civiles ; contrairement au semestre de seconde partie
d'année universitaire.
Par exemple :
@ -235,21 +237,22 @@ def get_annee_diplome_semestre(
if (
1 <= sem_id <= nbre_sem_formation
): # Si le semestre est un semestre BUT => problème si formation BUT en 1 an ??
nbreSemRestant = (
nb_sem_restants = (
nbre_sem_formation - sem_id
) # nombre de semestres restant avant diplome
nbreAnRestant = nbreSemRestant // 2 # nombre d'annees restant avant diplome
# Flag permettant d'activer ou désactiver un increment à prendre en compte en cas de semestre décalé
nb_annees_restantes = (
nb_sem_restants // 2
) # nombre d'annees restant avant diplome
# Flag permettant d'activer ou désactiver un increment
# à prendre en compte en cas de semestre décalé
# avec 1 - delta = 0 si semestre de 1ere partie d'année / 1 sinon
delta = annee_fin - annee_debut
decalage = nbreSemRestant % 2 # 0 si S4, 1 si S3, 0 si S2, 1 si S1
decalage = nb_sem_restants % 2 # 0 si S4, 1 si S3, 0 si S2, 1 si S1
increment = decalage * (1 - delta)
return annee_fin + nbreAnRestant + increment
return annee_fin + nb_annees_restantes + increment
def get_cosemestres_diplomants(
annee_diplome: int
) -> dict[int, FormSemestre]:
def get_cosemestres_diplomants(annee_diplome: int) -> dict[int, FormSemestre]:
"""Ensemble des cosemestres donnant lieu à diplomation à l'``annee_diplome``.
**Définition** : Un co-semestre est un semestre :
@ -264,15 +267,15 @@ def get_cosemestres_diplomants(
Returns:
Un dictionnaire {fid: FormSemestre(fid)} contenant les cosemestres
"""
tousLesSems = (
tous_les_sems = (
sco_formsemestre.do_formsemestre_list()
) # tous les semestres memorisés dans scodoc
cosemestres_fids = {
sem["id"]
for sem in tousLesSems
if get_annee_diplome_semestre(sem) == annee_diplome
}
sem["id"]
for sem in tous_les_sems
if get_annee_diplome_semestre(sem) == annee_diplome
}
cosemestres = {}
for fid in cosemestres_fids:
@ -281,5 +284,3 @@ def get_cosemestres_diplomants(
cosemestres[fid] = cosem
return cosemestres

View File

@ -37,13 +37,13 @@ Created on 17/01/2024
"""
import pandas as pd
import app.pe.pe_rcs
from app.models import FormSemestre, Identite, Formation
from app.pe import pe_comp, pe_affichage
from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu
from app.comp.res_sem import load_formsemestre_results
class EtudiantsJuryPE:
"""Classe centralisant la gestion des étudiants à prendre en compte dans un jury de PE"""
@ -123,20 +123,15 @@ class EtudiantsJuryPE:
# Les étudiants à prendre dans le diplôme, étudiants ayant abandonnés non compris
self.etudiants_diplomes = self.get_etudiants_diplomes()
"""Les identités des étudiants diplômés"""
self.diplomes_ids = set(self.etudiants_diplomes.keys())
"""Les identifiants des étudiants diplômés"""
self.etudiants_ids = set(self.identites.keys())
"""Les identifiants des étudiants (diplômés, redoublants ou ayant abandonnés) à traiter"""
# Les abandons (pour debug)
self.abandons = self.get_etudiants_redoublants_ou_reorientes()
"""Les identités des étudiants ayant redoublés ou ayant abandonnés"""
# Les identités des étudiants ayant redoublés ou ayant abandonnés
self.abandons_ids = set(self.abandons)
"""Les identifiants des étudiants ayant redoublés ou ayant abandonnés"""
# Les identifiants des étudiants ayant redoublés ou ayant abandonnés
# Synthèse
pe_affichage.pe_print(
@ -279,7 +274,7 @@ class EtudiantsJuryPE:
semestres_significatifs = self.get_semestres_significatifs(etudid)
# Tri des semestres par numéro de semestre
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT+1):
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
# les semestres de n°i de l'étudiant:
semestres_i = {
fid: sem_sig
@ -288,18 +283,20 @@ class EtudiantsJuryPE:
}
self.cursus[etudid][f"S{i}"] = semestres_i
def get_formsemestres_terminaux_aggregat(self, aggregat: str):
def get_formsemestres_terminaux_aggregat(
self, aggregat: str
) -> dict[int, FormSemestre]:
"""Pour un aggrégat donné, ensemble des formsemestres terminaux possibles pour l'aggrégat
(pour l'aggrégat '3S' incluant S1+S2+S3, a pour semestre terminal S3).
Ces formsemestres traduisent :
* les différents parcours des étudiants liés par exemple au choix de modalité (par ex: S1 FI + S2 FI + S3 FI
ou S1 FI + S2 FI + S3 UFA), en renvoyant les formsemestre_id du S3 FI et du S3 UFA.
* les éventuelles situations de redoublement (par ex pour 1 étudiant ayant redoublé sa 2ème année :
S1 + S2 + S3 (1ère session) et S1 + S2 + S3 + S4 + S3 (2ème session), en renvoyant les formsemestre_id du
S3 (1ère session) et du S3 (2ème session)
* les différents parcours des étudiants liés par exemple au choix de modalité
(par ex: S1 FI + S2 FI + S3 FI ou S1 FI + S2 FI + S3 UFA), en renvoyant les
formsemestre_id du S3 FI et du S3 UFA.
* les éventuelles situations de redoublement (par ex pour 1 étudiant ayant
redoublé sa 2ème année :
S1 + S2 + S3 (1ère session) et S1 + S2 + S3 + S4 + S3 (2ème session), en
renvoyant les formsemestre_id du S3 (1ère session) et du S3 (2ème session)
Args:
aggregat: L'aggrégat
@ -316,7 +313,6 @@ class EtudiantsJuryPE:
formsemestres_terminaux[fid] = trajectoire.formsemestre_final
return formsemestres_terminaux
def nbre_etapes_max_diplomes(self, etudids: list[int]) -> int:
"""Partant d'un ensemble d'étudiants,
nombre de semestres (étapes) maximum suivis par les étudiants du jury.
@ -407,7 +403,7 @@ def get_etudiants_dans_semestres(semestres: dict[int, FormSemestre]) -> set:
return etudiants_ids
def get_annee_diplome(etud: Identite) -> int:
def get_annee_diplome(etud: Identite) -> int | None:
"""L'année de diplôme prévue d'un étudiant en fonction de ses semestres
d'inscription (pour un BUT).
@ -415,13 +411,14 @@ def get_annee_diplome(etud: Identite) -> int:
identite: L'identité d'un étudiant
Returns:
L'année prévue de sa diplômation
L'année prévue de sa diplômation, ou None si aucun semestre
"""
formsemestres_apc = get_semestres_apc(etud)
if formsemestres_apc:
dates_possibles_diplome = []
"""Années de diplômation prédites en fonction des semestres (d'une formation APC) d'un étudiant"""
# Années de diplômation prédites en fonction des semestres
# (d'une formation APC) d'un étudiant
for sem_base in formsemestres_apc:
annee = pe_comp.get_annee_diplome_semestre(sem_base)
if annee:
@ -452,23 +449,26 @@ def get_semestres_apc(identite: Identite) -> list:
def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool:
"""Détermine si un étudiant a arrêté sa formation. Il peut s'agir :
* d'une réorientation à l'initiative du jury de semestre ou d'une démission (on pourrait
utiliser les code NAR pour réorienté & DEM pour démissionnaire des résultats du jury renseigné dans la BDD,
mais pas nécessaire ici)
* d'une réorientation à l'initiative du jury de semestre ou d'une démission
(on pourrait utiliser les code NAR pour réorienté & DEM pour démissionnaire
des résultats du jury renseigné dans la BDD, mais pas nécessaire ici)
* d'un arrêt volontaire : l'étudiant disparait des listes d'inscrits (sans pour autant avoir été indiqué NAR ou DEM).
* d'un arrêt volontaire : l'étudiant disparait des listes d'inscrits (sans pour
autant avoir été indiqué NAR ou DEM).
Dans les cas, on considérera que l'étudiant a arrêté sa formation s'il n'est pas dans l'un des "derniers" cosemestres
(semestres conduisant à la même année de diplômation) connu dans Scodoc.
Dans les cas, on considérera que l'étudiant a arrêté sa formation s'il n'est pas
dans l'un des "derniers" cosemestres (semestres conduisant à la même année de diplômation)
connu dans Scodoc.
Par ex: au moment du jury PE en fin de S5 (pas de S6 renseigné dans Scodoc), l'étudiant doit appartenir à une
instance des S5 qui conduisent à la diplomation dans l'année visée. S'il n'est que dans un S4, il a sans doute
arrêté. A moins qu'il ne soit parti à l'étranger et , pas de notes.
Par ex: au moment du jury PE en fin de S5 (pas de S6 renseigné dans Scodoc),
l'étudiant doit appartenir à une instance des S5 qui conduisent à la diplomation dans
l'année visée. S'il n'est que dans un S4, il a sans doute arrêté. A moins qu'il ne soit
parti à l'étranger et là, pas de notes.
TODO:: Cas de l'étranger, à coder/tester
**Attention** : Cela suppose que toutes les instances d'un semestre donné (par ex: toutes les instances de S6
accueillant un étudiant soient créées ; sinon les étudiants non inscrits dans un S6 seront considérés comme
ayant abandonnés)
**Attention** : Cela suppose que toutes les instances d'un semestre donné
(par ex: toutes les instances de S6 accueillant un étudiant soient créées ; sinon les
étudiants non inscrits dans un S6 seront considérés comme ayant abandonnés)
TODO:: Peut-être à mettre en regard avec les propositions d'inscriptions d'étudiants dans un nouveau semestre
Pour chaque étudiant, recherche son dernier semestre en date (validé ou non) et
@ -487,7 +487,6 @@ def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool:
TODO:: A reprendre pour le cas des étudiants à l'étranger
TODO:: A reprendre si BUT avec semestres décalés
"""
# Les semestres APC de l'étudiant
semestres = get_semestres_apc(etud)
semestres_apc = {sem.semestre_id: sem for sem in semestres}

View File

@ -1,16 +1,59 @@
from app.comp import moy_sem
##############################################################################
#
# 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
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Thu Sep 8 09:36:33 2016
@author: barasc
"""
import pandas as pd
import numpy as np
from app.pe.pe_tabletags import TableTag, MoyenneTag
from app.pe.pe_etudiant import EtudiantsJuryPE
from app.pe.pe_rcs import RCS, RCSsJuryPE
from app.pe.pe_rcstag import RCSTag
import pandas as pd
import numpy as np
class RCSInterclasseTag(TableTag):
# -------------------------------------------------------------------------------------------------------------------
"""
Interclasse l'ensemble des étudiants diplômés à une année
donnée (celle du jury), pour un RCS donné (par ex: 'S2', '3S')
en reportant :
* les moyennes obtenues sur la trajectoire qu'il ont suivi pour atteindre
le numéro de semestre de fin de l'aggrégat (indépendamment de son
formsemestre)
* calculant le classement sur les étudiants diplômes
"""
def __init__(
self,
nom_rcs: str,
@ -18,15 +61,6 @@ class RCSInterclasseTag(TableTag):
rcss_jury_pe: RCSsJuryPE,
rcss_tags: dict[tuple, RCSTag],
):
"""
Interclasse l'ensemble des étudiants diplômés à une année
donnée (celle du jury), pour un RCS donné (par ex: 'S2', '3S')
en reportant :
* les moyennes obtenues sur la trajectoire qu'il ont suivi pour atteindre le numéro de semestre de fin de l'aggrégat (indépendamment de son
formsemestres)
* calculant le classement sur les étudiants diplômes
"""
TableTag.__init__(self)
self.nom_rcs = nom_rcs
@ -43,7 +77,8 @@ class RCSInterclasseTag(TableTag):
for etudid in self.diplomes_ids
}
# Les trajectoires (et leur version tagguées), en ne gardant que celles associées à l'aggrégat
# Les trajectoires (et leur version tagguées), en ne gardant que
# celles associées à l'aggrégat
self.rcss: dict[int, RCS] = {}
"""Ensemble des trajectoires associées à l'aggrégat"""
for trajectoire_id in rcss_jury_pe.rcss:
@ -54,9 +89,7 @@ class RCSInterclasseTag(TableTag):
self.trajectoires_taggues: dict[int, RCS] = {}
"""Ensemble des trajectoires tagguées associées à l'aggrégat"""
for trajectoire_id in self.rcss:
self.trajectoires_taggues[trajectoire_id] = rcss_tags[
trajectoire_id
]
self.trajectoires_taggues[trajectoire_id] = rcss_tags[trajectoire_id]
# Les trajectoires suivies par les étudiants du jury, en ne gardant que
# celles associées aux diplomés

View File

@ -44,41 +44,44 @@ Created on Fri Sep 9 09:15:05 2016
import io
import os
import time
from zipfile import ZipFile
import numpy as np
import pandas as pd
from app.pe.pe_affichage import NOM_STAT_PROMO, SANS_NOTE, NOM_STAT_GROUPE
from app.pe.pe_etudiant import *
from app.pe.pe_rcs import *
import app.pe.pe_comp as pe_comp
import app.pe.pe_affichage as pe_affichage
from app.pe.pe_etudiant import * # TODO A éviter -> pe_etudiant.
from app.pe.pe_rcs import * # TODO A éviter
from app.pe.pe_rcstag import RCSTag
from app.pe.pe_semtag import SemestreTag
from app.pe.pe_interclasstag import RCSInterclasseTag
from app.pe.pe_rcstag import RCSTag
import app.pe.pe_affichage as pe_affichage
import pandas as pd
class JuryPE(object):
def __init__(self, diplome):
"""
Classe mémorisant toutes les informations nécessaires pour établir un jury de PE, sur la base
d'une année de diplôme. De ce semestre est déduit :
1. l'année d'obtention du DUT,
2. tous les étudiants susceptibles à ce stade (au regard de leur parcours) d'être diplomés.
"""
Classe mémorisant toutes les informations nécessaires pour établir un jury de PE, sur la base
d'une année de diplôme. De ce semestre est déduit :
1. l'année d'obtention du DUT,
2. tous les étudiants susceptibles à ce stade (au regard de leur parcours) d'être diplomés.
Args:
diplome : l'année d'obtention du diplome BUT et du jury de PE (généralement février XXXX)
"""
Args:
diplome : l'année d'obtention du diplome BUT et du jury de PE (généralement février XXXX)
"""
def __init__(self, diplome):
pe_affichage.pe_start_log()
self.diplome = diplome
"L'année du diplome"
self.nom_export_zip = f"Jury_PE_{self.diplome}"
"Nom du zip où ranger les fichiers générés"
# Chargement des étudiants à prendre en compte Sydans le jury
pe_affichage.pe_print(
f"Données de poursuite d'étude générées le {time.strftime('%d/%m/%Y à %H:%M')}\n"
)
# Chargement des étudiants à prendre en compte dans le jury
pe_affichage.pe_print(
f"""*** Recherche et chargement des étudiants diplômés en {
self.diplome}"""
@ -98,6 +101,8 @@ class JuryPE(object):
self._gen_xls_interclassements_rcss(zipfile)
self._gen_xls_synthese_jury_par_tag(zipfile)
self._gen_xls_synthese_par_etudiant(zipfile)
# et le log
self._add_log_to_zip(zipfile)
# Fin !!!! Tada :)
@ -161,9 +166,7 @@ class JuryPE(object):
self.rcss.cree_rcss(self.etudiants)
# Génère les moyennes par tags des trajectoires
pe_affichage.pe_print(
"*** Calcule les moyennes par tag des RCS possibles"
)
pe_affichage.pe_print("*** Calcule les moyennes par tag des RCS possibles")
self.rcss_tags = compute_trajectoires_tag(
self.rcss, self.etudiants, self.sems_tags
)
@ -255,6 +258,11 @@ class JuryPE(object):
zipfile, f"synthese_jury_{self.diplome}_par_etudiant.xlsx", output.read()
)
def _add_log_to_zip(self, zipfile):
"""Add a text file with the log messages"""
log_data = pe_affichage.pe_get_log()
self.add_file_to_zip(zipfile, "pe_log.txt", log_data)
def add_file_to_zip(self, zipfile: ZipFile, filename: str, data, path=""):
"""Add a file to given zip
All files under NOM_EXPORT_ZIP/
@ -381,14 +389,13 @@ class JuryPE(object):
champ = (descr, "", "note")
notes_traj = moy_traj.get_notes()
donnees.loc[etudids_communs, champ] = notes_traj.loc[
etudids_communs]
etudids_communs
]
# Les rangs
champ = (descr, NOM_STAT_GROUPE, "class.")
rangs = moy_traj.get_rangs_inscrits()
donnees.loc[etudids_communs, champ] = rangs.loc[
etudids_communs
]
donnees.loc[etudids_communs, champ] = rangs.loc[etudids_communs]
# Les mins
champ = (descr, NOM_STAT_GROUPE, "min")
@ -402,7 +409,7 @@ class JuryPE(object):
# Les moys
champ = (descr, NOM_STAT_GROUPE, "moy")
moys = moy_traj.get_max()
moys = moy_traj.get_moy()
donnees.loc[etudids_communs, champ] = moys.loc[etudids_communs]
# Ajoute les données d'interclassement
@ -430,7 +437,7 @@ class JuryPE(object):
# Les moys
champ = (descr, nom_stat_promo, "moy")
moys = moy_interclass.get_max()
moys = moy_interclass.get_moy()
donnees.loc[etudids_communs, champ] = moys.loc[etudids_communs]
df_synthese = df_synthese.join(donnees)
@ -487,9 +494,7 @@ class JuryPE(object):
# La trajectoire de l'étudiant sur l'aggrégat
trajectoire = self.rcss.suivi[etudid][aggregat]
if trajectoire:
trajectoire_tagguee = self.rcss_tags[
trajectoire.rcs_id
]
trajectoire_tagguee = self.rcss_tags[trajectoire.rcs_id]
if tag in trajectoire_tagguee.moyennes_tags:
# L'interclassement
interclass = self.interclassements_taggues[aggregat]
@ -512,6 +517,7 @@ class JuryPE(object):
df.sort_values(by=[("", "", "tag")], inplace=True)
return df
def get_formsemestres_etudiants(etudiants: EtudiantsJuryPE) -> dict:
"""Ayant connaissance des étudiants dont il faut calculer les moyennes pour
le jury PE (attribut `self.etudiant_ids) et de leur cursus (semestres
@ -534,6 +540,7 @@ def get_formsemestres_etudiants(etudiants: EtudiantsJuryPE) -> dict:
semestres = semestres | etudiants.cursus[etudid][cle]
return semestres
def compute_semestres_tag(etudiants: EtudiantsJuryPE) -> dict:
"""Créé les semestres taggués, de type 'S1', 'S2', ..., pour un groupe d'étudiants donnés.
Chaque semestre taggué est rattaché à l'un des FormSemestre faisant partie du cursus scolaire
@ -548,7 +555,7 @@ def compute_semestres_tag(etudiants: EtudiantsJuryPE) -> dict:
Un dictionnaire {fid: SemestreTag(fid)}
"""
"""Création des semestres taggués, de type 'S1', 'S2', ..."""
# Création des semestres taggués, de type 'S1', 'S2', ...
pe_affichage.pe_print("*** Création des semestres taggués")
formsemestres = get_formsemestres_etudiants(etudiants)

View File

@ -1,5 +1,15 @@
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on 01-2024
@author: barasc
"""
import app.pe.pe_comp as pe_comp
import app.pe.pe_affichage as pe_affichage
from app.models import FormSemestre
from app.pe.pe_etudiant import EtudiantsJuryPE, get_dernier_semestre_en_date
@ -59,39 +69,41 @@ TYPES_RCS = {
"descr": "Moyenne globale (S1+S2+S3+S4+S5+S6)",
},
}
"""Dictionnaire détaillant les différents regroupements cohérents
de semestres (RCS), en leur attribuant un nom et en détaillant
le nom des semestres qu'ils regroupement et l'affichage qui en sera fait
dans les tableurs de synthèse"""
"""Dictionnaire détaillant les différents regroupements cohérents
de semestres (RCS), en leur attribuant un nom et en détaillant
le nom des semestres qu'ils regroupent et l'affichage qui en sera fait
dans les tableurs de synthèse.
"""
TOUS_LES_RCS_AVEC_PLUSIEURS_SEM = [cle for cle in TYPES_RCS.keys() if not cle.startswith("S")]
TOUS_LES_RCS_AVEC_PLUSIEURS_SEM = [cle for cle in TYPES_RCS if not cle.startswith("S")]
TOUS_LES_RCS = list(TYPES_RCS.keys())
TOUS_LES_SEMESTRES = [cle for cle in TYPES_RCS.keys() if cle.startswith("S")]
TOUS_LES_SEMESTRES = [cle for cle in TYPES_RCS if cle.startswith("S")]
class RCS:
"""Modélise un ensemble de semestres d'étudiants
associé à un type de regroupement cohérent de semestres
donné (par ex: 'S2', '3S', '2A').
Si le RCS est un semestre de type Si, stocke le (ou les)
formsemestres de numéro i qu'ont suivi l'étudiant pour atteindre le Si
(en général 1 si personnes n'a redoublé, mais 2 s'il y a des redoublants)
Pour le RCS de type iS ou iA (par ex, 3A=S1+S2+S3), elle identifie
les semestres que les étudiants ont suivis pour les amener jusqu'au semestre
terminal de la trajectoire (par ex: ici un S3).
Ces semestres peuvent être :
* des S1+S2+S1+S2+S3 si redoublement de la 1ère année
* des S1+S2+(année de césure)+S3 si césure, ...
Args:
nom_rcs: Un nom du RCS (par ex: '5S')
semestre_final: Le semestre final du RCS
"""
def __init__(self, nom_rcs: str, semestre_final: FormSemestre):
"""Modélise un ensemble de semestres d'étudiants
associé à un type de regroupement cohérent de semestres
donné (par ex: 'S2', '3S', '2A').
Si le RCS est un semestre de type Si, stocke le (ou les)
formsemestres de numéro i qu'ont suivi l'étudiant pour atteindre le Si
(en général 1 si personnes n'a redoublé, mais 2 s'il y a des redoublants)
Pour le RCS de type iS ou iA (par ex, 3A=S1+S2+S3), elle identifie
les semestres que les étudiants ont suivis pour les amener jusqu'au semestre
terminal de la trajectoire (par ex: ici un S3).
Ces semestres peuvent être :
* des S1+S2+S1+S2+S3 si redoublement de la 1ère année
* des S1+S2+(année de césure)+S3 si césure, ...
Args:
nom_rcs: Un nom du RCS (par ex: '5S')
semestre_final: Le semestre final du RCS
"""
self.nom = nom_rcs
"""Nom du RCS"""
@ -121,21 +133,22 @@ class RCS:
semestre = self.semestres_aggreges[fid]
noms.append(f"S{semestre.semestre_id}({fid})")
noms = sorted(noms)
repr = f"{self.nom} ({self.formsemestre_final.formsemestre_id}) {self.formsemestre_final.date_fin.year}"
title = f"""{self.nom} ({
self.formsemestre_final.formsemestre_id}) {self.formsemestre_final.date_fin.year}"""
if verbose and noms:
repr += " - " + "+".join(noms)
return repr
title += " - " + "+".join(noms)
return title
class RCSsJuryPE:
"""Classe centralisant toutes les regroupements cohérents de
semestres (RCS) des étudiants à prendre en compte dans un jury PE
Args:
annee_diplome: L'année de diplomation
"""
def __init__(self, annee_diplome: int):
"""Classe centralisant toutes les regroupements cohérents de
semestres (RCS) des étudiants à prendre en compte dans un jury PE
Args:
annee_diplome: L'année de diplomation
"""
self.annee_diplome = annee_diplome
"""Année de diplômation"""
@ -155,7 +168,8 @@ class RCSsJuryPE:
"""
for nom_rcs in pe_comp.TOUS_LES_SEMESTRES + TOUS_LES_RCS_AVEC_PLUSIEURS_SEM:
"""L'aggrégat considéré (par ex: 3S=S1+S2+S3), son nom de son semestre terminal (par ex: S3) et son numéro (par ex: 3)"""
# L'aggrégat considéré (par ex: 3S=S1+S2+S3), son nom de son semestre
# terminal (par ex: S3) et son numéro (par ex: 3)
noms_semestre_de_aggregat = TYPES_RCS[nom_rcs]["aggregat"]
nom_semestre_terminal = noms_semestre_de_aggregat[-1]
@ -164,17 +178,17 @@ class RCSsJuryPE:
self.suivi[etudid] = {
aggregat: None
for aggregat in pe_comp.TOUS_LES_SEMESTRES
+ TOUS_LES_RCS_AVEC_PLUSIEURS_SEM
+ TOUS_LES_RCS_AVEC_PLUSIEURS_SEM
}
"""Le formsemestre terminal (dernier en date) associé au
semestre marquant la fin de l'aggrégat
(par ex: son dernier S3 en date)"""
# Le formsemestre terminal (dernier en date) associé au
# semestre marquant la fin de l'aggrégat
# (par ex: son dernier S3 en date)
semestres = etudiants.cursus[etudid][nom_semestre_terminal]
if semestres:
formsemestre_final = get_dernier_semestre_en_date(semestres)
"""Ajout ou récupération de la trajectoire"""
# Ajout ou récupération de la trajectoire
trajectoire_id = (nom_rcs, formsemestre_final.formsemestre_id)
if trajectoire_id not in self.rcss:
trajectoire = RCS(nom_rcs, formsemestre_final)
@ -182,21 +196,22 @@ class RCSsJuryPE:
else:
trajectoire = self.rcss[trajectoire_id]
"""La liste des semestres de l'étudiant à prendre en compte
pour cette trajectoire"""
# La liste des semestres de l'étudiant à prendre en compte
# pour cette trajectoire
semestres_a_aggreger = get_rcs_etudiant(
etudiants.cursus[etudid], formsemestre_final, nom_rcs
)
"""Ajout des semestres à la trajectoire"""
# Ajout des semestres à la trajectoire
trajectoire.add_semestres_a_aggreger(semestres_a_aggreger)
"""Mémoire la trajectoire suivie par l'étudiant"""
# Mémoire la trajectoire suivie par l'étudiant
self.suivi[etudid][nom_rcs] = trajectoire
def get_rcs_etudiant(semestres: dict[int:FormSemestre], formsemestre_final: FormSemestre, nom_rcs: str
):
def get_rcs_etudiant(
semestres: dict[int:FormSemestre], formsemestre_final: FormSemestre, nom_rcs: str
) -> dict[int, FormSemestre]:
"""Ensemble des semestres parcourus par un étudiant, connaissant
les semestres de son cursus,
dans le cadre du RCS visé et ayant pour semestre terminal `formsemestre_final`.
@ -224,7 +239,7 @@ def get_rcs_etudiant(semestres: dict[int:FormSemestre], formsemestre_final: Form
numero_semestre_terminal = formsemestre_final.semestre_id
# semestres_significatifs = self.get_semestres_significatifs(etudid)
semestres_significatifs = {}
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT+1):
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
semestres_significatifs = semestres_significatifs | semestres[f"S{i}"]
if nom_rcs.startswith("S"): # les semestres
@ -247,6 +262,7 @@ def get_rcs_etudiant(semestres: dict[int:FormSemestre], formsemestre_final: Form
semestres_aggreges[fid] = semestre
return semestres_aggreges
def get_descr_rcs(nom_rcs: str) -> str:
"""Renvoie la description pour les tableurs de synthèse
Excel d'un nom de RCS"""

View File

@ -35,33 +35,30 @@ Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
import numpy as np
import pandas as pd
import app.pe.pe_etudiant
from app import db, log, ScoValueError
from app.comp import res_sem, moy_ue, moy_sem
from app.comp.moy_sem import comp_ranks_series
from app.comp.res_compat import NotesTableCompat
from app import db, ScoValueError
from app import comp
from app.comp.res_sem import load_formsemestre_results
from app.models import FormSemestre
from app.models.moduleimpls import ModuleImpl
import app.pe.pe_affichage as pe_affichage
from app.pe.pe_tabletags import TableTag, MoyenneTag
from app.scodoc import sco_tag_module
from app.scodoc.codes_cursus import UE_SPORT
import app.pe.pe_affichage as pe_affichage
from app.pe.pe_tabletags import TableTag, TAGS_RESERVES, MoyenneTag
import pandas as pd
class SemestreTag(TableTag):
"""
Un SemestreTag représente les résultats des étudiants à un semestre, en donnant
accès aux moyennes par tag.
Il s'appuie principalement sur FormSemestre et sur ResultatsSemestreBUT.
"""
def __init__(self, formsemestre_id: int):
"""
Un SemestreTag représente les résultats des étudiants à un semestre, en donnant
accès aux moyennes par tag.
Il s'appuie principalement sur FormSemestre et sur ResultatsSemestreBUT.
Args:
nom: Nom à donner au SemestreTag
formsemestre_id: Identifiant du ``FormSemestre`` sur lequel il se base
"""
TableTag.__init__(self)
@ -103,27 +100,27 @@ class SemestreTag(TableTag):
dict_ues_competences = get_noms_competences_from_ues(self.nt.formsemestre)
noms_tags_comp = list(set(dict_ues_competences.values()))
noms_tags_auto = ["but"] + noms_tags_comp
self.tags = (
noms_tags_perso + noms_tags_auto
)
self.tags = noms_tags_perso + noms_tags_auto
"""Tags du semestre taggué"""
## Vérifie l'unicité des tags
if len(set(self.tags)) != len(self.tags):
intersection = list(set(noms_tags_perso) & set(noms_tags_auto))
liste_intersection = "\n".join([f"<li><code>{tag}</code></li>" for tag in intersection])
message = f"""Erreur dans le module PE : Un des tags saisis dans votre programme de formation
fait parti des tags réservés. En particulier,
votre semestre <em>{self.formsemestre.titre_annee()}</em>
contient le(s) tag(s) réservé(s) suivant :
liste_intersection = "\n".join(
[f"<li><code>{tag}</code></li>" for tag in intersection]
)
s = "s" if len(intersection) > 0 else ""
message = f"""Erreur dans le module PE : Un des tags saisis dans votre
programme de formation fait parti des tags réservés. En particulier,
votre semestre <em>{self.formsemestre.titre_annee()}</em>
contient le{s} tag{s} réservé{s} suivant :
<ul>
{liste_intersection}
</ul>
Modifiez votre programme de formation pour le(s) supprimer. Il(s) sera(ont) automatiquement à vos documents de poursuites d'études.
Modifiez votre programme de formation pour le{s} supprimer.
Il{s} ser{'ont' if s else 'a'} automatiquement à vos documents de poursuites d'études.
"""
raise ScoValueError(
message
)
raise ScoValueError(message)
# Calcul des moyennes & les classements de chaque étudiant à chaque tag
self.moyennes_tags = {}
@ -174,25 +171,25 @@ class SemestreTag(TableTag):
La série des moyennes
"""
"""Adaptation du mask de calcul des moyennes au tag visé"""
# Adaptation du mask de calcul des moyennes au tag visé
modimpls_mask = [
modimpl.module.ue.type != UE_SPORT
for modimpl in self.formsemestre.modimpls_sorted
]
"""Désactive tous les modules qui ne sont pas pris en compte pour ce tag"""
# Désactive tous les modules qui ne sont pas pris en compte pour ce tag
for i, modimpl in enumerate(self.formsemestre.modimpls_sorted):
if modimpl.moduleimpl_id not in tags_infos[tag]:
modimpls_mask[i] = False
"""Applique la pondération des coefficients"""
# Applique la pondération des coefficients
modimpl_coefs_ponderes_df = self.modimpl_coefs_df.copy()
for modimpl_id in tags_infos[tag]:
ponderation = tags_infos[tag][modimpl_id]["ponderation"]
modimpl_coefs_ponderes_df[modimpl_id] *= ponderation
"""Calcule les moyennes pour le tag visé dans chaque UE (dataframe etudid x ues)"""
moyennes_ues_tag = moy_ue.compute_ue_moys_apc(
# Calcule les moyennes pour le tag visé dans chaque UE (dataframe etudid x ues)#
moyennes_ues_tag = comp.moy_ue.compute_ue_moys_apc(
self.sem_cube,
self.etuds,
self.formsemestre.modimpls_sorted,
@ -203,13 +200,13 @@ class SemestreTag(TableTag):
block=self.formsemestre.block_moyennes,
)
"""Les ects"""
# Les ects
ects = self.ues_inscr_parcours_df.fillna(0.0) * [
ue.ects for ue in self.ues if ue.type != UE_SPORT
]
"""Calcule la moyenne générale dans le semestre (pondérée par le ECTS)"""
moy_gen_tag = moy_sem.compute_sem_moys_apc_using_ects(
# Calcule la moyenne générale dans le semestre (pondérée par le ECTS)
moy_gen_tag = comp.moy_sem.compute_sem_moys_apc_using_ects(
moyennes_ues_tag,
ects,
formation_id=self.formsemestre.formation_id,
@ -224,11 +221,6 @@ def get_moduleimpl(modimpl_id) -> dict:
modimpl = db.session.get(ModuleImpl, modimpl_id)
if modimpl:
return modimpl
if SemestreTag.DEBUG:
log(
"SemestreTag.get_moduleimpl( %s ) : le modimpl recherche n'existe pas"
% (modimpl_id)
)
return None
@ -260,27 +252,26 @@ def get_synthese_tags_personnalises_semestre(formsemestre: FormSemestre):
"""
synthese_tags = {}
"""Instance des modules du semestre"""
# Instance des modules du semestre
modimpls = formsemestre.modimpls_sorted
for modimpl in modimpls:
modimpl_id = modimpl.id
"""Liste des tags pour le module concerné"""
# Liste des tags pour le module concerné
tags = sco_tag_module.module_tag_list(modimpl.module.id)
"""Traitement des tags recensés, chacun pouvant étant de la forme
"mathématiques", "théorie", "pe:0", "maths:2"
"""
# Traitement des tags recensés, chacun pouvant étant de la forme
# "mathématiques", "théorie", "pe:0", "maths:2"
for tag in tags:
"""Extraction du nom du tag et du coeff de pondération"""
# Extraction du nom du tag et du coeff de pondération
(tagname, ponderation) = sco_tag_module.split_tagname_coeff(tag)
"""Ajout d'une clé pour le tag"""
# Ajout d'une clé pour le tag
if tagname not in synthese_tags:
synthese_tags[tagname] = {}
"""Ajout du module (modimpl) au tagname considéré"""
# Ajout du module (modimpl) au tagname considéré
synthese_tags[tagname][modimpl_id] = {
"modimpl": modimpl, # les données sur le module
# "coeff": modimpl.module.coefficient, # le coeff du module dans le semestre
@ -298,6 +289,8 @@ def get_noms_competences_from_ues(formsemestre: FormSemestre) -> dict[int, str]:
"""Partant d'un formsemestre, extrait le nom des compétences associés
à (ou aux) parcours des étudiants du formsemestre.
Ignore les UEs non associées à un niveau de compétence.
Args:
formsemestre: Un FormSemestre
@ -310,8 +303,8 @@ def get_noms_competences_from_ues(formsemestre: FormSemestre) -> dict[int, str]:
noms_competences = {}
for ue in nt.ues:
if ue.type != UE_SPORT:
ordre = ue.niveau_competence.ordre
if ue.niveau_competence and ue.type != UE_SPORT:
# ?? inutilisé ordre = ue.niveau_competence.ordre
nom = ue.niveau_competence.competence.titre
noms_competences[ue.ue_id] = f"comp. {nom}"
return noms_competences

View File

@ -102,11 +102,3 @@ def pe_view_sem_recap(formsemestre_id: int):
download_name=scu.sanitize_filename(jury.nom_export_zip + ".zip"),
as_attachment=True,
)
return render_template(
"pe/pe_view_sem_recap.j2",
annee_diplome=annee_diplome,
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre),
cosemestres=cosemestres,
)

View File

@ -396,7 +396,7 @@ class TF(object):
self.values[field] = int(self.values[field])
except ValueError:
msg.append(
f"valeur invalide ({self.values[field]}) pour le champs {field}"
f"valeur invalide ({self.values[field]}) pour le champ {field}"
)
ok = False
elif typ == "float" or typ == "real":
@ -404,7 +404,7 @@ class TF(object):
self.values[field] = float(self.values[field].replace(",", "."))
except ValueError:
msg.append(
f"valeur invalide ({self.values[field]}) pour le champs {field}"
f"valeur invalide ({self.values[field]}) pour le champ {field}"
)
ok = False
if ok:

View File

@ -265,7 +265,7 @@ def DBUpdateArgs(cnx, table, vals, where=None, commit=False, convert_empty_to_nu
# log('vals=%s\n'%vals)
except psycopg2.errors.StringDataRightTruncation as exc:
cnx.rollback()
raise ScoValueError("champs de texte trop long !") from exc
raise ScoValueError("champ de texte trop long !") from exc
except:
cnx.rollback() # get rid of this transaction
log('Exception in DBUpdateArgs:\n\treq="%s"\n\tvals="%s"\n' % (req, vals))

View File

@ -1,22 +1,28 @@
"""
Ecrit par Matthias Hartmann.
"""
from datetime import date, datetime, time, timedelta
from pytz import UTC
from flask import g
from flask_sqlalchemy.query import Query
from app import log, db, set_sco_dept
import app.scodoc.sco_utils as scu
from app.models import (
Identite,
FormSemestre,
FormSemestreInscription,
ModuleImpl,
ModuleImplInscription,
ScoDocSiteConfig,
)
from app.models.assiduites import Assiduite, Justificatif, compute_assiduites_justified
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_preferences
from app.scodoc import sco_cache
from app.scodoc import sco_etud
from app.models import ScoDocSiteConfig
from flask import g
import app.scodoc.sco_utils as scu
class CountCalculator:
@ -93,9 +99,9 @@ class CountCalculator:
evening if evening else ScoDocSiteConfig.get("assi_afternoon_time", "18:00")
)
self.non_work_days: list[
scu.NonWorkDays
] = scu.NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id)
self.non_work_days: list[scu.NonWorkDays] = (
scu.NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id)
)
delta_total: timedelta = datetime.combine(
date.min, self.evening
@ -371,6 +377,10 @@ def get_assiduites_stats(
assiduites = filter_by_formsemestre(
assiduites, Assiduite, filtered[key]
)
case "formsemestre_modimpls":
assiduites = filter_by_modimpls(
assiduites, Assiduite, filtered[key]
)
case "est_just":
assiduites = filter_assiduites_by_est_just(
assiduites, filtered[key]
@ -489,7 +499,9 @@ def filter_by_formsemestre(
formsemestre: FormSemestre,
) -> Query:
"""
Filtrage d'une collection en fonction d'un formsemestre
Filtrage d'une collection : conserve les élements tels que
- l'étudiant est inscrit au formsemestre
- et la plage de dates intersecte celle du formsemestre
"""
if formsemestre is None:
@ -504,14 +516,46 @@ def filter_by_formsemestre(
.filter(FormSemestreInscription.formsemestre_id == formsemestre.id)
)
form_date_debut = formsemestre.date_debut + timedelta(days=1)
form_date_fin = formsemestre.date_fin + timedelta(days=1)
collection_result = collection_result.filter(
collection_class.date_debut >= form_date_debut
collection_class.date_debut <= formsemestre.date_fin
).filter(collection_class.date_fin >= formsemestre.date_debut)
return collection_result
def filter_by_modimpls(
collection_query: Assiduite | Justificatif,
collection_class: Assiduite | Justificatif,
formsemestre: FormSemestre,
) -> Query:
"""
Filtrage d'une collection d'assiduités: conserve les élements
- si l'étudiant est inscrit au formsemestre
- Et que l'assiduité concerne un moduleimpl de ce formsemestre
Ne fait rien sur les justificatifs.
Ne fait rien si formsemestre is None
"""
if (collection_class != Assiduite) or (formsemestre is None):
return collection_query
# restreint aux inscrits:
collection_result = (
collection_query.join(Identite, collection_class.etudid == Identite.id)
.join(
FormSemestreInscription,
Identite.id == FormSemestreInscription.etudid,
)
.filter(FormSemestreInscription.formsemestre_id == formsemestre.id)
)
return collection_result.filter(collection_class.date_fin <= form_date_fin)
collection_result = (
collection_result.join(ModuleImpl)
.join(ModuleImplInscription)
.filter(ModuleImplInscription.etudid == collection_class.etudid)
)
return collection_result
def justifies(justi: Justificatif, obj: bool = False) -> list[int] | Query:

View File

@ -166,9 +166,9 @@ def process_field(
values={pprint.pformat(cdict)}
"""
)
text = f"""<para><i>format invalide: champs</i> {missing_key} <i>inexistant !</i></para>"""
text = f"""<para><i>format invalide: champ</i> {missing_key} <i>inexistant !</i></para>"""
scu.flash_once(
f"Attention: format PDF invalide (champs {field}, clef {missing_key})"
f"Attention: format PDF invalide (champ {field}, clef {missing_key})"
)
raise
except: # pylint: disable=bare-except

View File

@ -126,53 +126,59 @@ def html_edit_formation_apc(
UniteEns.type != codes_cursus.UE_SPORT,
).first()
H += [
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Ressources du S{semestre_idx}",
create_element_msg="créer une nouvelle ressource",
# matiere_parent=matiere_parent,
modules=ressources_in_sem,
module_type=ModuleType.RESSOURCE,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else "",
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Situations d'Apprentissage et d'Évaluation (SAÉs) S{semestre_idx}",
create_element_msg="créer une nouvelle SAÉ",
# matiere_parent=matiere_parent,
modules=saes_in_sem,
module_type=ModuleType.SAE,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else "",
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Autres modules (non BUT) du S{semestre_idx}",
create_element_msg="créer un nouveau module",
modules=other_modules_in_sem,
module_type=ModuleType.STANDARD,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else """<span class="fontred">créer une UE pour pouvoir ajouter des modules</span>""",
(
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Ressources du S{semestre_idx}",
create_element_msg="créer une nouvelle ressource",
# matiere_parent=matiere_parent,
modules=ressources_in_sem,
module_type=ModuleType.RESSOURCE,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else ""
),
(
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Situations d'Apprentissage et d'Évaluation (SAÉs) S{semestre_idx}",
create_element_msg="créer une nouvelle SAÉ",
# matiere_parent=matiere_parent,
modules=saes_in_sem,
module_type=ModuleType.SAE,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else ""
),
(
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Autres modules (non BUT) du S{semestre_idx}",
create_element_msg="créer un nouveau module",
modules=other_modules_in_sem,
module_type=ModuleType.STANDARD,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else """<span class="fontred">créer une UE pour pouvoir ajouter des modules</span>"""
),
]
return "\n".join(H)
@ -202,7 +208,7 @@ def html_ue_infos(ue):
)
return render_template(
"pn/ue_infos.j2",
titre=f"UE {ue.acronyme} {ue.titre}",
titre=f"UE {ue.acronyme} {ue.titre or ''}",
ue=ue,
formsemestres=formsemestres,
nb_etuds_valid_ue=nb_etuds_valid_ue,

View File

@ -104,7 +104,7 @@ def matiere_create(ue_id=None):
default_numero = max([mat.numero for mat in ue.matieres] or [9]) + 1
H = [
html_sco_header.sco_header(page_title="Création d'une matière"),
f"""<h2>Création d'une matière dans l'UE {ue.titre} ({ue.acronyme})</h2>
f"""<h2>Création d'une matière dans l'UE {ue.titre or ''} ({ue.acronyme})</h2>
<p class="help">Les matières sont des groupes de modules dans une UE
d'une formation donnée. Les matières servent surtout pour la
présentation (bulletins, etc) mais <em>n'ont pas de rôle dans le calcul

View File

@ -85,7 +85,7 @@ _moduleEditor = ndb.EditableTable(
"heures_tp": ndb.float_null_is_zero,
"numero": ndb.int_null_is_zero,
"coefficient": ndb.float_null_is_zero,
"module_type": ndb.int_null_is_zero
"module_type": ndb.int_null_is_zero,
#'ects' : ndb.float_null_is_null
},
)
@ -387,14 +387,16 @@ def module_edit(
"scodoc/help/modules.j2",
is_apc=is_apc,
semestre_id=semestre_id,
formsemestres=FormSemestre.query.filter(
ModuleImpl.formsemestre_id == FormSemestre.id,
ModuleImpl.module_id == module_id,
)
.order_by(FormSemestre.date_debut)
.all()
if not create
else None,
formsemestres=(
FormSemestre.query.filter(
ModuleImpl.formsemestre_id == FormSemestre.id,
ModuleImpl.module_id == module_id,
)
.order_by(FormSemestre.date_debut)
.all()
if not create
else None
),
create=create,
),
]
@ -413,9 +415,11 @@ def module_edit(
}
if module:
module_types |= {
scu.ModuleType(module.module_type)
if module.module_type
else scu.ModuleType.STANDARD
(
scu.ModuleType(module.module_type)
if module.module_type
else scu.ModuleType.STANDARD
)
}
# Numéro du module
# cherche le numero adéquat (pour placer le module en fin de liste)
@ -571,15 +575,17 @@ def module_edit(
"input_type": "menu",
"title": "Rattachement :" if is_apc else "Matière :",
"explanation": (
"UE de rattachement, utilisée notamment pour les malus"
+ (
" (module utilisé, ne peut pas être changé de semestre)"
if in_use
else ""
(
"UE de rattachement, utilisée notamment pour les malus"
+ (
" (module utilisé, ne peut pas être changé de semestre)"
if in_use
else ""
)
)
)
if is_apc
else "un module appartient à une seule matière.",
if is_apc
else "un module appartient à une seule matière."
),
"labels": mat_names,
"allowed_values": ue_mat_ids,
"enabled": unlocked,
@ -733,7 +739,7 @@ def module_edit(
"title": f"""<span class="fontred">{scu.EMO_WARNING }
L'UE <a class="stdlink" href="{
url_for("notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue.id)
}">{ue.acronyme} {ue.titre}</a>
}">{ue.acronyme} {ue.titre or ''}</a>
n'est pas associée à un niveau de compétences
</span>""",
},
@ -766,12 +772,14 @@ def module_edit(
request.base_url,
scu.get_request_args(),
descr,
html_foot_markup=f"""<div class="sco_tag_module_edit"><span
html_foot_markup=(
f"""<div class="sco_tag_module_edit"><span
class="sco_tag_edit"><textarea data-module_id="{module_id}" class="module_tag_editor"
>{','.join(sco_tag_module.module_tag_list(module_id))}</textarea></span></div>
"""
if not create
else "",
if not create
else ""
),
initvalues=module_dict if module else {},
submitlabel="Modifier ce module" if module else "Créer ce module",
cancelbutton="Annuler",
@ -814,7 +822,7 @@ def module_edit(
tf[2]["matiere_id"] = matiere.id
else:
matiere_id = sco_edit_matiere.do_matiere_create(
{"ue_id": ue.id, "titre": ue.titre, "numero": 1},
{"ue_id": ue.id, "titre": ue.titre or "", "numero": 1},
)
tf[2]["matiere_id"] = matiere_id

View File

@ -133,7 +133,15 @@ def do_ue_create(args, allow_empty_ue_code=False):
break
args["ue_code"] = code
# last checks
if not args.get("acronyme"):
raise ScoValueError("acronyme vide")
args["coefficient"] = args.get("coefficient", None)
if args["coefficient"] == "":
args["coefficient"] = None
# create
# XXX TODO utiliser UniteEns.create_from_dict
ue_id = _ueEditor.create(cnx, args)
log(f"do_ue_create: created {ue_id} with {args}")
@ -159,7 +167,7 @@ def do_ue_delete(ue: UniteEns, delete_validations=False, force=False):
if not ue.can_be_deleted():
raise ScoNonEmptyFormationObject(
f"UE (id={ue.id}, dud)",
msg=ue.titre,
msg=f"{ue.titre or ''} ({ue.acronyme})",
dest_url=url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
@ -303,11 +311,13 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
<p class="help">Note: sauf exception, l'UE n'a pas de coefficient associé.
Seuls les <em>modules</em> ont des coefficients.
</p>""",
f"""
(
f"""
<h4>UE du semestre S{ue.semestre_idx}</h4>
"""
if is_apc and ue
else "",
if is_apc and ue
else ""
),
]
ue_types = cursus.ALLOWED_UE_TYPES
@ -629,8 +639,8 @@ def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False):
)
if not ue.can_be_deleted():
raise ScoNonEmptyFormationObject(
f"UE",
msg=ue.titre,
"UE",
msg=f"{ue.titre or ''} ({ue.acronyme})",
dest_url=url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
@ -641,7 +651,7 @@ def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False):
if not dialog_confirmed:
return scu.confirm_dialog(
f"<h2>Suppression de l'UE {ue.titre} ({ue.acronyme})</h2>",
f"<h2>Suppression de l'UE {ue.titre or ''} ({ue.acronyme})</h2>",
dest_url="",
parameters={"ue_id": ue.id},
cancel_url=url_for(
@ -1442,7 +1452,7 @@ def ue_sharing_code(ue_code: str = "", ue_id: int = None, hide_ue_id: int = None
H.append("<ul>")
for ue in ues:
H.append(
f"""<li>{ue.acronyme} ({ue.titre}) dans
f"""<li>{ue.acronyme} ({ue.titre or ''}) dans
<a class="stdlink" href="{
url_for("notes.ue_table",
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)}"

View File

@ -283,7 +283,7 @@ def evaluation_create_form(
"coef. mod.:" +str(coef_ue) if coef_ue
else "ce module n'a pas de coef. dans cette UE"
})</span>
<span class="eval_coef_ue_titre">{ue.titre}</span>
<span class="eval_coef_ue_titre">{ue.titre or ''}</span>
""",
"allow_null": False,
# ok si poids nul ou coef vers l'UE nul:

View File

@ -258,13 +258,17 @@ def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid):
submitlabel="Enregistrer ces validations",
cancelbutton="Annuler",
initvalues=initvalues,
cssclass="tf_ext_edit_ue_validations ext_apc"
if formsemestre.formation.is_apc()
else "tf_ext_edit_ue_validations",
cssclass=(
"tf_ext_edit_ue_validations ext_apc"
if formsemestre.formation.is_apc()
else "tf_ext_edit_ue_validations"
),
# En APC, stocke les coefficients pour l'affichage de la moyenne en direct
form_attrs=f"""data-ue_coefs='[{', '.join(str(ue.ects or 0) for ue in ues)}]'"""
if formsemestre.formation.is_apc()
else "",
form_attrs=(
f"""data-ue_coefs='[{', '.join(str(ue.ects or 0) for ue in ues)}]'"""
if formsemestre.formation.is_apc()
else ""
),
)
if tf[0] == -1:
return "<h4>annulation</h4>"
@ -421,12 +425,18 @@ def _ue_form_description(
"input_type": "text",
"size": 4,
"template": itemtemplate,
"title": "<tt>"
+ (f"S{ue.semestre_idx} " if ue.semestre_idx is not None else "")
+ f"<b>{ue.acronyme}</b></tt> {ue.titre}"
+ f" ({ue.ects} ECTS)"
if ue.ects is not None
else "",
"title": (
"<tt>"
+ (
f"S{ue.semestre_idx} "
if ue.semestre_idx is not None
else ""
)
+ f"<b>{ue.acronyme}</b></tt> {ue.titre or ''}"
+ f" ({ue.ects} ECTS)"
if ue.ects is not None
else ""
),
"attributes": [coef_disabled],
},
)

View File

@ -281,9 +281,11 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str:
menu_inscriptions = [
{
"title": "Gérer les inscriptions aux UE et modules"
if formsemestre.formation.is_apc()
else "Gérer les inscriptions aux modules",
"title": (
"Gérer les inscriptions aux UE et modules"
if formsemestre.formation.is_apc()
else "Gérer les inscriptions aux modules"
),
"endpoint": "notes.moduleimpl_inscriptions_stats",
"args": {"formsemestre_id": formsemestre_id},
}
@ -619,9 +621,9 @@ def formsemestre_description_table(
if ue.color:
for k in list(ue_info.keys()):
if not k.startswith("_"):
ue_info[
f"_{k}_td_attrs"
] = f'style="background-color: {ue.color} !important;"'
ue_info[f"_{k}_td_attrs"] = (
f'style="background-color: {ue.color} !important;"'
)
if not is_apc:
# n'affiche la ligne UE qu'en formation classique
# car l'UE de rattachement n'a pas d'intérêt en BUT
@ -1050,9 +1052,11 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
formsemestre_id=formsemestre_id, page_title="Tableau de bord"
),
formsemestre_warning_apc_setup(formsemestre, nt),
formsemestre_warning_etuds_sans_note(formsemestre, nt)
if can_change_all_notes
else "",
(
formsemestre_warning_etuds_sans_note(formsemestre, nt)
if can_change_all_notes
else ""
),
"""<p style="font-size: 130%"><b>Tableau de bord&nbsp;: </b>""",
]
if formsemestre.est_courant():
@ -1226,7 +1230,7 @@ def formsemestre_tableau_modules(
ue = modimpl.module.ue
if show_ues and (prev_ue_id != ue.id):
prev_ue_id = ue.id
titre = ue.titre
titre = ue.titre or ""
if use_ue_coefs:
titre += f""" <b>(coef. {ue.coefficient or 0.0})</b>"""
H.append(

View File

@ -728,7 +728,9 @@ def formsemestre_recap_parcours_table(
)
# Dispense BUT ?
if (etudid, ue.id) in nt.dispense_ues:
moy_ue_txt = "" if (ue_status and ue_status["is_capitalized"]) else ""
moy_ue_txt = (
"" if (ue_status and ue_status["is_capitalized"]) else ""
)
explanation_ue.append("non inscrit (dispense)")
else:
moy_ue_txt = scu.fmt_note(moy_ue)
@ -1098,7 +1100,7 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
ue_names = ["Choisir..."] + [
f"""{('S'+str(ue.semestre_idx)+' : ') if ue.semestre_idx is not None else ''
}{ue.acronyme} {ue.titre} ({ue.ue_code or ""})"""
}{ue.acronyme} {ue.titre or ''} ({ue.ue_code or ""})"""
for ue in ues
]
ue_ids = [""] + [ue.id for ue in ues]

View File

@ -319,6 +319,7 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
<tr>
{'<th>UE</th>' if not is_apc else ""}
<th>Code</th>
<th>Module</th>
<th>Inscrits</th>
<th></th>
</tr>
@ -348,6 +349,7 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
<td class="formsemestre_status_code">{
modimpl.module.code or "(module sans code)"
}</td>
<td class="formsemestre_status_module">{modimpl.module.titre or ""}</td>
<td class="formsemestre_status_inscrits">{
mod_nb_inscrits[modimpl.id]}</td><td>{c_link}</td>
</tr>

View File

@ -327,34 +327,10 @@ def fiche_etud(etudid=None):
info["link_inscrire_ailleurs"] = ""
# Liste des annotations
annotations_list = []
annotations = EtudAnnotation.query.filter_by(etudid=etud.id).order_by(
sa.desc(EtudAnnotation.date)
html_annotations_list = "\n".join(
[] if restrict_etud_data else get_html_annotations_list(etud)
)
for annot in annotations:
del_link = (
f"""<td class="annodel"><a href="{
url_for("scolar.doSuppressAnnotation",
scodoc_dept=g.scodoc_dept, etudid=etudid, annotation_id=annot.id)}">{
scu.icontag(
"delete_img",
border="0",
alt="suppress",
title="Supprimer cette annotation",
)
}</a></td>"""
if sco_permissions_check.can_suppress_annotation(annot.id)
else ""
)
author = User.query.filter_by(user_name=annot.author).first()
annotations_list.append(
f"""<tr><td><span class="annodate">Le {annot.date.strftime("%d/%m/%Y") if annot.date else "?"}
par {author.get_prenomnom() if author else "?"} :
</span><span class="annoc">{annot.comment or ""}</span></td>{del_link}</tr>
"""
)
info["liste_annotations"] = "\n".join(annotations_list)
# fiche admission
infos_admission = _infos_admission(etud, restrict_etud_data)
has_adm_notes = any(
@ -442,11 +418,7 @@ def fiche_etud(etudid=None):
</div>"""
else:
info["debouche_html"] = "" # pas de boite "devenir"
#
if info["liste_annotations"]:
info["tit_anno"] = '<div class="fichetitre">Annotations</div>'
else:
info["tit_anno"] = ""
# Inscriptions
info[
"inscriptions_mkup"
@ -517,7 +489,9 @@ def fiche_etud(etudid=None):
)
info_naissance = (
f"""<tr><td class="fichetitre2">Né{etud.e} le :</td><td>{info["info_naissance"]}</td></tr>"""
f"""<tr><td class="fichetitre2">Né{etud.e} le :</td>
<td>{info["info_naissance"]}</td></tr>
"""
if info["info_naissance"]
else ""
)
@ -538,6 +512,35 @@ def fiche_etud(etudid=None):
"""
)
info["annotations_mkup"] = (
f"""
<div class="ficheannotations">
<div class="fichetitre">Annotations</div>
<table id="etudannotations">{html_annotations_list}</table>
<form action="doAddAnnotation" method="GET" class="noprint">
<input type="hidden" name="etudid" value="{etudid}">
<b>Ajouter une annotation sur {etud.nomprenom}: </b>
<div>
<textarea name="comment" rows="4" cols="50" value=""></textarea>
<div style="font-size: small; font-style: italic;">
<div>Ces annotations sont lisibles par tous les utilisateurs ayant la permission
<tt>ViewEtudData</tt> dans ce département (souvent les enseignants et le
secrétariat).
</div>
<div>L'annotation commençant par "PE:" est un avis de poursuite d'études.</div>
</div>
</div>
<input type="hidden" name="author" width=12 value="{current_user}">
<input type="submit" value="Ajouter annotation">
</form>
</div>
"""
if not restrict_etud_data
else ""
)
tmpl = (
"""<div class="menus_etud">%(menus_etud)s</div>
<div class="fiche_etud" id="fiche_etud"><table>
@ -564,27 +567,7 @@ def fiche_etud(etudid=None):
%(debouche_html)s
<div class="ficheannotations">
%(tit_anno)s
<table id="etudannotations">%(liste_annotations)s</table>
<form action="doAddAnnotation" method="GET" class="noprint">
<input type="hidden" name="etudid" value="%(etudid)s">
<b>Ajouter une annotation sur %(nomprenom)s: </b>
<table><tr>
<tr><td><textarea name="comment" rows="4" cols="50" value=""></textarea>
<br><font size=-1>
<i>Ces annotations sont lisibles par tous les enseignants et le secrétariat.</i>
<br>
<i>L'annotation commençant par "PE:" est un avis de poursuite d'études.</i>
</font>
</td></tr>
<tr><td>
<input type="hidden" name="author" width=12 value="%(authuser)s">
<input type="submit" value="Ajouter annotation"></td></tr>
</table>
</form>
</div>
%(annotations_mkup)s
<div class="code_nip">code NIP: %(code_nip)s</div>
@ -613,31 +596,41 @@ def fiche_etud(etudid=None):
def _format_adresse(adresse: Adresse | None) -> dict:
"""{ "telephonestr" : ..., "telephonemobilestr" : ... } (formats html)"""
d = {
"telephonestr": ("<b>Tél.:</b> " + scu.format_telephone(adresse.telephone))
if (adresse and adresse.telephone)
else "",
"telephonestr": (
("<b>Tél.:</b> " + scu.format_telephone(adresse.telephone))
if (adresse and adresse.telephone)
else ""
),
"telephonemobilestr": (
"<b>Mobile:</b> " + scu.format_telephone(adresse.telephonemobile)
)
if (adresse and adresse.telephonemobile)
else "",
("<b>Mobile:</b> " + scu.format_telephone(adresse.telephonemobile))
if (adresse and adresse.telephonemobile)
else ""
),
# e-mail:
"email_link": ", ".join(
[
f"""<a class="stdlink" href="mailto:{m}">{m}</a>"""
for m in [adresse.email, adresse.emailperso]
if m
]
)
if adresse and (adresse.email or adresse.emailperso)
else "",
"domicile": (adresse.domicile or "")
if adresse
and (adresse.domicile or adresse.codepostaldomicile or adresse.villedomicile)
else "<em>inconnue</em>",
"paysdomicile": f"{sco_etud.format_pays(adresse.paysdomicile)}"
if adresse and adresse.paysdomicile
else "",
"email_link": (
", ".join(
[
f"""<a class="stdlink" href="mailto:{m}">{m}</a>"""
for m in [adresse.email, adresse.emailperso]
if m
]
)
if adresse and (adresse.email or adresse.emailperso)
else ""
),
"domicile": (
(adresse.domicile or "")
if adresse
and (
adresse.domicile or adresse.codepostaldomicile or adresse.villedomicile
)
else "<em>inconnue</em>"
),
"paysdomicile": (
f"{sco_etud.format_pays(adresse.paysdomicile)}"
if adresse and adresse.paysdomicile
else ""
),
}
d["telephones"] = (
f"<br>{d['telephonestr']} &nbsp;&nbsp; {d['telephonemobilestr']}"
@ -680,15 +673,15 @@ def _infos_admission(etud: Identite, restrict_etud_data: bool) -> dict:
"info_lycee": info_lycee,
"rapporteur": etud.admission.rapporteur if not restrict_etud_data else "",
"rap": rap,
"commentaire": (etud.admission.commentaire or "")
if not restrict_etud_data
else "",
"classement": (etud.admission.classement or "")
if not restrict_etud_data
else "",
"type_admission": (etud.admission.type_admission or "")
if not restrict_etud_data
else "",
"commentaire": (
(etud.admission.commentaire or "") if not restrict_etud_data else ""
),
"classement": (
(etud.admission.classement or "") if not restrict_etud_data else ""
),
"type_admission": (
(etud.admission.type_admission or "") if not restrict_etud_data else ""
),
"math": (etud.admission.math or "") if not restrict_etud_data else "",
"physique": (etud.admission.physique or "") if not restrict_etud_data else "",
"anglais": (etud.admission.anglais or "") if not restrict_etud_data else "",
@ -696,6 +689,39 @@ def _infos_admission(etud: Identite, restrict_etud_data: bool) -> dict:
}
def get_html_annotations_list(etud: Identite) -> list[str]:
"""Liste de chaînes html décrivant les annotations."""
html_annotations_list = []
annotations = EtudAnnotation.query.filter_by(etudid=etud.id).order_by(
sa.desc(EtudAnnotation.date)
)
for annot in annotations:
del_link = (
f"""<td class="annodel"><a href="{
url_for("scolar.doSuppressAnnotation",
scodoc_dept=g.scodoc_dept, etudid=etud.id, annotation_id=annot.id)}">{
scu.icontag(
"delete_img",
border="0",
alt="suppress",
title="Supprimer cette annotation",
)
}</a></td>"""
if sco_permissions_check.can_suppress_annotation(annot.id)
else ""
)
author = User.query.filter_by(user_name=annot.author).first()
html_annotations_list.append(
f"""<tr><td><span class="annodate">Le {
annot.date.strftime("%d/%m/%Y") if annot.date else "?"}
par {author.get_prenomnom() if author else "?"} :
</span><span class="annoc">{annot.comment or ""}</span></td>{del_link}</tr>
"""
)
return html_annotations_list
def menus_etud(etudid):
"""Menu etudiant (operations sur l'etudiant)"""
authuser = current_user

View File

@ -494,7 +494,7 @@ def _normalize_apo_fields(infolist):
infolist: liste de dict renvoyés par le portail Apogee
recode les champs: paiementinscription (-> booleen), datefinalisationinscription (date)
ajoute le champs 'paiementinscription_str' : 'ok', 'Non' ou '?'
ajoute le champ 'paiementinscription_str' : 'ok', 'Non' ou '?'
ajoute les champs 'etape' (= None) et 'prenom' ('') s'ils ne sont pas présents.
ajoute le champ 'civilite_etat_civil' (=''), et 'prenom_etat_civil' (='') si non présent.
"""

View File

@ -1605,7 +1605,7 @@ class BasePreferences:
"bul_display_publication",
{
"initvalue": 1,
"title": "Indique si les bulletins sont publiés",
"title": "Afficher icône indiquant si les bulletins sont publiés",
"explanation": "décocher si vous n'avez pas de portail étudiant publiant les bulletins",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],

View File

@ -342,13 +342,15 @@ def _build_page(
"\n".join(options),
"""</select>
""",
""
if read_only
else f"""
(
""
if read_only
else f"""
<input type="hidden" name="formsemestre_id" value="{sem['formsemestre_id']}"/>
<input type="submit" name="submitted" value="Appliquer les modifications"/>
&nbsp;<a href="#help">aide</a>
""",
"""
),
sco_inscr_passage.etuds_select_boxes(
etuds_by_cat,
sel_inscrits=False,
@ -356,9 +358,11 @@ def _build_page(
base_url=base_url,
read_only=read_only,
),
""
if read_only
else """<p/><input type="submit" name="submitted" value="Appliquer les modifications"/>""",
(
""
if read_only
else """<p/><input type="submit" name="submitted" value="Appliquer les modifications"/>"""
),
formsemestre_synchro_etuds_help(sem),
"""</form>""",
]
@ -420,9 +424,9 @@ def list_synch(sem, annee_apogee=None):
log(f"XXX key2etud etudid={etudid}, type {type(etudid)}")
etud = etuds[0]
etud["inscrit"] = is_inscrit # checkbox state
etud[
"datefinalisationinscription"
] = date_finalisation_inscr_by_nip.get(key, None)
etud["datefinalisationinscription"] = (
date_finalisation_inscr_by_nip.get(key, None)
)
if key in etudsapo_ident:
etud["etape"] = etudsapo_ident[key].get("etape", "")
else:
@ -855,7 +859,7 @@ def formsemestre_import_etud_admission(
if import_email:
if not "mail" in data_apo:
raise ScoValueError(
"la réponse portail n'a pas le champs requis 'mail'"
"la réponse portail n'a pas le champ requis 'mail'"
)
if (
adresse.email != data_apo["mail"]

View File

@ -870,7 +870,7 @@ def stripquotes(s):
return s
def suppress_accents(s):
def suppress_accents(s: str) -> str:
"remove accents and suppress non ascii characters from string s"
if isinstance(s, str):
return (
@ -1018,7 +1018,7 @@ def flash_errors(form):
"""Flashes form errors (version sommaire)"""
for field, errors in form.errors.items():
flash(
"Erreur: voir le champs %s" % (getattr(form, field).label.text,),
"Erreur: voir le champ %s" % (getattr(form, field).label.text,),
"warning",
)
# see https://getbootstrap.com/docs/4.0/components/alerts/

View File

@ -401,9 +401,11 @@ class RowAssiJusti(tb.Row):
self.add_cell(
"entry_date",
"Saisie le",
self.ligne["entry_date"].strftime("%d/%m/%y à %H:%M")
if self.ligne["entry_date"]
else "?",
(
self.ligne["entry_date"].strftime("%d/%m/%y à %H:%M")
if self.ligne["entry_date"]
else "?"
),
data={"order": self.ligne["entry_date"] or ""},
raw_content=self.ligne["entry_date"],
classes=["small-font"],

View File

@ -682,9 +682,9 @@ class RowRecap(tb.Row):
self.add_ue_modimpls_cols(ue, ue_status["is_capitalized"])
self.nb_ues_etud_parcours = len(res.etud_parcours_ues_ids(etud.id))
ue_valid_txt = (
ue_valid_txt_html
) = f"{self.nb_ues_validables}/{self.nb_ues_etud_parcours}"
ue_valid_txt = ue_valid_txt_html = (
f"{self.nb_ues_validables}/{self.nb_ues_etud_parcours}"
)
if self.nb_ues_warning:
ue_valid_txt_html += " " + scu.EMO_WARNING
cell_class = ""
@ -708,9 +708,9 @@ class RowRecap(tb.Row):
# sous-classé par JuryRow pour ajouter les codes
table: TableRecap = self.table
formsemestre: FormSemestre = table.res.formsemestre
table.group_titles[
"col_ue"
] = f"UEs du S{formsemestre.semestre_id} {formsemestre.annee_scolaire()}"
table.group_titles["col_ue"] = (
f"UEs du S{formsemestre.semestre_id} {formsemestre.annee_scolaire()}"
)
col_id = f"moy_ue_{ue.id}"
val = (
ue_status["moy"]
@ -740,7 +740,7 @@ class RowRecap(tb.Row):
)
table.foot_title_row.cells[col_id].target_attrs[
"title"
] = f"""{ue.titre} S{ue.semestre_idx or '?'}"""
] = f"""{ue.titre or ue.acronyme} S{ue.semestre_idx or '?'}"""
def add_ue_modimpls_cols(self, ue: UniteEns, is_capitalized: bool):
"""Ajoute à row les moyennes des modules (ou ressources et SAÉs) dans l'UE"""

View File

@ -18,7 +18,14 @@ from app.scodoc import sco_utils as scu
class TableAssi(tb.Table):
"""Table listant les statistiques d'assiduité des étudiants
L'id de la ligne est etuid, et le row stocke etud.
L'id de la ligne est etudid, et le row stocke etud.
On considère les assiduités entre les dates indiquées.
Si formsemestre_modimpls est spécifié, restreint aux assiduités associées à des
moduleimpls de ce formsemestre.
Si convert_values, transforme les nombre en chaines ("12.34"), pour le html.
"""
def __init__(
@ -26,12 +33,19 @@ class TableAssi(tb.Table):
etuds: list[Identite] = None,
dates: tuple[str, str] = None,
formsemestre: FormSemestre = None,
formsemestre_modimpls: FormSemestre | None = None,
convert_values=False,
**kwargs,
):
self.rows: list["RowEtud"] = [] # juste pour que VSCode nous aide sur .rows
classes = ["gt_table"]
self.dates = [str(dates[0]) + "T00:00", str(dates[1]) + "T23:59"]
self.formsemestre = formsemestre
self.formsemestre_modimpls = formsemestre_modimpls
if convert_values:
self.fmt_num = lambda x: f"{x:2.3g}"
else:
self.fmt_num = lambda x: x
super().__init__(
row_class=RowAssi,
classes=classes,
@ -71,6 +85,7 @@ class RowAssi(tb.Row):
def add_etud_cols(self):
"""Ajoute les colonnes"""
etud = self.etud
fmt_num = self.table.fmt_num
self.table.group_titles.update(
{
"etud_codes": "Codes",
@ -104,12 +119,12 @@ class RowAssi(tb.Row):
)
stats = self._get_etud_stats(etud)
for key, value in stats.items():
self.add_cell(key, value[0], f"{value[1] - value[2]}", "assi_stats")
self.add_cell(key, value[0], fmt_num(value[1] - value[2]), "assi_stats")
if key != "present":
self.add_cell(
key + "_justi",
value[0] + " Justifiées",
f"{value[2]}",
fmt_num(value[2]),
"assi_stats",
)
@ -122,15 +137,17 @@ class RowAssi(tb.Row):
self.add_cell(
"justificatifs_att",
"Justificatifs en Attente",
f"{compte_justificatifs_att.count()}",
fmt_num(compte_justificatifs_att.count()),
)
self.add_cell(
"justificatifs", "Justificatifs", f"{compte_justificatifs.count()}"
"justificatifs", "Justificatifs", fmt_num(compte_justificatifs.count())
)
def _get_etud_stats(self, etud: Identite) -> dict[str, list[str, float, float]]:
"""
Renvoie le comptage (dans la métrique du département) des différents états d'assiduité d'un étudiant
Renvoie le comptage (dans la métrique du département) des différents états
d'assiduité d'un étudiant.
Considère les dates.
Returns :
{
@ -158,6 +175,7 @@ class RowAssi(tb.Row):
"date_debut": self.dates[0],
"date_fin": self.dates[1],
"etat": "absent,present,retard", # pour tout compter d'un coup
"formsemestre_modimpls": self.table.formsemestre_modimpls,
"split": 1, # afin d'avoir la division des stats en état, etatjust, etatnonjust
},
)

View File

@ -8,6 +8,13 @@
{% block app_content %}
<style>
label.stats_checkbox {
font-weight: normal;
margin-left: 16px;
}
</style>
<h2>Visualisation de l'assiduité {{gr_tit|safe}}</h2>
<div class="stats-inputs">
@ -18,6 +25,12 @@
<button onclick="stats()">Changer</button>
<a style="margin-left:32px;" href="{{request.url}}&fmt=xlsx">{{scu.ICON_XLS|safe}}</a>
<label class="stats_checkbox">
<input type="checkbox" id="formsemestre_modimpls_box"> restreindre l'assiduité aux
modules de ce semestre
</label>
</div>
{{tableau | safe}}
@ -40,6 +53,19 @@
window.addEventListener('load', () => {
document.querySelector('#stats_date_debut').value = date_debut;
document.querySelector('#stats_date_fin').value = date_fin;
// La checkbox pour restreindre aux modules du semestre:
var url = new URL(window.location.href);
var checkbox = document.getElementById('formsemestre_modimpls_box');
checkbox.checked = url.searchParams.has('formsemestre_modimpls_id');
checkbox.addEventListener('change', function() {
if (this.checked) {
url.searchParams.set('formsemestre_modimpls_id', {{sco.formsemestre.id}});
} else {
url.searchParams.delete('formsemestre_modimpls_id');
}
window.location.href = url.href;
});
})
</script>

View File

@ -49,7 +49,7 @@ table#edt2group tbody tr.active-row {
</div>
{% if ScoDocSiteConfig.get("edt_ics_group_field") %}
<div>Les groupes sont extrait du champs <b>{{ScoDocSiteConfig.get("edt_ics_group_field")}}</b>
<div>Les groupes sont extrait du champ <b>{{ScoDocSiteConfig.get("edt_ics_group_field")}}</b>
à l'aide de l'expression régulière: <tt>{{ScoDocSiteConfig.get("edt_ics_group_regexp")}}</tt>
</div>
{% else %}

View File

@ -1,7 +1,7 @@
{# Édition liste UEs APC #}
{% for semestre_idx in semestre_ids %}
<div class="formation_list_ues">
<div class="formation_list_ues_titre">Unités d'Enseignement
<div class="formation_list_ues_titre">Unités d'Enseignement
semestre {{semestre_idx}} &nbsp;-&nbsp; {{ects_by_sem[semestre_idx] | safe}} ECTS
</div>
<div class="formation_list_ues_content">
@ -9,14 +9,14 @@
{% for ue in ues_by_sem[semestre_idx] %}
<li class="notes_ue_list">
{% if editable and not loop.first %}
<a href="{{ url_for('notes.ue_move',
<a href="{{ url_for('notes.ue_move',
scodoc_dept=g.scodoc_dept, ue_id=ue.id, after=0 )
}}" class="aud">{{icons.arrow_up|safe}}</a>
{% else %}
{{icons.arrow_none|safe}}
{% endif %}
{% if editable and not loop.last %}
<a href="{{ url_for('notes.ue_move',
<a href="{{ url_for('notes.ue_move',
scodoc_dept=g.scodoc_dept, ue_id=ue.id, after=1 )
}}" class="aud">{{icons.arrow_down|safe}}</a>
{% else %}
@ -24,7 +24,7 @@
{% endif %}
</span>
<a class="smallbutton" href="{{ url_for('notes.ue_delete',
<a class="smallbutton" href="{{ url_for('notes.ue_delete',
scodoc_dept=g.scodoc_dept, ue_id=ue.id)
}}">{% if editable and not ue.modules.count() %}{{icons.delete|safe}}{% else
%}{{icons.delete_disabled|safe}}{% endif %}</a>
@ -34,12 +34,12 @@
ue.color if ue.color is not none else 'blue'}}"></span>
<b>{{ue.acronyme}} <a class="discretelink" href="{{
url_for('notes.ue_infos', scodoc_dept=g.scodoc_dept, ue_id=ue.id)}}" title="{{ue.acronyme}}: {{
('pas de compétence associée'
if ue.niveau_competence is none
('pas de compétence associée'
if ue.niveau_competence is none
else 'compétence ' + ue.niveau_competence.annee + ' ' + ue.niveau_competence.competence.titre_long)
if ue.type == 0
else ''
}}">{{ue.titre}}</a>
}}">{{ue.titre or ue.acronyme}}</a>
</b>
{% set virg = joiner(", ") %}
<span class="ue_code">(
@ -66,7 +66,7 @@
</div>
{% endif %}
{% if editable and not ue.is_locked() %}
<a class="stdlink" href="{{ url_for('notes.ue_edit',
<a class="stdlink" href="{{ url_for('notes.ue_edit',
scodoc_dept=g.scodoc_dept, ue_id=ue.id)
}}">modifier</a>
{% endif %}
@ -100,8 +100,8 @@
{% if editable %}
<ul>
<li class="notes_ue_list notes_ue_list_add"><a class="stdlink" href="{{
url_for('notes.ue_create',
scodoc_dept=g.scodoc_dept,
url_for('notes.ue_create',
scodoc_dept=g.scodoc_dept,
formation_id=formation.id,
default_semestre_idx=semestre_idx,
)}}">ajouter une UE</a>

View File

@ -4,7 +4,7 @@
{% block app_content %}
<!-- begin ue_infos -->
<h2>Unité d'Enseignement {{ue.acronyme|e}} {{ue.titre}}</h2>
<h2>Unité d'Enseignement {{ue.acronyme|e}} {{(ue.titre or '')|e}}</h2>
<div class="ue_infos">
@ -36,7 +36,7 @@
{% if loop.first %}
<ul>
{% endif %}
<li><a href="{{url_for('notes.formsemestre_status',
<li><a href="{{url_for('notes.formsemestre_status',
scodoc_dept=g.scodoc_dept, formsemestre_id=sem.id )}}">{{sem.titre_mois()}}</a></li>
{% if loop.last %}
</ul>

View File

@ -874,9 +874,11 @@ def choix_date() -> str:
if ok:
return redirect(
url_for(
"assiduites.signal_assiduites_group"
if request.args.get("readonly") is None
else "assiduites.visu_assiduites_group",
(
"assiduites.signal_assiduites_group"
if request.args.get("readonly") is None
else "assiduites.visu_assiduites_group"
),
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
group_ids=group_ids,
@ -1294,13 +1296,26 @@ def etat_abs_date():
@scodoc
@permission_required(Permission.ScoView)
def visu_assi_group():
"""Visualisation de l'assiduité d'un groupe entre deux dates"""
"""Visualisation de l'assiduité d'un groupe entre deux dates.
Paramètres:
- date_debut, date_fin (format ISO)
- fmt : format d'export, html (défaut) ou xls
- group_ids : liste des groupes
- formsemestre_modimpls_id: id d'un formasemestre, si fournit restreint les
comptages aux assiduités liées à des modules de ce formsemestre.
"""
# Récupération des paramètres de la requête
dates = {
"debut": request.args.get("date_debut"),
"fin": request.args.get("date_fin"),
}
formsemestre_modimpls_id = request.args.get("formsemestre_modimpls_id")
formsemestre_modimpls = (
None
if formsemestre_modimpls_id is None
else FormSemestre.get_formsemestre(formsemestre_modimpls_id)
)
fmt = request.args.get("fmt", "html")
group_ids: list[int] = request.args.get("group_ids", None)
@ -1317,7 +1332,11 @@ def visu_assi_group():
# Génération du tableau des assiduités
table: TableAssi = TableAssi(
etuds=etuds, dates=list(dates.values()), formsemestre=formsemestre
etuds=etuds,
dates=list(dates.values()),
formsemestre=formsemestre,
formsemestre_modimpls=formsemestre_modimpls,
convert_values=(fmt == "html"),
)
# Export en XLS
@ -2150,9 +2169,6 @@ def _module_selector_multiple(
return render_template(
"assiduites/widgets/moduleimpl_selector_multiple.j2",
choices=choices,
formsemestre_id=only_form.id
if only_form
else list(modimpls_by_formsemestre.keys())[0],
moduleimpl_id=moduleimpl_id,
)

View File

@ -113,7 +113,7 @@ def table_modules_ue_coefs(formation_id, semestre_idx=None, parcours_id: int = N
"y": 1, # 1ere ligne
"style": "title_ue",
"data": ue.acronyme,
"title": ue.titre,
"title": ue.titre or ue.acronymexs,
}
for (col, ue) in enumerate(ues, start=2)
]
@ -214,11 +214,13 @@ def edit_modules_ue_coefs():
{lockicon}
</h2>
""",
"""<span class="warning">Formation verrouilée car un ou plusieurs
(
"""<span class="warning">Formation verrouilée car un ou plusieurs
semestres verrouillés l'utilisent.
</span>"""
if locked
else "",
if locked
else ""
),
render_template(
"pn/form_modules_ue_coefs.j2",
formation=formation,

View File

@ -0,0 +1,56 @@
"""etudiant_annotations : ajoute clé externe etudiant et moduleimpl
Revision ID: 2e4875004e12
Revises: 3fa988ff8970
Create Date: 2024-02-11 12:10:36.743212
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "2e4875004e12"
down_revision = "3fa988ff8970"
branch_labels = None
depends_on = None
def upgrade():
# Supprime les annotations orphelines
op.execute(
"""DELETE FROM etud_annotations
WHERE etudid NOT IN (SELECT id FROM identite);
"""
)
# Ajoute clé:
with op.batch_alter_table("etud_annotations", schema=None) as batch_op:
batch_op.create_foreign_key(None, "identite", ["etudid"], ["id"])
# Et modif liée au commit 072d013590abf715395bc987fb48de49f6750527
with op.batch_alter_table("notes_moduleimpl", schema=None) as batch_op:
batch_op.drop_constraint(
"notes_moduleimpl_responsable_id_fkey", type_="foreignkey"
)
batch_op.create_foreign_key(
None, "user", ["responsable_id"], ["id"], ondelete="SET NULL"
)
# cet index en trop trainait depuis longtemps...
with op.batch_alter_table("assiduites", schema=None) as batch_op:
batch_op.drop_index("ix_assiduites_user_id")
def downgrade():
with op.batch_alter_table("notes_moduleimpl", schema=None) as batch_op:
batch_op.drop_constraint(None, type_="foreignkey")
batch_op.create_foreign_key(
"notes_moduleimpl_responsable_id_fkey", "user", ["responsable_id"], ["id"]
)
with op.batch_alter_table("etud_annotations", schema=None) as batch_op:
batch_op.drop_constraint(None, type_="foreignkey")
with op.batch_alter_table("assiduites", schema=None) as batch_op:
batch_op.create_index("ix_assiduites_user_id", ["user_id"], unique=False)

View File

@ -1,7 +1,7 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
SCOVERSION = "9.6.936"
SCOVERSION = "9.6.939"
SCONAME = "ScoDoc"

View File

@ -204,8 +204,8 @@ def test_etudiants(api_headers):
all_unique = True
list_ids = [etud["id"] for etud in etud_nip]
for id in list_ids:
if list_ids.count(id) > 1:
for etudid in list_ids:
if list_ids.count(etudid) > 1:
all_unique = False
assert all_unique is True
@ -226,8 +226,8 @@ def test_etudiants(api_headers):
all_unique = True
list_ids = [etud["id"] for etud in etud_ine]
for id in list_ids:
if list_ids.count(id) > 1:
for etudid in list_ids:
if list_ids.count(etudid) > 1:
all_unique = False
assert all_unique is True
@ -267,6 +267,53 @@ def test_etudiants_by_name(api_headers):
assert etuds[0]["nom"] == "RÉGNIER"
def test_etudiant_annotations(api_headers):
"""
Routes:
POST /etudiant/etudid/<int:etudid>/annotation
GET /etudiant/etudid/<int:etudid>[/long]
"""
admin_header = get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN)
args = {
"prenom": "Annoté",
"nom": "Bach A",
"dept": DEPT_ACRONYM,
"civilite": "M",
}
etud = POST_JSON(
"/etudiant/create",
args,
headers=admin_header,
)
assert etud["nom"] == args["nom"].upper()
etudid = etud["id"]
# récupère annotation (liste vide)
etud = GET(f"/etudiant/etudid/{etudid}", headers=api_headers)
assert etud["nom"]
assert etud["annotations"] == []
# ajoute annotation
annotation = POST_JSON(
f"/etudiant/etudid/{etudid}/annotation",
{"comment": "annotation 1"},
headers=admin_header,
)
assert annotation
annotation_id = annotation["id"]
etud = GET(f"/etudiant/etudid/{etudid}", headers=api_headers)
assert len(etud["annotations"]) == 0 # pas le droit => cachée
etud = GET(f"/etudiant/etudid/{etudid}", headers=admin_header)
assert len(etud["annotations"]) == 1 # ok avec admin
assert etud["annotations"][0]["comment"] == "annotation 1"
assert etud["annotations"][0]["id"] == annotation_id
# Supprime annotation
POST_JSON(
f"/etudiant/etudid/{etudid}/annotation/{annotation_id}/delete",
headers=admin_header,
)
etud = GET(f"/etudiant/etudid/{etudid}", headers=api_headers)
assert len(etud["annotations"]) == 0
def test_etudiant_photo(api_headers):
"""
Routes : /etudiant/etudid/<int:etudid>/photo en GET et en POST

View File

@ -11,14 +11,12 @@ Usage: pytest tests/scenarios/test_scenario1_formation.py
"""
# code écrit par Fares Amer, mai 2021 et porté sur ScoDoc 8 en août 2021
import random
from tests.unit import sco_fake_gen
from app.scodoc import sco_edit_module
from app.scodoc import sco_formations
from app.scodoc import sco_moduleimpl
@pytest.mark.skip # test obsolete
def test_scenario1(test_client):
"""Applique "scenario 1"""
run_scenario1()
@ -28,7 +26,9 @@ def run_scenario1():
G = sco_fake_gen.ScoFake(verbose=False)
# Lecture fichier XML local:
with open("tests/unit/formation-exemple-1.xml") as f:
with open(
"tests/ressources/formations/formation-exemple-1.xml", encoding="utf8"
) as f:
doc = f.read()
# --- Création de la formation

View File

@ -15,7 +15,14 @@ from flask import current_app
import app
from app import db
from app.models import Admission, Adresse, Departement, FormSemestre, Identite
from app.models import (
Admission,
Adresse,
Departement,
EtudAnnotation,
FormSemestre,
Identite,
)
from app.scodoc import sco_etud
from app.scodoc import sco_find_etud, sco_import_etuds
from config import TestConfig
@ -116,6 +123,32 @@ def test_etat_civil(test_client):
assert e_d["ne"] == "(e)"
def test_etud_annotations(test_client):
"Test ajout/suppression annotations"
dept = Departement.query.first()
args = {"nom": "nom_a", "prenom": "prénom_a", "civilite": "M", "dept_id": dept.id}
etud = Identite.create_etud(**args)
db.session.flush()
# Ajout annotations
etud.annotations.append(EtudAnnotation(comment="annotation 1"))
etud.annotations.append(EtudAnnotation(comment="annotation 2"))
db.session.add(etud)
db.session.commit()
assert etud.annotations.count() == 2
# Suppression de la première
a = etud.annotations.first()
assert a.comment == "annotation 1"
db.session.delete(a)
db.session.commit()
assert db.session.get(Identite, etud.id)
assert etud.annotations.count() == 1
# Suppression de l'étudiant (cascade)
a2_id = etud.annotations.first().id
db.session.delete(etud)
db.session.commit()
assert db.session.get(EtudAnnotation, a2_id) is None
def test_etud_legacy(test_client):
"Test certaines fonctions scodoc7 (sco_etud)"
dept = Departement.query.first()