diff --git a/app/auth/models.py b/app/auth/models.py index 073f687e9a019907174e7cb7245e86d20792f3ba..3115119135f9d88a94ec991a8f1f0cb4d6f60bce 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -353,8 +353,8 @@ class User(UserMixin, db.Model): return mails # Permissions management: - def has_permission(self, perm: int, dept=False): - """Check if user has permission `perm` in given `dept`. + def has_permission(self, perm: int, dept: str = False): + """Check if user has permission `perm` in given `dept` (acronym). Similar to Zope ScoDoc7 `has_permission`` Args: diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 82b6d3dc3ee57f082538fd73e06419f68e29b8dc..0435281a368f39f4c95db5c4c6259561cba3ab09 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -70,16 +70,11 @@ class Assiduite(db.Model): def to_dict(self, format_api=True) -> dict: """Retourne la représentation json de l'assiduité""" etat = self.etat - username = self.user_id + user: User = None if format_api: etat = EtatAssiduite.inverse().get(self.etat).name if self.user_id is not None: - user: User = db.session.get(User, self.user_id) - - if user is None: - username = "Non renseigné" - else: - username = user.get_prenomnom() + user = db.session.get(User, self.user_id) data = { "assiduite_id": self.id, "etudid": self.etudid, @@ -90,7 +85,8 @@ class Assiduite(db.Model): "etat": etat, "desc": self.description, "entry_date": self.entry_date, - "user_id": username, + "user_id": None if user is None else user.id, # l'uid + "user_name": None if user is None else user.user_name, # le login "est_just": self.est_just, "external_data": self.external_data, } diff --git a/app/models/departements.py b/app/models/departements.py index 6f3f775989708296827824e1a2a54fa053508dda..d4005d24d833acd41d792b5538fbfbd76de0923a 100644 --- a/app/models/departements.py +++ b/app/models/departements.py @@ -80,8 +80,6 @@ class Departement(db.Model): def create_dept(acronym: str, visible=True) -> Departement: "Create new departement" - from app.models import ScoPreference - if Departement.invalid_dept_acronym(acronym): raise ScoValueError("acronyme departement invalide") existing = Departement.query.filter_by(acronym=acronym).count() diff --git a/app/models/etudiants.py b/app/models/etudiants.py index a451526f7de1182b205e228453da234554fcbca0..6dc036782de2e05c043cfb5e865e878f9691b736 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -86,6 +86,50 @@ class Identite(db.Model): f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>" ) + def clone(self, not_copying=(), new_dept_id: int = None): + """Clone, not copying the given attrs + Clone aussi les adresses. + 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. + """ + if new_dept_id == self.dept_id: + raise ScoValueError( + "clonage étudiant: le département destination est identique à celui de départ" + ) + # Vérifie les contraintes d'unicité + # ("dept_id", "code_nip") et ("dept_id", "code_ine") + if ( + self.code_nip is not None + and Identite.query.filter_by( + dept_id=new_dept_id, code_nip=self.code_nip + ).count() + > 0 + ) or ( + self.code_ine is not None + and Identite.query.filter_by( + dept_id=new_dept_id, code_ine=self.code_ine + ).count() + > 0 + ): + raise ScoValueError( + """clonage étudiant: un étudiant de même code existe déjà + dans le département destination""" + ) + d = dict(self.__dict__) + d.pop("id", None) # get rid of id + d.pop("_sa_instance_state", None) # get rid of SQLAlchemy special attr + d.pop("departement", None) # relationship + d["dept_id"] = new_dept_id + 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) + log( + f"cloning etud <{self.id} {self.nom!r} {self.prenom!r}> in dept_id={new_dept_id}" + ) + return copy + def html_link_fiche(self) -> str: "lien vers la fiche" return f"""<a class="stdlink" href="{ @@ -660,6 +704,19 @@ 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__) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 3f8f61f13099993cddbd25686a3905ba44beaa49..a5b20b1248ee97d38347b72af354936456a992d3 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -177,11 +177,15 @@ class FormSemestre(db.Model): """ @classmethod - def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre": - """ "FormSemestre ou 404, cherche uniquement dans le département courant""" + def get_formsemestre( + cls, formsemestre_id: int, dept_id: int = None + ) -> "FormSemestre": + """ "FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant""" if g.scodoc_dept: + dept_id = dept_id if dept_id is not None else g.scodoc_dept_id + if dept_id is not None: return cls.query.filter_by( - id=formsemestre_id, dept_id=g.scodoc_dept_id + id=formsemestre_id, dept_id=dept_id ).first_or_404() return cls.query.filter_by(id=formsemestre_id).first_or_404() diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index b673a3eb2df8416604ae71f2902d754ceaca5295..b0334ae78c9b2377f68dc167361285ce0ebe5577 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -237,7 +237,7 @@ class TF(object): def setdefaultvalues(self): "set default values and convert numbers to strings" - for (field, descr) in self.formdescription: + for field, descr in self.formdescription: # special case for boolcheckbox if descr.get("input_type", None) == "boolcheckbox" and self.submitted(): if field not in self.values: @@ -278,7 +278,7 @@ class TF(object): "check values. Store .result and returns msg" ok = 1 msg = [] - for (field, descr) in self.formdescription: + for field, descr in self.formdescription: val = self.values[field] # do not check "unckecked" items if descr.get("withcheckbox", False): @@ -287,7 +287,7 @@ class TF(object): # null values allow_null = descr.get("allow_null", True) if not allow_null: - if val == "" or val == None: + if val is None or (isinstance(val, str) and not val.strip()): msg.append( "Le champ '%s' doit être renseigné" % descr.get("title", field) ) @@ -871,7 +871,7 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); def _ReadOnlyVersion(self, formdescription): "Generate HTML for read-only view of the form" R = ['<table class="tf-ro">'] - for (field, descr) in formdescription: + for field, descr in formdescription: R.append(self._ReadOnlyElement(field, descr)) R.append("</table>") return R diff --git a/app/scodoc/sco_archives_justificatifs.py b/app/scodoc/sco_archives_justificatifs.py index c6dbd4472b057a3daba5e4583e79d0c3a628ddef..125569d0190ea131f59757c536043a18927d3b32 100644 --- a/app/scodoc/sco_archives_justificatifs.py +++ b/app/scodoc/sco_archives_justificatifs.py @@ -19,26 +19,35 @@ class Trace: """gestionnaire de la trace des fichiers justificatifs""" def __init__(self, path: str) -> None: - log(f"init Trace {path}") self.path: str = path + "/_trace.csv" self.content: dict[str, list[datetime, datetime, str]] = {} self.import_from_file() def import_from_file(self): """import trace from file""" - if os.path.isfile(self.path): - with open(self.path, "r", encoding="utf-8") as file: + + def import_from_csv(path): + with open(path, "r", encoding="utf-8") as file: for line in file.readlines(): csv = line.split(",") if len(csv) < 4: continue fname: str = csv[0] + if fname not in os.listdir(self.path.replace("/_trace.csv", "")): + continue entry_date: datetime = is_iso_formated(csv[1], True) delete_date: datetime = is_iso_formated(csv[2], True) user_id = csv[3] - self.content[fname] = [entry_date, delete_date, user_id] + if os.path.isfile(self.path): + import_from_csv(self.path) + else: + parent_dir: str = self.path[: self.path.rfind("/", 0, self.path.rfind("/"))] + if os.path.isfile(parent_dir + "/_trace.csv"): + import_from_csv(parent_dir + "/_trace.csv") + self.save_trace() + def set_trace(self, *fnames: str, mode: str = "entry", current_user: str = None): """Ajoute une trace du fichier donné mode : entry / delete @@ -57,9 +66,11 @@ class Trace: ) self.save_trace() - def save_trace(self): + def save_trace(self, new_path: str = None): """Enregistre la trace dans le fichier _trace.csv""" lines: list[str] = [] + if new_path is not None: + self.path = new_path for fname, traced in self.content.items(): date_fin: datetime or None = traced[1].isoformat() if traced[1] else "None" if traced[0] is not None: @@ -126,7 +137,6 @@ class JustificatifArchiver(BaseArchiver): ) fname: str = self.store(archive_id, filename, data, dept_id=etud.dept_id) - log(f"obj_dir {self.get_obj_dir(etud.id, dept_id=etud.dept_id)} | {archive_id}") trace = Trace(archive_id) trace.set_trace(fname, mode="entry") if user_id is not None: diff --git a/app/scodoc/sco_bulletins_pdf.py b/app/scodoc/sco_bulletins_pdf.py index a3786c828cdf7b816e7c78a34ac25bd43d4e5866..61a44cf0061287805691ca0eceb0f33f55c6f2cb 100644 --- a/app/scodoc/sco_bulletins_pdf.py +++ b/app/scodoc/sco_bulletins_pdf.py @@ -136,7 +136,7 @@ class WrapDict(object): try: value = self.dict[key] except KeyError: - raise + return f"XXX {key} invalide XXX" if value is None: return self.none_value return value diff --git a/app/scodoc/sco_evaluation_db.py b/app/scodoc/sco_evaluation_db.py index 38110c6c8da3fc9817c215176377935a4a622d84..2d58b5773487f4e93db64d23a812dd211dd7b153 100644 --- a/app/scodoc/sco_evaluation_db.py +++ b/app/scodoc/sco_evaluation_db.py @@ -31,6 +31,7 @@ import flask from flask import url_for, g from flask_login import current_user +import sqlalchemy as sa from app import db, log @@ -72,7 +73,7 @@ _evaluationEditor = ndb.EditableTable( ) -def get_evaluation_dict(args: dict) -> list[dict]: +def get_evaluations_dict(args: dict) -> list[dict]: """Liste evaluations, triées numero (or most recent date first). Fonction de transition pour ancien code ScoDoc7. @@ -83,7 +84,12 @@ def get_evaluation_dict(args: dict) -> list[dict]: 'descrheure' : ' de 15h00 à 16h30' """ # calcule duree (chaine de car.) de chaque evaluation et ajoute jour_iso, matin, apresmidi - return [e.to_dict() for e in Evaluation.query.filter_by(**args)] + return [ + e.to_dict() + for e in Evaluation.query.filter_by(**args).order_by( + sa.desc(Evaluation.numero), sa.desc(Evaluation.date_debut) + ) + ] def do_evaluation_list_in_formsemestre(formsemestre_id): @@ -91,7 +97,7 @@ def do_evaluation_list_in_formsemestre(formsemestre_id): mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) evals = [] for modimpl in mods: - evals += get_evaluation_dict(args={"moduleimpl_id": modimpl["moduleimpl_id"]}) + evals += get_evaluations_dict(args={"moduleimpl_id": modimpl["moduleimpl_id"]}) return evals @@ -161,7 +167,6 @@ def moduleimpl_evaluation_move(evaluation_id: int, after=0, redirect=1): (published) """ evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id) - moduleimpl_id = evaluation.moduleimpl_id redirect = int(redirect) # access: can change eval ? if not evaluation.moduleimpl.can_edit_evaluation(current_user): @@ -171,12 +176,12 @@ def moduleimpl_evaluation_move(evaluation_id: int, after=0, redirect=1): Evaluation.moduleimpl_evaluation_renumber( evaluation.moduleimpl, only_if_unumbered=True ) - e = get_evaluation_dict(args={"evaluation_id": evaluation_id})[0] + e = get_evaluations_dict(args={"evaluation_id": evaluation_id})[0] after = int(after) # 0: deplace avant, 1 deplace apres if after not in (0, 1): raise ValueError('invalid value for "after"') - mod_evals = get_evaluation_dict({"moduleimpl_id": e["moduleimpl_id"]}) + mod_evals = get_evaluations_dict({"moduleimpl_id": e["moduleimpl_id"]}) if len(mod_evals) > 1: idx = [p["evaluation_id"] for p in mod_evals].index(evaluation_id) neigh = None # object to swap with diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py index 7c5ffd4743ac37057f93043e151bf5969905890a..f6beeceb593328f826de5007a1e9bc984a7cd05e 100644 --- a/app/scodoc/sco_evaluations.py +++ b/app/scodoc/sco_evaluations.py @@ -133,7 +133,7 @@ def do_evaluation_etat( ) # { etudid : note } # ---- Liste des groupes complets et incomplets - E = sco_evaluation_db.get_evaluation_dict(args={"evaluation_id": evaluation_id})[0] + E = sco_evaluation_db.get_evaluations_dict(args={"evaluation_id": evaluation_id})[0] M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] is_malus = Mod["module_type"] == ModuleType.MALUS # True si module de malus diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 7b1ceb41a95af0ef41e311fe7b405514a42ee9b6..bd0f6f42aa37c790cbd49ff3e6339ef98ec0c092 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -1445,7 +1445,7 @@ def do_formsemestre_delete(formsemestre_id): mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) for mod in mods: # evaluations - evals = sco_evaluation_db.get_evaluation_dict( + evals = sco_evaluation_db.get_evaluations_dict( args={"moduleimpl_id": mod["moduleimpl_id"]} ) for e in evals: diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index 282b51957bf79072f0f857c57c1d25bbbe1cd6d2..7837c299f2a9de3250c174034bb202deeaf83509 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -275,14 +275,16 @@ def do_formsemestre_inscription_with_modules( etat=scu.INSCRIT, etape=None, method="inscription_with_modules", + dept_id: int = None, ): """Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS (donc sauf le sport) + Si dept_id est spécifié, utilise ce département au lieu du courant. """ group_ids = group_ids or [] if isinstance(group_ids, int): group_ids = [group_ids] - formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id) # inscription au semestre args = {"formsemestre_id": formsemestre_id, "etudid": etudid} if etat is not None: diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 1a0163b4ba3f0ef314275b76a1885c8f8cdf85d7..520af7e8f4e06eca4629e793ddd470ef0504783c 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -490,7 +490,7 @@ def retreive_formsemestre_from_request() -> int: modimpl = modimpl[0] formsemestre_id = modimpl["formsemestre_id"] elif "evaluation_id" in args: - E = sco_evaluation_db.get_evaluation_dict( + E = sco_evaluation_db.get_evaluations_dict( {"evaluation_id": args["evaluation_id"]} ) if not E: @@ -884,7 +884,7 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str: jour = datetime.date.today().isoformat(), group_ids=group.id, )}"> - <button>Visualiser l'assiduité</button></a> + <button>Visualiser</button></a> </div> <div> <a class="btn" href="{ diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index 6ac88dc51c0c28afad7008dbf8ade13b1d925f63..2f06599389c8d8064dcc6be63228a1745a8866d6 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -69,10 +69,10 @@ def do_evaluation_listenotes( mode = None if moduleimpl_id: mode = "module" - evals = sco_evaluation_db.get_evaluation_dict({"moduleimpl_id": moduleimpl_id}) + evals = sco_evaluation_db.get_evaluations_dict({"moduleimpl_id": moduleimpl_id}) elif evaluation_id: mode = "eval" - evals = sco_evaluation_db.get_evaluation_dict({"evaluation_id": evaluation_id}) + evals = sco_evaluation_db.get_evaluations_dict({"evaluation_id": evaluation_id}) else: raise ValueError("missing argument: evaluation or module") if not evals: diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 554ce78046ed1e01f56474ecec0c554984f011f0..4b62d3817689a6470af4438a45b6ced8c46d4b9f 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -642,6 +642,12 @@ def menus_etud(etudid): "args": {"etudid": etud["etudid"]}, "enabled": authuser.has_permission(Permission.ScoEtudInscrit), }, + { + "title": "Copier dans un autre département...", + "endpoint": "scolar.etud_copy_in_other_dept", + "args": {"etudid": etud["etudid"]}, + "enabled": authuser.has_permission(Permission.ScoEtudInscrit), + }, { "title": "Supprimer cet étudiant...", "endpoint": "scolar.etudident_delete", @@ -656,7 +662,9 @@ def menus_etud(etudid): }, ] - return htmlutils.make_menu("Étudiant", menuEtud, alone=True) + return htmlutils.make_menu( + "Étudiant", menuEtud, alone=True, css_class="menu-etudiant" + ) def etud_info_html(etudid, with_photo="1", debug=False): diff --git a/app/scodoc/sco_placement.py b/app/scodoc/sco_placement.py index d0cc4ca1b6729d7ed42d5a92a7f021aaae917efd..daaa2f1866e1fd2a8d973a5bb59cb6efaa4e5866 100644 --- a/app/scodoc/sco_placement.py +++ b/app/scodoc/sco_placement.py @@ -138,7 +138,7 @@ class PlacementForm(FlaskForm): def set_evaluation_infos(self, evaluation_id): """Initialise les données du formulaire avec les données de l'évaluation.""" - eval_data = sco_evaluation_db.get_evaluation_dict( + eval_data = sco_evaluation_db.get_evaluations_dict( {"evaluation_id": evaluation_id} ) if not eval_data: @@ -239,7 +239,7 @@ class PlacementRunner: self.groups_ids = [ gid if gid != TOUS else form.tous_id for gid in form["groups"].data ] - self.eval_data = sco_evaluation_db.get_evaluation_dict( + self.eval_data = sco_evaluation_db.get_evaluations_dict( {"evaluation_id": self.evaluation_id} )[0] self.groups = sco_groups.listgroups(self.groups_ids) diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py index a558bce129cd7d6193d3ad8a0d12a2a2d9988f77..203865eb735694ae08f736d2c9ce1823ae52b776 100644 --- a/app/scodoc/sco_report.py +++ b/app/scodoc/sco_report.py @@ -524,11 +524,11 @@ def table_suivi_cohorte( # 3-- Regroupe les semestres par date de debut P = [] # liste de periodsem - class periodsem(object): + class PeriodSem: pass # semestre de depart: - porigin = periodsem() + porigin = PeriodSem() d, m, y = [int(x) for x in sem["date_debut"].split("/")] porigin.datedebut = datetime.datetime(y, m, d) porigin.sems = [sem] @@ -543,7 +543,7 @@ def table_suivi_cohorte( merged = True break if not merged: - p = periodsem() + p = PeriodSem() p.datedebut = s["date_debut_dt"] p.sems = [s] P.append(p) @@ -743,7 +743,7 @@ def formsemestre_suivi_cohorte( civilite=None, statut="", only_primo=False, -): +) -> str: """Affiche suivi cohortes par numero de semestre""" annee_bac = str(annee_bac or "") annee_admission = str(annee_admission or "") @@ -794,14 +794,6 @@ def formsemestre_suivi_cohorte( '<p><a href="%s&percent=1">Afficher les résultats en pourcentages</a></p>' % burl ) - help = ( - pplink - + """ - <p class="help">Nombre d'étudiants dans chaque semestre. Les dates indiquées sont les dates approximatives de <b>début</b> des semestres (les semestres commençant à des dates proches sont groupés). Le nombre de diplômés est celui à la <b>fin</b> du semestre correspondant. Lorsqu'il y a moins de %s étudiants dans une case, vous pouvez afficher leurs noms en passant le curseur sur le chiffre.</p> -<p class="help">Les menus permettent de n'étudier que certaines catégories d'étudiants (titulaires d'un type de bac, garçons ou filles). La case "restreindre aux primo-entrants" permet de ne considérer que les étudiants qui n'ont jamais été inscrits dans ScoDoc avant le semestre considéré.</p> - """ - % (MAX_ETUD_IN_DESCR,) - ) H = [ html_sco_header.sco_header(page_title=tab.page_title), @@ -824,7 +816,20 @@ def formsemestre_suivi_cohorte( percent=percent, ), t, - help, + f"""{pplink} + <p class="help">Nombre d'étudiants dans chaque semestre. + Les dates indiquées sont les dates approximatives de <b>début</b> des semestres + (les semestres commençant à des dates proches sont groupés). Le nombre de diplômés + est celui à la <b>fin</b> du semestre correspondant. + Lorsqu'il y a moins de {MAX_ETUD_IN_DESCR} étudiants dans une case, vous pouvez + afficher leurs noms en passant le curseur sur le chiffre. + </p> + <p class="help">Les menus permettent de n'étudier que certaines catégories + d'étudiants (titulaires d'un type de bac, garçons ou filles). + La case "restreindre aux primo-entrants" permet de ne considérer que les étudiants + qui n'ont jamais été inscrits dans ScoDoc avant le semestre considéré. + </p> + """, expl, html_sco_header.sco_footer(), ] @@ -870,35 +875,33 @@ def _gen_form_selectetuds( else: selected = 'selected="selected"' F = [ - """<form id="f" method="get" action="%s"> + f"""<form id="f" method="get" action="{request.base_url}"> <p>Bac: <select name="bac" onchange="javascript: submit(this);"> - <option value="" %s>tous</option> + <option value="" {selected}>tous</option> """ - % (request.base_url, selected) ] for b in bacs: if bac == b: selected = 'selected="selected"' else: selected = "" - F.append('<option value="%s" %s>%s</option>' % (b, selected, b)) + F.append(f'<option value="{b}" {selected}>{b}</option>') F.append("</select>") if bacspecialite: selected = "" else: selected = 'selected="selected"' F.append( - """ Bac/Specialité: <select name="bacspecialite" onchange="javascript: submit(this);"> - <option value="" %s>tous</option> + f""" Bac/Specialité: <select name="bacspecialite" onchange="javascript: submit(this);"> + <option value="" {selected}>tous</option> """ - % selected ) for b in bacspecialites: if bacspecialite == b: selected = 'selected="selected"' else: selected = "" - F.append('<option value="%s" %s>%s</option>' % (b, selected, b)) + F.append(f'<option value="{b}" {selected}>{b}</option>') F.append("</select>") # F.append( @@ -910,46 +913,44 @@ def _gen_form_selectetuds( ) # F.append( - """ Genre: <select name="civilite" onchange="javascript: submit(this);"> - <option value="" %s>tous</option> + f""" Genre: <select name="civilite" onchange="javascript: submit(this);"> + <option value="" {selected}>tous</option> """ - % selected ) for b in civilites: if civilite == b: selected = 'selected="selected"' else: selected = "" - F.append('<option value="%s" %s>%s</option>' % (b, selected, b)) + F.append(f'<option value="{b}" {selected}>{b}</option>') F.append("</select>") F.append( - """ Statut: <select name="statut" onchange="javascript: submit(this);"> - <option value="" %s>tous</option> + f""" Statut: <select name="statut" onchange="javascript: submit(this);"> + <option value="" {selected}>tous</option> """ - % selected ) for b in statuts: if statut == b: selected = 'selected="selected"' else: selected = "" - F.append('<option value="%s" %s>%s</option>' % (b, selected, b)) + F.append(f'<option value="{b}" {selected}>{b}</option>') F.append("</select>") - if only_primo: - checked = 'checked="1"' - else: - checked = "" F.append( - '<br><input type="checkbox" name="only_primo" onchange="javascript: submit(this);" %s/>Restreindre aux primo-entrants' - % checked + f"""<br> + <input type="checkbox" name="only_primo" + onchange="javascript: submit(this);" + {'checked="1"' if only_primo else ""}/>Restreindre aux primo-entrants + <input type="hidden" name="formsemestre_id" value="{formsemestre_id}"/> + <input type="hidden" name="percent" value="{percent}"/> + + </p> + </form> + """ ) - F.append( - '<input type="hidden" name="formsemestre_id" value="%s"/>' % formsemestre_id - ) - F.append('<input type="hidden" name="percent" value="%s"/>' % percent) - F.append("</p></form>") + return "\n".join(F) @@ -964,7 +965,7 @@ def _gen_select_annee(field, values, value) -> str: return menu_html + "</select>" -def _descr_etud_set(etudids): +def _descr_etud_set(etudids) -> str: "textual html description of a set of etudids" etuds = [] for etudid in etudids: @@ -980,15 +981,22 @@ def _count_dem_reo(formsemestre_id, etudids): "count nb of demissions and reorientation in this etud set" formsemestre = FormSemestre.get_formsemestre(formsemestre_id) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - + validations_annuelles = nt.get_validations_annee() if nt.is_apc else {} dems = set() reos = set() for etudid in etudids: if nt.get_etud_etat(etudid) == "D": dems.add(etudid) - dec = nt.get_etud_decision_sem(etudid) - if dec and dec["code"] in codes_cursus.CODES_SEM_REO: - reos.add(etudid) + if nt.is_apc: + # BUT: utilise les validations annuelles + validation = validations_annuelles.get(etudid) + if validation and validation.code in codes_cursus.CODES_SEM_REO: + reos.add(etudid) + else: + # Autres formations: validations de semestres + dec = nt.get_etud_decision_sem(etudid) + if dec and dec["code"] in codes_cursus.CODES_SEM_REO: + reos.add(etudid) return dems, reos diff --git a/app/scodoc/sco_undo_notes.py b/app/scodoc/sco_undo_notes.py index 61b84c0c4a7b048fb71bf7cb7b58ef1cd3a85527..dd1895048529087c458a4bd9daadd45d3f57cfa6 100644 --- a/app/scodoc/sco_undo_notes.py +++ b/app/scodoc/sco_undo_notes.py @@ -149,7 +149,7 @@ def list_operations(evaluation_id): def evaluation_list_operations(evaluation_id): """Page listing operations on evaluation""" - E = sco_evaluation_db.get_evaluation_dict({"evaluation_id": evaluation_id})[0] + E = sco_evaluation_db.get_evaluations_dict({"evaluation_id": evaluation_id})[0] M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] Ops = list_operations(evaluation_id) diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index baf81050392de74359ee4cde9f0f3ae32be530e3..500b41094ce2cffdb08de279985eb512508f772e 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -10,12 +10,12 @@ html, body { - margin: 0; - padding: 0; - width: 100%; background-color: var(--sco-color-background); font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 12pt; + margin: 0; + padding: 0; + width: 100%; } @media print { @@ -24,6 +24,10 @@ body { } } +div.container { + margin-bottom: 24px; +} + h1, h2, h3 { @@ -1672,6 +1676,10 @@ formsemestre_page_title .lock img { font-family: Arial, Helvetica, sans-serif; } +.menu-etudiant>li { + width: 200px !important; +} + span.inscr_addremove_menu { width: 150px; } diff --git a/app/templates/assiduites/pages/ajout_justificatif.j2 b/app/templates/assiduites/pages/ajout_justificatif.j2 index 28c4b6159abcb06e56488559a4d6677f26a915f9..c4885b3f8e61549b63746b0a913aed6789e0b75e 100644 --- a/app/templates/assiduites/pages/ajout_justificatif.j2 +++ b/app/templates/assiduites/pages/ajout_justificatif.j2 @@ -219,20 +219,27 @@ } function dayOnly() { + const { deb, fin } = getDates(); if (document.getElementById('justi_journee').checked) { document.getElementById("justi_date_debut").type = "date" + document.getElementById("justi_date_debut").value = deb.slice(0, deb.indexOf('T')) + document.getElementById("justi_date_fin").type = "date" + document.getElementById("justi_date_fin").value = fin.slice(0, fin.indexOf('T')) } else { document.getElementById("justi_date_debut").type = "datetime-local" + document.getElementById("justi_date_debut").value = `${deb}T${assi_morning}` + document.getElementById("justi_date_fin").type = "datetime-local" + document.getElementById("justi_date_fin").value = `${fin}T${assi_evening}` } } function getDates() { if (document.querySelector('.page #justi_journee').checked) { const date_str_deb = document.querySelector(".page #justi_date_debut").value - const date_str_fin = document.querySelector(".page #justi_date_debut").value + const date_str_fin = document.querySelector(".page #justi_date_fin").value diff --git a/app/templates/scolar/etud_copy_in_other_dept.j2 b/app/templates/scolar/etud_copy_in_other_dept.j2 new file mode 100644 index 0000000000000000000000000000000000000000..55b4f77ffdce21495d0d57346f19d9b35ea2ab90 --- /dev/null +++ b/app/templates/scolar/etud_copy_in_other_dept.j2 @@ -0,0 +1,99 @@ +{# -*- mode: jinja-html -*- #} +{% extends 'base.j2' %} + +{% block styles %} +{{super()}} +<style> +.dept-name { + font-size: 120%; + font-weight: bold; +} +.dept { + background-color: bisque; + border-radius: 12px; + padding: 8px; + margin-bottom: 12px; +} +.dept label { + font-weight: normal; +} +button[name="action"] { + margin-right: 32px; +} +#submit-button:disabled { + background-color: #CCCCCC; + color: #888888; + cursor: not-allowed; + border: 1px solid #AAAAAA; +} +</style> +{% endblock %} + +{% block app_content %} + +<h2>Création d'une copie de {{ etud.html_link_fiche() | safe }}</h2> + +<div class="help"> + +<p>Utiliser cette page lorsqu'un étudinat change de département. ScoDoc gère +séparéement les étudiants des départements. Il faut donc dans ce cas +exceptionnel créer une copie de l'étudiant et l'inscrire dans un semestre de son +nouveau département. Seules les donénes sur l'identité de l'étudiant (état +civil, adresse, ...) sont dupliquées. Dans le noveau département, les résultats +obtenus dans le département d'origine ne seront pas visibles. +</p> + +<p>Si des UEs ou compétences de l'ancien département doivent être validées dans +le nouveau, il faudra utiliser ensuite une "validation d'UE antérieure". +</p> + +<p>Attention: seuls les départements dans lesquels vous avez la permission +d'inscrire des étudiants sont présentés ici. Il faudra peut-être solliciter +l'administrateur de ce ScoDoc. +</p> + +<p>Dans chaque département autorisés, seuls les semestres non verrouillés sont +montrés. Choisir le semestre destination et valider le formulaire. +</p> + +<p>Ensuite, ne pas oublier d'inscrire l'étudiant à ses groupes, notamment son +parcours si besoin. +</p> + +</div> + +<form method="POST"> + {% for dept in departements.values() %} + <div class="dept"> + <div class="dept-name">Département {{ dept.acronym }}</div> + {% for sem in formsemestres_by_dept[dept.id]%} + <div> + <label> + <input type="radio" class="formsemestre" name="formsemestre_id" value="{{ sem.id }}"> + {{ sem.html_link_status() | safe }} + </label> + </div> + {% endfor %} + </div> + {% endfor %} + <button type="submit" name="action" value="submit" disabled id="submit-button">Créer une copie de l'étudiant et l'inscrire au semestre choisi</button> + <button type="submit" name="action" value="cancel">Annuler</button> +</form> + +<script> + const radioButtons = document.querySelectorAll('input.formsemestre'); + const submitButton = document.getElementById('submit-button'); + + radioButtons.forEach(radioButton => { + radioButton.addEventListener('change', () => { + const isAnyRadioButtonChecked = [...radioButtons].some(radioButton => radioButton.checked); + if (isAnyRadioButtonChecked) { + submitButton.removeAttribute('disabled'); + } else { + submitButton.setAttribute('disabled', 'disabled'); + } + }); + }); +</script> + +{% endblock %} \ No newline at end of file diff --git a/app/views/assiduites.py b/app/views/assiduites.py index d8dda7168033fbbebc8ec95e5279ce9645e63cd8..f9a579d5a9d5e97c84500f301b06a01c33553ef0 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -517,7 +517,7 @@ def ajout_justificatif_etud(): dept_id=g.scodoc_dept_id, ), assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"), - assi_evening=ScoDocSiteConfig.get("assi_evening_time", "18:00"), + assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"), ), ).build() @@ -1129,7 +1129,7 @@ def signal_assiduites_diff(): defdem=_get_etuds_dem_def(formsemestre), timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"), timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"), - timeEvening=ScoDocSiteConfig.get("assi_evening_time", "18:00:00"), + timeEvening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00"), defaultDates=_get_days_between_dates(date_deb, date_fin), nonworkdays=_non_work_days(), ), diff --git a/app/views/notes.py b/app/views/notes.py index 9f198ba3bcb88543180e945cbb2fbb5719822de7..af1d011aff7dc85f7bccc30f5469e9e9f91d8e5d 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -407,14 +407,13 @@ def moduleimpl_evaluation_renumber(moduleimpl_id): ) Evaluation.moduleimpl_evaluation_renumber(modimpl) # redirect to moduleimpl page: - if redirect: - return flask.redirect( - url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=moduleimpl_id, - ) + return flask.redirect( + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=moduleimpl_id, ) + ) sco_publish( diff --git a/app/views/scolar.py b/app/views/scolar.py index 7b13e2dd4f386a2703a6446474d24cbad41c8954..7d1cf2e258dfff2b39ffa742f3fb57bb9d921b30 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -31,11 +31,12 @@ issu de ScoDoc7 / ZScolar.py Emmanuel Viennet, 2021 """ import datetime -import requests import time +import requests + import flask -from flask import url_for, flash, render_template, make_response +from flask import abort, flash, make_response, render_template, url_for from flask import g, request from flask_json import as_json from flask_login import current_user @@ -43,6 +44,7 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed from wtforms import SubmitField +import app from app import db from app import log from app.decorators import ( @@ -52,6 +54,7 @@ from app.decorators import ( permission_required_compat_scodoc7, ) from app.models import ( + Departement, FormSemestre, Identite, Partition, @@ -69,6 +72,7 @@ from app.scodoc.scolog import logdb from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( AccessDenied, + ScoPermissionDenied, ScoValueError, ) @@ -1770,6 +1774,77 @@ def _etudident_create_or_edit_form(edit): ) +@bp.route("/etud_copy_in_other_dept/<int:etudid>", methods=["GET", "POST"]) +@scodoc +@permission_required( + Permission.ScoView +) # il faut aussi ScoEtudInscrit dans le nouveau dept +def etud_copy_in_other_dept(etudid: int): + """Crée une copie de l'étudiant (avec ses adresses et codes) dans un autre département + et l'inscrit à un formsemestre + """ + etud = Identite.get_etud(etudid) + if request.method == "POST": + action = request.form.get("action") + if action == "cancel": + return flask.redirect( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id) + ) + try: + formsemestre_id = int(request.form.get("formsemestre_id")) + except ValueError: + log("etud_copy_in_other_dept: invalid formsemestre_id") + abort(404, description="formsemestre_id invalide") + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + if not current_user.has_permission( + Permission.ScoEtudInscrit, formsemestre.departement.acronym + ): + raise ScoPermissionDenied("non autorisé") + new_etud = etud.clone(new_dept_id=formsemestre.dept_id) + db.session.commit() + # Attention: change le département pour opérer dans le nouveau + # avec les anciennes fonctions ScoDoc7 + orig_dept = g.scodoc_dept + try: + app.set_sco_dept(formsemestre.departement.acronym, open_cnx=False) + sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules( + formsemestre.id, + new_etud.id, + method="etud_copy_in_other_dept", + dept_id=formsemestre.dept_id, + ) + finally: + app.set_sco_dept(orig_dept, open_cnx=False) + flash(f"Etudiant dupliqué et inscrit en {formsemestre.departement.acronym}") + # Attention, ce redirect change de département ! + return flask.redirect( + url_for( + "scolar.ficheEtud", + scodoc_dept=formsemestre.departement.acronym, + etudid=new_etud.id, + ) + ) + departements = { + dept.id: dept + for dept in Departement.query.order_by(Departement.acronym) + if current_user.has_permission(Permission.ScoEtudInscrit, dept.acronym) + and dept.id != etud.dept_id + } + formsemestres_by_dept = { + dept.id: dept.formsemestres.filter_by(etat=True) + .filter(FormSemestre.modalite != "EXT") + .order_by(FormSemestre.date_debut, FormSemestre.semestre_id) + .all() + for dept in departements.values() + } + return render_template( + "scolar/etud_copy_in_other_dept.j2", + departements=departements, + etud=etud, + formsemestres_by_dept=formsemestres_by_dept, + ) + + @bp.route("/etudident_delete", methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoEtudInscrit) diff --git a/sco_version.py b/sco_version.py index 61dc9fa95b5133160b7cbd077437d80005b7217b..68196cdec1ea46b12ac45aba67582f79095ed9eb 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.35" +SCOVERSION = "9.6.38" SCONAME = "ScoDoc" diff --git a/tests/api/setup_test_api.py b/tests/api/setup_test_api.py index 63df403b5a4ddc8896903a3bed895d9260fb77da..adcd2e0a36909746dd0564c3e701a4cea0cc05fc 100644 --- a/tests/api/setup_test_api.py +++ b/tests/api/setup_test_api.py @@ -129,7 +129,7 @@ def check_fields(data: dict, fields: dict = None): """ assert set(data.keys()) == set(fields.keys()) for key in data: - if key in ("moduleimpl_id", "desc", "user_id", "external_data"): + if key in ("moduleimpl_id", "desc", "external_data"): assert ( isinstance(data[key], fields[key]) or data[key] is None ), f"error [{key}:{type(data[key])}, {data[key]}, {fields[key]}]" diff --git a/tests/api/test_api_assiduites.py b/tests/api/test_api_assiduites.py index 1333047182538da2d3edb86f83fa19f6eec359a3..1a7fb653df0d4d7c47cc33b831985076a98f204a 100644 --- a/tests/api/test_api_assiduites.py +++ b/tests/api/test_api_assiduites.py @@ -6,6 +6,7 @@ Ecrit par HARTMANN Matthias """ from random import randint +from types import NoneType from tests.api.setup_test_api import ( GET, @@ -34,7 +35,8 @@ ASSIDUITES_FIELDS = { "etat": str, "desc": str, "entry_date": str, - "user_id": str, + "user_id": (int, NoneType), + "user_name": (str, NoneType), "est_just": bool, "external_data": dict, }