Compare commits

...

7 Commits

16 changed files with 155 additions and 86 deletions

View File

@ -9,6 +9,7 @@ from app.models.but_refcomp import ApcNiveau
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.scodoc import sco_preferences
class ApcValidationRCUE(db.Model):
@ -218,15 +219,18 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
decisions["descr_decisions_rcue"] = ""
decisions["descr_decisions_niveaux"] = ""
# --- Année: prend la validation pour l'année scolaire et l'ordre de ce semestre
annee_but = (formsemestre.semestre_id + 1) // 2
validation = ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
ordre=annee_but,
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
).first()
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
if sco_preferences.get_preference("bul_but_code_annuel", formsemestre.id):
annee_but = (formsemestre.semestre_id + 1) // 2
validation = ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
ordre=annee_but,
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
).first()
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
else:
decisions["decision_annee"] = None
else:
decisions["decision_annee"] = None
return decisions

View File

@ -5,6 +5,7 @@ from flask import g
import pandas as pd
from app import db, log
from app import models
from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN
from app.models.but_refcomp import ApcNiveau, ApcParcours
@ -12,7 +13,7 @@ from app.models.modules import Module
from app.scodoc import sco_utils as scu
class UniteEns(db.Model):
class UniteEns(models.ScoDocModel):
"""Unité d'Enseignement (UE)"""
__tablename__ = "notes_ue"
@ -81,7 +82,7 @@ class UniteEns(db.Model):
'EXTERNE' if self.is_external else ''})>"""
def clone(self):
"""Create a new copy of this ue.
"""Create a new copy of this ue, add to session.
Ne copie pas le code, ni le code Apogée, ni les liens au réf. de comp.
(parcours et niveau).
"""
@ -100,8 +101,26 @@ class UniteEns(db.Model):
coef_rcue=self.coef_rcue,
color=self.color,
)
db.session.add(ue)
return ue
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields from the given dict to model's attributes values. No side effect.
args: dict with args in application.
returns: dict to store in model's db.
"""
args = args.copy()
if "type" in args:
args["type"] = int(args["type"] or 0)
if "is_external" in args:
args["is_external"] = scu.to_bool(args["is_external"])
if "ects" in args:
args["ects"] = float(args["ects"])
return args
def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
"""as a dict, with the same conversions as in ScoDoc7.
If convert_objects, convert all attributes to native types

View File

@ -259,10 +259,17 @@ class EtudiantsJuryPE:
} # les semestres de n°i de l'étudiant
self.cursus[etudid][nom_sem] = semestres_i
def get_trajectoire(self, etudid: int, formsemestre_final: FormSemestre):
def get_trajectoire(self, etudid: int, formsemestre_final: FormSemestre, nom_aggregat: str):
"""Ensemble des semestres parcourus par
un étudiant pour l'amener à un semestre terminal.
Si nom_aggregat est de type "Si", limite les semestres à ceux de numéro i.
Par ex: si formsemestre_terminal est un S3 et nom_agrregat "S3", ne prend en compte que les
semestres 3.
Si nom_aggregat est de type "iA" ou "iS" (incluant plusieurs numéros de semestres), prend en
compte les dit numéros de semestres.
Par ex: si formsemestre_terminal est un S3, ensemble des S1,
S2, S3 suivi pour l'amener au S3 (il peut y avoir plusieurs S1,
ou S2, ou S3 s'il a redoublé).
@ -277,12 +284,19 @@ class EtudiantsJuryPE:
numero_semestre_terminal = formsemestre_final.semestre_id
semestres_significatifs = self.get_semestres_significatifs(etudid)
# Semestres de n° inférieur (pax ex: des S1, S2, S3 pour un S3 terminal)
# et qui lui sont antérieurs
if nom_aggregat.startswith("S"): # les semestres
numero_semestres_possibles =[numero_semestre_terminal]
elif nom_aggregat.endswith("A"): # les années
numero_semestres_possibles = [int(sem[-1]) for sem in pe_comp.PARCOURS[nom_aggregat]["aggregat"]]
assert numero_semestre_terminal in numero_semestres_possibles
else: # les xS = tous les semestres jusqu'à Sx (pax ex: des S1, S2, S3 pour un S3 terminal)
numero_semestres_possibles = list(range(1, numero_semestre_terminal+1))
semestres_aggreges = {}
for fid, semestre in semestres_significatifs.items():
# Semestres parmi ceux de n° possibles & qui lui sont antérieurs
if (
semestre.semestre_id <= numero_semestre_terminal
semestre.semestre_id in numero_semestres_possibles
and semestre.date_fin <= formsemestre_final.date_fin
):
semestres_aggreges[fid] = semestre

View File

@ -318,29 +318,35 @@ class JuryPE(object):
}
for aggregat in aggregats:
"""La trajectoire de l'étudiant sur l'aggrégat"""
# La trajectoire de l'étudiant sur l'aggrégat
trajectoire = self.trajectoires.suivi[etudid][aggregat]
"""Les moyennes par tag de cette trajectoire"""
# Les moyennes par tag de cette trajectoire
donnees[etudid] |= {
f"{aggregat} notes ": "-",
f"{aggregat} class. (groupe)": "-",
f"{aggregat} min/moy/max (groupe)": "-",
}
if trajectoire:
trajectoire_tagguee = self.trajectoires_tagguees[
trajectoire.trajectoire_id
]
bilan = trajectoire_tagguee.moyennes_tags[tag]
if tag in trajectoire_tagguee.moyennes_tags:
bilan = trajectoire_tagguee.moyennes_tags[tag]
donnees[etudid] |= {
f"{aggregat} notes ": round(bilan['notes'].loc[etudid], 2),
f"{aggregat} class. (groupe)": f"{bilan['classements'].loc[etudid]}/{bilan['nb_inscrits']}",
f"{aggregat} min/moy/max (groupe)": f"{bilan['min']:.1f}/{bilan['moy']:.1f}/{bilan['max']:.1f}",
}
donnees[etudid] |= {
f"{aggregat} notes ": f"{bilan['notes'].loc[etudid]:.1f}",
f"{aggregat} class. (groupe)": f"{bilan['classements'].loc[etudid]}/{bilan['nb_inscrits']}",
f"{aggregat} min/moy/max (groupe)": f"{bilan['min']:.1f}/{bilan['moy']:.1f}/{bilan['max']:.1f}",
}
else:
donnees[etudid] |= {
f"{aggregat} notes ": "-",
f"{aggregat} class. (groupe)": "-",
f"{aggregat} min/moy/max (groupe)": "-",
}
"""L'interclassement"""
interclass = self.interclassements_taggues[aggregat]
donnees[etudid] |= {
f"{aggregat} class. (promo)": "-",
f"{aggregat} min/moy/max (promo)": "-",
}
if tag in interclass.moyennes_tags:
bilan = interclass.moyennes_tags[tag]
@ -348,11 +354,6 @@ class JuryPE(object):
f"{aggregat} class. (promo)": f"{bilan['classements'].loc[etudid]}/{bilan['nb_inscrits']}",
f"{aggregat} min/moy/max (promo)": f"{bilan['min']:.1f}/{bilan['moy']:.1f}/{bilan['max']:.1f}",
}
else:
donnees[etudid] |= {
f"{aggregat} class. (promo)": "-",
f"{aggregat} min/moy/max (promo)": "-",
}
# Fin de l'aggrégat
# Construction du dataFrame

View File

@ -36,8 +36,7 @@ class Trajectoire:
"""Les semestres à aggréger"""
self.semestres_aggreges = {}
def add_semestres_a_aggreger(self, semestres: dict[int: FormSemestre]):
def add_semestres_a_aggreger(self, semestres: dict[int:FormSemestre]):
"""Ajoute des semestres au semestre à aggréger
Args:
@ -45,8 +44,6 @@ class Trajectoire:
"""
self.semestres_aggreges = self.semestres_aggreges | semestres
def get_repr(self):
"""Représentation textuelle d'une trajectoire
basée sur ses semestres aggrégés"""
@ -72,11 +69,10 @@ class TrajectoiresJuryPE:
self.annee_diplome = annee_diplome
"""Toutes les trajectoires possibles"""
self.trajectoires: dict[tuple: Trajectoire] = {}
self.trajectoires: dict[tuple:Trajectoire] = {}
"""Quelle trajectoires pour quel étudiant :
dictionnaire {etudid: {nom_aggregat: Trajectoire}}"""
self.suivi: dict[int: str] = {}
self.suivi: dict[int:str] = {}
def cree_trajectoires(self, etudiants: EtudiantsJuryPE):
"""Créé toutes les trajectoires, au regard du cursus des étudiants
@ -84,15 +80,17 @@ class TrajectoiresJuryPE:
"""
for nom_aggregat in pe_comp.TOUS_LES_SEMESTRES + pe_comp.TOUS_LES_AGGREGATS:
"""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 = pe_comp.PARCOURS[nom_aggregat]["aggregat"]
nom_semestre_terminal = noms_semestre_de_aggregat[-1]
for etudid in etudiants.cursus:
if etudid not in self.suivi:
self.suivi[etudid] = {aggregat: None
for aggregat in pe_comp.TOUS_LES_SEMESTRES + pe_comp.TOUS_LES_AGGREGATS}
self.suivi[etudid] = {
aggregat: None
for aggregat in pe_comp.TOUS_LES_SEMESTRES
+ pe_comp.TOUS_LES_AGGREGATS
}
"""Le formsemestre terminal (dernier en date) associé au
semestre marquant la fin de l'aggrégat
@ -111,7 +109,9 @@ class TrajectoiresJuryPE:
"""La liste des semestres de l'étudiant à prendre en compte
pour cette trajectoire"""
semestres_a_aggreger = etudiants.get_trajectoire(etudid, formsemestre_final)
semestres_a_aggreger = etudiants.get_trajectoire(
etudid, formsemestre_final, nom_aggregat
)
"""Ajout des semestres à la trajectoire"""
trajectoire.add_semestres_a_aggreger(semestres_a_aggreger)
@ -138,7 +138,7 @@ def get_trajectoires_etudid(trajectoires, etudid):
liste.append(trajet.trajectoire_id)
return liste
def get_semestres_a_aggreger(self, aggregat: str, formsemestre_id_terminal: int):
"""Pour un nom d'aggrégat donné (par ex: 'S3') et un semestre terminal cible
identifié par son formsemestre_id (par ex: 'S3 2022-2023'),
@ -162,4 +162,3 @@ def get_semestres_a_aggreger(self, aggregat: str, formsemestre_id_terminal: int)
formsemestres_etudiant = cursus_etudiant[formsemestre_id_terminal]
formsemestres = formsemestres | formsemestres_etudiant
return formsemestres

View File

@ -300,6 +300,7 @@ class EditableTable(object):
output_formators={},
input_formators={},
aux_tables=[],
convert_empty_to_nulls=True, # les arguments vides sont traduits en NULL
convert_null_outputs_to_empty=True,
html_quote=False, # changed in 9.0.10
fields_creators={}, # { field : [ sql_command_to_create_it ] }
@ -321,6 +322,7 @@ class EditableTable(object):
self.output_formators = output_formators
self.input_formators = input_formators
self.convert_null_outputs_to_empty = convert_null_outputs_to_empty
self.convert_empty_to_nulls = convert_empty_to_nulls
self.html_quote = html_quote
self.fields_creators = fields_creators
self.filter_nulls = filter_nulls
@ -351,6 +353,7 @@ class EditableTable(object):
self.table_name,
vals,
commit=True,
convert_empty_to_nulls=self.convert_empty_to_nulls,
return_id=(self.id_name is not None),
ignore_conflicts=self.insert_ignore_conflicts,
)
@ -444,7 +447,7 @@ def dictfilter(d, fields, filter_nulls=True):
"""returns a copy of d with only keys listed in "fields" and non null values"""
r = {}
for f in fields:
if f in d and (d[f] != None or not filter_nulls):
if f in d and (d[f] is not None or not filter_nulls):
try:
val = d[f].strip()
except:

View File

@ -795,7 +795,9 @@ def etud_descr_situation_semestre(
descr_mention = ""
# Décisions APC / BUT
if pv.get("decision_annee", {}):
if pv.get("decision_annee", {}) and sco_preferences.get_preference(
"bul_but_code_annuel", formsemestre.id
):
infos["descr_decision_annee"] = "Décision année: " + pv.get(
"decision_annee", {}
).get("code", "")

View File

@ -89,6 +89,7 @@ _ueEditor = ndb.EditableTable(
"color",
"niveau_competence_id",
),
convert_empty_to_nulls=False, # necessaire pour ue_code == ""
sortkey="numero",
input_formators={
"type": ndb.int_null_is_zero,
@ -110,7 +111,7 @@ def ue_list(*args, **kw):
return _ueEditor.list(cnx, *args, **kw)
def do_ue_create(args):
def do_ue_create(args, allow_empty_ue_code=False):
"create an ue"
cnx = ndb.GetDBConnexion()
# check duplicates
@ -120,18 +121,18 @@ def do_ue_create(args):
f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé !
(chaque UE doit avoir un acronyme unique dans la formation)"""
)
if (
(not "ue_code" in args)
or (args["ue_code"] is None)
or (not args["ue_code"].strip())
):
# évite les conflits de code
while True:
cursor = db.session.execute(sa.text("select notes_newid_ucod();"))
code = cursor.fetchone()[0]
if UniteEns.query.filter_by(ue_code=code).count() == 0:
break
args["ue_code"] = code
if "ue_code" not in args or args["ue_code"] is None or not args["ue_code"].strip():
if allow_empty_ue_code:
args["ue_code"] = ""
else:
# évite les conflits: génère nouveau ue_code
while True:
cursor = db.session.execute(sa.text("select notes_newid_ucod();"))
code = cursor.fetchone()[0]
if UniteEns.query.filter_by(ue_code=code).count() == 0:
break
args["ue_code"] = code
# create
ue_id = _ueEditor.create(cnx, args)
log(f"do_ue_create: created {ue_id} with {args}")

View File

@ -168,16 +168,14 @@ def formsemestre_associate_new_version(
formation_id=new_formation_id,
)
)
else:
return flask.redirect(
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
return flask.redirect(
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
else:
raise ScoValueError("Méthode invalide")
)
raise ScoValueError("Méthode invalide")
def do_formsemestres_associate_new_version(

View File

@ -117,6 +117,7 @@ def formation_export_dict(
ues = ues.all()
ues.sort(key=lambda u: (u.semestre_idx or 0, u.numero or 0, u.acronyme))
f_dict["ue"] = []
ue: UniteEns
for ue in ues:
ue_dict = ue.to_dict()
f_dict["ue"].append(ue_dict)
@ -142,8 +143,8 @@ def formation_export_dict(
if not export_codes_apo:
ue_dict.pop("code_apogee", None)
if ue_dict["ects"] is None:
del ue_dict["ects"]
if ue_dict.get("ects") is None:
ue_dict.pop("ects", None)
mats = sco_edit_matiere.matiere_list({"ue_id": ue.id})
mats.sort(key=lambda m: m["numero"] or 0)
ue_dict["matiere"] = mats
@ -363,7 +364,9 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False):
ue_info[1]["niveau_competence_id"] = _formation_retreive_apc_niveau(
referentiel_competence_id, ue_info[1]
)
ue_id = sco_edit_ue.do_ue_create(ue_info[1])
# Note: si le code est indiqué "" dans le xml, il faut le conserver vide
# pour la comparaison ultérieure des formations XXX
ue_id = sco_edit_ue.do_ue_create(ue_info[1], allow_empty_ue_code=True)
ue: UniteEns = db.session.get(UniteEns, ue_id)
assert ue
if xml_ue_id:

View File

@ -1592,7 +1592,9 @@ def formsemestre_edit_options(formsemestre_id):
ok, err = sco_permissions_check.check_access_diretud(formsemestre_id)
if not ok:
return err
return sco_preferences.SemPreferences(formsemestre_id).edit(categories=["bul"])
return sco_preferences.SemPreferences(formsemestre_id).edit(
categories=["bul", "bul_but_pdf"]
)
def formsemestre_change_publication_bul(

View File

@ -233,7 +233,7 @@ PREF_CATEGORIES = (
(
"bul_but_pdf",
{
"title": "Réglages des bulletins BUT (pdf)",
"title": "Réglages des bulletins BUT",
"related": (
"bul",
"bul_margins",
@ -1742,6 +1742,17 @@ class BasePreferences:
"category": "bul_but_pdf",
},
),
(
"bul_but_code_annuel",
{
"initvalue": 1,
"title": "Bulletins BUT: afficher la décision annuelle",
"explanation": "car en cours d'année elle n'a parfois pas de sens",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "bul_but_pdf",
},
),
# XXX A COMPLETER, voir sco_formsemestre_edit.py XXX
# bul_mail
(
@ -2245,7 +2256,7 @@ class BasePreferences:
self.load()
H = [
html_sco_header.sco_header(
page_title="Préférences",
page_title=f"Préférences {g.scodoc_dept}",
javascripts=["js/detail_summary_persistence.js"],
),
f"<h2>Préférences globales pour le département {g.scodoc_dept}</h2>",

View File

@ -106,7 +106,7 @@ class ScoTag(object):
"""
if not title or len(title) > 32:
return False
if re.match(r"^[A-Za-z0-9\-_$!\.]*(:[0-9]*)?$", title):
if re.match(r"^[\w0-9\-_$!?+=,&\.]*(:[0-9]*)?$", title):
return True
return False
@ -259,6 +259,7 @@ def module_tag_set(module_id="", taglist=None):
# Check tags syntax
for tag in taglist:
if not ScoTag.check_tag_title(tag):
log(f"module_tag_set({module_id}): invalid tag title")
return scu.json_error(404, "invalid tag")
# TODO code à moderniser (+ revoir classe ScoTag, utiliser modèle)

View File

@ -23,21 +23,24 @@
{% block app_content %}
<h1>Modification du compte ScoDoc <tt>{{form.user_name.data}}</tt></h1>
<div class="help">
<p>Identifiez-vous avec votre mot de passe actuel</p>
<div class="help" style="margin-top: 32px; margin-bottom: 32px;">
<p>Le mot de passe ScoDoc doit être suffisament complexe.
Il n'a rien à voir avec celui de votre compte ENT (utilisé pour le service CAS).
</p>
</div>
<form method=post>
<form method="post">
{{ form.user_name }}
{{ form.csrf_token }}
<table class="tf">
<tbody>
{{ render_field(form.old_password, size=14, auth_name=auth_username,
style="padding:1px; margin-left: 1em; margin-top: 4px;") }}
<tr>
<td colspan="" 2">
<p>Vous pouvez changer le mot de passe et/ou l'adresse email.</p>
<p>Les champs laissés vides ne seront pas modifiés.</p>
</td>
<td colspan="2">Vous pouvez changer le mot de passe et/ou l'adresse email.</td>
</tr>
<tr>
<td colspan="2">Les champs laissés vides ne seront pas modifiés.</td>
</tr>
{{ render_field(form.new_password, size=14,
style="padding:1px; margin-left: 1em; margin-top: 12px;") }}

View File

@ -82,6 +82,15 @@
<a class="stdlink" href="{{url_for('auth.logout')}}">logout</a>
</b>
</p>
{% if not (ScoDocSiteConfig.is_cas_enabled() and not user.cas_allow_scodoc_login) %}
<p><b>
<a class="stdlink" href="{{
url_for( 'users.form_change_password',
scodoc_dept=g.scodoc_dept, user_name=user.user_name)
}}">Changer votre mot de passe ScoDoc ou votre mail</a>
</b>
</p>
{% endif %}
</div>
{% endif %}

View File

@ -824,7 +824,6 @@ def ue_clone():
ue_id = int(request.form.get("ue_id"))
ue = UniteEns.query.get_or_404(ue_id)
ue2 = ue.clone()
db.session.add(ue2)
db.session.commit()
flash(f"UE {ue.acronyme} dupliquée")
return flask.redirect(