diff --git a/app/forms/assiduite/ajout_assiduite_etud.py b/app/forms/assiduite/ajout_assiduite_etud.py index 8c3423accf36f2e7bdece4aa075aa057188c9c31..302f73776216762af9e748b7f522b6cb91c0ecda 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 0000000000000000000000000000000000000000..ca284c0148c9670510c92998725080f620b5f5f0 --- /dev/null +++ b/app/forms/assiduite/edit_assiduite_etud.py @@ -0,0 +1,58 @@ +""" """ + +from flask_wtf import FlaskForm +from wtforms import 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, + }, + ) + + submit = SubmitField("Enregistrer") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 241e44aa636f1c34c6ed8d754477f02fa454d489..b741005e4269058e9d3f4e4c292e365c78cb845e 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 <date> par <User>" @@ -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): """ diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index e796923f2b1cc81e3723f7ac23ec829b9787d4cd..91c651e37a8b1c74008d868fa879cb3a2f3f2093 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -870,21 +870,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 c0c7dbbba76b397fbdbde850bd70e1c00b1f34fd..7b0204b92bc52007317a368bb37cbe7d005085f8 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'<a title="Détails" href="{url}">ℹ️</a>') - - # 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'<a title="Détails/Modifier" href="{url}">ℹ️</a>') 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'<a title="Modifier" href="{url}">📝</a>') + html.append(f'<a title="Détails/Modifier" href="{url}">ℹ️</a>') # Supprimer url = url_for( diff --git a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 index 221f3b496132cedb6905fc4e3d0031f39e596692..a6527203b220a307d6d70cd2a08d764ac7451900 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 { </style> <div class="tab-content"> <h2>{{title|safe}}</h2> - + {% if readonly %} + <h3 class="rouge">Vous n'avez pas la permission de modifier ce justificatif</h3> + {% endif %} {% if justif %} + <div class="informations"> + <div class="info-saisie"> - Saisie par {{justif.user.get_prenomnom() if justif.user else "inconnu"}} - le {{justif.entry_date.strftime(scu.DATEATIME_FMT) if justif.entry_date else "?"}} + <span>Saisie par {{justif.saisie_par}} le {{justif.entry_date}}</span> + </div> + + <div class="info-row"> + <span class="info-label">Assiduités concernées: </span> + {% if justif.justification.assiduites %} + <ul> + {% for assi in justif.justification.assiduites %} + <li><a href="{{url_for('assiduites.edit_assiduite_etud', + assiduite_id=assi.assiduite_id, scodoc_dept=g.scodoc_dept) + }}" target="_blank">{{assi.etat}} du {{assi.date_debut}} au + {{assi.date_fin}}</a> + </li> + {% endfor %} + </ul> + {% else %} + <span class="text">Aucune</span> + {% endif %} + </div> + </div> {% endif %} @@ -110,7 +132,9 @@ div.submit > input { {% for filename in filenames %} <li><span data-justif_id="{{justif.id}}" class="suppr_fichier_just" >{{scu.icontag("delete_img", alt="supprimer", title="Supprimer")|safe}}</span> - {{filename}}</li> + <a href="{{url_for('apiweb.justif_export',justif_id=justif.justif_id, + filename=filename, scodoc_dept=g.scodoc_dept)}}">{{filename}}</a> + </li> {% endfor %} </ul> {% endif %} @@ -126,11 +150,28 @@ div.submit > input { <span class="help" style="margin-left: 12px;">laisser vide pour date courante</span> {{ render_field_errors(form, 'entry_date') }} + {% if readonly == False %} {# Submit #} <div class="submit"> {{ form.submit }} {{ form.cancel }} </div> + <div class="info-row"> + <a + style="color:red;" + href="{{url_for( + 'assiduites.tableau_assiduite_actions', + type='justificatif', + action='supprimer', + obj_id=justif.justif_id, + scodoc_dept=g.scodoc_dept, + )}}" + >Supprimer le justificatif</a> + </div> + {% endif %} + + + </fieldset> </form> </section> diff --git a/app/templates/assiduites/pages/calendrier_assi_etud.j2 b/app/templates/assiduites/pages/calendrier_assi_etud.j2 index d782229443a9f806265523da437807088c2039a4..b4fa38547c7f024f1ae6ca07fb6e750847f7f6ce 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}`); }) }); </script> 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 0000000000000000000000000000000000000000..8b0930e879b2056e284c3dcb7e36c24d7d111d30 --- /dev/null +++ b/app/templates/assiduites/pages/edit_assiduite_etud.j2 @@ -0,0 +1,165 @@ +{# Ajout d'une "assiduité" sur un étudiant #} + +{% extends "sco_page.j2" %} +{% import 'wtf.j2' as wtf %} + + +{% block styles %} +{{super()}} +<link rel="stylesheet" href="{{scu.STATIC_DIR}}/css/assiduites.css"> + +<style> + .info-row { + margin-top: 12px; + } + + .info-label { + font-weight: bold; + } + #assi_etat{ + list-style: none; + } + + .info-etat { + font-size: 110%; + font-weight: bold; + background-color: rgb(253, 234, 210); + border: 1px solid grey; + border-radius: 4px; + padding: 4px; + } + + .info-saisie { + margin-top: 12px; + margin-bottom: 12px; + font-style: italic; + } +</style> +{% endblock %} + +{% block app_content %} +<div class="tab-content"> + <h2>Détails Assiduité concernant {{etud.html_link_fiche()|safe}}</h2> + + <div id="informations"> + <div class="info-saisie"> + <span>Saisie par {{objet.saisie_par}} le {{objet.entry_date}}</span> + </div> + <div class="info-row"> + <span class="info-label">Période :</span> du <b>{{objet.date_debut}}</b> au <b>{{objet.date_fin}}</b> + </div> + <div class="info-row"> + <span class="info-label">Module :</span> {{objet.module}} + </div> + <div class="info-row"> + <span class="info-label">État de l'assiduité :</span><span class="info-etat">{{objet.etat}}</span> + </div> + <div class="info-row"> + <span class="info-label">Description:</span> + {% if objet.description != "" and objet.description is not None %} + <span class="text">{{objet.description}}</span> + {% else %} + <span class="text fontred">Pas de description</span> + {% endif %} + </span> + </div> + {# Affichage des justificatifs si assiduité justifiée #} + {% if objet.etat != "Présence" %} + <div class="info-row"> + <span class="info-label">Justifiée: </span> + {% if objet.justification.est_just %} + <span class="text">Oui</span> + {% else %} + <span class="text fontred">Non</span> + {% if not objet.justification.justificatifs %} + <a + href="{{url_for( + 'assiduites.tableau_assiduite_actions', + type='assiduite', + action='justifier', + obj_id=objet.assiduite_id, + scodoc_dept=g.scodoc_dept, + )}}" + >Justifier l'assiduité</a> + {% endif %} + {% endif %} + </div> + <div class="info-row"> + {% if not objet.justification.justificatifs %} + <span class="text info-label">Pas de justificatif associé</span> + {% else %} + <span class="text info-label">Justificatifs associés:</span> + <ul> + {% for justi in objet.justification.justificatifs %} + <li> + <a href="{{url_for('assiduites.edit_justificatif_etud', + justif_id=justi.justif_id,scodoc_dept=g.scodoc_dept)}}" + target="_blank" rel="noopener noreferrer" style="{{'color:red;' if justi.etat != 'Valide'}}">Justificatif {{justi.etat}} du {{justi.date_debut}} au + {{justi.date_fin}}</a> + </li> + {% endfor %} + </ul> + {% endif %} + </div> + {% endif %} + </div> + + {% if readonly != True %} + <h2 style="margin-top: 24px;">Modification de l'assiduité</h2> + {% for err_msg in form.error_messages %} + <div class="wtf-error-messages"> + {{ err_msg }} + </div> + {% endfor %} + + <form id="edit-assiduite-form" method="post"> + {{ form.hidden_tag() }} + {# Type d'évènement #} + <div class="radio-assi_etat"> + {{ form.assi_etat.label }} + {{ form.assi_etat() }} + </div> + {# Menu module #} + <div class="select-module"> + {{ form.modimpl.label }} : + {{ form.modimpl }} + {{ render_field_errors(form, 'modimpl') }} + </div> + {# Description #} + <div> + <div>{{ form.description.label }}</div> + {{ form.description() }} + {{ render_field_errors(form, 'description') }} + </div> + {# Submit #} + <div class="submit info-row"> + {{ form.submit }} {{ form.cancel }} + </div> + + + </form> + <div class="info-row"> + <a + style="color:red;" + href="{{url_for( + 'assiduites.tableau_assiduite_actions', + type='assiduite', + action='supprimer', + obj_id=objet.assiduite_id, + scodoc_dept=g.scodoc_dept, + )}}" + >Supprimer l'assiduité</a> + </div> + {% else %} + <h3 class="rouge">Vous n'avez pas la permission de modifier cette assiduité</h3> + {% endif %} + +</div> + +{% endblock app_content %} + +{% block scripts %} +{{ super() }} +<script src="{{scu.STATIC_DIR}}/js/etud_info.js"></script> +{% include "sco_timepicker.j2" %} +{% endblock scripts %} \ No newline at end of file diff --git a/app/views/assiduites.py b/app/views/assiduites.py index e71a3246961fd17e21e747af68b3e151192c0adb..b98d61ecd01c8e50012493ea8b2ed0036b5b5d13 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, @@ -538,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, ) @@ -612,7 +611,7 @@ def bilan_etud(): @bp.route("/edit_justificatif_etud/<int:justif_id>", methods=["GET", "POST"]) @scodoc -@permission_required(Permission.AbsChange) +@permission_required(Permission.ScoView) def edit_justificatif_etud(justif_id: int): """ Edition d'un justificatif. @@ -624,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) @@ -652,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) @@ -667,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), ) @@ -1672,20 +1685,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 @@ -1698,9 +1709,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) ) @@ -2152,6 +2162,106 @@ def signal_assiduites_hebdo(): ) +@bp.route("edit_assiduite_etud/<int:assiduite_id>", 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 or -1 + if isinstance(moduleimpl_id, int): + try: + ModuleImpl.get_moduleimpl(moduleimpl_id) + except ValueError: + form.error_messages.append("Module invalide") + moduleimpl_id = -1 + form.ok = False + + if form.ok: + assi.etat = etat + assi.description = description + if moduleimpl_id != -1: + assi.set_moduleimpl(moduleimpl_id) + + 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) + + return render_template( + "assiduites/pages/edit_assiduite_etud.j2", + etud=etud, + sco=ScoData(etud, formsemestre=formsemestre), + form=form, + readonly=readonly, + 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"""