diff --git a/app/api/assiduites.py b/app/api/assiduites.py index 1abb6479df..2a2dd5e28e 100644 --- a/app/api/assiduites.py +++ b/app/api/assiduites.py @@ -1,6 +1,6 @@ ############################################################################## # ScoDoc -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """ScoDoc 9 API : Assiduités @@ -532,7 +532,7 @@ def assiduite_create(etudid: int = None, nip=None, ine=None): # On créé l'assiduité # 200 + obj si réussi # 404 + message d'erreur si non réussi - code, obj = _create_one(data, etud) + code, obj = create_one_assiduite(data, etud) if code == 404: errors.append({"indice": i, "message": obj}) else: @@ -590,7 +590,7 @@ def assiduites_create(): # route sans département set_sco_dept(etud.departement.acronym) - code, obj = _create_one(data, etud) + code, obj = create_one_assiduite(data, etud) if code == 404: errors.append({"indice": i, "message": obj}) else: @@ -600,14 +600,14 @@ def assiduites_create(): return {"errors": errors, "success": success} -def _create_one( +def create_one_assiduite( data: dict, etud: Identite, ) -> tuple[int, object]: """ - _create_one Création d'une assiduité à partir d'une représentation JSON + create_one_assiduite: création d'une assiduité à partir d'un dict - Cette fonction vérifie la représentation JSON + Cette fonction vérifie les données du dict (qui vient du JSON API ou d'ailleurs) Puis crée l'assiduité si la représentation est valide. @@ -761,7 +761,7 @@ def assiduite_delete(): # Pour chaque assiduite_id on essaye de supprimer l'assiduité for i, assiduite_id in enumerate(assiduites_list): - # De la même façon que "_create_one" + # De la même façon que "create_one_assiduite" # Ici le code est soit 200 si réussi ou 404 si raté # Le message est le message d'erreur si erreur code, msg = _delete_one(assiduite_id) @@ -1014,7 +1014,9 @@ def _edit_one(assiduite_unique: Assiduite, data: dict) -> tuple[int, str]: else assiduite_unique.external_data ) - if force and not (external_data is not None and external_data.get("module", False) != ""): + if force and not ( + external_data is not None and external_data.get("module", False) != "" + ): errors.append( "param 'moduleimpl_id' : le moduleimpl_id ne peut pas être nul" ) diff --git a/app/forms/assiduite/ajout_assiduite_etud.py b/app/forms/assiduite/ajout_assiduite_etud.py new file mode 100644 index 0000000000..70a8b494ce --- /dev/null +++ b/app/forms/assiduite/ajout_assiduite_etud.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" +Formulaire ajout d'une "assiduité" sur un étudiant +""" + +from flask_wtf import FlaskForm +from wtforms import ( + SelectField, + StringField, + SubmitField, + RadioField, + TextAreaField, + validators, +) + + +class AjoutAssiduiteEtudForm(FlaskForm): + "Formulaire de saisie d'une assiduité pour un étudiant" + assi_etat = RadioField( + "Signaler:", + choices=[("absent", "absence"), ("retard", "retard"), ("present", "présence")], + default="absent", + validators=[ + validators.DataRequired("spécifiez le type d'évènement à signaler"), + ], + ) + 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 (si plusieurs jours)", + validators=[validators.Length(max=10)], + render_kw={ + "class": "datepicker", + "size": 10, + "id": "assi_date_fin", + }, + ) + modimpl = SelectField( + "Module", + choices={}, # will be populated dynamically + ) + assi_raison = TextAreaField( + "Raison", + render_kw={ + # "name": "assi_raison", + "id": "assi_raison", + "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 937e11c43c..0330d6bb1b 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -194,7 +194,8 @@ class Assiduite(db.Model): user_id=user_id, ) db.session.add(nouv_assiduite) - log(f"create_assiduite: {etud.id} {nouv_assiduite}") + db.session.flush() + log(f"create_assiduite: {etud.id} id={nouv_assiduite.id} {nouv_assiduite}") Scolog.logdb( method="create_assiduite", etudid=etud.id, @@ -308,6 +309,8 @@ class Justificatif(db.Model): ) entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + "date de création de l'élément: date de saisie" + # pourrait devenir date de dépot au secrétariat, si différente user_id = db.Column( db.Integer, diff --git a/app/models/etudiants.py b/app/models/etudiants.py index e37d0dab0b..6148331004 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -340,6 +340,42 @@ class Identite(db.Model, models.ScoDocModel): reverse=True, ) + def get_modimpls_by_formsemestre( + self, annee_scolaire: int + ) -> dict[int, list["ModuleImpl"]]: + """Pour chaque semestre de l'année indiquée dans lequel l'étudiant + est inscrit à des moduleimpls, liste ceux ci. + { formsemestre_id : [ modimpl, ... ] } + annee_scolaire est un nombre: eg 2023 + """ + date_debut_annee = scu.date_debut_anne_scolaire(annee_scolaire) + date_fin_annee = scu.date_fin_anne_scolaire(annee_scolaire) + modimpls = ( + ModuleImpl.query.join(ModuleImplInscription) + .join(FormSemestre) + .filter( + (FormSemestre.date_debut <= date_fin_annee) + & (FormSemestre.date_fin >= date_debut_annee) + ) + .join(Identite) + .filter_by(id=self.id) + ) + # Tri, par semestre puis par module, suivant le type de formation: + formsemestres = sorted( + {m.formsemestre for m in modimpls}, key=lambda s: s.sort_key() + ) + modimpls_by_formsemestre = {} + for formsemestre in formsemestres: + modimpls_sem = [m for m in modimpls if m.formsemestre_id == formsemestre.id] + if formsemestre.formation.is_apc(): + modimpls_sem.sort(key=lambda m: m.module.sort_key_apc()) + else: + modimpls_sem.sort( + key=lambda m: (m.module.ue.numero or 0, m.module.numero or 0) + ) + modimpls_by_formsemestre[formsemestre.id] = modimpls_sem + return modimpls_by_formsemestre + @classmethod def convert_dict_fields(cls, args: dict) -> dict: """Convert fields in the given dict. No other side effect. @@ -937,3 +973,8 @@ class EtudAnnotation(db.Model): etudid = db.Column(db.Integer) # sans contrainte (compat ScoDoc 7)) author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user comment = db.Column(db.Text) + + +from app.models.formsemestre import FormSemestre +from app.models.modules import Module +from app.models.moduleimpls import ModuleImpl, ModuleImplInscription diff --git a/app/models/groups.py b/app/models/groups.py index 8b7ed5690d..dd89109bc6 100644 --- a/app/models/groups.py +++ b/app/models/groups.py @@ -11,8 +11,9 @@ from operator import attrgetter from sqlalchemy.exc import IntegrityError from app import db, log -from app.models import ScoDocModel, Scolog, GROUPNAME_STR_LEN, SHORT_STR_LEN +from app.models import ScoDocModel, GROUPNAME_STR_LEN, SHORT_STR_LEN from app.models.etudiants import Identite +from app.models.events import Scolog from app.scodoc import sco_cache from app.scodoc import sco_utils as scu from app.scodoc.sco_exceptions import AccessDenied, ScoValueError diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index 0613ec0282..719a824bcc 100755 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -45,7 +45,7 @@ def sidebar_common(): f"""ScoDoc {SCOVERSION}
Accueil
{current_user.user_name}
déconnexion @@ -69,7 +69,7 @@ def sidebar_common(): if current_user.has_permission(Permission.EditPreferences): H.append( - f"""Paramétrage
""" ) @@ -129,7 +129,7 @@ def sidebar(etudid: int = None): if current_user.has_permission(Permission.AbsChange): H.append( f""" -
  • Ajouter
  • +
  • Ajouter
  • Justifier
  • """ ) @@ -152,8 +152,8 @@ def sidebar(etudid: int = None): # Logo H.append( f"""
    -
    @@ -161,7 +161,7 @@ def sidebar(etudid: int = None): { scu.icontag("scologo_img", no_size=True) }
    - + """ ) diff --git a/app/scodoc/sco_dump_db.py b/app/scodoc/sco_dump_db.py index 851509f14e..4474f38279 100644 --- a/app/scodoc/sco_dump_db.py +++ b/app/scodoc/sco_dump_db.py @@ -82,7 +82,7 @@ def sco_dump_and_send_db( fcntl.flock(x, fcntl.LOCK_EX | fcntl.LOCK_NB) except (IOError, OSError) as e: raise ScoValueError( - "Un envoi de la base {db_name} est déjà en cours, re-essayer plus tard" + f"Un envoi de la base {db_name} est déjà en cours, re-essayer plus tard" ) from e try: diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 17a03b955a..8c87edf05f 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -1556,13 +1556,18 @@ def is_assiduites_module_forced( return retour -def get_assiduites_time_config(config_type: str) -> str: +def get_assiduites_time_config(config_type: str) -> str | int: + "Renvoie config demandée" + # config_type devrait être le nom de la variable de config pour rester cohérent... from app.models import ScoDocSiteConfig match config_type: - case "matin": + case "matin" | "assi_morning_time": return ScoDocSiteConfig.get("assi_morning_time", "08:00:00") - case "aprem": + case "aprem" | "assi_afternoon_time": return ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00") - case "pivot": + case "pivot" | "assi_lunch_time": return ScoDocSiteConfig.get("assi_lunch_time", "13:00:00") + case "tick" | "assi_tick_time": + return ScoDocSiteConfig.get("assi_tick_time", 15) + raise ValueError(f"invalid config_type: {config_type}") diff --git a/app/static/css/assiduites.css b/app/static/css/assiduites.css index 941a655792..e331b39384 100644 --- a/app/static/css/assiduites.css +++ b/app/static/css/assiduites.css @@ -649,3 +649,13 @@ font-weight: normal; margin-right: 12px; } + +section.assi-form { + margin-bottom: 12px; +} +table.liste_assi td.date { + width: 140px; +} +table.liste_assi td.actions { + width: 70px; +} diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 97d195715f..61542b668f 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1199,7 +1199,8 @@ div.vertical_spacing_but { margin-top: 12px; } -span.wtf-field ul.errors li { +span.wtf-field ul.errors li, +span.wtf-field-error { color: red; } diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 8b4df342c9..4ec47b72ab 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -988,8 +988,7 @@ function createAssiduiteComplete(assiduite, etudid) { if (data.errors["0"].message == "Module non renseigné") { const HTML = `

    Attention, le module doit obligatoirement être renseigné.

    -

    Cela vient de la configuration du semestre ou plus largement du département.

    -

    Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.

    +

    Voir configuration du semestre ou du département.

    `; const content = document.createElement("div"); @@ -1002,7 +1001,6 @@ function createAssiduiteComplete(assiduite, etudid) { ) { const HTML = `

    Attention, l'étudiant n'est pas inscrit à ce module.

    -

    Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.

    `; const content = document.createElement("div"); @@ -1015,8 +1013,8 @@ function createAssiduiteComplete(assiduite, etudid) { "Duplication: la période rentre en conflit avec une plage enregistrée" ) { const HTML = ` -

    L'assiduité n'a pas pu être enregistrée car une autre assiduité existe sur la période sélectionnée

    -

    Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.

    +

    L'assiduité n'a pas pu être enregistrée car un autre évènement + existe sur la période sélectionnée

    `; const content = document.createElement("div"); @@ -1657,7 +1655,7 @@ function getSingleEtud(etudid) { } function isSingleEtud() { - return location.href.includes("SignaleAssiduiteEtud"); + return location.href.includes("ajout_assiduite_etud"); } function getCurrentAssiduiteModuleImplId() { diff --git a/app/static/libjs/timepicker-1.3.5/jquery.timepicker.min.css b/app/static/libjs/timepicker-1.3.5/jquery.timepicker.min.css new file mode 100644 index 0000000000..51748c92d7 --- /dev/null +++ b/app/static/libjs/timepicker-1.3.5/jquery.timepicker.min.css @@ -0,0 +1 @@ +.ui-timepicker-container{position:absolute;overflow:hidden;box-sizing:border-box}.ui-timepicker,.ui-timepicker-viewport{box-sizing:content-box;height:205px;display:block;margin:0}.ui-timepicker{list-style:none;padding:0 1px;text-align:center}.ui-timepicker-viewport{padding:0;overflow:auto;overflow-x:hidden}.ui-timepicker-standard{font-family:Verdana,Arial,sans-serif;font-size:1.1em;background-color:#FFF;border:1px solid #AAA;color:#222;margin:0;padding:2px}.ui-timepicker-standard a{border:1px solid transparent;color:#222;display:block;padding:.2em .4em;text-decoration:none}.ui-timepicker-standard .ui-state-hover{background-color:#DADADA;border:1px solid #999;font-weight:400;color:#212121}.ui-timepicker-standard .ui-menu-item{margin:0;padding:0}.ui-timepicker-corners,.ui-timepicker-corners .ui-corner-all{-moz-border-radius:4px;-webkit-border-radius:4px;border-radius:4px}.ui-timepicker-hidden{display:none}.ui-timepicker-no-scrollbar .ui-timepicker{border:none}/*# sourceMappingURL=jquery.timepicker.min.css.map */ \ No newline at end of file diff --git a/app/static/libjs/timepicker-1.3.5/jquery.timepicker.min.js b/app/static/libjs/timepicker-1.3.5/jquery.timepicker.min.js new file mode 100644 index 0000000000..4e264e4a9c --- /dev/null +++ b/app/static/libjs/timepicker-1.3.5/jquery.timepicker.min.js @@ -0,0 +1,2 @@ +!function(e){"object"==typeof module&&"object"==typeof module.exports?e(require("jquery"),window,document):"undefined"!=typeof jQuery&&e(jQuery,window,document)}(function(e,t,i,n){!function(){function t(e,t,i){return new Array(i+1-e.length).join(t)+e}function n(){if(1===arguments.length){var t=arguments[0];return"string"==typeof t&&(t=e.fn.timepicker.parseTime(t)),new Date(0,0,0,t.getHours(),t.getMinutes(),t.getSeconds())}return 3===arguments.length?new Date(0,0,0,arguments[0],arguments[1],arguments[2]):2===arguments.length?new Date(0,0,0,arguments[0],arguments[1],0):new Date(0,0,0)}e.TimePicker=function(){var t=this;t.container=e(".ui-timepicker-container"),t.ui=t.container.find(".ui-timepicker"),0===t.container.length&&(t.container=e("
    ").addClass("ui-timepicker-container").addClass("ui-timepicker-hidden ui-helper-hidden").appendTo("body").hide(),t.ui=e("
    ").addClass("ui-timepicker").addClass("ui-widget ui-widget-content ui-menu").addClass("ui-corner-all").appendTo(t.container),t.viewport=e("").addClass("ui-timepicker-viewport").appendTo(t.ui),e.fn.jquery>="1.4.2"&&t.ui.delegate("a","mouseenter.timepicker",function(){t.activate(!1,e(this).parent())}).delegate("a","mouseleave.timepicker",function(){t.deactivate(!1)}).delegate("a","click.timepicker",function(i){i.preventDefault(),t.select(!1,e(this).parent())}))},e.TimePicker.count=0,e.TimePicker.instance=function(){return e.TimePicker._instance||(e.TimePicker._instance=new e.TimePicker),e.TimePicker._instance},e.TimePicker.prototype={keyCode:{ALT:18,BLOQ_MAYUS:20,CTRL:17,DOWN:40,END:35,ENTER:13,HOME:36,LEFT:37,NUMPAD_ENTER:108,PAGE_DOWN:34,PAGE_UP:33,RIGHT:39,SHIFT:16,TAB:9,UP:38},_items:function(t,i){var r,a,o=this,s=e(""),c=null;for(-1===t.options.timeFormat.indexOf("m")&&t.options.interval%60!==0&&(t.options.interval=60*Math.max(Math.round(t.options.interval/60),1)),r=i?n(i):t.options.startTime?n(t.options.startTime):n(t.options.startHour,t.options.startMinutes),a=new Date(r.getTime()+864e5);a>r;)o._isValidTime(t,r)&&(c=e("
  • ").addClass("ui-menu-item").appendTo(s),e("").addClass("ui-corner-all").text(e.fn.timepicker.formatTime(t.options.timeFormat,r)).appendTo(c),c.data("time-value",r)),r=new Date(r.getTime()+60*t.options.interval*1e3);return s.children()},_isValidTime:function(e,t){var i=null,r=null;return t=n(t),null!==e.options.minTime?i=n(e.options.minTime):null===e.options.minHour&&null===e.options.minMinutes||(i=n(e.options.minHour,e.options.minMinutes)),null!==e.options.maxTime?r=n(e.options.maxTime):null===e.options.maxHour&&null===e.options.maxMinutes||(r=n(e.options.maxHour,e.options.maxMinutes)),null!==i&&null!==r?t>=i&&r>=t:null!==i?t>=i:null!==r?r>=t:!0},_hasScroll:function(){var e="undefined"!=typeof this.ui.prop?"prop":"attr";return this.ui.height()r?i.ui.scrollTop(a+r):r>=o&&i.ui.scrollTop(a+r-o+t.height())}i.active=t.eq(0).children("a").addClass("ui-state-hover").attr("id","ui-active-item").end()}},deactivate:function(){var e=this;e.active&&(e.active.children("a").removeClass("ui-state-hover").removeAttr("id"),e.active=null)},next:function(e){return(this.closed()||this.instance===e)&&this._move(e,"next",".ui-menu-item:first"),e.element},previous:function(e){return(this.closed()||this.instance===e)&&this._move(e,"prev",".ui-menu-item:last"),e.element},first:function(e){return this.instance===e?this.active&&0===this.active.prevAll(".ui-menu-item").length:!1},last:function(e){return this.instance===e?this.active&&0===this.active.nextAll(".ui-menu-item").length:!1},selected:function(e){return this.instance===e&&this.active?this.active:null},open:function(t){var n=this,r=t.getTime(),a=t.options.dynamic&&r;if(!t.options.dropdown)return t.element;switch(t.element.data("timepicker-event-namespace",Math.random()),e(i).bind("click.timepicker-"+t.element.data("timepicker-event-namespace"),function(e){t.element.get(0)===e.target?t.element.data("timepicker-user-clicked-outside",!1):t.element.data("timepicker-user-clicked-outside",!0).blur()}),(t.rebuild||!t.items||a)&&(t.items=n._items(t,a?r:null)),(t.rebuild||n.instance!==t||a)&&(e.fn.jquery<"1.4.2"?(n.viewport.children().remove(),n.viewport.append(t.items),n.viewport.find("a").bind("mouseover.timepicker",function(){n.activate(t,e(this).parent())}).bind("mouseout.timepicker",function(){n.deactivate(t)}).bind("click.timepicker",function(i){i.preventDefault(),n.select(t,e(this).parent())})):(n.viewport.children().detach(),n.viewport.append(t.items))),t.rebuild=!1,n.container.removeClass("ui-helper-hidden ui-timepicker-hidden ui-timepicker-standard ui-timepicker-corners").show(),t.options.theme){case"standard":n.container.addClass("ui-timepicker-standard");break;case"standard-rounded-corners":n.container.addClass("ui-timepicker-standard ui-timepicker-corners")}n.container.hasClass("ui-timepicker-no-scrollbar")||t.options.scrollbar||(n.container.addClass("ui-timepicker-no-scrollbar"),n.viewport.css({paddingRight:40}));var o=n.container.outerHeight()-n.container.height(),s=t.options.zindex?t.options.zindex:t.element.offsetParent().css("z-index"),c=t.element.offset();n.container.css({top:c.top+t.element.outerHeight(),left:c.left}),n.container.show(),n.container.css({left:t.element.offset().left,height:n.ui.outerHeight()+o,width:t.element.outerWidth(),zIndex:s,cursor:"default"});var u=n.container.width()-(n.ui.outerWidth()-n.ui.width());return n.ui.css({width:u}),n.viewport.css({width:u}),t.items.css({width:u}),n.instance=t,r?t.items.each(function(){var i,a=e(this);return i=e.fn.jquery<"1.4.2"?e.fn.timepicker.parseTime(a.find("a").text()):a.data("time-value"),i.getTime()===r.getTime()?(n.activate(t,a),!1):!0}):n.deactivate(t),t.element},close:function(t){var n=this;return n.instance===t&&(n.container.addClass("ui-helper-hidden ui-timepicker-hidden").hide(),n.ui.scrollTop(0),n.ui.children().removeClass("ui-state-hover")),e(i).unbind("click.timepicker-"+t.element.data("timepicker-event-namespace")),t.element},closed:function(){return this.ui.is(":hidden")},destroy:function(e){var t=this;return t.close(e,!0),e.element.unbind(".timepicker").data("TimePicker",null)},parse:function(t,i){return e.fn.timepicker.parseTime(i)},format:function(t,i,n){return n=n||t.options.timeFormat,e.fn.timepicker.formatTime(n,i)},getTime:function(t){var i=this,n=e.fn.timepicker.parseTime(t.element.val());return n instanceof Date&&!i._isValidTime(t,n)?null:n instanceof Date&&t.selectedTime?t.format(n)===t.format(t.selectedTime)?t.selectedTime:n:n instanceof Date?n:null},setTime:function(t,i,r){var a=this,o=t.selectedTime;if("string"==typeof i&&(i=t.parse(i)),i&&i.getMinutes&&a._isValidTime(t,i)){if(i=n(i),t.selectedTime=i,t.element.val(t.format(i,t.options.timeFormat)),r)return t}else t.selectedTime=null;return null===o&&null===t.selectedTime||(t.element.trigger("time-change",[i]),e.isFunction(t.options.change)&&t.options.change.apply(t.element,[i])),t.element},option:function(t,i,n){if("undefined"==typeof n)return t.options[i];var r,a,o=t.getTime();"string"==typeof i?(r={},r[i]=n):r=i,a=["minHour","minMinutes","minTime","maxHour","maxMinutes","maxTime","startHour","startMinutes","startTime","timeFormat","interval","dropdown"],e.each(r,function(i){t.options[i]=r[i],t.rebuild=t.rebuild||e.inArray(i,a)>-1}),t.rebuild&&t.setTime(o)}},e.TimePicker.defaults={timeFormat:"hh:mm p",minHour:null,minMinutes:null,minTime:null,maxHour:null,maxMinutes:null,maxTime:null,startHour:null,startMinutes:null,startTime:null,interval:30,dynamic:!0,theme:"standard",zindex:null,dropdown:!0,scrollbar:!1,change:function(){}},e.TimePicker.methods={chainable:["next","previous","open","close","destroy","setTime"]},e.fn.timepicker=function(t){if("string"==typeof t){var i,n,r=Array.prototype.slice.call(arguments,1);return i="option"===t&&arguments.length>2?"each":-1!==e.inArray(t,e.TimePicker.methods.chainable)?"each":"map",n=this[i](function(){var i=e(this),n=i.data("TimePicker");return"object"==typeof n?n[t].apply(n,r):void 0}),"map"===i&&1===this.length?e.makeArray(n).shift():"map"===i?e.makeArray(n):n}if(1===this.length&&this.data("TimePicker"))return this.data("TimePicker");var a=e.extend({},e.TimePicker.defaults,t);return this.each(function(){e.TimePicker.instance().register(this,a)})},e.fn.timepicker.formatTime=function(e,i){var n=i.getHours(),r=n%12,a=i.getMinutes(),o=i.getSeconds(),s={hh:t((0===r?12:r).toString(),"0",2),HH:t(n.toString(),"0",2),mm:t(a.toString(),"0",2),ss:t(o.toString(),"0",2),h:0===r?12:r,H:n,m:a,s:o,p:n>11?"PM":"AM"},c=e,u="";for(u in s)s.hasOwnProperty(u)&&(c=c.replace(new RegExp(u,"g"),s[u]));return c=c.replace(new RegExp("a","g"),n>11?"pm":"am")},e.fn.timepicker.parseTime=function(){var t=[[/^(\d+)$/,"$1"],[/^:(\d)$/,"$10"],[/^:(\d+)/,"$1"],[/^(\d):([7-9])$/,"0$10$2"],[/^(\d):(\d\d)$/,"$1$2"],[/^(\d):(\d{1,})$/,"0$1$20"],[/^(\d\d):([7-9])$/,"$10$2"],[/^(\d\d):(\d)$/,"$1$20"],[/^(\d\d):(\d*)$/,"$1$2"],[/^(\d{3,}):(\d)$/,"$10$2"],[/^(\d{3,}):(\d{2,})/,"$1$2"],[/^(\d):(\d):(\d)$/,"0$10$20$3"],[/^(\d{1,2}):(\d):(\d\d)/,"$10$2$3"]],i=t.length;return function(r){var a=n(new Date),o=!1,s=!1,c=!1,u=!1,l=!1;if("undefined"==typeof r||!r.toLowerCase)return null;r=r.toLowerCase(),o=/a/.test(r),s=o?!1:/p/.test(r),r=r.replace(/[^0-9:]/g,"").replace(/:+/g,":");for(var m=0;i>m;m+=1)if(t[m][0].test(r)){r=r.replace(t[m][0],t[m][1]);break}return r=r.replace(/:/g,""),1===r.length?c=r:2===r.length?c=r:3===r.length||5===r.length?(c=r.substr(0,1),u=r.substr(1,2),l=r.substr(3,2)):(4===r.length||r.length>5)&&(c=r.substr(0,2),u=r.substr(2,2),l=r.substr(4,2)),r.length>0&&r.length<5&&(r.length<3&&(u=0),l=0),c===!1||u===!1||l===!1?!1:(c=parseInt(c,10),u=parseInt(u,10),l=parseInt(l,10),o&&12===c?c=0:s&&12>c&&(c+=12),c>24?r.length>=6?e.fn.timepicker.parseTime(r.substr(0,5)):e.fn.timepicker.parseTime(r+"0"+(o?"a":"")+(s?"p":"")):(a.setHours(c,u,l),a))}}()}()}); +//# sourceMappingURL=jquery.timepicker.min.js.map \ No newline at end of file diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py index 756c12fde5..db3348d806 100644 --- a/app/tables/liste_assiduites.py +++ b/app/tables/liste_assiduites.py @@ -49,7 +49,7 @@ class ListeAssiJusti(tb.Table): # Instanciation de la classe parent super().__init__( row_class=RowAssiJusti, - classes=["gt_table", "gt_left"], + classes=["liste_assi", "gt_table", "gt_left"], **kwargs, with_foot_titles=False, ) @@ -101,19 +101,19 @@ class ListeAssiJusti(tb.Table): """ Applique la pagination à une requête SQLAlchemy en fonction des paramètres de la classe. - Cette méthode prend une requête SQLAlchemy et applique la pagination en utilisant les attributs `page` et - `NB_PAR_PAGE` de la classe `ListeAssiJusti`. + Cette méthode prend une requête SQLAlchemy et applique la pagination en utilisant les + attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`. Args: - query (Query): La requête SQLAlchemy à paginer. Il s'agit d'une requête qui a déjà été construite et - qui est prête à être exécutée. + query (Query): La requête SQLAlchemy à paginer. Il s'agit d'une requête qui a déjà + été construite et qui est prête à être exécutée. Returns: Pagination: Un objet Pagination qui encapsule les résultats de la requête paginée. Note: - Cette méthode ne modifie pas la requête originale; elle renvoie plutôt un nouvel objet qui contient les - résultats paginés. + Cette méthode ne modifie pas la requête originale; elle renvoie plutôt un nouvel + objet qui contient les résultats paginés. """ return query.paginate( page=self.options.page, per_page=self.options.nb_ligne_page, error_out=False @@ -123,8 +123,9 @@ class ListeAssiJusti(tb.Table): """ Combine les requêtes d'assiduités et de justificatifs en une seule requête. - Cette fonction prend en entrée deux requêtes optionnelles, une pour les assiduités et une pour les justificatifs, - et renvoie une requête combinée qui sélectionne un ensemble spécifique de colonnes pour chaque type d'objet. + Cette fonction prend en entrée deux requêtes optionnelles, une pour les assiduités + et une pour les justificatifs, et renvoie une requête combinée qui sélectionne + un ensemble spécifique de colonnes pour chaque type d'objet. Les colonnes sélectionnées sont: - obj_id: l'identifiant de l'objet (assiduite_id pour les assiduités, justif_id pour les justificatifs) @@ -138,13 +139,17 @@ class ListeAssiJusti(tb.Table): - user_id : l'identifiant de l'utilisateur qui a signalé l'assiduité ou le justificatif Args: - query_assiduite (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy pour les assiduités. - Si None, aucune assiduité ne sera incluse dans la requête combinée. Defaults to None. - query_justificatif (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy pour les justificatifs. - Si None, aucun justificatif ne sera inclus dans la requête combinée. Defaults to None. + query_assiduite (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy + pour les assiduités. + Si None (default), aucune assiduité ne sera incluse dans la requête combinée. + + query_justificatif (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy + pour les justificatifs. + Si None (default), aucun justificatif ne sera inclus dans la requête combinée. Returns: - sqlalchemy.orm.Query: Une requête combinée qui peut être exécutée pour obtenir les résultats. + sqlalchemy.orm.Query: Une requête combinée qui peut être exécutée pour + obtenir les résultats. Raises: ValueError: Si aucune requête n'est fournie (les deux paramètres sont None). @@ -220,6 +225,10 @@ class RowAssiJusti(tb.Row): ) def ajouter_colonnes(self, lien_redirection: str = None): + # Ajout colonne actions + if self.table.options.show_actions: + self._actions() + # Ajout de l'étudiant self.table: ListeAssiJusti if self.table.options.show_etu: @@ -235,6 +244,7 @@ class RowAssiJusti(tb.Row): self.ligne["date_debut"].strftime("%d/%m/%y à %H:%M"), data={"order": self.ligne["date_debut"]}, raw_content=self.ligne["date_debut"], + column_classes={"date"}, ) # Date de fin self.add_cell( @@ -243,15 +253,12 @@ class RowAssiJusti(tb.Row): self.ligne["date_fin"].strftime("%d/%m/%y à %H:%M"), raw_content=self.ligne["date_fin"], data={"order": self.ligne["date_fin"]}, + column_classes={"date"}, ) # Ajout des colonnes optionnelles self._optionnelles() - # Ajout colonne actions - if self.table.options.show_actions: - self._actions() - # Ajout de l'utilisateur ayant saisie l'objet self._utilisateur() @@ -263,6 +270,7 @@ class RowAssiJusti(tb.Row): data={"order": self.ligne["entry_date"]}, raw_content=self.ligne["entry_date"], classes=["small-font"], + column_classes={"entry_date"}, ) def _type(self) -> None: @@ -338,7 +346,9 @@ class RowAssiJusti(tb.Row): self.add_cell("module", "Module", "", data={"order": ""}) def _utilisateur(self) -> None: - utilisateur: User = User.query.get(self.ligne["user_id"]) + utilisateur: User = ( + User.query.get(self.ligne["user_id"]) if self.ligne["user_id"] else None + ) self.add_cell( "user", @@ -359,7 +369,7 @@ class RowAssiJusti(tb.Row): obj_id=self.ligne["obj_id"], scodoc_dept=g.scodoc_dept, ) - html.append(f'ℹ️') # utiliser url_for + html.append(f'ℹ️') # Modifier url = url_for( @@ -369,7 +379,7 @@ class RowAssiJusti(tb.Row): obj_id=self.ligne["obj_id"], scodoc_dept=g.scodoc_dept, ) - html.append(f'📝') # utiliser url_for + html.append(f'📝') # Supprimer url = url_for( @@ -381,7 +391,13 @@ class RowAssiJusti(tb.Row): ) html.append(f'') # utiliser url_for - self.add_cell("actions", "Actions", " ".join(html), no_excel=True) + self.add_cell( + "actions", + "", + " ".join(html), + no_excel=True, + column_classes={"actions"}, + ) class AssiFiltre: diff --git a/app/templates/assiduites/pages/ajout_assiduite_etud.j2 b/app/templates/assiduites/pages/ajout_assiduite_etud.j2 new file mode 100644 index 0000000000..ed91b4218d --- /dev/null +++ b/app/templates/assiduites/pages/ajout_assiduite_etud.j2 @@ -0,0 +1,123 @@ +{# Ajout d'une "assiduité" sur un étudiant #} + +{% extends "sco_page.j2" %} +{% import 'wtf.j2' as wtf %} + + +{% block styles %} + {{super()}} + + +{% endblock %} + +{% block app_content %} + + +
    +

    Signaler une absence, retard ou présence pour {{etud.html_link_fiche()|safe}}

    + + {% if 'general_errors' in form.errors %} +
    + {% for error in form.errors['general_errors'] %} + {{ error }} + {% endfor %} +
    + {% endif %} + +
    + {{ form.hidden_tag() }} + {# Type d'évènement #} +
    + {{ form.assi_etat.label }} + {{ form.assi_etat() }} +
    + {# Dates et heures #} +
    + {{ form.date_debut.label }} : {{ form.date_debut }} + de {{ form.heure_debut }} à {{ form.heure_fin }} + laisser les heures vides pour signaler la journée entière + {{ render_field_errors(form, 'date_debut') }} + {{ render_field_errors(form, 'heure_debut') }} + {{ render_field_errors(form, 'heure_fin') }} + +
    + {{ form.date_fin.label }} : {{ form.date_fin }} + si le jour de fin est différent, + les heures seront ignorées (journées complètes) + {{ render_field_errors(form, 'date_fin') }} +
    +
    + {# Menu module #} +
    + {{ form.modimpl.label }} : + {{ form.modimpl }} + {{ render_field_errors(form, 'modimpl') }} +
    + {# Raison #} +
    +
    {{ form.assi_raison.label }}
    + {{ form.assi_raison() }} + {{ render_field_errors(form, 'assi_raison') }} +
    + {# Submit #} +
    + {{ form.submit }} {{ form.cancel }} +
    +
    + +
    + {{tableau | safe }} +
    + +
    + +{% endblock app_content %} + +{% block scripts %} +{{ super() }} + + + + +{% endblock scripts %} diff --git a/app/templates/assiduites/pages/ajout_assiduites.j2 b/app/templates/assiduites/pages/ajout_assiduites.j2 index a2d92c563d..ca65d0329a 100644 --- a/app/templates/assiduites/pages/ajout_assiduites.j2 +++ b/app/templates/assiduites/pages/ajout_assiduites.j2 @@ -3,7 +3,7 @@ {% block pageContent %}
    -

    Signaler un évènement pour {{etud.html_link_fiche()|safe}}

    +

    Signaler une absence, présence ou retard pour {{etud.html_link_fiche()|safe}}

    {% if saisie_eval %}

    @@ -19,7 +19,10 @@
    Date de début - + + Journée entière
    @@ -50,7 +53,7 @@
    Raison - +
    @@ -95,7 +98,17 @@ } {% endblock %} + +{% macro render_field_errors(form, field_name) %} + {% if form[field_name].errors %} +
    + {% for error in form[field_name].errors %} + {{ error }} + {% endfor %} +
    + {% endif %} +{% endmacro %} diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 index 95bb93cd67..73050751af 100755 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -62,20 +62,20 @@ {% endif %} {% endif %} @@ -83,7 +83,7 @@ {# LOGO #}
    -
    - \ No newline at end of file + diff --git a/app/views/assiduites.py b/app/views/assiduites.py index b08c2ebf6b..ba5aa61831 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -32,13 +32,14 @@ from flask import abort, url_for, redirect, Response from flask_login import current_user from app import db - +from app.api.assiduites import create_one_assiduite from app.comp import res_sem from app.comp.res_compat import NotesTableCompat from app.decorators import ( scodoc, permission_required, ) +from app.forms.assiduite.ajout_assiduite_etud import AjoutAssiduiteEtudForm from app.models import ( FormSemestre, Identite, @@ -48,6 +49,7 @@ from app.models import ( Departement, Evaluation, ) +from app.scodoc.codes_cursus import UE_STANDARD from app.auth.models import User from app.models.assiduites import get_assiduites_justif, compute_assiduites_justified import app.tables.liste_assiduites as liste_assi @@ -246,19 +248,19 @@ def bilan_dept(): return "\n".join(H) -@bp.route("/SignaleAssiduiteEtud") +@bp.route("/ajout_assiduite_etud", methods=["GEt", "POST"]) @scodoc @permission_required(Permission.AbsChange) -def signal_assiduites_etud(): +def ajout_assiduite_etud(): """ - signal_assiduites_etud Saisie de l'assiduité d'un étudiant + ajout_assiduite_etud Saisie d'une assiduité d'un étudiant Args: etudid (int): l'identifiant de l'étudiant date_deb, date_fin: heures début et fin (ISO sans timezone) moduleimpl_id - evaluation_id - saisie_eval : si présent, mode "évaluation" + evaluation_id : si présent, mode "évaluation" + fmt: si xls, renvoie le tableau des assiduités enregistrées Returns: str: l'html généré """ @@ -266,11 +268,13 @@ def signal_assiduites_etud(): etud = Identite.get_etud(etudid) # Gestion évaluations (appel à la page depuis les évaluations) - saisie_eval: bool = request.args.get("saisie_eval") is not None + evaluation_id: int = request.args.get("evaluation_id") + saisie_eval = evaluation_id is not None + date_deb: str = request.args.get("date_deb") date_fin: str = request.args.get("date_fin") moduleimpl_id: int = request.args.get("moduleimpl_id", "") - evaluation_id: int = request.args.get("evaluation_id") + redirect_url: str = ( "#" if not saisie_eval @@ -281,21 +285,32 @@ def signal_assiduites_etud(): ) ) - # Préparation de la page (Header) - header: str = html_sco_header.sco_header( - page_title="Saisie assiduité", - init_qtip=True, - javascripts=[ - "js/assiduites.js", - "js/date_utils.js", - "js/etud_info.js", - ], - cssstyles=CSSSTYLES - + [ - "css/assiduites.css", - ], - ) + form = AjoutAssiduiteEtudForm(request.form) + # On dresse la liste des modules de l'année scolaire en cours + # auxquels est inscrit l'étudiant pour peupler le menu "module" + modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire()) + 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) + choices[formsemestre.titre_annee()] = [ + (m.id, m.module.code) + for m in modimpls_by_formsemestre[formsemestre_id] + if m.module.ue.type == UE_STANDARD + ] + form.modimpl.choices = choices + if form.validate_on_submit(): + if form.cancel.data: # cancel button + return redirect(redirect_url) + ok = _record_assiduite_etud(etud, form) + if ok: + flash("enregistré") + return redirect(redirect_url) + + # Le tableau des assiduités+justificatifs déjà en base: is_html, tableau = _prepare_tableau( liste_assi.AssiJustifData.from_etudiants( etud, @@ -305,52 +320,123 @@ def signal_assiduites_etud(): filtre=liste_assi.AssiFiltre(type_obj=1), options=liste_assi.AssiDisplayOptions(show_module=True), ) + # if not is_html: return tableau - # Génération de la page - return HTMLBuilder( - header, - _mini_timeline(), - render_template( - "assiduites/pages/ajout_assiduites.j2", - sco=ScoData(etud), - assi_limit_annee=sco_preferences.get_preference( - "assi_limit_annee", - dept_id=g.scodoc_dept_id, - ), - assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"), - assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"), - saisie_eval=saisie_eval, - date_deb=date_deb, - date_fin=date_fin, - etud=etud, - redirect_url=redirect_url, - moduleimpl_id=moduleimpl_id, - tableau=tableau, - ), - # render_template( - # "assiduites/pages/signal_assiduites_etud.j2", - # sco=ScoData(etud), - # date=_dateiso_to_datefr(date), - # morning=morning, - # lunch=lunch, - # timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])), - # afternoon=afternoon, - # nonworkdays=_non_work_days(), - # forcer_module=sco_preferences.get_preference( - # "forcer_module", dept_id=g.scodoc_dept_id - # ), - # diff=_differee( - # etudiants=[sco_etud.get_etud_info(etudid=etud.etudid, filled=True)[0]], - # moduleimpl_select=select, - # ), - # saisie_eval=saisie_eval, - # date_deb=date_deb, - # date_fin=date_fin, - # redirect_url=redirect_url, - # moduleimpl_id=moduleimpl_id, - # ), - ).build() + + return render_template( + "assiduites/pages/ajout_assiduite_etud.j2", + etud=etud, + form=form, + moduleimpl_id=moduleimpl_id, + redirect_url=redirect_url, + sco=ScoData(etud), + tableau=tableau, + scu=scu, + ) + + +def _record_assiduite_etud( + etud: Identite, + form: AjoutAssiduiteEtudForm, +) -> bool: + """Enregistre les données du formulaire de saisie assiduité. + Returns ok if successfully recorded, else put error info in the form. + Format attendu des données du formulaire: + form.assi_etat.data : 'absent' + form.date_debut.data : '05/12/2023' + form.heure_debut.data : '09:06' (heure locale du serveur) + """ + ok = True + debut_jour = "00:00" + fin_jour = "23:59:59" + # On commence par convertir individuellement tous les champs + try: + date_debut = datetime.datetime.strptime(form.date_debut.data, "%d/%m/%Y") + except ValueError: + form.date_debut.errors.append("date début invalide") + ok = False + try: + date_fin = ( + datetime.datetime.strptime(form.date_fin.data, "%d/%m/%Y") + if form.date_fin.data + else None + ) + except ValueError: + form.date_fin.errors.append("date fin invalide") + ok = False + + if date_fin: + # ignore les heures si plusieurs jours + heure_debut = datetime.time.fromisoformat(debut_jour) # 0h + heure_fin = datetime.time.fromisoformat(fin_jour) # minuit + else: + try: + heure_debut = datetime.time.fromisoformat( + form.heure_debut.data or debut_jour + ) + except ValueError: + form.heure_debut.errors.append("heure début invalide") + ok = False + try: + heure_fin = datetime.time.fromisoformat(form.heure_fin.data or fin_jour) + except ValueError: + form.heure_fin.errors.append("heure fin invalide") + ok = False + # Le module (avec "autre") + mod_data = form.modimpl.data + if mod_data: + if mod_data == "autre": + moduleimpl_id = "autre" + else: + try: + moduleimpl_id = int(mod_data) + except ValueError: + form.modimpl.error("choix de module invalide") + ok = False + else: + moduleimpl_id = None + if not ok: + return False + # Vérifie cohérence des dates/heures + dt_debut = datetime.datetime.combine(date_debut, heure_debut) + dt_fin = datetime.datetime.combine(date_fin or date_debut, heure_fin) + if dt_fin <= dt_debut: + form.errors["general_errors"] = ["Erreur: dates début/fin incohérentes"] + return False + data = { + "date_debut": dt_debut.isoformat(), + "date_fin": dt_fin.isoformat(), + "etat": form.assi_etat.data, + "moduleimpl_id": moduleimpl_id, + } + ok, result = create_one_assiduite(data, etud) + if ok == 200: + # assiduite_id = result["assiduite_id"] + return True + form.errors["general_errors"] = [f"Erreur: {result}"] + return False + + # # Génération de la page + # return HTMLBuilder( + # header, + # _mini_timeline(), + # render_template( + # "assiduites/pages/ajout_assiduites.j2", + # sco=ScoData(etud), + # assi_limit_annee=sco_preferences.get_preference( + # "assi_limit_annee", + # dept_id=g.scodoc_dept_id, + # ), + # saisie_eval=saisie_eval, + # date_deb=date_deb, + # date_fin=date_fin, + # etud=etud, + # redirect_url=redirect_url, + # moduleimpl_id=moduleimpl_id, + # tableau=tableau, + # scu=scu, + # ), @bp.route("/ListeAssiduitesEtud") @@ -1513,10 +1599,12 @@ def signal_evaluation_abs(etudid: int = None, evaluation_id: int = None): # rediriger vers page saisie return redirect( url_for( - "assiduites.signal_assiduites_etud", + "assiduites.ajout_assiduite_etud", etudid=etudid, evaluation_id=evaluation.id, - date_deb=evaluation.date_debut.strftime("%Y-%m-%dT%H:%M:%S"), + date_deb=evaluation.date_debut.strftime( + "%Y-%m-%dT%H:%M:%S" + ), # XXX TODO date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"), moduleimpl_id=evaluation.moduleimpl.id, saisie_eval="true", @@ -1540,12 +1628,14 @@ def signal_evaluation_abs(etudid: int = None, evaluation_id: int = None): if "Duplication" in msg: msg = """Une autre saisie concerne déjà cette période. En cliquant sur continuer vous serez redirigé vers la page de - saisie des assiduités de l'étudiant.""" + saisie de l'assiduité de l'étudiant.""" dest: str = url_for( - "assiduites.signal_assiduites_etud", + "assiduites.ajout_assiduite_etud", etudid=etudid, evaluation_id=evaluation.id, - date_deb=evaluation.date_debut.strftime("%Y-%m-%dT%H:%M:%S"), + date_deb=evaluation.date_debut.strftime( + "%Y-%m-%dT%H:%M:%S" + ), # XXX TODO date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"), moduleimpl_id=evaluation.moduleimpl.id, saisie_eval="true", diff --git a/app/views/users.py b/app/views/users.py index 143a5e4c78..3c0c11972b 100644 --- a/app/views/users.py +++ b/app/views/users.py @@ -535,7 +535,7 @@ def create_user_form(user_name=None, edit=0, all_roles=True): "d", { "input_type": "separator", - "title": f"L'utilisateur sera crée dans le département {auth_dept}", + "title": f"L'utilisateur sera créé dans le département {auth_dept}", }, ) )