forked from ScoDoc/ScoDoc
- 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:
parent
574fae2170
commit
3fdef1d4a0
@ -44,4 +44,8 @@ def get_etud(etudid=None, nip=None, ine=None) -> models.Identite:
|
|||||||
query = query.join(Departement).filter(
|
query = query.join(Departement).filter(
|
||||||
or_(Departement.acronym == acronym for acronym in allowed_depts)
|
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
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
from app import db
|
||||||
|
|
||||||
CODE_STR_LEN = 16 # chaine pour les codes
|
CODE_STR_LEN = 16 # chaine pour les codes
|
||||||
SHORT_STR_LEN = 32 # courtes chaine, eg acronymes
|
SHORT_STR_LEN = 32 # courtes chaine, eg acronymes
|
||||||
@ -21,6 +22,67 @@ convention = {
|
|||||||
|
|
||||||
metadata_obj = sqlalchemy.MetaData(naming_convention=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.absences import Absence, AbsenceNotification, BilletAbsence
|
||||||
from app.models.departements import Departement
|
from app.models.departements import Departement
|
||||||
from app.models.etudiants import (
|
from app.models.etudiants import (
|
||||||
|
@ -88,7 +88,7 @@ class Identite(db.Model):
|
|||||||
|
|
||||||
def clone(self, not_copying=(), new_dept_id: int = None):
|
def clone(self, not_copying=(), new_dept_id: int = None):
|
||||||
"""Clone, not copying the given attrs
|
"""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.
|
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.
|
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:
|
for k in not_copying:
|
||||||
d.pop(k, None)
|
d.pop(k, None)
|
||||||
copy = self.__class__(**d)
|
copy = self.__class__(**d)
|
||||||
copy.adresses = [adr.clone() for adr in self.adresses]
|
|
||||||
db.session.add(copy)
|
db.session.add(copy)
|
||||||
|
copy.adresses = [adr.clone() for adr in self.adresses]
|
||||||
|
for admission in self.admission:
|
||||||
|
copy.admission.append(admission.clone())
|
||||||
log(
|
log(
|
||||||
f"cloning etud <{self.id} {self.nom!r} {self.prenom!r}> in dept_id={new_dept_id}"
|
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:
|
def html_link_fiche(self) -> str:
|
||||||
"lien vers la fiche"
|
"lien vers la fiche"
|
||||||
return f"""<a class="stdlink" href="{
|
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>"""
|
}">{self.nomprenom}</a>"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -677,7 +679,7 @@ def pivot_year(y) -> int:
|
|||||||
return y
|
return y
|
||||||
|
|
||||||
|
|
||||||
class Adresse(db.Model):
|
class Adresse(db.Model, models.ScoDocModel):
|
||||||
"""Adresse d'un étudiant
|
"""Adresse d'un étudiant
|
||||||
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule)
|
(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)
|
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):
|
def to_dict(self, convert_nulls_to_str=False):
|
||||||
"""Représentation dictionnaire,"""
|
"""Représentation dictionnaire,"""
|
||||||
e = dict(self.__dict__)
|
e = dict(self.__dict__)
|
||||||
@ -726,7 +715,7 @@ class Adresse(db.Model):
|
|||||||
return e
|
return e
|
||||||
|
|
||||||
|
|
||||||
class Admission(db.Model):
|
class Admission(db.Model, models.ScoDocModel):
|
||||||
"""Informations liées à l'admission d'un étudiant"""
|
"""Informations liées à l'admission d'un étudiant"""
|
||||||
|
|
||||||
__tablename__ = "admissions"
|
__tablename__ = "admissions"
|
||||||
@ -818,16 +807,6 @@ class Admission(db.Model):
|
|||||||
args_dict[key] = value
|
args_dict[key] = value
|
||||||
return args_dict
|
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
|
# Suivi scolarité / débouchés
|
||||||
class ItemSuivi(db.Model):
|
class ItemSuivi(db.Model):
|
||||||
|
@ -267,33 +267,30 @@ def identite_edit_nocheck(cnx, args):
|
|||||||
db.session.commit()
|
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.
|
"""Check if nom and prenom are valid.
|
||||||
Also check for duplicates (homonyms), excluding etudid :
|
Also check for duplicates (homonyms), excluding etudid :
|
||||||
in general, homonyms are allowed, but it may be useful to generate a warning.
|
in general, homonyms are allowed, but it may be useful to generate a warning.
|
||||||
Returns:
|
Returns:
|
||||||
True | False, NbHomonyms
|
True | False, homonyms
|
||||||
"""
|
"""
|
||||||
if not nom or (not prenom and not scu.CONFIG.ALLOW_NULL_PRENOM):
|
if not nom or (not prenom and not scu.CONFIG.ALLOW_NULL_PRENOM):
|
||||||
return False, 0
|
return False, []
|
||||||
nom = nom.lower().strip()
|
nom = nom.lower().strip()
|
||||||
if prenom:
|
if prenom:
|
||||||
prenom = prenom.lower().strip()
|
prenom = prenom.lower().strip()
|
||||||
# Don't allow some special cars (eg used in sql regexps)
|
# 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):
|
if scu.FORBIDDEN_CHARS_EXP.search(nom) or scu.FORBIDDEN_CHARS_EXP.search(prenom):
|
||||||
return False, 0
|
return False, 0
|
||||||
# Now count homonyms (dans tous les départements):
|
# Liste homonymes (dans tous les départements):
|
||||||
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
|
query = Identite.query.filter(
|
||||||
req = """SELECT id
|
Identite.nom.ilike(nom + "%"), Identite.prenom.ilike(prenom + "%")
|
||||||
FROM identite
|
)
|
||||||
WHERE lower(nom) ~ %(nom)s
|
if etudid is not None:
|
||||||
and lower(prenom) ~ %(prenom)s
|
query = query.filter(Identite.id != etudid)
|
||||||
"""
|
return True, query.all()
|
||||||
if etudid:
|
|
||||||
req += " and id <> %(etudid)s"
|
|
||||||
cursor.execute(req, {"nom": nom, "prenom": prenom, "etudid": etudid})
|
|
||||||
res = cursor.dictfetchall()
|
|
||||||
return True, len(res)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True):
|
def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True):
|
||||||
|
@ -409,18 +409,19 @@ def scolars_import_excel_file(
|
|||||||
raise ScoValueError("Code INE dupliqué (%s)" % values["code_ine"])
|
raise ScoValueError("Code INE dupliqué (%s)" % values["code_ine"])
|
||||||
# Check nom/prenom
|
# Check nom/prenom
|
||||||
ok = False
|
ok = False
|
||||||
|
homonyms = []
|
||||||
if "nom" in values and "prenom" in values:
|
if "nom" in values and "prenom" in values:
|
||||||
ok, nb_homonyms = sco_etud.check_nom_prenom(
|
ok, homonyms = sco_etud.check_nom_prenom_homonyms(
|
||||||
cnx, nom=values["nom"], prenom=values["prenom"]
|
nom=values["nom"], prenom=values["prenom"]
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise ScoValueError(
|
raise ScoValueError(
|
||||||
"nom ou prénom invalide sur la ligne %d" % (linenum)
|
f"nom ou prénom invalide sur la ligne {linenum}"
|
||||||
)
|
)
|
||||||
if nb_homonyms:
|
if homonyms:
|
||||||
np_imported_homonyms += 1
|
np_imported_homonyms += 1
|
||||||
# Insert in DB tables
|
# Insert in DB tables
|
||||||
formsemestre_id_etud = _import_one_student(
|
_import_one_student(
|
||||||
cnx,
|
cnx,
|
||||||
formsemestre_id,
|
formsemestre_id,
|
||||||
values,
|
values,
|
||||||
@ -431,11 +432,11 @@ def scolars_import_excel_file(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verification proportion d'homonymes: si > 10%, abandonne
|
# Verification proportion d'homonymes: si > 10%, abandonne
|
||||||
log("scolars_import_excel_file: detected %d homonyms" % 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:
|
if check_homonyms and np_imported_homonyms > len(created_etudids) / 10:
|
||||||
log("scolars_import_excel_file: too many homonyms")
|
log("scolars_import_excel_file: too many homonyms")
|
||||||
raise ScoValueError(
|
raise ScoValueError(
|
||||||
"Il y a trop d'homonymes (%d étudiants)" % np_imported_homonyms
|
f"Il y a trop d'homonymes ({np_imported_homonyms} étudiants)"
|
||||||
)
|
)
|
||||||
except:
|
except:
|
||||||
cnx.rollback()
|
cnx.rollback()
|
||||||
@ -444,7 +445,7 @@ def scolars_import_excel_file(
|
|||||||
# here we try to remove all created students
|
# here we try to remove all created students
|
||||||
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
|
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
|
||||||
for etudid in created_etudids:
|
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(
|
cursor.execute(
|
||||||
"delete from notes_moduleimpl_inscription where etudid=%(etudid)s",
|
"delete from notes_moduleimpl_inscription where etudid=%(etudid)s",
|
||||||
{"etudid": etudid},
|
{"etudid": etudid},
|
||||||
|
@ -1701,10 +1701,8 @@ function deleteJustificatif(justif_id) {
|
|||||||
|
|
||||||
function errorAlert() {
|
function errorAlert() {
|
||||||
const html = `
|
const html = `
|
||||||
<h3>Avez vous les droits suffisant pour cette action ?</h3>
|
<h4>Il peut s'agir d'un problème de droits, ou d'une modification survenue sur le serveur.</h4>
|
||||||
<p>Si c'est bien le cas : demandez de l'aide sur le canal Assistance de ScoDoc</p>
|
<p>Si le problème persiste, demandez de l'aide sur le Discord d'assistance de ScoDoc</p>
|
||||||
<br>
|
|
||||||
<p><i>pour les développeurs : l'erreur est affichée dans la console JS</i></p>
|
|
||||||
|
|
||||||
`;
|
`;
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
|
@ -54,6 +54,7 @@ from app.decorators import (
|
|||||||
permission_required_compat_scodoc7,
|
permission_required_compat_scodoc7,
|
||||||
)
|
)
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
Admission,
|
||||||
Departement,
|
Departement,
|
||||||
FormSemestre,
|
FormSemestre,
|
||||||
Identite,
|
Identite,
|
||||||
@ -1374,11 +1375,9 @@ def _etudident_create_or_edit_form(edit):
|
|||||||
# setup form init values
|
# setup form init values
|
||||||
if not etudid:
|
if not etudid:
|
||||||
raise ValueError("missing etudid parameter")
|
raise ValueError("missing etudid parameter")
|
||||||
|
etud_o: Identite = Identite.get_etud(etudid)
|
||||||
descr.append(("etudid", {"default": etudid, "input_type": "hidden"}))
|
descr.append(("etudid", {"default": etudid, "input_type": "hidden"}))
|
||||||
H.append(
|
H.append(f"""<h2>Modification des données de {etud_o.html_link_fiche()}</h2>""")
|
||||||
'<h2>Modification d\'un étudiant (<a href="%s">fiche</a>)</h2>'
|
|
||||||
% url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
|
|
||||||
)
|
|
||||||
initvalues = sco_etud.etudident_list(cnx, {"etudid": etudid})
|
initvalues = sco_etud.etudident_list(cnx, {"etudid": etudid})
|
||||||
assert len(initvalues) == 1
|
assert len(initvalues) == 1
|
||||||
initvalues = initvalues[0]
|
initvalues = initvalues[0]
|
||||||
@ -1724,9 +1723,10 @@ def _etudident_create_or_edit_form(edit):
|
|||||||
etudid = tf[2]["etudid"]
|
etudid = tf[2]["etudid"]
|
||||||
else:
|
else:
|
||||||
etudid = None
|
etudid = None
|
||||||
ok, NbHomonyms = sco_etud.check_nom_prenom(
|
ok, homonyms = sco_etud.check_nom_prenom_homonyms(
|
||||||
cnx, nom=tf[2]["nom"], prenom=tf[2]["prenom"], etudid=etudid
|
nom=tf[2]["nom"], prenom=tf[2]["prenom"], etudid=etudid
|
||||||
)
|
)
|
||||||
|
nb_homonyms = len(homonyms)
|
||||||
if not ok:
|
if not ok:
|
||||||
return (
|
return (
|
||||||
"\n".join(H)
|
"\n".join(H)
|
||||||
@ -1736,16 +1736,29 @@ def _etudident_create_or_edit_form(edit):
|
|||||||
+ A
|
+ A
|
||||||
+ F
|
+ F
|
||||||
)
|
)
|
||||||
# log('NbHomonyms=%s' % NbHomonyms)
|
if not tf[2]["dont_check_homonyms"] and nb_homonyms > 0:
|
||||||
if not tf[2]["dont_check_homonyms"] and NbHomonyms > 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 (
|
return (
|
||||||
"\n".join(H)
|
"\n".join(H)
|
||||||
+ tf_error_message(
|
+ 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]
|
+ tf[1]
|
||||||
+ "<p>"
|
+ "<p>"
|
||||||
+ A
|
+ A
|
||||||
|
+ homonyms_html
|
||||||
+ F
|
+ F
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1754,10 +1767,13 @@ def _etudident_create_or_edit_form(edit):
|
|||||||
etudid = etud["etudid"]
|
etudid = etud["etudid"]
|
||||||
else:
|
else:
|
||||||
# modif d'un etudiant
|
# modif d'un etudiant
|
||||||
etud_o = db.session.get(Identite, tf[2]["etudid"])
|
|
||||||
etud_o.from_dict(tf[2])
|
etud_o.from_dict(tf[2])
|
||||||
db.session.add(etud_o)
|
db.session.add(etud_o)
|
||||||
admission = etud_o.admission.first()
|
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])
|
admission.from_dict(tf[2])
|
||||||
db.session.add(admission)
|
db.session.add(admission)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
Loading…
Reference in New Issue
Block a user