Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96

This commit is contained in:
Emmanuel Viennet 2024-03-19 20:59:13 +01:00
commit 1b1b8ebdc4
5 changed files with 275 additions and 66 deletions

View File

@ -1,6 +1,6 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
"""Gestion de l'assiduité (assiduités + justificatifs) """Gestion de l'assiduité (assiduités + justificatifs)"""
"""
from datetime import datetime from datetime import datetime
from flask_login import current_user from flask_login import current_user
@ -336,13 +336,19 @@ class Assiduite(ScoDocModel):
""" """
return get_formsemestre_from_data(self.to_dict()) return get_formsemestre_from_data(self.to_dict())
def get_module(self, traduire: bool = False) -> int | str: def get_module(self, traduire: bool = False) -> Module | str:
"TODO documenter" """
Retourne le module associé à l'assiduité
Si traduire est vrai, retourne le titre du module précédé du code
Sinon rentourne l'objet Module ou None
"""
if self.moduleimpl_id is not None: if self.moduleimpl_id is not None:
modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id)
mod: Module = Module.query.get(modimpl.module_id)
if traduire: if traduire:
modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id)
mod: Module = Module.query.get(modimpl.module_id)
return f"{mod.code} {mod.titre}" return f"{mod.code} {mod.titre}"
return mod
elif self.external_data is not None and "module" in self.external_data: elif self.external_data is not None and "module" in self.external_data:
return ( return (

View File

@ -12,7 +12,7 @@ from sqlalchemy import desc, literal, union, asc
from app import db, g from app import db, g
from app.auth.models import User from app.auth.models import User
from app.models import Assiduite, Identite, Justificatif from app.models import Assiduite, Identite, Justificatif, Module
from app.scodoc.sco_utils import ( from app.scodoc.sco_utils import (
EtatAssiduite, EtatAssiduite,
EtatJustificatif, EtatJustificatif,
@ -534,10 +534,45 @@ class RowAssiJusti(tb.Row):
if self.table.options.show_module: if self.table.options.show_module:
if self.ligne["type"] == "assiduite": if self.ligne["type"] == "assiduite":
assi: Assiduite = Assiduite.query.get(self.ligne["obj_id"]) assi: Assiduite = Assiduite.query.get(self.ligne["obj_id"])
mod: str = assi.get_module(True) if self.table.no_pagination:
self.add_cell("module", "Module", mod, data={"order": mod}) mod: Module = assi.get_module(False)
code = mod.code if isinstance(mod, Module) else ""
titre = ""
if isinstance(mod, Module):
titre = mod.titre
elif isinstance(mod, str):
titre = mod
else:
titre = "Non Spécifié"
self.add_cell(
"code_module", "Code Module", code, data={"order": code}
)
self.add_cell(
"titre_module",
"Titre Module",
titre,
data={"order": titre},
)
else:
mod: Module = assi.get_module(True)
self.add_cell(
"module",
"Module",
mod,
data={"order": mod},
)
else: else:
self.add_cell("module", "Module", "", data={"order": ""}) if self.table.no_pagination:
self.add_cell("module", "Module", "", data={"order": ""})
else:
self.add_cell("code_module", "Code Module", "", data={"order": ""})
self.add_cell(
"titre_module",
"Titre Module",
"",
data={"order": ""},
)
def _utilisateur(self) -> None: def _utilisateur(self) -> None:
utilisateur: User = ( utilisateur: User = (

View File

@ -20,6 +20,39 @@ le semestre concerné (saisie par jour ou saisie différée).
<br> <br>
{{billets | safe}} {{billets | safe}}
<br>
<div>
<h3>Télécharger l'assiduité</h3>
<form action="{{url_for('assiduites.recup_assiduites_plage', scodoc_dept=g.scodoc_dept)}}" method="post">
<label for="datedeb">
Du&nbsp;:
<input type="text" class="datepicker" id="datedeb" name="datedeb">
</label>
<br>
<label for="datefin">
Au&nbsp;:
<input type="text" class="datepicker" id="datefin" name="datefin">
</label>
<br>
<label for="formsemestre_id">Télécharger l'assiduité de </label>
<select name="formsemestre_id" id="formsemestre_id">
<option value="">Tout le département</option>
{% for id, titre in formsemestres.items() %}
{% if formsemestre_id == id %}
<option value="{{id}}" selected>{{titre}}</option>
{% else %}
<option value="{{id}}">{{titre}}</option>
{% endif %}
{% endfor %}
</select>
<br>
<input type="submit" value="Télécharger" name="telecharger">
</form>
</div>
<br> <br>
<section class="nonvalide"> <section class="nonvalide">
{{tableau | safe }} {{tableau | safe }}

View File

@ -17,6 +17,15 @@
gap: 0.5em; gap: 0.5em;
} }
#actions {
flex-direction: row;
align-items: center;
margin-bottom: 5px;
}
#actions label{
margin: 0;
}
#fix { #fix {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -49,21 +58,24 @@
} }
#tableau-periode { #tableau-periode {
display: flex;
flex-direction: column;
overflow-x: scroll; overflow-x: scroll;
max-width: var(--sco-content-max-width); max-width: var(--sco-content-max-width);
} }
#tableau-periode .pdp { #tableau-periode .pdp {
width: 2em; width: 5em;
height: 2em; border-radius: 8px;
border-radius: 50%;
} }
.grid-table { .header {
display: grid; background-color: #f9f9f9;
grid-template-columns: 200px repeat({{ etudiants|length }}, 1fr); padding: 10px;
width: var(--sco-content-max-width); text-align: center;
border: 1px solid #ddd;
} }
.cell, .header { .cell, .header {
border: 1px solid #ddd; border: 1px solid #ddd;
padding: 10px; padding: 10px;
@ -74,19 +86,23 @@
} }
.header{ .header{
justify-content: space-between; justify-content: space-between;
} }
.cell{
.cell {
display: flex;
align-items: center;
justify-content: center; justify-content: center;
border: 1px solid #ddd;
padding: 10px;
text-align: center;
width: 256px;
} }
.cell p{ .cell p{
text-align: center; text-align: center;
} }
.sticky { .sticky {
position: sticky; position: sticky;
left: 0; top: 0;
background-color: #f9f9f9; background-color: #f9f9f9;
z-index: 2; z-index: 2;
} }
@ -95,12 +111,24 @@
display: block; display: block;
top: 0; top: 0;
z-index: 0; z-index: 0;
width: 100% !important;
min-width: inherit !important;
}
.assi-btns {
display: flex;
gap: 4px;
} }
.pointer{ .pointer{
cursor: pointer; cursor: pointer;
} }
.ligne{
display: flex;
gap: 1px;
}
</style> </style>
{% endblock styles %} {% endblock styles %}
@ -124,6 +152,10 @@ function afficherPDP(checked) {
} else { } else {
gtrcontent.removeAttribute("data-pdp"); gtrcontent.removeAttribute("data-pdp");
} }
// On sauvegarde le choix dans le localStorage
localStorage.setItem("scodoc-signal_assiduites_diff-pdp", `${checked}`);
pdp.checked = checked;
} }
/** /**
@ -156,7 +188,7 @@ async function nouvellePeriode(period = null) {
moduleimpl_id = period.moduleimpl_id; moduleimpl_id = period.moduleimpl_id;
}else{ }else{
//Sinon on vérifie qu'on a bien des valeurs //Sinon on vérifie qu'on a bien des valeurs
const text = document.createTextNode("Veuillez remplir tous les champs pour ajouter une période.") const text = document.createTextNode("Veuillez remplir tous les champs pour ajouter une plage.")
if (date == "" || debut == "" || fin == "" || moduleimpl_id == "") { if (date == "" || debut == "" || fin == "" || moduleimpl_id == "") {
openAlertModal( openAlertModal(
"Erreur", "Erreur",
@ -168,10 +200,11 @@ async function nouvellePeriode(period = null) {
// On ajoute la nouvelle période au tableau // On ajoute la nouvelle période au tableau
let periodeDiv = document.createElement("div"); let periodeDiv = document.createElement("div");
periodeDiv.classList.add("cell", "sticky"); periodeDiv.classList.add("cell", "header");
periodeDiv.id = `periode-${periodId}`; periodeDiv.id = `periode-${periodId}`;
const periodP = document.createElement("p"); const periodP = document.createElement("p");
periodP.textContent = `Période du ${date} de ${debut} à ${fin}`; periodP.textContent = `Plage du ${date} de ${debut} à ${fin}`;
// On ajoute le moduleimpl // On ajoute le moduleimpl
const modP = document.createElement("p"); const modP = document.createElement("p");
@ -184,7 +217,7 @@ async function nouvellePeriode(period = null) {
// On supprime toutes les cases du tableau correspondant à cette période // On supprime toutes les cases du tableau correspondant à cette période
document document
.querySelectorAll( .querySelectorAll(
`.cell[data-periodeid="${periodeDiv.getAttribute("data-periodeid")}"]` `[data-periodeid="${periodeDiv.getAttribute("data-periodeid")}"]`
) )
.forEach((e) => e.remove()); .forEach((e) => e.remove());
// On supprime la période de la Map periodes // On supprime la période de la Map periodes
@ -195,11 +228,11 @@ async function nouvellePeriode(period = null) {
periodeDiv.appendChild(modP); periodeDiv.appendChild(modP);
periodeDiv.appendChild(close); periodeDiv.appendChild(close);
periodeDiv.setAttribute("data-periodeid", periodId); periodeDiv.setAttribute("data-periodeid", periodId);
document.getElementById("tableau-periode").appendChild(periodeDiv); document.getElementById("tete-table").appendChild(periodeDiv);
// On récupère les étudiants (etudids) // On récupère les étudiants (etudids)
let etudids = [ let etudids = [
...document.querySelectorAll("#tableau-periode .header[data-etudid]"), ...document.querySelectorAll(".ligne[data-etudid]"),
].map((e) => e.getAttribute("data-etudid")); ].map((e) => e.getAttribute("data-etudid"));
// On génère une date de début et de fin de la période // On génère une date de début et de fin de la période
@ -249,7 +282,7 @@ async function nouvellePeriode(period = null) {
cell.setAttribute("data-etudid", etudid); cell.setAttribute("data-etudid", etudid);
cell.setAttribute("data-periodeid", periodId); cell.setAttribute("data-periodeid", periodId);
cell.id = `cell-${etudid}-${periodId}`; cell.id = `cell-${etudid}-${periodId}`;
document.getElementById("tableau-periode").appendChild(cell); document.querySelector(`.ligne[data-etudid="${etudid}"]`).appendChild(cell);
//Vérification inscription au module //Vérification inscription au module
// Si l'étudiant n'est pas inscrit, on le notifie et on passe à l'étudiant suivant // Si l'étudiant n'est pas inscrit, on le notifie et on passe à l'étudiant suivant
@ -265,6 +298,10 @@ async function nouvellePeriode(period = null) {
const assiduites = data[etudid]; const assiduites = data[etudid];
// Si l'étudiant n'a pas d'assiduité, on crée les boutons assiduité // Si l'étudiant n'a pas d'assiduité, on crée les boutons assiduité
if (assiduites.length == 0) { if (assiduites.length == 0) {
const assi_btns = document.createElement('div');
assi_btns.classList.add('assi-btns');
["present", "retard", "absent"].forEach((value) => { ["present", "retard", "absent"].forEach((value) => {
const cbox = document.createElement("input"); const cbox = document.createElement("input");
cbox.type = "checkbox"; cbox.type = "checkbox";
@ -284,8 +321,9 @@ async function nouvellePeriode(period = null) {
// Si une valeur par défaut est donnée alors on l'applique // Si une valeur par défaut est donnée alors on l'applique
cbox.checked = etatDef.value == value; cbox.checked = etatDef.value == value;
cell.appendChild(cbox); assi_btns.appendChild(cbox);
}); });
cell.appendChild(assi_btns);
} else { } else {
// Si une (ou plus) assiduité sont trouvée pour la période // Si une (ou plus) assiduité sont trouvée pour la période
// alors on affiche les informations de la première assiduité // alors on affiche les informations de la première assiduité
@ -297,6 +335,8 @@ async function nouvellePeriode(period = null) {
.catch((error) => { .catch((error) => {
console.error("Error:", error); console.error("Error:", error);
}); });
document.getElementById("tableau-periode").classList.remove("hidden");
} }
/** /**
* Permet de récupérer la saisie puis créer les assiduités grâce à l'api * Permet de récupérer la saisie puis créer les assiduités grâce à l'api
@ -337,6 +377,7 @@ function sauvegarderAssiduites() {
} }
}); });
} }
// Une fois les assiduités générées, on les envoie à l'api // Une fois les assiduités générées, on les envoie à l'api
async_post( async_post(
"../../api/assiduites/create", "../../api/assiduites/create",
@ -344,7 +385,7 @@ function sauvegarderAssiduites() {
// Si la requête passe // Si la requête passe
async (data) => { async (data) => {
// On supprime toutes les cases du tableau pour le mettre à jour // On supprime toutes les cases du tableau pour le mettre à jour
document.querySelectorAll(".cell").forEach((e) => e.remove()); document.querySelectorAll("[data-periodeid]").forEach((e)=>e.remove())
// On recrée les périodes // On recrée les périodes
// (cela permet de redemander les assiduités, donc mettre à jour les cases) // (cela permet de redemander les assiduités, donc mettre à jour les cases)
@ -402,7 +443,7 @@ function sauvegarderAssiduites() {
const period = periodes.get(periodeId); const period = periodes.get(periodeId);
const li = document.createElement("li"); const li = document.createElement("li");
// On affiche la période // On affiche la période
li.textContent = `Période du ${period.date_debut.format( li.textContent = `Plage du ${period.date_debut.format(
"DD/MM/YYYY HH:mm" "DD/MM/YYYY HH:mm"
)} à ${period.date_fin.format("HH:mm")}`; )} à ${period.date_fin.format("HH:mm")}`;
@ -474,7 +515,8 @@ if (window.forceModule) {
* - On vérifie si la date est un jour travaillé * - On vérifie si la date est un jour travaillé
*/ */
async function main() { async function main() {
afficherPDP(pdp.checked); const checked = localStorage.getItem("scodoc-signal_assiduites_diff-pdp") == "true";
afficherPDP(checked);
$("#date").on("change", async function (d) { $("#date").on("change", async function (d) {
// On vérifie si la date est un jour travaillé // On vérifie si la date est un jour travaillé
dateCouranteEstTravaillee(); dateCouranteEstTravaillee();
@ -497,8 +539,8 @@ main();
<div id="fix"> <div id="fix">
<!-- Nouvelle période <!-- Nouvelle Plage
Permet de créer une nouvelle ligne pour une nouvelle période Permet de créer une nouvelle ligne pour une nouvelle Plage
( (
Jour, -> datepicker Jour, -> datepicker
Heure de début, -> timepicker Heure de début, -> timepicker
@ -529,38 +571,34 @@ main();
{{moduleimpl_select | safe}} {{moduleimpl_select | safe}}
</label> </label>
<button id="add_periode" onclick="nouvellePeriode()">Ajouter une période</button> <button id="add_periode" onclick="nouvellePeriode()">Ajouter une plage</button>
</div> </div>
</div>
<!-- Boutons d'actions
<!-- Boutons d'actions
- Sauvegarder - Sauvegarder
- Afficher la photo de profil - Afficher la photo de profil
- Assiduité par défaut (aucune, present, retard, absent) - Assiduité par défaut (aucune, present, retard, absent)
---> --->
<br>
<div id="actions" class="flex">
<button id="save" onclick="sauvegarderAssiduites()">ENREGISTRER</button>
<label for="pdp">
Photo de profil :
<input type="checkbox" name="pdp" id="pdp" checked onclick="afficherPDP(this.checked)">
</label>
<div id="actions" class="box"> <label for="etatDef">
<label for="pdp"> Intialiser les étudiants comme :
Photo de profil : <select name="etatDef" id="etatDef">
<input type="checkbox" name="pdp" id="pdp" checked onclick="afficherPDP(this.checked)"> <option value="">-</option>
</label> <option value="present">présents</option>
<option value="retard">en retard</option>
<label for="etatDef"> <option value="absent">absents</option>
Assiduité par défaut : </select>
<select name="etatDef" id="etatDef"> </label>
<option value="">Aucune</option>
<option value="present">Présence</option>
<option value="retard">Retard</option>
<option value="absent">Absence</option>
</select>
</label>
<button id="save" onclick="sauvegarderAssiduites()">Sauvegarder l'assiduité</button>
</div>
</div> </div>
<br>
<!-- Tableau à double entrée <!-- Tableau à double entrée
Colonne : Etudiants (Header = Nom, Prénom, Photo (si actif)) Colonne : Etudiants (Header = Nom, Prénom, Photo (si actif))
Ligne : Période (Header = Jour, Heure de début, Heure de fin, ModuleImplId) Ligne : Période (Header = Jour, Heure de début, Heure de fin, ModuleImplId)
@ -570,17 +608,27 @@ main();
---> --->
<div id="tableau-periode" class="grid-table"> <div id="tableau-periode" class="grid-table">
<!-- Header de la première colonne --> <!-- Première ligne : Plages -->
<div class="header sticky">Période</div> <div class="ligne" id="tete-table">
<!-- Headers des autres colonnes (noms des étudiants) --> <div class="cell header sticky">Étudiants</div>
{# <div class="cell header" periode-id="X">Plage X</div> #}
</div>
{# ... #}
<hr class="hidden" id="separator">
{% for etud in etudiants %} {% for etud in etudiants %}
<div class="header etudinfo" data-etudid="{{etud.etudid}}" id="head-{{etud.etudid}}"> <div class="ligne" data-etudid="{{etud.etudid}}">
<img src="../../api/etudiant/etudid/{{etud.etudid}}/photo?size=small" alt="{{etud.nomprenom}}" class="pdp"> <div class="cell etudinfo sticky" id="head-{{etud.etudid}}">
<span>{{ etud.nomprenom }}</span> <img src="../../api/etudiant/etudid/{{etud.etudid}}/photo?size=small" alt="{{etud.nomprenom}}" class="pdp">
<span>{{ etud.nomprenom }}</span>
</div>
{# <div class="cell" periode-id="X">Assiduité Plage 1</div> #}
</div> </div>
{% endfor %} {% endfor %}
<!-- Sera remplis avec les nouvelles périodes -->
</div> </div>

View File

@ -186,6 +186,12 @@ def bilan_dept():
if not table[0]: if not table[0]:
return table[1] return table[1]
# Récupération des formsemestres (pour le menu déroulant)
formsemestres: Query = FormSemestre.get_dept_formsemestres_courants(dept)
formsemestres_choices: dict[int, str] = {
fs.id: fs.titre_annee() for fs in formsemestres
}
# Peuplement du template jinja # Peuplement du template jinja
return render_template( return render_template(
"assiduites/pages/bilan_dept.j2", "assiduites/pages/bilan_dept.j2",
@ -193,6 +199,8 @@ def bilan_dept():
search_etud=sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud"), search_etud=sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud"),
billets=billets, billets=billets,
sco=ScoData(formsemestre=formsemestre), sco=ScoData(formsemestre=formsemestre),
formsemestres=formsemestres_choices,
formsemestre_id=None if not formsemestre else formsemestre.id,
) )
@ -1565,6 +1573,85 @@ def _prepare_tableau(
) )
@bp.route("/recup_assiduites_plage", methods=["POST"])
@scodoc
@permission_required(Permission.AbsChange)
def recup_assiduites_plage():
"""
Renvoie un fichier excel contenant toutes les assiduités d'une plage
La plage est définie par les valeurs "datedeb" et "datefin" du formulaire
Par défaut tous les étudiants du département sont concernés
Si le champs "formsemestre_id" est présent dans le formulaire et est non vide,
seuls les étudiants inscrits dans ce semestre sont concernés.
"""
date_deb: datetime.datetime = request.form.get("datedeb")
date_fin: datetime.datetime = request.form.get("datefin")
# Vérification des dates
try:
date_deb = datetime.datetime.strptime(date_deb, "%d/%m/%Y")
except ValueError as exc:
raise ScoValueError("date_debut invalide", dest_url=request.referrer) from exc
try:
date_fin = datetime.datetime.strptime(date_fin, "%d/%m/%Y")
except ValueError as exc:
raise ScoValueError("date_fin invalide", dest_url=request.referrer) from exc
# Récupération des étudiants
etuds: Query = []
formsemestre_id: str | None = request.form.get("formsemestre_id")
name: str = ""
if formsemestre_id is not None and formsemestre_id != "":
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
etuds = formsemestre.etuds
name = formsemestre.session_id()
else:
dept: Departement = Departement.query.get_or_404(g.scodoc_dept_id)
etuds = dept.etudiants
name = dept.acronym
# Récupération des assiduités
assiduites: Query = Assiduite.query.filter(
Assiduite.etudid.in_([etud.id for etud in etuds])
)
# Filtrage des assiduités en fonction des dates données
assiduites = scass.filter_by_date(assiduites, Assiduite, date_deb, date_fin)
table_data: liste_assi.AssiJustifData = liste_assi.AssiJustifData(
assiduites_query=assiduites,
)
options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions(
show_pres=True,
show_reta=True,
show_module=True,
show_etu=True,
)
date_deb_str: str = date_deb.strftime("%d-%m-%Y")
date_fin_str: str = date_fin.strftime("%d-%m-%Y")
filename: str = f"assiduites_{name}_{date_deb_str}_{date_fin_str}"
tableau: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti(
table_data,
options=options,
titre="tableau-dept-" + filename,
no_pagination=True,
)
return scu.send_file(
tableau.excel(),
filename=filename,
mime=scu.XLSX_MIMETYPE,
suffix=scu.XLSX_SUFFIX,
)
@bp.route("/tableau_assiduite_actions", methods=["GET", "POST"]) @bp.route("/tableau_assiduite_actions", methods=["GET", "POST"])
@scodoc @scodoc
@permission_required(Permission.AbsChange) @permission_required(Permission.AbsChange)