WIP: formulaire ajout justificatif

This commit is contained in:
Emmanuel Viennet 2023-12-12 03:05:31 +01:00
parent 99b182e1c7
commit bf4b69c9b2
16 changed files with 383 additions and 393 deletions

View File

@ -640,9 +640,9 @@ def justif_import(justif_id: int = None):
db.session.commit() db.session.commit()
return {"filename": fname} return {"filename": fname}
except ScoValueError as err: except ScoValueError as exc:
# Si cela ne fonctionne pas on renvoie une erreur # Si cela ne fonctionne pas on renvoie une erreur
return json_error(404, err.args[0]) return json_error(404, exc.args[0])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"]) @bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"])

View File

@ -26,9 +26,11 @@
""" """
Formulaire ajout d'une "assiduité" sur un étudiant Formulaire ajout d'une "assiduité" sur un étudiant
Formulaire ajout d'un justificatif sur un étudiant
""" """
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import MultipleFileField
from wtforms import ( from wtforms import (
SelectField, SelectField,
StringField, StringField,
@ -37,20 +39,15 @@ from wtforms import (
TextAreaField, TextAreaField,
validators, validators,
) )
from wtforms.validators import DataRequired
class AjoutAssiduiteEtudForm(FlaskForm): class AjoutAssiOrJustForm(FlaskForm):
"Formulaire de saisie d'une assiduité pour un étudiant" """Elements communs aux deux formulaires ajout
assiduité et justificatif
"""
error_message = "" # used to report our errors error_message = "" # used to report our errors
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_debut = StringField(
"Date de début", "Date de début",
validators=[validators.Length(max=10)], validators=[validators.Length(max=10)],
@ -89,14 +86,9 @@ class AjoutAssiduiteEtudForm(FlaskForm):
"id": "assi_date_fin", "id": "assi_date_fin",
}, },
) )
modimpl = SelectField(
"Module",
choices={}, # will be populated dynamically
)
assi_raison = TextAreaField( assi_raison = TextAreaField(
"Raison", "Raison",
render_kw={ render_kw={
# "name": "assi_raison",
"id": "assi_raison", "id": "assi_raison",
"cols": 75, "cols": 75,
"rows": 4, "rows": 4,
@ -114,3 +106,37 @@ class AjoutAssiduiteEtudForm(FlaskForm):
) )
submit = SubmitField("Enregistrer") submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
"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"),
],
)
modimpl = SelectField(
"Module",
choices={}, # will be populated dynamically
)
class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
"Formulaire de saisie d'un justificatif pour un étudiant"
etat = SelectField(
"État du justificatif",
choices=[
("", "Choisir..."), # Placeholder
("attente", "En attente de validation"),
("non_valide", "Non valide"),
("modifie", "Modifié"),
("valide", "Valide"),
],
validators=[DataRequired(message="This field is required.")],
)
fichiers = MultipleFileField()

View File

@ -373,13 +373,13 @@ class Justificatif(db.Model):
} }
return data return data
def __str__(self) -> str: def __repr__(self) -> str:
"chaine pour journaux et debug (lisible par humain français)" "chaine pour journaux et debug (lisible par humain français)"
try: try:
etat_str = EtatJustificatif(self.etat).name etat_str = EtatJustificatif(self.etat).name
except ValueError: except ValueError:
etat_str = "Invalide" etat_str = "Invalide"
return f"""Justificatif {etat_str} de { return f"""Justificatif id={self.id} {etat_str} de {
self.date_debut.strftime("%d/%m/%Y %Hh%M") self.date_debut.strftime("%d/%m/%Y %Hh%M")
} à { } à {
self.date_fin.strftime("%d/%m/%Y %Hh%M") self.date_fin.strftime("%d/%m/%Y %Hh%M")
@ -411,7 +411,7 @@ class Justificatif(db.Model):
db.session.add(nouv_justificatif) db.session.add(nouv_justificatif)
log(f"create_justificatif: {etud.id} {nouv_justificatif}") log(f"create_justificatif: etudid={etud.id} {nouv_justificatif}")
Scolog.logdb( Scolog.logdb(
method="create_justificatif", method="create_justificatif",
etudid=etud.id, etudid=etud.id,
@ -482,8 +482,6 @@ def compute_assiduites_justified(
etudid: int, justificatifs: list[Justificatif] = None, reset: bool = False etudid: int, justificatifs: list[Justificatif] = None, reset: bool = False
) -> list[int]: ) -> list[int]:
""" """
compute_assiduites_justified_faster
Args: Args:
etudid (int): l'identifiant de l'étudiant etudid (int): l'identifiant de l'étudiant
justificatifs (list[Justificatif]): La liste des justificatifs qui seront utilisés justificatifs (list[Justificatif]): La liste des justificatifs qui seront utilisés

View File

@ -122,26 +122,40 @@ def sidebar(etudid: int = None):
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, cur_sem) nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, cur_sem)
nbabsnj = nbabs - nbabsjust nbabsnj = nbabs - nbabsjust
H.append( H.append(
f"""<span title="absences du { cur_sem["date_debut"] } au { cur_sem["date_fin"] }">({sco_preferences.get_preference("assi_metrique", None)}) f"""<span title="absences du { cur_sem["date_debut"] } au {
cur_sem["date_fin"] }">({
sco_preferences.get_preference("assi_metrique", None)})
<br>{ nbabsjust } J., { nbabsnj } N.J.</span>""" <br>{ nbabsjust } J., { nbabsnj } N.J.</span>"""
) )
H.append("<ul>") H.append("<ul>")
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.ajout_assiduite_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li> <li><a href="{ url_for('assiduites.ajout_assiduite_etud',
<li><a href="{ url_for('assiduites.ajout_justificatif_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li> 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>
""" """
) )
if sco_preferences.get_preference("handle_billets_abs"): if sco_preferences.get_preference("handle_billets_abs"):
H.append( H.append(
f"""<li><a href="{ url_for('absences.billets_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Billets</a></li>""" f"""<li><a href="{ url_for('absences.billets_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Billets</a></li>"""
) )
H.append( H.append(
f""" f"""
<li><a href="{ url_for('assiduites.calendrier_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Calendrier</a></li> <li><a href="{ url_for('assiduites.calendrier_assi_etud',
<li><a href="{ url_for('assiduites.liste_assiduites_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Liste</a></li> scodoc_dept=g.scodoc_dept, etudid=etudid)
<li><a href="{ url_for('assiduites.bilan_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Bilan</a></li> }">Calendrier</a></li>
<li><a href="{ url_for('assiduites.liste_assiduites_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Liste</a></li>
<li><a href="{ url_for('assiduites.bilan_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Bilan</a></li>
</ul> </ul>
""" """
) )

View File

@ -122,10 +122,10 @@ class JustificatifArchiver(BaseArchiver):
archive_name: str = None, archive_name: str = None,
description: str = "", description: str = "",
user_id: str = None, user_id: str = None,
) -> str: ) -> tuple[str, str]:
""" """
Ajoute un fichier dans une archive "justificatif" pour l'etudid donné Ajoute un fichier dans une archive "justificatif" pour l'etudid donné
Retourne l'archive_name utilisé Retourne l'archive_name utilisé et le filename
""" """
if archive_name is None: if archive_name is None:
archive_id: str = self.create_obj_archive( archive_id: str = self.create_obj_archive(

View File

@ -316,7 +316,7 @@ class BulletinGeneratorLegacy(sco_bulletins_generator.BulletinGenerator):
# --- Absences # --- Absences
H.append( H.append(
f"""<p> f"""<p>
<a href="{ url_for('assiduites.calendrier_etud', <a href="{ url_for('assiduites.calendrier_assi_etud',
scodoc_dept=g.scodoc_dept, etudid=I['etudid']) }" class="bull_link"> scodoc_dept=g.scodoc_dept, etudid=I['etudid']) }" class="bull_link">
<b>Absences :</b>{I['nbabs']} demi-journées, dont {I['nbabsjust']} justifiées <b>Absences :</b>{I['nbabs']} demi-journées, dont {I['nbabsjust']} justifiées
(pendant ce semestre). (pendant ce semestre).

View File

@ -134,8 +134,11 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
if nbabs: if nbabs:
H.append( H.append(
f"""<p class="bul_abs"> f"""<p class="bul_abs">
<a href="{ url_for('assiduites.calendrier_etud', scodoc_dept=g.scodoc_dept, etudid=self.infos["etudid"]) }" class="bull_link"> <a href="{ url_for('assiduites.calendrier_assi_etud',
<b>Absences :</b> {self.infos['nbabs']} demi-journées, dont {self.infos['nbabsjust']} justifiées scodoc_dept=g.scodoc_dept,
etudid=self.infos["etudid"]) }" class="bull_link">
<b>Absences :</b> {self.infos['nbabs']} demi-journées, dont {
self.infos['nbabsjust']} justifiées
(pendant ce semestre). (pendant ce semestre).
</a></p> </a></p>
""" """
@ -186,7 +189,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
if can_edit_app: if can_edit_app:
H.append( H.append(
f"""<p><a class="stdlink" href="{ f"""<p><a class="stdlink" href="{
url_for('notes.appreciation_add_form', scodoc_dept=g.scodoc_dept, url_for('notes.appreciation_add_form', scodoc_dept=g.scodoc_dept,
etudid=self.etud.etudid, etudid=self.etud.etudid,
formsemestre_id=self.formsemestre.id formsemestre_id=self.formsemestre.id
) )

View File

@ -147,7 +147,7 @@ def evaluation_check_absences_html(
H.append(': <span class="eval_check_absences_ok">ok</span>') H.append(': <span class="eval_check_absences_ok">ok</span>')
H.append("</h2>") H.append("</h2>")
def etudlist(etudids, linkabs=False): def etudlist(etudids: list[int], linkabs=False):
H.append("<ul>") H.append("<ul>")
if not etudids and show_ok: if not etudids and show_ok:
H.append("<li>aucun</li>") H.append("<li>aucun</li>")

View File

@ -1,15 +1,15 @@
:root { :root {
--color-present: #6bdb83; --color-present: #6bdb83;
--color-absent: #e62a11; --color-absent: #e62a11;
--color-absent-clair: #f25d4a; --color-absent-clair: rgb(252, 151, 50);
--color-retard: #f0c865; --color-retard: #f0c865;
--color-justi: #7059ff; --color-justi: #29b1b9;
--color-justi-clair: #6885e3; --color-justi-clair: #48f6ff;
--color-justi-invalide: #a84476; --color-justi-invalide: #a84476;
--color-nonwork: #badfff; --color-nonwork: #badfff;
--color-absent-justi: #e65ab7; --color-absent-justi: #ff86d7;
--color-retard-justi: #ffef7a; --color-retard-justi: #e8c6eb;
--color-error: #e62a11; --color-error: #e62a11;
--color-warning: #eec660; --color-warning: #eec660;
@ -657,19 +657,35 @@ section.assi-form {
table.liste_assi td.date { table.liste_assi td.date {
width: 140px; width: 140px;
} }
table.liste_assi.dataTable tbody td.date-debut {
padding-left: 12px;
}
table.liste_assi td.actions { table.liste_assi td.actions {
white-space: nowrap; /* boutons horizontalement */ white-space: nowrap; /* boutons horizontalement */
} }
table.liste_assi td.actions a:last-child {
padding-right: 12px;
}
tr.row-assiduite td { tr.row-assiduite td {
border-bottom: 1px solid grey; border-bottom: 1px solid grey;
} }
table.liste_assi tbody tr td.assi-type {
padding-left: 8px;
padding-right: 4px;
}
tr.row-assiduite.absent td.assi-type { tr.row-assiduite.absent td.assi-type {
background-color: var(--color-absent-clair); background-color: var(--color-absent-clair);
} }
tr.row-assiduite.absent.justifiee td.assi-type {
background-color: var(--color-absent-justi);
}
tr.row-assiduite.retard td.assi-type { tr.row-assiduite.retard td.assi-type {
background-color: var(--color-retard); background-color: var(--color-retard);
} }
tr.row-assiduite.present td.assi-type { tr.row-assiduite.present td.assi-type {
background-color: var(--color-present); background-color: var(--color-present);
} }
tr.row-justificatif.valide td.assi-type {
background-color: var(--color-justi);
}

View File

@ -247,7 +247,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"}, column_classes={"date", "date-debut"},
) )
# Date de fin # Date de fin
self.add_cell( self.add_cell(
@ -256,7 +256,7 @@ 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"}, column_classes={"date", "date-fin"},
) )
# Ajout des colonnes optionnelles # Ajout des colonnes optionnelles
@ -280,14 +280,16 @@ class RowAssiJusti(tb.Row):
obj_type: str = "" obj_type: str = ""
is_assiduite: bool = self.ligne["type"] == "assiduite" is_assiduite: bool = self.ligne["type"] == "assiduite"
if is_assiduite: if is_assiduite:
justifiee: str = "Justifiée" if self.ligne["est_just"] else ""
self.classes.append("row-assiduite") self.classes.append("row-assiduite")
self.classes.append(EtatAssiduite(self.ligne["etat"]).name.lower()) self.classes.append(EtatAssiduite(self.ligne["etat"]).name.lower())
if self.ligne["est_just"]:
self.classes.append("justifiee")
etat: str = { etat: str = {
EtatAssiduite.PRESENT: "Présence", EtatAssiduite.PRESENT: "Présence",
EtatAssiduite.ABSENT: "Absence", EtatAssiduite.ABSENT: "Absence",
EtatAssiduite.RETARD: "Retard", EtatAssiduite.RETARD: "Retard",
}.get(self.ligne["etat"]) }.get(self.ligne["etat"])
justifiee: str = "Justifiée" if self.ligne["est_just"] else ""
obj_type = f"{etat} {justifiee}" obj_type = f"{etat} {justifiee}"
else: else:
self.classes.append("row-justificatif") self.classes.append("row-justificatif")
@ -462,12 +464,12 @@ class AssiFiltre:
type_filtrage, date = val_filtre type_filtrage, date = val_filtre
match (type_filtrage): match (type_filtrage):
# On garde uniquement les dates supérieur au filtre # On garde uniquement les dates supérieures au filtre
case 2: case 2:
query_filtree = query_filtree.filter( query_filtree = query_filtree.filter(
getattr(obj_class, cle_filtre) > date getattr(obj_class, cle_filtre) > date
) )
# On garde uniquement les dates inférieur au filtre # On garde uniquement les dates inférieures au filtre
case 1: case 1:
query_filtree = query_filtree.filter( query_filtree = query_filtree.filter(
getattr(obj_class, cle_filtre) < date getattr(obj_class, cle_filtre) < date

View File

@ -1,245 +0,0 @@
{% include "assiduites/widgets/toast.j2" %}
{% block pageContent %}
<div class="pageContent">
<h3>Justifier des absences ou retards</h3>
<section class="justi-form page">
<fieldset>
<div class="justi-row">
<div class="justi-label">
<legend for="justi_date_debut" required>Date de début</legend>
<scodoc-datetime name="justi_date_debut" id="justi_date_debut"> </scodoc-datetime>
<span>Journée entière</span> <input type="checkbox" name="justi_journee" id="justi_journee">
</div>
<div class="justi-label" id="date_fin">
<legend for="justi_date_fin" required>Date de fin</legend>
<scodoc-datetime name="justi_date_fin" id="justi_date_fin"></scodoc-datetime>
</div>
</div>
<div class="justi-row">
<div class="justi-label">
<legend for="justi_etat" required>Etat du justificatif</legend>
<select name="justi_etat" id="justi_etat">
<option value="attente" selected>En Attente de validation</option>
<option value="non_valide">Non Valide</option>
<option value="modifie">Modifié</option>
<option value="valide">Valide</option>
</select>
</div>
</div>
<div class="justi-row">
<div class="justi-label">
<legend for="justi_raison">Raison</legend>
<textarea name="justi_raison" id="justi_raison" cols="50" rows="10" maxlength="500"></textarea>
</div>
</div>
<div class="justi-row">
<div class="justi-sect">
</div>
<div class="justi-label">
<legend for="justi_fich">Importer un fichier</legend>
<input type="file" name="justi_fich" id="justi_fich" multiple>
</div>
</div>
<div class="justi-row">
<button onclick="validerFormulaire(this)">Créer le justificatif</button>
<button onclick="effacerFormulaire()">Remettre à zero</button>
</div>
</fieldset>
</section>
<section class="assi-liste">
{{tableau | safe }}
</section>
</div>
<style>
.justi-row {
margin: 5px 0;
}
.justi-form fieldset {
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.pageContent {
max-width: var(--sco-content-max-width);
margin-top: 15px;
}
.justi-label {
margin: 0 10px;
}
[required]::after {
content: "*";
color: var(--color-error);
}
</style>
<script>
function validateFields() {
const field = document.querySelector('.justi-form')
const { deb, fin } = getDates()
const date_debut = new Date(deb);
const date_fin = new Date(fin);
if (deb == "" || fin == "" || !date_debut.isValid() || !date_fin.isValid()) {
openAlertModal("Erreur détéctée", document.createTextNode("Il faut indiquer une date de début et une date de fin valide."), "", color = "crimson");
return false;
}
if (date_fin.isBefore(date_debut)) {
openAlertModal("Erreur détéctée", document.createTextNode("La date de fin doit se trouver après la date de début."), "", color = "crimson");
return false;
}
return true
}
function fieldsToJustificatif() {
const field = document.querySelector('.justi-form.page')
const { deb, fin } = getDates()
const etat = field.querySelector('#justi_etat').value;
const raison = field.querySelector('#justi_raison').value;
return {
date_debut: new Date(deb).toFakeIso(),
date_fin: new Date(fin).toFakeIso(),
etat: etat,
raison: raison,
}
}
function importFiles(justif_id) {
const field = document.querySelector('.justi-form')
const in_files = field.querySelector('#justi_fich');
const path = getUrl() + `/api/justificatif/${justif_id}/import`;
const requests = []
Array.from(in_files.files).forEach((f) => {
pushToast(generateToast(document.createTextNode(`Importation du fichier : ${f.name} commencée`), color = "var(--color-information)"));
const fd = new FormData();
fd.append('file', f);
requests.push(
$.ajax(
{
url: path,
type: 'POST',
data: fd,
dateType: 'json',
contentType: false,
processData: false,
success: () => {
pushToast(generateToast(document.createTextNode(`Importation du fichier : ${f.name} finie`)));
},
}
)
)
});
$.when(...requests).done(() => {
location.reload();
})
}
function validerFormulaire(btn) {
if (!validateFields()) return
const justificatif = fieldsToJustificatif();
let justif_id = null;
let couverture = null;
createJustificatif(justificatif, (data) => {
if (Object.keys(data.errors).length > 0) {
console.error(data.errors);
errorAlert();
}
if (Object.keys(data.success).length > 0) {
couverture = data.success[0].message.couverture
justif_id = data.success[0].message.justif_id;
importFiles(justif_id);
return;
}
})
btn.disabled = true;
setTimeout(() => {
btn.disabled = false;
}, 1000)
}
function effacerFormulaire() {
const field = document.querySelector('.justi-form')
field.querySelector('#justi_date_debut').value = "";
field.querySelector('#justi_date_fin').value = "";
field.querySelector('#justi_etat').value = "attente";
field.querySelector('#justi_raison').value = "";
field.querySelector('#justi_fich').value = "";
}
function dayOnly() {
const date_deb = document.getElementById("justi_date_debut");
const date_fin = document.getElementById("justi_date_fin");
if (document.getElementById('justi_journee').checked) {
date_deb.setAttribute("show", "date")
date_fin.setAttribute("show", "date")
document.querySelector(`legend[for="justi_date_fin"]`).removeAttribute("required")
} else {
date_deb.removeAttribute("show")
date_fin.removeAttribute("show")
document.querySelector(`legend[for="justi_date_fin"]`).setAttribute("required", "")
}
}
function getDates() {
const date_deb = document.querySelector(".page #justi_date_debut")
const date_fin = document.querySelector(".page #justi_date_fin")
const journee = document.querySelector('.page #justi_journee').checked
const deb = date_deb.valueAsObject.date + "T" + (journee ? assi_morning : date_deb.valueAsObject.time)
let fin = "T" + (journee ? assi_evening : date_fin.valueAsObject.time)
if (journee) {
fin = (date_fin.valueAsObject.date || date_deb.valueAsObject.date) + fin
} else {
fin = date_fin.valueAsObject.date + fin
}
return {
"deb": deb,
"fin": fin,
}
}
const etudid = {{ sco.etud.id }};
const assi_limit_annee = "{{ assi_limit_annee }}" == "True" ? true : false;
const assi_morning = '{{assi_morning}}';
const assi_evening = '{{assi_evening}}';
window.onload = () => {
document.getElementById('justi_journee').addEventListener('click', () => { dayOnly() });
dayOnly()
document.getElementById("justi_date_debut").valueAsObject = { time: assi_morning }
document.getElementById("justi_date_fin").valueAsObject = { time: assi_evening }
}
</script>
{% endblock pageContent %}

View File

@ -0,0 +1,105 @@
{% 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-justificatif-etud {
margin-bottom: 24px;
}
form#ajout-justificatif-etud > div {
margin-bottom: 16px;
}
div.submit {
margin-top: 12px;
}
div.submit > input {
margin-right: 16px;
}
</style>
<div class="tab-content">
<h2>Justifier des absences ou retards</h2>
<section class="justi-form page">
<form id="ajout-justificatif-etud" method="post">
<fieldset>
{{ form.hidden_tag() }}
{# 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 indiquer 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>
{# Etat #}
<div>
<div>{{ form.etat.label }}</div>
{{ form.etat() }}
{{ render_field_errors(form, 'etat') }}
</div>
{# Raison #}
<div>
<div>{{ form.assi_raison.label }}</div>
{{ form.assi_raison() }}
{{ render_field_errors(form, 'assi_raison') }}
</div>
{# Fichier(s) justificatif(s) #}
<div>
<div>{{ form.fichiers.label }}</div>
{{ form.fichiers() }}
{{ render_field_errors(form, 'fichiers') }}
</div>
{# Date dépot #}
{{ form.entry_date.label }}&nbsp;: {{ form.entry_date }}
<span class="help">laisser vide pour date courante</span>
{{ render_field_errors(form, 'entry_date') }}
{# Submit #}
<div class="submit">
{{ form.submit }} {{ form.cancel }}
</div>
</fieldset>
</form>
</section>
<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

@ -1,5 +1,5 @@
<div> <div>
<div class="sco_box_title">Évènements enregistrés pour cet étudiant</div> <div class="sco_box_title">{{ titre }}</div>
<div id="options-tableau"> <div id="options-tableau">
{% if afficher_options != false %} {% if afficher_options != false %}
<input type="checkbox" id="show_pres" name="show_pres" <input type="checkbox" id="show_pres" name="show_pres"

View File

@ -71,7 +71,7 @@
etudid=sco.etud.id) }}">Billets</a></li> etudid=sco.etud.id) }}">Billets</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
<li><a href="{{ url_for('assiduites.calendrier_etud', scodoc_dept=g.scodoc_dept, <li><a href="{{ url_for('assiduites.calendrier_assi_etud', scodoc_dept=g.scodoc_dept,
etudid=sco.etud.id) }}">Calendrier</a></li> etudid=sco.etud.id) }}">Calendrier</a></li>
<li><a href="{{ url_for('assiduites.liste_assiduites_etud', scodoc_dept=g.scodoc_dept, <li><a href="{{ url_for('assiduites.liste_assiduites_etud', scodoc_dept=g.scodoc_dept,
etudid=sco.etud.id) }}">Liste</a></li> etudid=sco.etud.id) }}">Liste</a></li>

View File

@ -32,14 +32,18 @@ from flask import g, request, render_template, flash
from flask import abort, url_for, redirect, Response 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, log
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.forms.assiduite.ajout_assiduite_etud import (
AjoutAssiOrJustForm,
AjoutAssiduiteEtudForm,
AjoutJustificatifEtudForm,
)
from app.models import ( from app.models import (
Assiduite, Assiduite,
Departement, Departement,
@ -53,7 +57,7 @@ from app.models import (
) )
from app.scodoc.codes_cursus import UE_STANDARD 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 from app.models.assiduites import get_assiduites_justif, compute_assiduites_justified
from app.tables.list_etuds import RowEtud, TableEtud from app.tables.list_etuds import RowEtud, TableEtud
import app.tables.liste_assiduites as liste_assi import app.tables.liste_assiduites as liste_assi
@ -63,7 +67,6 @@ from app.views import ScoData
# --------------- # ---------------
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import safehtml
from app.scodoc import sco_moduleimpl from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_groups_view from app.scodoc import sco_groups_view
@ -254,7 +257,7 @@ def bilan_dept():
@bp.route("/ajout_assiduite_etud", methods=["GET", "POST"]) @bp.route("/ajout_assiduite_etud", methods=["GET", "POST"])
@scodoc @scodoc
@permission_required(Permission.AbsChange) @permission_required(Permission.AbsChange)
def ajout_assiduite_etud(): def ajout_assiduite_etud() -> str:
""" """
ajout_assiduite_etud Saisie d'une assiduité d'un étudiant ajout_assiduite_etud Saisie d'une assiduité d'un étudiant
@ -315,12 +318,11 @@ def ajout_assiduite_etud():
liste_assi.AssiJustifData.from_etudiants( liste_assi.AssiJustifData.from_etudiants(
etud, etud,
), ),
filename=f"assiduite-{etudid}", filename=f"assiduite-{etud.nom or ''}",
afficher_etu=False, afficher_etu=False,
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
@ -336,16 +338,13 @@ def ajout_assiduite_etud():
) )
def _record_assiduite_etud( def _get_dates_from_assi_form(
etud: Identite, form: AjoutAssiOrJustForm,
form: AjoutAssiduiteEtudForm, ) -> tuple[bool, datetime.datetime, datetime.datetime, datetime.datetime]:
) -> bool: """Prend les dates et heures du form, les vérifie
"""Enregistre les données du formulaire de saisie assiduité. puis converti en deux datetime, en timezone du serveur.
Returns ok if successfully recorded, else put error info in the form. Ramène ok=True si ok.
Format attendu des données du formulaire: Met des messages d'erreur dans le form.
form.assi_etat.data : 'absent'
form.date_debut.data : '05/12/2023'
form.heure_debut.data : '09:06' (heure locale du serveur)
""" """
ok = True ok = True
debut_jour = "00:00" debut_jour = "00:00"
@ -384,6 +383,44 @@ def _record_assiduite_etud(
except ValueError: except ValueError:
form.heure_fin.errors.append("heure fin invalide") form.heure_fin.errors.append("heure fin invalide")
ok = False ok = 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
# La date de dépot (si vide, la date actuelle)
dt_entry_date = (
datetime.datetime.strptime(form.entry_date.data, "%d/%m/%Y")
if form.entry_date.data
else None
)
# Ajoute time zone serveur
dt_debut_tz_server = scu.TIME_ZONE.localize(dt_debut)
dt_fin_tz_server = scu.TIME_ZONE.localize(dt_fin)
dt_entry_date_tz_server = (
scu.TIME_ZONE.localize(dt_entry_date) if dt_entry_date else None
)
return ok, dt_debut_tz_server, dt_fin_tz_server, dt_entry_date_tz_server
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,
dt_debut_tz_server,
dt_fin_tz_server,
dt_entry_date_tz_server,
) = _get_dates_from_assi_form(form)
# Le module (avec "autre") # Le module (avec "autre")
mod_data = form.modimpl.data mod_data = form.modimpl.data
if mod_data: if mod_data:
@ -397,26 +434,10 @@ def _record_assiduite_etud(
ok = False ok = False
else: else:
moduleimpl_id = None moduleimpl_id = None
# La date de dépot (si viden date actuelle)
dt_entry_date = (
datetime.datetime.strptime(form.entry_date.data, "%d/%m/%Y")
if form.entry_date.data
else None
)
if not ok: if not ok:
return False 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
# Ajoute time zone serveur
dt_debut_tz_server = scu.TIME_ZONE.localize(dt_debut)
dt_fin_tz_server = scu.TIME_ZONE.localize(dt_fin)
dt_entry_date_tz_server = (
scu.TIME_ZONE.localize(dt_entry_date) if dt_entry_date else None
)
external_data = None external_data = None
moduleimpl: ModuleImpl | None = None moduleimpl: ModuleImpl | None = None
match moduleimpl_id: match moduleimpl_id:
@ -446,27 +467,6 @@ def _record_assiduite_etud(
form.error_message = f"Erreur: {exc.args[0]}" form.error_message = f"Erreur: {exc.args[0]}"
return False 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("/liste_assiduites_etud") @bp.route("/liste_assiduites_etud")
@scodoc @scodoc
@ -585,7 +585,9 @@ def bilan_etud():
).build() ).build()
@bp.route("/AjoutJustificatifEtud") @bp.route(
"/ajout_justificatif_etud", methods=["GET", "POST"]
) # was AjoutJustificatifEtud
@scodoc @scodoc
@permission_required(Permission.AbsChange) @permission_required(Permission.AbsChange)
def ajout_justificatif_etud(): def ajout_justificatif_etud():
@ -597,63 +599,130 @@ def ajout_justificatif_etud():
Returns: Returns:
str: l'html généré str: l'html généré
""" """
etud = Identite.get_etud(request.args.get("etudid"))
# Récupération de l'étudiant concerné redirect_url = url_for(
etudid = request.args.get("etudid", -1) "assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
etud: Identite = Identite.query.get_or_404(etudid)
if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département")
# Préparation de la page (header)
header: str = html_sco_header.sco_header(
page_title="Justificatifs",
init_qtip=True,
javascripts=[
"js/assiduites.js",
"js/date_utils.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
) )
tableau = _prepare_tableau( form = AjoutJustificatifEtudForm()
if form.validate_on_submit():
if form.cancel.data: # cancel button
return redirect(redirect_url)
ok = _record_justificatif_etud(etud, form)
if ok:
return redirect(redirect_url)
is_html, tableau = _prepare_tableau(
liste_assi.AssiJustifData.from_etudiants( liste_assi.AssiJustifData.from_etudiants(
etud, etud,
), ),
filename=f"justificatifs-{etudid}", filename=f"justificatifs-{etud.nom or ''}",
afficher_etu=False, afficher_etu=False,
filtre=liste_assi.AssiFiltre(type_obj=2), filtre=liste_assi.AssiFiltre(type_obj=2),
options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True), options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True),
afficher_options=False, afficher_options=False,
titre="Justificatifs enregistrés pour cet étudiant",
) )
if not tableau[0]: if not is_html:
return tableau[1] return tableau
# Peuplement du template jinja return render_template(
return HTMLBuilder( "assiduites/pages/ajout_justificatif_etud.j2",
header, assi_limit_annee=sco_preferences.get_preference(
render_template( "assi_limit_annee",
"assiduites/pages/ajout_justificatif.j2", dept_id=g.scodoc_dept_id,
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"),
tableau=tableau[1],
), ),
).build() etud=etud,
form=form,
page_title="Justificatifs",
redirect_url=redirect_url,
sco=ScoData(etud),
scu=scu,
tableau=tableau,
)
@bp.route("/CalendrierAssiduitesEtud") def _record_justificatif_etud(
etud: Identite,
form: AjoutJustificatifEtudForm,
) -> bool:
"""Enregistre les données du formulaire de saisie justificatif (et ses fichiers).
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,
dt_debut_tz_server,
dt_fin_tz_server,
dt_entry_date_tz_server,
) = _get_dates_from_assi_form(form)
if not ok:
return False
etat = scu.EtatJustificatif.get(form.etat.data)
try:
just = Justificatif.create_justificatif(
etud,
dt_debut_tz_server,
dt_fin_tz_server,
etat=etat,
raison=form.assi_raison.data,
entry_date=dt_entry_date_tz_server,
user_id=current_user.id,
)
db.session.add(just)
if not _upload_justificatif_files(just, form):
db.session.rollback()
return False
db.session.commit()
compute_assiduites_justified(etud.id, [just])
flash("Justificatif enregistré")
return True
except ScoValueError as exc:
db.session.rollback()
form.error_message = f"Erreur: {exc.args[0]}"
return False
def _upload_justificatif_files(
just: Justificatif, form: AjoutJustificatifEtudForm
) -> bool:
"""Enregistre les fichiers du formulaire de création de justificatif"""
# Utilisation de l'archiver de justificatifs
archiver: JustificatifArchiver = JustificatifArchiver()
archive_name: str = just.fichier
try:
# On essaye de sauvegarder les fichiers
for file in form.fichiers.data or []:
archive_name, _ = archiver.save_justificatif(
just.etudiant,
filename=file.filename,
data=file.stream.read(),
archive_name=archive_name,
user_id=current_user.id,
)
flash(f"Fichier {file.filename} enregistré")
if form.fichiers.data:
# On actualise l'archive du justificatif
just.fichier = archive_name
db.session.add(just)
return True
except ScoValueError as exc:
log(f"_upload_justificatif_files: error on {file.filename} for etud {etud.id}")
form.error_message = f"Erreur sur fichier justificatif: {exc.args[0]}"
return False
@bp.route("/calendrier_assi_etud")
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def calendrier_etud(): def calendrier_assi_etud():
""" """
calendrier_etud : Affichage d'un calendrier des assiduités de l'étudiant Affichage d'un calendrier de l'assiduité de l'étudiant
Args: Args:
etudid (int): l'identifiant de l'étudiant etudid (int): l'identifiant de l'étudiant
@ -1028,7 +1097,7 @@ class RowEtudWithAssi(RowEtud):
self.etat_assiduite = etat_assiduite self.etat_assiduite = etat_assiduite
# remplace lien vers fiche par lien vers calendrier # remplace lien vers fiche par lien vers calendrier
self.target_url = url_for( self.target_url = url_for(
"assiduites.calendrier_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id "assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
) )
self.target_title = f"Calendrier de {etud.nomprenom}" self.target_title = f"Calendrier de {etud.nomprenom}"
@ -1206,6 +1275,7 @@ def _prepare_tableau(
filtre: liste_assi.AssiFiltre = None, filtre: liste_assi.AssiFiltre = None,
options: liste_assi.AssiDisplayOptions = None, options: liste_assi.AssiDisplayOptions = None,
afficher_options: bool = True, afficher_options: bool = True,
titre="Évènements enregistrés pour cet étudiant",
) -> tuple[bool, Response | str]: ) -> tuple[bool, Response | str]:
""" """
Prépare un tableau d'assiduités / justificatifs Prépare un tableau d'assiduités / justificatifs
@ -1274,6 +1344,7 @@ def _prepare_tableau(
total_pages=table.total_pages, total_pages=table.total_pages,
options=options, options=options,
afficher_options=afficher_options, afficher_options=afficher_options,
titre=titre,
) )

View File

@ -1,7 +1,7 @@
# -*- mode: python -*- # -*- mode: python -*-
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
SCOVERSION = "9.6.67" SCOVERSION = "9.6.68"
SCONAME = "ScoDoc" SCONAME = "ScoDoc"