Update opolka/ScoDoc from ScoDoc/ScoDoc #2

Merged
opolka merged 1272 commits from ScoDoc/ScoDoc:master into master 2024-05-27 09:11:04 +02:00
20 changed files with 554 additions and 133 deletions
Showing only changes of commit 16e63069a5 - Show all commits

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
"""ScoDoc 9 API : Assiduités """ScoDoc 9 API : Assiduités
@ -532,7 +532,7 @@ def assiduite_create(etudid: int = None, nip=None, ine=None):
# On créé l'assiduité # On créé l'assiduité
# 200 + obj si réussi # 200 + obj si réussi
# 404 + message d'erreur si non 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: if code == 404:
errors.append({"indice": i, "message": obj}) errors.append({"indice": i, "message": obj})
else: else:
@ -590,7 +590,7 @@ def assiduites_create():
# route sans département # route sans département
set_sco_dept(etud.departement.acronym) set_sco_dept(etud.departement.acronym)
code, obj = _create_one(data, etud) code, obj = create_one_assiduite(data, etud)
if code == 404: if code == 404:
errors.append({"indice": i, "message": obj}) errors.append({"indice": i, "message": obj})
else: else:
@ -600,14 +600,14 @@ def assiduites_create():
return {"errors": errors, "success": success} return {"errors": errors, "success": success}
def _create_one( def create_one_assiduite(
data: dict, data: dict,
etud: Identite, etud: Identite,
) -> tuple[int, object]: ) -> 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. 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é # Pour chaque assiduite_id on essaye de supprimer l'assiduité
for i, assiduite_id in enumerate(assiduites_list): 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é # Ici le code est soit 200 si réussi ou 404 si raté
# Le message est le message d'erreur si erreur # Le message est le message d'erreur si erreur
code, msg = _delete_one(assiduite_id) 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 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( errors.append(
"param 'moduleimpl_id' : le moduleimpl_id ne peut pas être nul" "param 'moduleimpl_id' : le moduleimpl_id ne peut pas être nul"
) )

View File

@ -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})

View File

@ -194,7 +194,8 @@ class Assiduite(db.Model):
user_id=user_id, user_id=user_id,
) )
db.session.add(nouv_assiduite) 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( Scolog.logdb(
method="create_assiduite", method="create_assiduite",
etudid=etud.id, etudid=etud.id,
@ -308,6 +309,8 @@ class Justificatif(db.Model):
) )
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) 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( user_id = db.Column(
db.Integer, db.Integer,

View File

@ -340,6 +340,42 @@ class Identite(db.Model, models.ScoDocModel):
reverse=True, 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 @classmethod
def convert_dict_fields(cls, args: dict) -> dict: def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect. """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)) etudid = db.Column(db.Integer) # sans contrainte (compat ScoDoc 7))
author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user
comment = db.Column(db.Text) comment = db.Column(db.Text)
from app.models.formsemestre import FormSemestre
from app.models.modules import Module
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription

View File

@ -11,8 +11,9 @@ from operator import attrgetter
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app import db, log 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.etudiants import Identite
from app.models.events import Scolog
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError from app.scodoc.sco_exceptions import AccessDenied, ScoValueError

View File

@ -129,7 +129,7 @@ def sidebar(etudid: int = None):
if current_user.has_permission(Permission.AbsChange): if current_user.has_permission(Permission.AbsChange):
H.append( H.append(
f""" f"""
<li><a href="{ url_for('assiduites.signal_assiduites_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li> <li><a href="{ url_for('assiduites.ajout_assiduite_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li>
<li><a href="{ url_for('assiduites.ajout_justificatif_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li> <li><a href="{ url_for('assiduites.ajout_justificatif_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li>
""" """
) )

View File

@ -82,7 +82,7 @@ def sco_dump_and_send_db(
fcntl.flock(x, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(x, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (IOError, OSError) as e: except (IOError, OSError) as e:
raise ScoValueError( 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 ) from e
try: try:

View File

@ -1556,13 +1556,18 @@ def is_assiduites_module_forced(
return retour 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 from app.models import ScoDocSiteConfig
match config_type: match config_type:
case "matin": case "matin" | "assi_morning_time":
return ScoDocSiteConfig.get("assi_morning_time", "08:00:00") 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") 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") 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}")

View File

@ -649,3 +649,13 @@
font-weight: normal; font-weight: normal;
margin-right: 12px; margin-right: 12px;
} }
section.assi-form {
margin-bottom: 12px;
}
table.liste_assi td.date {
width: 140px;
}
table.liste_assi td.actions {
width: 70px;
}

View File

@ -1199,7 +1199,8 @@ div.vertical_spacing_but {
margin-top: 12px; margin-top: 12px;
} }
span.wtf-field ul.errors li { span.wtf-field ul.errors li,
span.wtf-field-error {
color: red; color: red;
} }

View File

@ -988,8 +988,7 @@ function createAssiduiteComplete(assiduite, etudid) {
if (data.errors["0"].message == "Module non renseigné") { if (data.errors["0"].message == "Module non renseigné") {
const HTML = ` const HTML = `
<p>Attention, le module doit obligatoirement être renseigné.</p> <p>Attention, le module doit obligatoirement être renseigné.</p>
<p>Cela vient de la configuration du semestre ou plus largement du département.</p> <p>Voir configuration du semestre ou du département.</p>
<p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
`; `;
const content = document.createElement("div"); const content = document.createElement("div");
@ -1002,7 +1001,6 @@ function createAssiduiteComplete(assiduite, etudid) {
) { ) {
const HTML = ` const HTML = `
<p>Attention, l'étudiant n'est pas inscrit à ce module.</p> <p>Attention, l'étudiant n'est pas inscrit à ce module.</p>
<p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
`; `;
const content = document.createElement("div"); 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" "Duplication: la période rentre en conflit avec une plage enregistrée"
) { ) {
const HTML = ` const HTML = `
<p>L'assiduité n'a pas pu être enregistrée car une autre assiduité existe sur la période sélectionnée</p> <p>L'assiduité n'a pas pu être enregistrée car un autre évènement
<p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p> existe sur la période sélectionnée</p>
`; `;
const content = document.createElement("div"); const content = document.createElement("div");
@ -1657,7 +1655,7 @@ function getSingleEtud(etudid) {
} }
function isSingleEtud() { function isSingleEtud() {
return location.href.includes("SignaleAssiduiteEtud"); return location.href.includes("ajout_assiduite_etud");
} }
function getCurrentAssiduiteModuleImplId() { function getCurrentAssiduiteModuleImplId() {

View File

@ -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 */

File diff suppressed because one or more lines are too long

View File

@ -49,7 +49,7 @@ class ListeAssiJusti(tb.Table):
# Instanciation de la classe parent # Instanciation de la classe parent
super().__init__( super().__init__(
row_class=RowAssiJusti, row_class=RowAssiJusti,
classes=["gt_table", "gt_left"], classes=["liste_assi", "gt_table", "gt_left"],
**kwargs, **kwargs,
with_foot_titles=False, 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. 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 Cette méthode prend une requête SQLAlchemy et applique la pagination en utilisant les
`NB_PAR_PAGE` de la classe `ListeAssiJusti`. attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`.
Args: Args:
query (Query): La requête SQLAlchemy à paginer. Il s'agit d'une requête qui a déjà été construite et query (Query): La requête SQLAlchemy à paginer. Il s'agit d'une requête qui a déjà
qui est prête à être exécutée. été construite et qui est prête à être exécutée.
Returns: Returns:
Pagination: Un objet Pagination qui encapsule les résultats de la requête paginée. Pagination: Un objet Pagination qui encapsule les résultats de la requête paginée.
Note: Note:
Cette méthode ne modifie pas la requête originale; elle renvoie plutôt un nouvel objet qui contient les Cette méthode ne modifie pas la requête originale; elle renvoie plutôt un nouvel
résultats paginés. objet qui contient les résultats paginés.
""" """
return query.paginate( return query.paginate(
page=self.options.page, per_page=self.options.nb_ligne_page, error_out=False 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. 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, Cette fonction prend en entrée deux requêtes optionnelles, une pour les assiduités
et renvoie une requête combinée qui sélectionne un ensemble spécifique de colonnes pour chaque type d'objet. 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: Les colonnes sélectionnées sont:
- obj_id: l'identifiant de l'objet (assiduite_id pour les assiduités, justif_id pour les justificatifs) - 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 - user_id : l'identifiant de l'utilisateur qui a signalé l'assiduité ou le justificatif
Args: Args:
query_assiduite (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy pour les assiduités. query_assiduite (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy
Si None, aucune assiduité ne sera incluse dans la requête combinée. Defaults to None. pour les assiduités.
query_justificatif (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy pour les justificatifs. Si None (default), aucune assiduité ne sera incluse dans la requête combinée.
Si None, aucun justificatif ne sera inclus dans la requête combinée. Defaults to None.
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: 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: Raises:
ValueError: Si aucune requête n'est fournie (les deux paramètres sont None). 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): def ajouter_colonnes(self, lien_redirection: str = None):
# Ajout colonne actions
if self.table.options.show_actions:
self._actions()
# Ajout de l'étudiant # Ajout de l'étudiant
self.table: ListeAssiJusti self.table: ListeAssiJusti
if self.table.options.show_etu: if self.table.options.show_etu:
@ -235,6 +244,7 @@ class RowAssiJusti(tb.Row):
self.ligne["date_debut"].strftime("%d/%m/%y à %H:%M"), self.ligne["date_debut"].strftime("%d/%m/%y à %H:%M"),
data={"order": self.ligne["date_debut"]}, data={"order": self.ligne["date_debut"]},
raw_content=self.ligne["date_debut"], raw_content=self.ligne["date_debut"],
column_classes={"date"},
) )
# Date de fin # Date de fin
self.add_cell( self.add_cell(
@ -243,15 +253,12 @@ class RowAssiJusti(tb.Row):
self.ligne["date_fin"].strftime("%d/%m/%y à %H:%M"), self.ligne["date_fin"].strftime("%d/%m/%y à %H:%M"),
raw_content=self.ligne["date_fin"], raw_content=self.ligne["date_fin"],
data={"order": self.ligne["date_fin"]}, data={"order": self.ligne["date_fin"]},
column_classes={"date"},
) )
# Ajout des colonnes optionnelles # Ajout des colonnes optionnelles
self._optionnelles() self._optionnelles()
# Ajout colonne actions
if self.table.options.show_actions:
self._actions()
# Ajout de l'utilisateur ayant saisie l'objet # Ajout de l'utilisateur ayant saisie l'objet
self._utilisateur() self._utilisateur()
@ -263,6 +270,7 @@ class RowAssiJusti(tb.Row):
data={"order": self.ligne["entry_date"]}, data={"order": self.ligne["entry_date"]},
raw_content=self.ligne["entry_date"], raw_content=self.ligne["entry_date"],
classes=["small-font"], classes=["small-font"],
column_classes={"entry_date"},
) )
def _type(self) -> None: def _type(self) -> None:
@ -338,7 +346,9 @@ class RowAssiJusti(tb.Row):
self.add_cell("module", "Module", "", data={"order": ""}) self.add_cell("module", "Module", "", data={"order": ""})
def _utilisateur(self) -> None: 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( self.add_cell(
"user", "user",
@ -359,7 +369,7 @@ class RowAssiJusti(tb.Row):
obj_id=self.ligne["obj_id"], obj_id=self.ligne["obj_id"],
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
) )
html.append(f'<a title="Détails" href="{url}"></a>') # utiliser url_for html.append(f'<a title="Détails" href="{url}"></a>')
# Modifier # Modifier
url = url_for( url = url_for(
@ -369,7 +379,7 @@ class RowAssiJusti(tb.Row):
obj_id=self.ligne["obj_id"], obj_id=self.ligne["obj_id"],
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
) )
html.append(f'<a title="Modifier" href="{url}">📝</a>') # utiliser url_for html.append(f'<a title="Modifier" href="{url}">📝</a>')
# Supprimer # Supprimer
url = url_for( url = url_for(
@ -381,7 +391,13 @@ class RowAssiJusti(tb.Row):
) )
html.append(f'<a title="Supprimer" href="{url}">❌</a>') # utiliser url_for html.append(f'<a title="Supprimer" href="{url}">❌</a>') # utiliser url_for
self.add_cell("actions", "Actions", "&ensp;".join(html), no_excel=True) self.add_cell(
"actions",
"",
"&ensp;".join(html),
no_excel=True,
column_classes={"actions"},
)
class AssiFiltre: class AssiFiltre:

View File

@ -0,0 +1,123 @@
{# 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}}/libjs/timepicker-1.3.5/jquery.timepicker.min.css"/>
<link rel="stylesheet" href="{{scu.STATIC_DIR}}/css/assiduites.css">
{% endblock %}
{% block app_content %}
<style>
form#ajout-assiduite-etud {
margin-bottom: 24px;
}
form#ajout-assiduite-etud > div {
margin-bottom: 16px;
}
/* Target the container of the radio buttons */
div.radio-assi_etat {
font-size: 120%;
}
div.radio-assi_etat ul {
list-style-type: none; /* Remove bullets */
display: inline-block;
margin-right: 16px;
}
/* Style each radio button and label */
div.radio-assi_etat ul li {
display: inline-block; /* Display radio buttons inline */
margin-right: 16px; /* Space between each radio button */
}
div.radio-assi_etat input[type="radio"] + label {
font-weight: normal;
}
div.radio-assi_etat input[type="radio"]:checked + label {
/* Style for checked state */
font-weight: bold;
}
</style>
<div class="tab-content">
<h2>Signaler une absence, retard ou présence pour {{etud.html_link_fiche()|safe}}</h2>
{% if 'general_errors' in form.errors %}
<div class="wtf-error-messages">
{% for error in form.errors['general_errors'] %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
<form id="ajout-assiduite-etud" method="post">
{{ form.hidden_tag() }}
{# Type d'évènement #}
<div class="radio-assi_etat">
{{ form.assi_etat.label }}
{{ form.assi_etat() }}
</div>
{# Dates et heures #}
<div class="dates-heures">
{{ form.date_debut.label }}&nbsp;: {{ form.date_debut }}
de {{ form.heure_debut }} à {{ form.heure_fin }}
<span class="help">laisser les heures vides pour signaler la journée entière</span>
{{ render_field_errors(form, 'date_debut') }}
{{ render_field_errors(form, 'heure_debut') }}
{{ render_field_errors(form, 'heure_fin') }}
<div>
{{ form.date_fin.label }}&nbsp;: {{ form.date_fin }}
<span class="help">si le jour de fin est différent,
les heures seront ignorées (journées complètes)</span>
{{ render_field_errors(form, 'date_fin') }}
</div>
</div>
{# Menu module #}
<div class="select-module">
{{ form.modimpl.label }}&nbsp;:
{{ form.modimpl }}
{{ render_field_errors(form, 'modimpl') }}
</div>
{# Raison #}
<div>
<div>{{ form.assi_raison.label }}</div>
{{ form.assi_raison() }}
{{ render_field_errors(form, 'assi_raison') }}
</div>
{# Submit #}
<div>
{{ form.submit }} {{ form.cancel }}
</div>
</form>
<section class="assi-liste">
{{tableau | safe }}
</section>
</div>
{% endblock app_content %}
{% block scripts %}
{{ super() }}
<script src="{{scu.STATIC_DIR}}/libjs/timepicker-1.3.5/jquery.timepicker.min.js"></script>
<script src="{{scu.STATIC_DIR}}/js/assiduites.js"></script>
<script src="{{scu.STATIC_DIR}}/js/etud_info.js"></script>
<script>
$('.timepicker').timepicker({
timeFormat: 'HH:mm',
interval: {{ scu.get_assiduites_time_config("assi_tick_time") }},
minTime: "{{ scu.get_assiduites_time_config("assi_morning_time") }}",
maxTime: "{{ scu.get_assiduites_time_config("assi_afternoon_time") }}",
startTime: "{{ scu.get_assiduites_time_config("assi_morning_time") }}",
dynamic: false,
dropdown: true,
scrollbar: false
});
</script>
{% endblock scripts %}

View File

@ -3,7 +3,7 @@
{% block pageContent %} {% block pageContent %}
<div class="pageContent"> <div class="pageContent">
<h3>Signaler un évènement pour {{etud.html_link_fiche()|safe}}</h3> <h3>Signaler une absence, présence ou retard pour {{etud.html_link_fiche()|safe}}</h3>
{% if saisie_eval %} {% if saisie_eval %}
<div id="saisie_eval"> <div id="saisie_eval">
<br> <br>
@ -19,7 +19,10 @@
<div class="assi-row"> <div class="assi-row">
<div class="assi-label"> <div class="assi-label">
<legend for="assi_date_debut" required>Date de début</legend> <legend for="assi_date_debut" required>Date de début</legend>
<scodoc-datetime name="assi_date_debut" id="assi_date_debut"> </scodoc-datetime> <input type="text" name="assi_date_debut" id="assi_date_debut" size="10"
class="datepicker">
<input type="text" name="assi_heure_debut" id="assi_heure_debut" size="5"
class="timepicker">
<span>Journée entière</span> <input type="checkbox" name="assi_journee" id="assi_journee"> <span>Journée entière</span> <input type="checkbox" name="assi_journee" id="assi_journee">
</div> </div>
<div class="assi-label" id="date_fin"> <div class="assi-label" id="date_fin">
@ -50,7 +53,7 @@
<div class="assi-row"> <div class="assi-row">
<div class="assi-label"> <div class="assi-label">
<legend for="assi_raison">Raison</legend> <legend for="assi_raison">Raison</legend>
<textarea name="assi_raison" id="assi_raison" cols="50" rows="10" maxlength="500"></textarea> <textarea name="assi_raison" id="assi_raison" cols="75" rows="4" maxlength="500"></textarea>
</div> </div>
</div> </div>
@ -95,7 +98,17 @@
} }
</style> </style>
<script> <script>
$('.timepicker').timepicker({
timeFormat: 'HH:mm',
interval: {{ scu.get_assiduites_time_config("assi_tick_time") }},
minTime: "{{ scu.get_assiduites_time_config("assi_morning_time") }}",
maxTime: "{{ scu.get_assiduites_time_config("assi_afternoon_time") }}",
defaultTime: 'now',
startTime: "{{ scu.get_assiduites_time_config("assi_morning_time") }}",
dynamic: false,
dropdown: true,
scrollbar: false
});
function validateFields() { function validateFields() {
const field = document.querySelector('.assi-form') const field = document.querySelector('.assi-form')

View File

@ -56,3 +56,13 @@
</script> </script>
{% endblock %} {% endblock %}
{% macro render_field_errors(form, field_name) %}
{% if form[field_name].errors %}
<div>
{% for error in form[field_name].errors %}
<span class="wtf-field-error">{{ error }}</span>
{% endfor %}
</div>
{% endif %}
{% endmacro %}

View File

@ -62,7 +62,7 @@
{% endif %} {% endif %}
<ul> <ul>
{% if current_user.has_permission(sco.Permission.AbsChange) %} {% if current_user.has_permission(sco.Permission.AbsChange) %}
<li><a href="{{ url_for('assiduites.signal_assiduites_etud', scodoc_dept=g.scodoc_dept, <li><a href="{{ url_for('assiduites.ajout_assiduite_etud', scodoc_dept=g.scodoc_dept,
etudid=sco.etud.id) }}">Ajouter</a></li> etudid=sco.etud.id) }}">Ajouter</a></li>
<li><a href="{{ url_for('assiduites.ajout_justificatif_etud', scodoc_dept=g.scodoc_dept, <li><a href="{{ url_for('assiduites.ajout_justificatif_etud', scodoc_dept=g.scodoc_dept,
etudid=sco.etud.id) }}">Justifier</a></li> etudid=sco.etud.id) }}">Justifier</a></li>

View File

@ -32,13 +32,14 @@ from flask import abort, url_for, redirect, Response
from flask_login import current_user from flask_login import current_user
from app import db from app import db
from app.api.assiduites import create_one_assiduite
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.decorators import ( from app.decorators import (
scodoc, scodoc,
permission_required, permission_required,
) )
from app.forms.assiduite.ajout_assiduite_etud import AjoutAssiduiteEtudForm
from app.models import ( from app.models import (
FormSemestre, FormSemestre,
Identite, Identite,
@ -48,6 +49,7 @@ from app.models import (
Departement, Departement,
Evaluation, Evaluation,
) )
from app.scodoc.codes_cursus import UE_STANDARD
from app.auth.models import User from app.auth.models import User
from app.models.assiduites import get_assiduites_justif, compute_assiduites_justified from app.models.assiduites import get_assiduites_justif, compute_assiduites_justified
import app.tables.liste_assiduites as liste_assi import app.tables.liste_assiduites as liste_assi
@ -246,19 +248,19 @@ def bilan_dept():
return "\n".join(H) return "\n".join(H)
@bp.route("/SignaleAssiduiteEtud") @bp.route("/ajout_assiduite_etud", methods=["GEt", "POST"])
@scodoc @scodoc
@permission_required(Permission.AbsChange) @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: Args:
etudid (int): l'identifiant de l'étudiant etudid (int): l'identifiant de l'étudiant
date_deb, date_fin: heures début et fin (ISO sans timezone) date_deb, date_fin: heures début et fin (ISO sans timezone)
moduleimpl_id moduleimpl_id
evaluation_id evaluation_id : si présent, mode "évaluation"
saisie_eval : si présent, mode "évaluation" fmt: si xls, renvoie le tableau des assiduités enregistrées
Returns: Returns:
str: l'html généré str: l'html généré
""" """
@ -266,11 +268,13 @@ def signal_assiduites_etud():
etud = Identite.get_etud(etudid) etud = Identite.get_etud(etudid)
# Gestion évaluations (appel à la page depuis les évaluations) # 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_deb: str = request.args.get("date_deb")
date_fin: str = request.args.get("date_fin") date_fin: str = request.args.get("date_fin")
moduleimpl_id: int = request.args.get("moduleimpl_id", "") moduleimpl_id: int = request.args.get("moduleimpl_id", "")
evaluation_id: int = request.args.get("evaluation_id")
redirect_url: str = ( redirect_url: str = (
"#" "#"
if not saisie_eval if not saisie_eval
@ -281,21 +285,32 @@ def signal_assiduites_etud():
) )
) )
# Préparation de la page (Header) form = AjoutAssiduiteEtudForm(request.form)
header: str = html_sco_header.sco_header( # On dresse la liste des modules de l'année scolaire en cours
page_title="Saisie assiduité", # auxquels est inscrit l'étudiant pour peupler le menu "module"
init_qtip=True, modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire())
javascripts=[ choices = {
"js/assiduites.js", "": [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]
"js/date_utils.js", }
"js/etud_info.js", for formsemestre_id in modimpls_by_formsemestre:
], formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
cssstyles=CSSSTYLES # indique le nom du semestre dans le menu (optgroup)
+ [ choices[formsemestre.titre_annee()] = [
"css/assiduites.css", (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( is_html, tableau = _prepare_tableau(
liste_assi.AssiJustifData.from_etudiants( liste_assi.AssiJustifData.from_etudiants(
etud, etud,
@ -305,52 +320,123 @@ def signal_assiduites_etud():
filtre=liste_assi.AssiFiltre(type_obj=1), filtre=liste_assi.AssiFiltre(type_obj=1),
options=liste_assi.AssiDisplayOptions(show_module=True), options=liste_assi.AssiDisplayOptions(show_module=True),
) )
#
if not is_html: if not is_html:
return tableau return tableau
# Génération de la page
return HTMLBuilder( return render_template(
header, "assiduites/pages/ajout_assiduite_etud.j2",
_mini_timeline(), etud=etud,
render_template( form=form,
"assiduites/pages/ajout_assiduites.j2", moduleimpl_id=moduleimpl_id,
sco=ScoData(etud), redirect_url=redirect_url,
assi_limit_annee=sco_preferences.get_preference( sco=ScoData(etud),
"assi_limit_annee", tableau=tableau,
dept_id=g.scodoc_dept_id, scu=scu,
), )
assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"),
assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"),
saisie_eval=saisie_eval, def _record_assiduite_etud(
date_deb=date_deb, etud: Identite,
date_fin=date_fin, form: AjoutAssiduiteEtudForm,
etud=etud, ) -> bool:
redirect_url=redirect_url, """Enregistre les données du formulaire de saisie assiduité.
moduleimpl_id=moduleimpl_id, Returns ok if successfully recorded, else put error info in the form.
tableau=tableau, Format attendu des données du formulaire:
), form.assi_etat.data : 'absent'
# render_template( form.date_debut.data : '05/12/2023'
# "assiduites/pages/signal_assiduites_etud.j2", form.heure_debut.data : '09:06' (heure locale du serveur)
# sco=ScoData(etud), """
# date=_dateiso_to_datefr(date), ok = True
# morning=morning, debut_jour = "00:00"
# lunch=lunch, fin_jour = "23:59:59"
# timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])), # On commence par convertir individuellement tous les champs
# afternoon=afternoon, try:
# nonworkdays=_non_work_days(), date_debut = datetime.datetime.strptime(form.date_debut.data, "%d/%m/%Y")
# forcer_module=sco_preferences.get_preference( except ValueError:
# "forcer_module", dept_id=g.scodoc_dept_id form.date_debut.errors.append("date début invalide")
# ), ok = False
# diff=_differee( try:
# etudiants=[sco_etud.get_etud_info(etudid=etud.etudid, filled=True)[0]], date_fin = (
# moduleimpl_select=select, datetime.datetime.strptime(form.date_fin.data, "%d/%m/%Y")
# ), if form.date_fin.data
# saisie_eval=saisie_eval, else None
# date_deb=date_deb, )
# date_fin=date_fin, except ValueError:
# redirect_url=redirect_url, form.date_fin.errors.append("date fin invalide")
# moduleimpl_id=moduleimpl_id, ok = False
# ),
).build() 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") @bp.route("/ListeAssiduitesEtud")
@ -1513,10 +1599,12 @@ def signal_evaluation_abs(etudid: int = None, evaluation_id: int = None):
# rediriger vers page saisie # rediriger vers page saisie
return redirect( return redirect(
url_for( url_for(
"assiduites.signal_assiduites_etud", "assiduites.ajout_assiduite_etud",
etudid=etudid, etudid=etudid,
evaluation_id=evaluation.id, 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"), date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"),
moduleimpl_id=evaluation.moduleimpl.id, moduleimpl_id=evaluation.moduleimpl.id,
saisie_eval="true", saisie_eval="true",
@ -1540,12 +1628,14 @@ def signal_evaluation_abs(etudid: int = None, evaluation_id: int = None):
if "Duplication" in msg: if "Duplication" in msg:
msg = """Une autre saisie concerne déjà cette période. msg = """Une autre saisie concerne déjà cette période.
En cliquant sur continuer vous serez redirigé vers la page de 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( dest: str = url_for(
"assiduites.signal_assiduites_etud", "assiduites.ajout_assiduite_etud",
etudid=etudid, etudid=etudid,
evaluation_id=evaluation.id, 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"), date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"),
moduleimpl_id=evaluation.moduleimpl.id, moduleimpl_id=evaluation.moduleimpl.id,
saisie_eval="true", saisie_eval="true",

View File

@ -535,7 +535,7 @@ def create_user_form(user_name=None, edit=0, all_roles=True):
"d", "d",
{ {
"input_type": "separator", "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}",
}, },
) )
) )