WIP: ajout_assiduite_etud

This commit is contained in:
Emmanuel Viennet 2023-12-05 21:04:38 +01:00
parent 7d4d26fe2b
commit 16e63069a5
20 changed files with 554 additions and 133 deletions

View File

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

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,
)
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,

View File

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

View File

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

View File

@ -129,7 +129,7 @@ def sidebar(etudid: int = None):
if current_user.has_permission(Permission.AbsChange):
H.append(
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>
"""
)

View File

@ -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:

View File

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

View File

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

View File

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

View File

@ -988,8 +988,7 @@ function createAssiduiteComplete(assiduite, etudid) {
if (data.errors["0"].message == "Module non renseigné") {
const HTML = `
<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>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
<p>Voir configuration du semestre ou du département.</p>
`;
const content = document.createElement("div");
@ -1002,7 +1001,6 @@ function createAssiduiteComplete(assiduite, etudid) {
) {
const HTML = `
<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");
@ -1015,8 +1013,8 @@ function createAssiduiteComplete(assiduite, etudid) {
"Duplication: la période rentre en conflit avec une plage enregistrée"
) {
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>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
<p>L'assiduité n'a pas pu être enregistrée car un autre évènement
existe sur la période sélectionnée</p>
`;
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() {

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
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'<a title="Détails" href="{url}"></a>') # utiliser url_for
html.append(f'<a title="Détails" href="{url}"></a>')
# 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'<a title="Modifier" href="{url}">📝</a>') # utiliser url_for
html.append(f'<a title="Modifier" href="{url}">📝</a>')
# Supprimer
url = url_for(
@ -381,7 +391,13 @@ class RowAssiJusti(tb.Row):
)
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:

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 %}
<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 %}
<div id="saisie_eval">
<br>
@ -19,7 +19,10 @@
<div class="assi-row">
<div class="assi-label">
<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">
</div>
<div class="assi-label" id="date_fin">
@ -50,7 +53,7 @@
<div class="assi-row">
<div class="assi-label">
<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>
@ -95,7 +98,17 @@
}
</style>
<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() {
const field = document.querySelector('.assi-form')

View File

@ -56,3 +56,13 @@
</script>
{% 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 %}
<ul>
{% 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>
<li><a href="{{ url_for('assiduites.ajout_justificatif_etud', scodoc_dept=g.scodoc_dept,
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 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,
return render_template(
"assiduites/pages/ajout_assiduite_etud.j2",
etud=etud,
redirect_url=redirect_url,
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/signal_assiduites_etud.j2",
# "assiduites/pages/ajout_assiduites.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,
# 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,
# ),
).build()
@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",

View File

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