- Liste homonymes sur formulaire édition étudiant;

- Ajoute helper class ScoDocModel
- Fix clonage étudiant
- Tolère données admission manquantes dans recherche get_etud
- Fix #771
This commit is contained in:
Emmanuel Viennet 2023-10-08 10:01:23 +02:00
parent 574fae2170
commit 3fdef1d4a0
7 changed files with 123 additions and 66 deletions

View File

@ -44,4 +44,8 @@ def get_etud(etudid=None, nip=None, ine=None) -> models.Identite:
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
return query.join(Admission).order_by(desc(Admission.annee)).first()
etud = query.join(Admission).order_by(desc(Admission.annee)).first()
# dans de rares cas (bricolages manuels, bugs), l'étudiant n'a pas de données d'admission
if etud is None:
etud = query.first()
return etud

View File

@ -4,6 +4,7 @@
"""
import sqlalchemy
from app import db
CODE_STR_LEN = 16 # chaine pour les codes
SHORT_STR_LEN = 32 # courtes chaine, eg acronymes
@ -21,6 +22,67 @@ convention = {
metadata_obj = sqlalchemy.MetaData(naming_convention=convention)
class ScoDocModel:
"Mixin class for our models. Add somme useful methods for editing, cloning, etc."
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain flush ou commit.
"""
d = dict(self.__dict__)
d.pop("id", None) # get rid of id
d.pop("_sa_instance_state", None) # get rid of SQLAlchemy special attr
for k in not_copying:
d.pop(k, None)
copy = self.__class__(**d)
db.session.add(copy)
return copy
@classmethod
def create_from_dict(cls, data: dict):
"""Create a new instance of the model with attributes given in dict.
The instance is added to the session (but not flushed nor committed).
Use only relevant arributes for the given model and ignore others.
"""
args = cls.filter_model_attributes(data)
obj = cls(**args)
db.session.add(obj)
return obj
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
By default, excluded == { 'id' }"""
excluded = {"id"} if excluded is None else set()
# Les attributs du modèle qui sont des variables: (élimine les __ et les alias comme adm_id)
my_attributes = [
a
for a in dir(cls)
if isinstance(
getattr(cls, a), sqlalchemy.orm.attributes.InstrumentedAttribute
)
]
# Filtre les arguments utiles
return {
k: v for k, v in data.items() if k in my_attributes and k not in excluded
}
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"Convert fields in the given dict. No side effect."
# virtual, by default, do nothing
return args
def from_dict(self, args: dict):
"Update object's fields given in dict. Add to session but don't commit."
args_dict = self.convert_dict_fields(self.filter_model_attributes(args))
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.add(self)
from app.models.absences import Absence, AbsenceNotification, BilletAbsence
from app.models.departements import Departement
from app.models.etudiants import (

View File

@ -88,7 +88,7 @@ class Identite(db.Model):
def clone(self, not_copying=(), new_dept_id: int = None):
"""Clone, not copying the given attrs
Clone aussi les adresses.
Clone aussi les adresses et infos d'admission.
Si new_dept_id est None, le nouvel étudiant n'a pas de département.
Attention: la copie n'a pas d'id avant le prochain flush ou commit.
"""
@ -123,8 +123,10 @@ class Identite(db.Model):
for k in not_copying:
d.pop(k, None)
copy = self.__class__(**d)
copy.adresses = [adr.clone() for adr in self.adresses]
db.session.add(copy)
copy.adresses = [adr.clone() for adr in self.adresses]
for admission in self.admission:
copy.admission.append(admission.clone())
log(
f"cloning etud <{self.id} {self.nom!r} {self.prenom!r}> in dept_id={new_dept_id}"
)
@ -133,7 +135,7 @@ class Identite(db.Model):
def html_link_fiche(self) -> str:
"lien vers la fiche"
return f"""<a class="stdlink" href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id)
url_for("scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id)
}">{self.nomprenom}</a>"""
@classmethod
@ -677,7 +679,7 @@ def pivot_year(y) -> int:
return y
class Adresse(db.Model):
class Adresse(db.Model, models.ScoDocModel):
"""Adresse d'un étudiant
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule)
"""
@ -704,19 +706,6 @@ class Adresse(db.Model):
)
description = db.Column(db.Text)
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain flush ou commit.
"""
d = dict(self.__dict__)
d.pop("id", None) # get rid of id
d.pop("_sa_instance_state", None) # get rid of SQLAlchemy special attr
for k in not_copying:
d.pop(k, None)
copy = self.__class__(**d)
db.session.add(copy)
return copy
def to_dict(self, convert_nulls_to_str=False):
"""Représentation dictionnaire,"""
e = dict(self.__dict__)
@ -726,7 +715,7 @@ class Adresse(db.Model):
return e
class Admission(db.Model):
class Admission(db.Model, models.ScoDocModel):
"""Informations liées à l'admission d'un étudiant"""
__tablename__ = "admissions"
@ -818,16 +807,6 @@ class Admission(db.Model):
args_dict[key] = value
return args_dict
def from_dict(self, args: dict): # TODO à refactoriser dans une super-classe
"update fields given in dict. Add to session but don't commit."
args_dict = Admission.convert_dict_fields(args)
args_dict.pop("adm_id", None)
args_dict.pop("id", None)
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.add(self)
# Suivi scolarité / débouchés
class ItemSuivi(db.Model):

View File

@ -267,33 +267,30 @@ def identite_edit_nocheck(cnx, args):
db.session.commit()
def check_nom_prenom(cnx, nom="", prenom="", etudid=None):
def check_nom_prenom_homonyms(
nom: str = "", prenom: str = "", etudid=None
) -> tuple[bool, list[Identite]]:
"""Check if nom and prenom are valid.
Also check for duplicates (homonyms), excluding etudid :
in general, homonyms are allowed, but it may be useful to generate a warning.
Returns:
True | False, NbHomonyms
True | False, homonyms
"""
if not nom or (not prenom and not scu.CONFIG.ALLOW_NULL_PRENOM):
return False, 0
return False, []
nom = nom.lower().strip()
if prenom:
prenom = prenom.lower().strip()
# Don't allow some special cars (eg used in sql regexps)
if scu.FORBIDDEN_CHARS_EXP.search(nom) or scu.FORBIDDEN_CHARS_EXP.search(prenom):
return False, 0
# Now count homonyms (dans tous les départements):
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
req = """SELECT id
FROM identite
WHERE lower(nom) ~ %(nom)s
and lower(prenom) ~ %(prenom)s
"""
if etudid:
req += " and id <> %(etudid)s"
cursor.execute(req, {"nom": nom, "prenom": prenom, "etudid": etudid})
res = cursor.dictfetchall()
return True, len(res)
# Liste homonymes (dans tous les départements):
query = Identite.query.filter(
Identite.nom.ilike(nom + "%"), Identite.prenom.ilike(prenom + "%")
)
if etudid is not None:
query = query.filter(Identite.id != etudid)
return True, query.all()
def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True):

View File

@ -409,18 +409,19 @@ def scolars_import_excel_file(
raise ScoValueError("Code INE dupliqué (%s)" % values["code_ine"])
# Check nom/prenom
ok = False
homonyms = []
if "nom" in values and "prenom" in values:
ok, nb_homonyms = sco_etud.check_nom_prenom(
cnx, nom=values["nom"], prenom=values["prenom"]
ok, homonyms = sco_etud.check_nom_prenom_homonyms(
nom=values["nom"], prenom=values["prenom"]
)
if not ok:
raise ScoValueError(
"nom ou prénom invalide sur la ligne %d" % (linenum)
f"nom ou prénom invalide sur la ligne {linenum}"
)
if nb_homonyms:
if homonyms:
np_imported_homonyms += 1
# Insert in DB tables
formsemestre_id_etud = _import_one_student(
_import_one_student(
cnx,
formsemestre_id,
values,
@ -431,11 +432,11 @@ def scolars_import_excel_file(
)
# Verification proportion d'homonymes: si > 10%, abandonne
log("scolars_import_excel_file: detected %d homonyms" % np_imported_homonyms)
log(f"scolars_import_excel_file: detected {np_imported_homonyms} homonyms")
if check_homonyms and np_imported_homonyms > len(created_etudids) / 10:
log("scolars_import_excel_file: too many homonyms")
raise ScoValueError(
"Il y a trop d'homonymes (%d étudiants)" % np_imported_homonyms
f"Il y a trop d'homonymes ({np_imported_homonyms} étudiants)"
)
except:
cnx.rollback()
@ -444,7 +445,7 @@ def scolars_import_excel_file(
# here we try to remove all created students
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
for etudid in created_etudids:
log("scolars_import_excel_file: deleting etudid=%s" % etudid)
log(f"scolars_import_excel_file: deleting etudid={etudid}")
cursor.execute(
"delete from notes_moduleimpl_inscription where etudid=%(etudid)s",
{"etudid": etudid},

View File

@ -1701,10 +1701,8 @@ function deleteJustificatif(justif_id) {
function errorAlert() {
const html = `
<h3>Avez vous les droits suffisant pour cette action ?</h3>
<p>Si c'est bien le cas : demandez de l'aide sur le canal Assistance de ScoDoc</p>
<br>
<p><i>pour les développeurs : l'erreur est affichée dans la console JS</i></p>
<h4>Il peut s'agir d'un problème de droits, ou d'une modification survenue sur le serveur.</h4>
<p>Si le problème persiste, demandez de l'aide sur le Discord d'assistance de ScoDoc</p>
`;
const div = document.createElement("div");

View File

@ -54,6 +54,7 @@ from app.decorators import (
permission_required_compat_scodoc7,
)
from app.models import (
Admission,
Departement,
FormSemestre,
Identite,
@ -1374,11 +1375,9 @@ def _etudident_create_or_edit_form(edit):
# setup form init values
if not etudid:
raise ValueError("missing etudid parameter")
etud_o: Identite = Identite.get_etud(etudid)
descr.append(("etudid", {"default": etudid, "input_type": "hidden"}))
H.append(
'<h2>Modification d\'un étudiant (<a href="%s">fiche</a>)</h2>'
% url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
H.append(f"""<h2>Modification des données de {etud_o.html_link_fiche()}</h2>""")
initvalues = sco_etud.etudident_list(cnx, {"etudid": etudid})
assert len(initvalues) == 1
initvalues = initvalues[0]
@ -1724,9 +1723,10 @@ def _etudident_create_or_edit_form(edit):
etudid = tf[2]["etudid"]
else:
etudid = None
ok, NbHomonyms = sco_etud.check_nom_prenom(
cnx, nom=tf[2]["nom"], prenom=tf[2]["prenom"], etudid=etudid
ok, homonyms = sco_etud.check_nom_prenom_homonyms(
nom=tf[2]["nom"], prenom=tf[2]["prenom"], etudid=etudid
)
nb_homonyms = len(homonyms)
if not ok:
return (
"\n".join(H)
@ -1736,16 +1736,29 @@ def _etudident_create_or_edit_form(edit):
+ A
+ F
)
# log('NbHomonyms=%s' % NbHomonyms)
if not tf[2]["dont_check_homonyms"] and NbHomonyms > 0:
if not tf[2]["dont_check_homonyms"] and nb_homonyms > 0:
homonyms_html = f"""
<div class="homonyms"
style="border-radius: 8px; border: 1px solid black; background-color: #fdd6ad; padding: 8px; max-width: 80%;">
<div><b>Homonymes</b> (dans tous les départements)</div>
<ul>
<li>{'</li><li>'.join( [ '<b style="margin-right: 2em;">' + e.departement.acronym + "</b>" + e.html_link_fiche() for e in homonyms ])}
</ul>
</div>
"""
return (
"\n".join(H)
+ tf_error_message(
"""Attention: il y a déjà un étudiant portant des noms et prénoms proches. Vous pouvez forcer la présence d'un homonyme en cochant "autoriser les homonymes" en bas du formulaire."""
"""Attention: il y a déjà un étudiant portant des noms et prénoms proches
(voir liste en bas de page).
Vous pouvez forcer la présence d'un homonyme en cochant
"autoriser les homonymes" en bas du formulaire.
"""
)
+ tf[1]
+ "<p>"
+ A
+ homonyms_html
+ F
)
@ -1754,10 +1767,13 @@ def _etudident_create_or_edit_form(edit):
etudid = etud["etudid"]
else:
# modif d'un etudiant
etud_o = db.session.get(Identite, tf[2]["etudid"])
etud_o.from_dict(tf[2])
db.session.add(etud_o)
admission = etud_o.admission.first()
if admission is None:
# ? ne devrait pas arriver mais...
admission = Admission()
admission.etudid = etud_o.id
admission.from_dict(tf[2])
db.session.add(admission)
db.session.commit()