diff --git a/app/forms/assiduite/ajout_assiduite_etud.py b/app/forms/assiduite/ajout_assiduite_etud.py index 8c3423ac..302f7377 100644 --- a/app/forms/assiduite/ajout_assiduite_etud.py +++ b/app/forms/assiduite/ajout_assiduite_etud.py @@ -62,6 +62,11 @@ class AjoutAssiOrJustForm(FlaskForm): if field: field.errors.append(err_msg) + def disable_all(self): + "Disable all fields" + for field in self: + field.render_kw = {"disabled": True} + date_debut = StringField( "Date de début", validators=[validators.Length(max=10)], @@ -175,36 +180,3 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm): validators=[DataRequired(message="This field is required.")], ) fichiers = MultipleFileField(label="Ajouter des fichiers") - - -class ChoixDateForm(FlaskForm): - """ - Formulaire de choix de date - (utilisé par la page de choix de date - si la date courante n'est pas dans le semestre) - """ - - def __init__(self, *args, **kwargs): - "Init form, adding a filed for our error messages" - super().__init__(*args, **kwargs) - self.ok = True - self.error_messages: list[str] = [] # used to report our errors - - def set_error(self, err_msg, field=None): - "Set error message both in form and field" - self.ok = False - self.error_messages.append(err_msg) - if field: - field.errors.append(err_msg) - - date = StringField( - "Date", - validators=[validators.Length(max=10)], - render_kw={ - "class": "datepicker", - "size": 10, - "id": "date", - }, - ) - submit = SubmitField("Enregistrer") - cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/forms/assiduite/edit_assiduite_etud.py b/app/forms/assiduite/edit_assiduite_etud.py new file mode 100644 index 00000000..cdcff62e --- /dev/null +++ b/app/forms/assiduite/edit_assiduite_etud.py @@ -0,0 +1,122 @@ +""" """ + +from flask_wtf import FlaskForm +from wtforms import ( + StringField, + SelectField, + RadioField, + TextAreaField, + validators, + SubmitField, +) +from app.scodoc.sco_utils import EtatAssiduite + + +class EditAssiForm(FlaskForm): + """ + Formulaire de modification d'une assiduité + """ + + def __init__(self, *args, **kwargs): + "Init form, adding a filed for our error messages" + super().__init__(*args, **kwargs) + self.ok = True + self.error_messages: list[str] = [] # used to report our errors + + def set_error(self, err_msg, field=None): + "Set error message both in form and field" + self.ok = False + self.error_messages.append(err_msg) + if field: + field.errors.append(err_msg) + + def disable_all(self): + "Disable all fields" + for field in self: + field.render_kw = {"disabled": True} + + assi_etat = RadioField( + "État:", + choices=[ + (EtatAssiduite.ABSENT.value, EtatAssiduite.ABSENT.version_lisible()), + (EtatAssiduite.RETARD.value, EtatAssiduite.RETARD.version_lisible()), + (EtatAssiduite.PRESENT.value, EtatAssiduite.PRESENT.version_lisible()), + ], + default="absent", + validators=[ + validators.DataRequired("spécifiez le type d'évènement à signaler"), + ], + ) + modimpl = SelectField( + "Module", + choices={}, # will be populated dynamically + ) + description = TextAreaField( + "Description", + render_kw={ + "id": "description", + "cols": 75, + "rows": 4, + "maxlength": 500, + }, + ) + date_debut = StringField( + "Date de début", + validators=[validators.Length(max=10)], + render_kw={ + "class": "datepicker", + "size": 10, + "id": "assi_date_debut", + }, + ) + heure_debut = StringField( + "Heure début", + default="", + validators=[validators.Length(max=5)], + render_kw={ + "class": "timepicker", + "size": 5, + "id": "assi_heure_debut", + }, + ) + heure_fin = StringField( + "Heure fin", + default="", + validators=[validators.Length(max=5)], + render_kw={ + "class": "timepicker", + "size": 5, + "id": "assi_heure_fin", + }, + ) + date_fin = StringField( + "Date de fin", + validators=[validators.Length(max=10)], + render_kw={ + "class": "datepicker", + "size": 10, + "id": "assi_date_fin", + }, + ) + entry_date = StringField( + "Date de dépôt ou saisie", + validators=[validators.Length(max=10)], + render_kw={ + "class": "datepicker", + "size": 10, + "id": "entry_date", + }, + ) + entry_time = StringField( + "Heure dépôt", + default="", + validators=[validators.Length(max=5)], + render_kw={ + "class": "timepicker", + "size": 5, + "id": "assi_heure_fin", + }, + ) + + submit = SubmitField("Enregistrer") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 241e44aa..7c5c1ce2 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -360,6 +360,16 @@ class Assiduite(ScoDocModel): return "Module non spécifié" if traduire else None + def get_moduleimpl_id(self) -> int | str | None: + """ + Retourne le ModuleImpl associé à l'assiduité + """ + if self.moduleimpl_id is not None: + return self.moduleimpl_id + if self.external_data is not None and "module" in self.external_data: + return self.external_data["module"] + return None + def get_saisie(self) -> str: """ retourne le texte "saisie le par " @@ -395,6 +405,14 @@ class Assiduite(ScoDocModel): if force: raise ScoValueError("Module non renseigné") + @classmethod + def get_assiduite(cls, assiduite_id: int) -> "Assiduite": + """Assiduité ou 404, cherche uniquement dans le département courant""" + query = Assiduite.query.filter_by(id=assiduite_id) + if g.scodoc_dept: + query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) + return query.first_or_404() + class Justificatif(ScoDocModel): """ @@ -685,10 +703,14 @@ def is_period_conflicting( date_fin: datetime, collection: Query, collection_cls: Assiduite | Justificatif, + obj_id: int = -1, ) -> bool: """ Vérifie si une date n'entre pas en collision avec les justificatifs ou assiduites déjà présentes + + On peut donner un objet_id pour exclure un objet de la vérification + (utile pour les modifications) """ # On s'assure que les dates soient avec TimeZone @@ -696,7 +718,9 @@ def is_period_conflicting( date_fin = localize_datetime(date_fin) count: int = collection.filter( - collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut + collection_cls.date_debut < date_fin, + collection_cls.date_fin > date_debut, + collection_cls.id != obj_id, ).count() return count > 0 diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py index 7074f919..bcf02772 100644 --- a/app/scodoc/sco_evaluations.py +++ b/app/scodoc/sco_evaluations.py @@ -427,7 +427,9 @@ class JourEval(sco_gen_cal.Jour): ) heure_fin_txt = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else "" - title = f"{e.description or e.moduleimpl.module.titre_str()}" + title = f"{e.moduleimpl.module.titre_str()}" + if e.description: + title += f" : {e.description}" if heure_debut_txt: title += f" de {heure_debut_txt} à {heure_fin_txt}" diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index 8c032996..9b79e782 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -978,7 +978,7 @@ def form_choix_jour_saisie_hebdo(groups_infos, moduleimpl_id=None): if not authuser.has_permission(Permission.AbsChange): return "" return f""" - + }">Saisie du jour ({datetime.date.today().strftime('%d/%m/%Y')}) """ @@ -1000,14 +1000,14 @@ def form_choix_saisie_semaine(groups_infos): moduleimpl_id = query_args.get("moduleimpl_id", [None])[0] semaine = datetime.datetime.now().strftime("%G-W%V") return f""" - + )}">Saisie à la semaine (semaine {''.join(semaine[-2:])}) """ diff --git a/app/static/css/minitimeline.css b/app/static/css/minitimeline.css index b1707f66..73bf6487 100644 --- a/app/static/css/minitimeline.css +++ b/app/static/css/minitimeline.css @@ -118,9 +118,8 @@ bottom: 100%; left: 50%; transform: translateX(-50%); - border-width: 6px; - border-style: solid; - border-color: transparent transparent #f9f9f9 transparent; + border: 10px solid transparent; + width: 100%; } .assiduite-bubble::after { diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 089469eb..0d12635c 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -53,7 +53,6 @@ async function async_get(path, success, errors) { * @param {CallableFunction} errors fonction à effectuer en cas d'échec */ async function async_post(path, data, success, errors) { - // console.log("async_post " + path); let response; try { response = await fetch(path, { @@ -655,9 +654,46 @@ function mettreToutLeMonde(etat, el = null) { if (!confirm("Effacer tout les évènements correspondant à cette plage ?")) { return; // annulation } - const assiduites_id = lignesEtuds + // On récupère les lignes avec une seule assiduité + let assiduites_id = lignesEtuds .filter((e) => e.getAttribute("type") == "edition") .map((e) => Number(e.getAttribute("assiduite_id"))); + + // On récupère les assiduités conflictuelles mais qui sont comprisent + // Dans la plage de suppression + const unDeleted = {}; + lignesEtuds + .filter((e) => e.getAttribute("type") == "conflit") + .forEach((e) => { + const etud = etuds.get(Number(e.getAttribute("etudid"))); + // On récupère les assiduités couvertent par la plage de suppression + etud.assiduites.forEach((a) => { + const date_debut = new Date(a.date_debut); + const date_fin = new Date(a.date_fin); + // On prend en compte uniquement les assiduités conflictuelles + // (qui intersectent la plage de suppression) + if ( + Date.intersect( + { deb: deb, fin: fin }, + { deb: date_debut, fin: date_fin } + ) + ) { + // Si l'assiduité est couverte par la plage de suppression + // On l'ajoute à la liste des assiduités à supprimer. + if ( + date_debut.isBetween(deb, fin, "[]") && + date_fin.isBetween(deb, fin, "[]") + ) { + assiduites_id.push(a.assiduite_id); + } + // Sinon on ajoute l'étudiant à la liste des étudiants non gérés + else { + unDeleted[a.etudid] = true; + } + } + }); + }); + afficheLoader(); async_post( @@ -672,6 +708,28 @@ function mettreToutLeMonde(etat, el = null) { console.error(data.errors); } envoiToastTous("remove", assiduites_id.length); + if (Object.keys(unDeleted).length == 0) return; + + let unDeletedEtuds = ` + + `; + + let html = ` +

Les assiduités des étudiants suivants n'ont pas été supprimées car elles ne sont pas incluses dans la plage de suppression :

+ ${unDeletedEtuds} + `; + const div = document.createElement("div"); + div.innerHTML = html; + openAlertModal("Assiduité non supprimée", div); }, (error) => { console.error("Erreur lors de la suppression de l'assiduité", error); @@ -681,14 +739,10 @@ function mettreToutLeMonde(etat, el = null) { return; } - // Création / édition des assiduités + // Création const assiduitesACreer = lignesEtuds .filter((e) => e.getAttribute("type") == "creation") .map((e) => Number(e.getAttribute("etudid"))); - const assiduitesAEditer = lignesEtuds - .filter((e) => e.getAttribute("type") == "edition") - .map((e) => Number(e.getAttribute("assiduite_id"))); - // création const promiseCreate = async_post( @@ -705,29 +759,15 @@ function mettreToutLeMonde(etat, el = null) { console.error("Erreur lors de la création de l'assiduité", error); } ); - const promiseEdit = async_post( - `../../api/assiduites/edit`, - assiduitesAEditer.map((assiduite_id) => { - return { ...assiduiteObjet, assiduite_id }; - }), - async (data) => { - if (data.errors.length > 0) { - console.error(data.errors); - } - }, - (error) => { - console.error("Erreur lors de l'édition de l'assiduité", error); - } - ); // Affiche un loader afficheLoader(); - Promise.all([promiseCreate, promiseEdit]).then(async () => { + Promise.all([promiseCreate]).then(async () => { retirerLoader(); await recupAssiduites(etuds, $("#date").datepicker("getDate")); creerTousLesEtudiants(etuds); - envoiToastTous(etat, assiduitesACreer.length + assiduitesAEditer.length); + envoiToastTous(etat, assiduitesACreer.length); }); } @@ -888,21 +928,12 @@ function setupAssiduiteBubble(el, assiduite) { const infos = document.createElement("a"); infos.className = ""; infos.textContent = `ℹ️`; - infos.title = "Cliquez pour plus d'informations"; + infos.title = "Détails / Modifier"; infos.target = "_blank"; - infos.href = `tableau_assiduite_actions?type=assiduite&action=details&obj_id=${assiduite.assiduite_id}`; - - // Ajout d'un lien pour modifier l'assiduité - const modifs = document.createElement("a"); - modifs.className = ""; - modifs.textContent = `📝`; - modifs.title = "Cliquez pour modifier l'assiduité"; - modifs.target = "_blank"; - modifs.href = `tableau_assiduite_actions?type=assiduite&action=modifier&obj_id=${assiduite.assiduite_id}`; + infos.href = `edit_assiduite_etud/${assiduite.assiduite_id}`; const actionsDiv = document.createElement("div"); actionsDiv.className = "assiduite-actions"; - actionsDiv.appendChild(modifs); actionsDiv.appendChild(infos); bubble.appendChild(actionsDiv); diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py index c0c7dbbb..7b0204b9 100644 --- a/app/tables/liste_assiduites.py +++ b/app/tables/liste_assiduites.py @@ -600,33 +600,22 @@ class RowAssiJusti(tb.Row): url: str html: list[str] = [] - # Détails - url = url_for( - "assiduites.tableau_assiduite_actions", - type=self.ligne["type"], - action="details", - obj_id=self.ligne["obj_id"], - scodoc_dept=g.scodoc_dept, - ) - html.append(f'ℹ️') - - # Modifier if self.ligne["type"] == "justificatif": + # Détails/Modifier assiduité url = url_for( "assiduites.edit_justificatif_etud", justif_id=self.ligne["obj_id"], scodoc_dept=g.scodoc_dept, - back_url=request.url, ) + html.append(f'ℹ️') else: + # Détails/Modifier assiduité url = url_for( - "assiduites.tableau_assiduite_actions", - type=self.ligne["type"], - action="modifier", - obj_id=self.ligne["obj_id"], + "assiduites.edit_assiduite_etud", + assiduite_id=self.ligne["obj_id"], scodoc_dept=g.scodoc_dept, ) - html.append(f'📝') + html.append(f'ℹ️') # Supprimer url = url_for( diff --git a/app/tables/visu_assiduites.py b/app/tables/visu_assiduites.py index 11fa4896..0a4e9cc9 100644 --- a/app/tables/visu_assiduites.py +++ b/app/tables/visu_assiduites.py @@ -8,6 +8,7 @@ import datetime from flask import g, url_for +from flask_login import current_user from app import log from app.models import FormSemestre, Identite, Justificatif from app.tables import table_builder as tb @@ -15,6 +16,7 @@ from app.scodoc import sco_preferences from app.scodoc import sco_utils as scu import app.scodoc.sco_assiduites as scass from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_permissions import Permission class TableAssi(tb.Table): @@ -171,6 +173,20 @@ class RowAssi(tb.Row): "justificatifs", "Justificatifs", fmt_num(compte_justificatifs.count()) ) + if current_user.has_permission(Permission.AbsChange): + ajout_url: str = url_for( + "assiduites.ajout_assiduite_etud", + scodoc_dept=g.scodoc_dept, + etudid=etud.id, + formsemestre_id=self.table.formsemestre.id, + ) + self.add_cell( + "lien_ajout", + "", + f"signaler assiduité", + no_excel=True, + ) + 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 diff --git a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 index 221f3b49..a6527203 100644 --- a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 +++ b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 @@ -44,11 +44,33 @@ div.submit > input {

{{title|safe}}

- + {% if readonly %} +

Vous n'avez pas la permission de modifier ce justificatif

+ {% endif %} {% if justif %} +
+
- Saisie par {{justif.user.get_prenomnom() if justif.user else "inconnu"}} - le {{justif.entry_date.strftime(scu.DATEATIME_FMT) if justif.entry_date else "?"}} + Saisie par {{justif.saisie_par}} le {{justif.entry_date}} +
+ +
+ Assiduités concernées: + {% if justif.justification.assiduites %} + + {% else %} + Aucune + {% endif %} +
+
{% endif %} @@ -110,7 +132,9 @@ div.submit > input { {% for filename in filenames %}
  • {{scu.icontag("delete_img", alt="supprimer", title="Supprimer")|safe}} - {{filename}}
  • + {{filename}} + {% endfor %} {% endif %} @@ -126,11 +150,28 @@ div.submit > input { laisser vide pour date courante {{ render_field_errors(form, 'entry_date') }} + {% if readonly == False %} {# Submit #}
    {{ form.submit }} {{ form.cancel }}
    + + {% endif %} + + + diff --git a/app/templates/assiduites/pages/calendrier_assi_etud.j2 b/app/templates/assiduites/pages/calendrier_assi_etud.j2 index d7822294..b4fa3854 100644 --- a/app/templates/assiduites/pages/calendrier_assi_etud.j2 +++ b/app/templates/assiduites/pages/calendrier_assi_etud.j2 @@ -242,7 +242,7 @@ Calendrier de l'assiduité document.querySelectorAll('[assi_id]').forEach((el, i) => { el.addEventListener('click', () => { const assi_id = el.getAttribute('assi_id'); - window.open(`${SCO_URL}Assiduites/tableau_assiduite_actions?type=assiduite&action=details&obj_id=${assi_id}`); + window.open(`${SCO_URL}Assiduites/edit_assiduite_etud/${assi_id}`); }) }); diff --git a/app/templates/assiduites/pages/edit_assiduite_etud.j2 b/app/templates/assiduites/pages/edit_assiduite_etud.j2 new file mode 100644 index 00000000..d80d58af --- /dev/null +++ b/app/templates/assiduites/pages/edit_assiduite_etud.j2 @@ -0,0 +1,180 @@ +{# Ajout d'une "assiduité" sur un étudiant #} + +{% extends "sco_page.j2" %} +{% import 'wtf.j2' as wtf %} + + +{% block styles %} +{{super()}} + + + +{% endblock %} + +{% block app_content %} +
    +

    Détails Assiduité concernant {{etud.html_link_fiche()|safe}}

    + +
    +
    + Saisie par {{objet.saisie_par}} le {{objet.entry_date}} +
    +
    + Période : du {{objet.date_debut}} au {{objet.date_fin}} +
    +
    + Module : {{objet.module}} +
    +
    + État de l'assiduité :{{objet.etat}} +
    +
    + Description: + {% if objet.description != "" and objet.description is not None %} + {{objet.description}} + {% else %} + Pas de description + {% endif %} + +
    + {# Affichage des justificatifs si assiduité justifiée #} + {% if objet.etat != "Présence" %} +
    + Justifiée: + {% if objet.justification.est_just %} + Oui + {% else %} + Non + {% if not objet.justification.justificatifs %} + Justifier l'assiduité + {% endif %} + {% endif %} +
    +
    + {% if not objet.justification.justificatifs %} + Pas de justificatif associé + {% else %} + Justificatifs associés: + + {% endif %} +
    + {% endif %} +
    + + {% if readonly != True %} +

    Modification de l'assiduité

    + {% for err_msg in form.error_messages %} +
    + {{ err_msg }} +
    + {% endfor %} + +
    + {{ form.hidden_tag() }} + {# Type d'évènement #} +
    + {{ form.assi_etat.label }} + {{ form.assi_etat() }} +
    +
    + {{ form.date_debut.label }} : {{ form.date_debut }} + à {{ form.heure_debut }} + {{ render_field_errors(form, 'date_debut') }} + {{ render_field_errors(form, 'heure_debut') }} +
    + {{ form.date_fin.label }} : {{ form.date_fin }} + à {{ form.heure_fin }} + {{ render_field_errors(form, 'date_fin') }} + {{ render_field_errors(form, 'heure_fin') }} +
    + {{ form.entry_date.label }} : {{ form.entry_date }} à {{ form.entry_time }} + +
    +
    + {# Menu module #} +
    + {{ form.modimpl.label }} : + {{ form.modimpl }} + {{ render_field_errors(form, 'modimpl') }} +
    + {# Description #} +
    +
    {{ form.description.label }}
    + {{ form.description() }} + {{ render_field_errors(form, 'description') }} +
    + {# Submit #} +
    + {{ form.submit }} {{ form.cancel }} +
    + + +
    + + {% else %} +

    Vous n'avez pas la permission de modifier cette assiduité

    + {% endif %} + +
    + +{% endblock app_content %} + +{% block scripts %} +{{ super() }} + +{% include "sco_timepicker.j2" %} +{% endblock scripts %} \ No newline at end of file diff --git a/app/templates/assiduites/pages/signal_assiduites_hebdo.j2 b/app/templates/assiduites/pages/signal_assiduites_hebdo.j2 index a312fb6c..ed5e4983 100644 --- a/app/templates/assiduites/pages/signal_assiduites_hebdo.j2 +++ b/app/templates/assiduites/pages/signal_assiduites_hebdo.j2 @@ -571,7 +571,6 @@ } let toCreate = []; // [{etudid:}] - let toEdit = [];// [{etudid:, assiduite_id:}] tds.forEach((td) => { // on ne touche pas aux conflits @@ -585,8 +584,6 @@ const assiduite_id = td.getAttribute("assiduite_id"); if (assiduite_id == "") { toCreate.push({ etudid: etudid }); - } else { - toEdit.push({ etudid: etudid, assiduite_id: Number(assiduite_id) }); } }) @@ -598,19 +595,9 @@ } }); - // Modification - toEdit = toEdit.map((el) => { - return { - ...assi, - etudid: el.etudid, - assiduite_id: el.assiduite_id, - } - }); - // Appel API let counts = { create: toCreate.length, - edit: toEdit.length } const promiseCreate = async_post( `../../api/assiduites/create`, @@ -633,35 +620,13 @@ console.error("Erreur lors de la création de l'assiduité", error); } ); - const promiseEdit = async_post( - `../../api/assiduites/edit`, - toEdit, - async (data) => { - if (data.errors.length > 0) { - console.error(data.errors); - data.errors.forEach((err) => { - let obj = toEdit[err.indice]; - let etu = etuds.find((el) => el.id == obj.etudid); - - const text = document.createTextNode(`Erreur pour ${etu.nom} ${etu.prenom} : ${err.message}`); - const toast = generateToast(text, "var(--color-error)"); - pushToast(toast); - }); - } - counts.edit = data.success.length; - }, - (error) => { - console.error("Erreur lors de l'édition de l'assiduité", error); - } - ); - // Affiche un loader afficheLoader(); - Promise.all([promiseCreate, promiseEdit]).then(async () => { + Promise.all([promiseCreate]).then(async () => { retirerLoader(); await recupAssiduitesHebdo(updateTable); - envoiToastTous("present", counts.create + counts.edit); + envoiToastTous("present", counts.create); }); } diff --git a/app/templates/assiduites/widgets/conflict.j2 b/app/templates/assiduites/widgets/conflict.j2 index 8a60eae2..653eb4ff 100644 --- a/app/templates/assiduites/widgets/conflict.j2 +++ b/app/templates/assiduites/widgets/conflict.j2 @@ -59,7 +59,7 @@ function getWidth(start, end) { const duration = (endTime - startTime) / 1000 / 60; - const percent = (duration / (t_end * 60 - t_start * 60)) * 100; + const percent = Math.min((duration / (t_end * 60 - t_start * 60)) * 100, 100); return percent + "%"; } diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 6286024d..8484e01d 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -36,6 +36,7 @@ from flask_login import current_user from flask_sqlalchemy.query import Query from markupsafe import Markup +from werkzeug.exceptions import HTTPException from app import db, log from app.comp import res_sem @@ -48,8 +49,8 @@ from app.forms.assiduite.ajout_assiduite_etud import ( AjoutAssiOrJustForm, AjoutAssiduiteEtudForm, AjoutJustificatifEtudForm, - ChoixDateForm, ) +from app.forms.assiduite.edit_assiduite_etud import EditAssiForm from app.models import ( Assiduite, Departement, @@ -65,7 +66,7 @@ from app.models import ( from app.scodoc.codes_cursus import UE_STANDARD from app.auth.models import User -from app.models.assiduites import get_assiduites_justif +from app.models.assiduites import get_assiduites_justif, is_period_conflicting from app.tables.list_etuds import RowEtud, TableEtud import app.tables.liste_assiduites as liste_assi @@ -225,6 +226,18 @@ def ajout_assiduite_etud() -> str | Response: etudid: int = request.args.get("etudid", -1) etud = Identite.get_etud(etudid) + formsemestre_id = request.args.get("formsemestre_id", None) + + # Gestion du semestre + formsemestre: FormSemestre | None = None + sems_etud: list[FormSemestre] = etud.get_formsemestres() + if formsemestre_id: + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + formsemestre = formsemestre if formsemestre in sems_etud else None + else: + formsemestre = [sem for sem in sems_etud if sem.est_courant()] + formsemestre = formsemestre[0] if formsemestre else None + # Gestion évaluations (appel à la page depuis les évaluations) evaluation_id: int | None = request.args.get("evaluation_id") saisie_eval = evaluation_id is not None @@ -246,26 +259,22 @@ def ajout_assiduite_etud() -> str | Response: modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire()) choices: OrderedDict = OrderedDict() choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")] - for formsemestre_id in modimpls_by_formsemestre: - formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) - # indique le nom du semestre dans le menu (optgroup) - group_name: str = formsemestre.titre_annee() - choices[group_name] = [ - (m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}") - for m in modimpls_by_formsemestre[formsemestre_id] - if m.module.ue.type == UE_STANDARD - ] + # indique le nom du semestre dans le menu (optgroup) + group_name: str = formsemestre.titre_annee() + choices[group_name] = [ + (m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}") + for m in modimpls_by_formsemestre[formsemestre.id] + if m.module.ue.type == UE_STANDARD + ] - if formsemestre.est_courant(): - choices.move_to_end(group_name, last=False) choices.move_to_end("", last=False) form.modimpl.choices = choices force_options: dict = None if form.validate_on_submit(): if form.cancel.data: # cancel button return redirect(redirect_url) - ok = _record_assiduite_etud(etud, form) + ok = _record_assiduite_etud(etud, form, formsemestre=formsemestre) if ok: flash("enregistré") return redirect(redirect_url) @@ -293,7 +302,7 @@ def ajout_assiduite_etud() -> str | Response: form=form, moduleimpl_id=moduleimpl_id, redirect_url=redirect_url, - sco=ScoData(etud), + sco=ScoData(etud, formsemestre=formsemestre), tableau=tableau, scu=scu, ) @@ -301,7 +310,9 @@ def ajout_assiduite_etud() -> str | Response: def _get_dates_from_assi_form( form: AjoutAssiOrJustForm, + etud: Identite, from_justif: bool = False, + formsemestre: FormSemestre | None = None, ) -> tuple[ bool, datetime.datetime | None, datetime.datetime | None, datetime.datetime | None ]: @@ -393,6 +404,41 @@ def _get_dates_from_assi_form( dt_debut_tz_server = dt_debut_tz_server.replace(hour=0, minute=0) dt_fin_tz_server = dt_fin_tz_server.replace(hour=23, minute=59) + # Vérification dates contenu dans un semestre de l'étudiant + dates_semestres: list[tuple[datetime.date, datetime.date]] = ( + [(sem.date_debut, sem.date_fin) for sem in etud.get_formsemestres()] + if formsemestre is None + else [(formsemestre.date_debut, formsemestre.date_fin)] + ) + + # Vérification date début + if not any( + [ + dt_debut_tz_server.date() >= deb and dt_debut_tz_server.date() <= fin + for deb, fin in dates_semestres + ] + ): + form.set_error( + "La date de début n'appartient à aucun semestre de l'étudiant" + if formsemestre is None + else "La date de début n'appartient pas au semestre", + form.date_debut, + ) + + # Vérification date fin + if form.date_fin.data and not any( + [ + dt_fin_tz_server.date() >= deb and dt_fin_tz_server.date() <= fin + for deb, fin in dates_semestres + ] + ): + form.set_error( + "La date de fin n'appartient à aucun semestre de l'étudiant" + if not formsemestre + else "La date de fin n'appartient pas au semestre", + form.date_fin, + ) + dt_entry_date_tz_server = ( scu.TIME_ZONE.localize(dt_entry_date) if dt_entry_date else None ) @@ -402,6 +448,7 @@ def _get_dates_from_assi_form( def _record_assiduite_etud( etud: Identite, form: AjoutAssiduiteEtudForm, + formsemestre: FormSemestre | None = None, ) -> bool: """Enregistre les données du formulaire de saisie assiduité. Returns ok if successfully recorded, else put error info in the form. @@ -415,7 +462,7 @@ def _record_assiduite_etud( dt_debut_tz_server, dt_fin_tz_server, dt_entry_date_tz_server, - ) = _get_dates_from_assi_form(form) + ) = _get_dates_from_assi_form(form, etud, formsemestre=formsemestre) # Le module (avec "autre") mod_data = form.modimpl.data if mod_data: @@ -492,10 +539,8 @@ def _record_assiduite_etud( assi: Assiduite = conflits.first() lien: str = url_for( - "assiduites.tableau_assiduite_actions", - type="assiduite", - action="details", - obj_id=assi.assiduite_id, + "assiduites.edit_assiduite_etud", + assiuite_id=assi.assiduite_id, scodoc_dept=g.scodoc_dept, ) @@ -566,7 +611,7 @@ def bilan_etud(): @bp.route("/edit_justificatif_etud/", methods=["GET", "POST"]) @scodoc -@permission_required(Permission.AbsChange) +@permission_required(Permission.ScoView) def edit_justificatif_etud(justif_id: int): """ Edition d'un justificatif. @@ -578,8 +623,19 @@ def edit_justificatif_etud(justif_id: int): Returns: str: l'html généré """ - justif = Justificatif.get_justificatif(justif_id) + try: + justif = Justificatif.get_justificatif(justif_id) + except HTTPException: + flash("Justificatif invalide") + return redirect(url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept)) + + readonly = not current_user.has_permission(Permission.AbsChange) + form = AjoutJustificatifEtudForm(obj=justif) + + if readonly: + form.disable_all() + # Set the default value for the etat field if request.method == "GET": form.date_debut.data = justif.date_debut.strftime(scu.DATE_FMT) @@ -606,7 +662,9 @@ def edit_justificatif_etud(justif_id: int): ) if form.validate_on_submit(): - if form.cancel.data: # cancel button + if form.cancel.data or not current_user.has_permission( + Permission.AbsChange + ): # cancel button return redirect(redirect_url) if _record_justificatif_etud(justif.etudiant, form, justif): return redirect(redirect_url) @@ -621,12 +679,13 @@ def edit_justificatif_etud(justif_id: int): etud=justif.etudiant, filenames=filenames, form=form, - justif=justif, + justif=_preparer_objet("justificatif", justif), nb_files=nb_files, title=f"Modification justificatif absence de {justif.etudiant.html_link_fiche()}", redirect_url=redirect_url, sco=ScoData(justif.etudiant), scu=scu, + readonly=not current_user.has_permission(Permission.AbsChange), ) @@ -682,32 +741,6 @@ def ajout_justificatif_etud(): ) -def _verif_date_form_justif( - form: AjoutJustificatifEtudForm, deb: datetime.datetime, fin: datetime.datetime -) -> tuple[datetime.datetime, datetime.datetime]: - """Gère les cas suivants : - - si on indique seulement une date de debut : journée 0h-23h59 - - si on indique date de debut et heures : journée +heure deb/fin - (déjà géré par _get_dates_from_assi_form) - - Si on indique une date de début et de fin sans heures : Journées 0h-23h59 - - Si on indique une date de début et de fin avec heures : On fait un objet avec - datedeb/heuredeb + datefin/heurefin (déjà géré par _get_dates_from_assi_form) - """ - - cas: list[bool] = [ - # cas 1 - not form.date_fin.data and not form.heure_debut.data, - # cas 3 - form.date_fin.data != "" and not form.heure_debut.data, - ] - - if any(cas): - deb = deb.replace(hour=0, minute=0) - fin = fin.replace(hour=23, minute=59) - - return deb, fin - - def _record_justificatif_etud( etud: Identite, form: AjoutJustificatifEtudForm, justif: Justificatif | None = None ) -> bool: @@ -724,7 +757,7 @@ def _record_justificatif_etud( dt_debut_tz_server, dt_fin_tz_server, dt_entry_date_tz_server, - ) = _get_dates_from_assi_form(form, from_justif=True) + ) = _get_dates_from_assi_form(form, etud, from_justif=True) if not ok: log("_record_justificatif_etud: dates invalides") form.set_error("Erreur: dates invalides") @@ -1626,20 +1659,18 @@ def _preparer_objet( # Gestion justification - if not objet.est_just: - objet_prepare["justification"] = {"est_just": False} - else: - objet_prepare["justification"] = {"est_just": True, "justificatifs": []} + objet_prepare["justification"] = { + "est_just": objet.est_just, + "justificatifs": [], + } - if not sans_gros_objet: - justificatifs: list[int] = get_assiduites_justif( - objet.assiduite_id, False + if not sans_gros_objet: + justificatifs: list[int] = get_assiduites_justif(objet.assiduite_id, False) + for justi_id in justificatifs: + justi: Justificatif = Justificatif.query.get(justi_id) + objet_prepare["justification"]["justificatifs"].append( + _preparer_objet("justificatif", justi, sans_gros_objet=True) ) - for justi_id in justificatifs: - justi: Justificatif = Justificatif.query.get(justi_id) - objet_prepare["justification"]["justificatifs"].append( - _preparer_objet("justificatif", justi, sans_gros_objet=True) - ) else: # objet == "justificatif" justif: Justificatif = objet @@ -1652,9 +1683,8 @@ def _preparer_objet( objet_prepare["justification"] = {"assiduites": [], "fichiers": {}} if not sans_gros_objet: - assiduites: list[int] = scass.justifies(justif) - for assi_id in assiduites: - assi: Assiduite = Assiduite.query.get(assi_id) + assiduites: list[Assiduite] = justif.get_assiduites() + for assi in assiduites: objet_prepare["justification"]["assiduites"].append( _preparer_objet("assiduite", assi, sans_gros_objet=True) ) @@ -2106,6 +2136,121 @@ def signal_assiduites_hebdo(): ) +@bp.route("edit_assiduite_etud/", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +def edit_assiduite_etud(assiduite_id: int): + """ + Page affichant les détails d'une assiduité + Si le current_user alors la page propose un formulaire de modification + """ + try: + assi: Assiduite = Assiduite.get_assiduite(assiduite_id=assiduite_id) + except HTTPException: + flash("Assiduité invalide") + return redirect(url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept)) + + etud: Identite = assi.etudiant + formsemestre: FormSemestre = assi.get_formsemestre() + + readonly: bool = not current_user.has_permission(Permission.AbsChange) + + form: EditAssiForm = EditAssiForm(request.form) + if readonly: + form.disable_all() + + # peuplement moduleimpl_select + modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire()) + choices: OrderedDict = OrderedDict() + choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")] + + # indique le nom du semestre dans le menu (optgroup) + group_name: str = formsemestre.titre_annee() + choices[group_name] = [ + (m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}") + for m in modimpls_by_formsemestre[formsemestre.id] + if m.module.ue.type == UE_STANDARD + ] + + choices.move_to_end("", last=False) + form.modimpl.choices = choices + + # Vérification formulaire + if form.validate_on_submit(): + if form.cancel.data: # cancel button + return redirect(request.referrer) + + # vérification des valeurs + + # Gestion de l'état + etat = form.assi_etat.data + try: + etat = int(etat) + etat = scu.EtatAssiduite.inverse().get(etat, None) + except ValueError: + etat = None + + if etat is None: + form.error_messages.append("État invalide") + form.ok = False + + description = form.description.data or "" + description = description.strip() + moduleimpl_id = form.modimpl.data if form.modimpl.data is not None else -1 + # Vérifications des dates / horaires + + ok, dt_deb, dt_fin, dt_entry = _get_dates_from_assi_form( + form, etud, from_justif=True, formsemestre=formsemestre + ) + if ok: + if is_period_conflicting( + dt_deb, dt_fin, etud.assiduites, Assiduite, assi.id + ): + form.set_error("La période est en conflit avec une autre assiduité") + form.ok = False + + if form.ok: + assi.etat = etat + assi.description = description + if moduleimpl_id != -1: + assi.set_moduleimpl(moduleimpl_id) + + assi.date_debut = dt_deb + assi.date_fin = dt_fin + assi.entry_date = dt_entry + + db.session.add(assi) + db.session.commit() + + scass.simple_invalidate_cache(assi.to_dict(format_api=True), assi.etudid) + + flash("enregistré") + return redirect(request.referrer) + + # Remplissage du formulaire + form.assi_etat.data = str(assi.etat) + form.description.data = assi.description + moduleimpl_id: int | str | None = assi.get_moduleimpl_id() or "" + form.modimpl.data = str(moduleimpl_id) + + form.date_debut.data = assi.date_debut.strftime(scu.DATE_FMT) + form.heure_debut.data = assi.date_debut.strftime(scu.TIME_FMT) + form.date_fin.data = assi.date_fin.strftime(scu.DATE_FMT) + form.heure_fin.data = assi.date_fin.strftime(scu.TIME_FMT) + form.entry_date.data = assi.entry_date.strftime(scu.DATE_FMT) + form.entry_time.data = assi.entry_date.strftime(scu.TIME_FMT) + + return render_template( + "assiduites/pages/edit_assiduite_etud.j2", + etud=etud, + sco=ScoData(etud, formsemestre=formsemestre), + form=form, + readonly=True, + objet=_preparer_objet("assiduite", assi), + title=f"Assiduité {etud.nom_short}", + ) + + def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str: """Génère la liste des assiduités d'un étudiant pour le bulletin mail""" @@ -2463,12 +2608,12 @@ class JourAssi(sco_gen_cal.Jour): (version journee divisée en demi-journees) """ heure_midi = scass.str_to_time(ScoDocSiteConfig.get("assi_lunch_time", "13:00")) - + plage: tuple[datetime.datetime, datetime.datetime] = () if matin: heure_matin = scass.str_to_time( ScoDocSiteConfig.get("assi_morning_time", "08:00") ) - matin = ( + plage = ( # date debut scu.localize_datetime( datetime.datetime.combine(self.date, heure_matin) @@ -2476,71 +2621,52 @@ class JourAssi(sco_gen_cal.Jour): # date fin scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)), ) - assiduites_matin = [ - assi - for assi in self.assiduites - if scu.is_period_overlapping( - (assi.date_debut, assi.date_fin), matin, bornes=False - ) - ] - justificatifs_matin = [ - justi - for justi in self.justificatifs - if scu.is_period_overlapping( - (justi.date_debut, justi.date_fin), matin, bornes=False - ) - ] - - etat = self._get_color_assiduites_cascade( - self._get_etats_from_assiduites(assiduites_matin), - show_pres=self.parent.show_pres, - show_reta=self.parent.show_reta, + else: + heure_soir = scass.str_to_time( + ScoDocSiteConfig.get("assi_afternoon_time", "17:00") ) - est_just = self._get_color_justificatifs_cascade( - self._get_etats_from_justificatifs(justificatifs_matin), + # séparation en demi journées + plage = ( + # date debut + scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)), + # date fin + scu.localize_datetime(datetime.datetime.combine(self.date, heure_soir)), ) - return f"color {etat} {est_just}" - - heure_soir = scass.str_to_time( - ScoDocSiteConfig.get("assi_afternoon_time", "17:00") - ) - - # séparation en demi journées - aprem = ( - # date debut - scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)), - # date fin - scu.localize_datetime(datetime.datetime.combine(self.date, heure_soir)), - ) - - assiduites_aprem = [ + assiduites = [ assi for assi in self.assiduites if scu.is_period_overlapping( - (assi.date_debut, assi.date_fin), aprem, bornes=False + (assi.date_debut, assi.date_fin), plage, bornes=False ) ] - justificatifs_aprem = [ + justificatifs = [ justi for justi in self.justificatifs if scu.is_period_overlapping( - (justi.date_debut, justi.date_fin), aprem, bornes=False + (justi.date_debut, justi.date_fin), plage, bornes=False ) ] etat = self._get_color_assiduites_cascade( - self._get_etats_from_assiduites(assiduites_aprem), + self._get_etats_from_assiduites(assiduites), show_pres=self.parent.show_pres, show_reta=self.parent.show_reta, ) est_just = self._get_color_justificatifs_cascade( - self._get_etats_from_justificatifs(justificatifs_aprem), + self._get_etats_from_justificatifs(justificatifs), ) + if est_just == "est_just" and any( + not assi.est_just + for assi in assiduites + if assi.etat != scu.EtatAssiduite.PRESENT + ): + est_just = "" + return f"color {etat} {est_just}" def _get_etats_from_assiduites(self, assiduites: Query) -> list[scu.EtatAssiduite]: