Assiduites : Front End

This commit is contained in:
iziram 2023-04-17 15:44:55 +02:00
parent 650deff2c6
commit 5a9d65788f
24 changed files with 8715 additions and 8 deletions

4
app/__init__.py Normal file → Executable file
View File

@ -322,6 +322,7 @@ def create_app(config_class=DevConfig):
from app.views import notes_bp from app.views import notes_bp
from app.views import users_bp from app.views import users_bp
from app.views import absences_bp from app.views import absences_bp
from app.views import assiduites_bp
from app.api import api_bp from app.api import api_bp
from app.api import api_web_bp from app.api import api_web_bp
@ -340,6 +341,9 @@ def create_app(config_class=DevConfig):
app.register_blueprint( app.register_blueprint(
absences_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Absences" absences_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Absences"
) )
app.register_blueprint(
assiduites_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Assiduites"
)
app.register_blueprint(api_bp, url_prefix="/ScoDoc/api") app.register_blueprint(api_bp, url_prefix="/ScoDoc/api")
app.register_blueprint(api_web_bp, url_prefix="/ScoDoc/<scodoc_dept>/api") app.register_blueprint(api_web_bp, url_prefix="/ScoDoc/<scodoc_dept>/api")

2
app/scodoc/html_sidebar.py Normal file → Executable file
View File

@ -126,7 +126,7 @@ def sidebar(etudid: int = None):
if current_user.has_permission(Permission.ScoAbsChange): if current_user.has_permission(Permission.ScoAbsChange):
H.append( H.append(
f""" f"""
<li><a href="{ url_for('absences.SignaleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li> <li><a href="{ url_for('assiduites.signal_assiduites_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li>
<li><a href="{ url_for('absences.JustifAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li> <li><a href="{ url_for('absences.JustifAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li>
<li><a href="{ url_for('absences.AnnuleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Supprimer</a></li> <li><a href="{ url_for('absences.AnnuleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Supprimer</a></li>
""" """

41
app/scodoc/sco_abs.py Normal file → Executable file
View File

@ -42,6 +42,8 @@ from app.scodoc import sco_cache
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.models import Assiduite, Justificatif
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
# --- Misc tools.... ------------------ # --- Misc tools.... ------------------
@ -1052,6 +1054,45 @@ def get_abs_count_in_interval(etudid, date_debut_iso, date_fin_iso):
return r return r
def get_assiduites_count_in_interval(etudid, date_debut_iso, date_fin_iso):
"""Les comptes d'absences de cet étudiant entre ces deux dates, incluses:
tuple (nb abs, nb abs justifiées)
Utilise un cache.
"""
key = str(etudid) + "_" + date_debut_iso + "_" + date_fin_iso + "_assiduites"
r = sco_cache.AbsSemEtudCache.get(key)
if not r:
date_debut: datetime.datetime = scu.is_iso_formated(date_debut_iso, True)
date_fin: datetime.datetime = scu.is_iso_formated(date_fin_iso, True)
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
justificatifs: Justificatif = Justificatif.query.filter_by(etudid=etudid)
assiduites = scass.filter_by_date(assiduites, Assiduite, date_debut, date_fin)
justificatifs = scass.filter_by_date(
justificatifs, Justificatif, date_debut, date_fin
)
calculator: scass.CountCalculator = scass.CountCalculator()
calculator.compute_assiduites(assiduites)
nb_abs: dict = calculator.to_dict()["demi"]
abs_just: list[Assiduite] = scass.get_all_justified(
etudid, date_debut, date_fin
)
calculator.reset()
calculator.compute_assiduites(abs_just)
nb_abs_just: dict = calculator.to_dict()["demi"]
r = (nb_abs, nb_abs_just)
ans = sco_cache.AbsSemEtudCache.set(key, r)
if not ans:
log("warning: get_assiduites_count failed to cache")
return r
def invalidate_abs_count(etudid, sem): def invalidate_abs_count(etudid, sem):
"""Invalidate (clear) cached counts""" """Invalidate (clear) cached counts"""
date_debut = sem["date_debut_iso"] date_debut = sem["date_debut_iso"]

8
app/scodoc/sco_formsemestre_status.py Normal file → Executable file
View File

@ -815,9 +815,9 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
</td> </td>
<td> <td>
<form action="{url_for( <form action="{url_for(
"absences.SignaleAbsenceGrSemestre", scodoc_dept=g.scodoc_dept "assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept
)}" method="get"> )}" method="get">
<input type="hidden" name="datefin" value="{ <input type="hidden" name="date" value="{
formsemestre.date_fin.strftime("%d/%m/%Y")}"/> formsemestre.date_fin.strftime("%d/%m/%Y")}"/>
<input type="hidden" name="group_ids" value="%(group_id)s"/> <input type="hidden" name="group_ids" value="%(group_id)s"/>
<input type="hidden" name="destination" value="{destination}"/> <input type="hidden" name="destination" value="{destination}"/>
@ -834,8 +834,8 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
</select> </select>
<a href="{ <a href="{
url_for("absences.choix_semaine", scodoc_dept=g.scodoc_dept) url_for("assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept)
}?group_id=%(group_id)s">saisie par semaine</a> }?group_ids=%(group_id)s&jour={datetime.date.today().isoformat()}&formsemestre_id={formsemestre.id}">saisie par semaine</a>
</form></td> </form></td>
""" """
else: else:

4
app/scodoc/sco_photos.py Normal file → Executable file
View File

@ -148,11 +148,11 @@ def get_photo_image(etudid=None, size="small"):
filename = photo_pathname(etud.photo_filename, size=size) filename = photo_pathname(etud.photo_filename, size=size)
if not filename: if not filename:
filename = UNKNOWN_IMAGE_PATH filename = UNKNOWN_IMAGE_PATH
r = _http_jpeg_file(filename) r = build_image_response(filename)
return r return r
def _http_jpeg_file(filename): def build_image_response(filename):
"""returns an image as a Flask response""" """returns an image as a Flask response"""
st = os.stat(filename) st = os.stat(filename)
last_modified = st.st_mtime # float timestamp last_modified = st.st_mtime # float timestamp

View File

@ -0,0 +1,475 @@
* {
box-sizing: border-box;
}
.selectors>* {
margin: 10px 0;
}
.selectors:disabled {
opacity: 0.5;
}
.no-display {
display: none !important;
}
/* === Gestion de la timeline === */
#tl_date {
visibility: hidden;
width: 0px;
height: 0px;
position: absolute;
left: 15%;
}
.infos {
position: relative;
width: fit-content;
}
#datestr {
cursor: pointer;
background-color: white;
border: 1px #444 solid;
border-radius: 5px;
padding: 5px;
}
#tl_slider {
width: 90%;
cursor: grab;
/* visibility: hidden; */
}
#datestr,
#time {
width: fit-content;
}
.ui-slider-handle.tl_handle {
background: none;
width: 25px;
height: 25px;
visibility: visible;
background-position: top;
background-size: cover;
border: none;
top: -180%;
cursor: grab;
}
#l_handle {
background-image: url(../icons/l_handle.svg);
}
#r_handle {
background-image: url(../icons/r_handle.svg);
}
.ui-slider-range.ui-widget-header.ui-corner-all {
background-color: #F9C768;
background-image: none;
opacity: 0.50;
visibility: visible;
}
/* === Gestion des etuds row === */
.etud_holder {
margin-top: 5vh;
}
.etud_row {
display: grid;
grid-template-columns: 10% 20% 30% 30%;
gap: 3.33%;
background-color: white;
border-radius: 15px;
width: 80%;
height: 50px;
margin: 0.5% 0;
box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
-webkit-box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
-moz-box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
}
.etud_row * {
display: flex;
justify-content: center;
align-items: center;
height: 50px;
}
/* --- Index --- */
.etud_row .index_field {
grid-column: 1;
}
/* --- Nom étud --- */
.etud_row .name_field {
grid-column: 2;
height: 100%;
}
.etud_row .name_field .name_set {
flex-direction: column;
align-items: flex-start;
margin: 0 5%;
}
.etud_row .name_field .name_set * {
padding: 0;
margin: 0;
}
.etud_row .name_field .name_set h4 {
font-size: small;
font-weight: 600;
}
.etud_row .name_field .name_set h5 {
font-size: x-small;
}
/* --- Barre assiduités --- */
.etud_row .assiduites_bar {
display: flex;
flex-wrap: wrap;
grid-column: 3;
position: relative;
}
.etud_row .assiduites_bar .filler {
height: 5px;
width: 90%;
background-color: white;
border: 1px solid #444;
}
.etud_row .assiduites_bar #prevDateAssi {
height: 7px;
width: 7px;
background-color: white;
border: 1px solid #444;
margin: 0px 8px;
}
.etud_row .assiduites_bar #prevDateAssi.single {
height: 9px;
width: 9px;
}
.etud_row.conflit {
background-color: #ff000061;
}
.etud_row .assiduites_bar .absent {
background-color: #ec5c49 !important;
}
.etud_row .assiduites_bar .present {
background-color: #37f05f !important;
}
.etud_row .assiduites_bar .retard {
background-color: #ecb52a !important;
}
/* --- Boutons assiduités --- */
.etud_row .btns_field {
grid-column: 4;
}
.btns_field:disabled {
opacity: 0.7;
}
.etud_row .btns_field * {
margin: 0 5%;
cursor: pointer;
width: 35px;
height: 35px;
}
.rbtn {
-webkit-appearance: none;
appearance: none;
}
.rbtn::before {
content: "";
display: inline-block;
width: 35px;
height: 35px;
background-position: center;
background-size: cover;
}
.rbtn.present::before {
background-image: url(../icons/present.svg);
}
.rbtn.absent::before {
background-image: url(../icons/absent.svg);
}
.rbtn.retard::before {
background-image: url(../icons/retard.svg);
}
.rbtn:checked:before {
outline: 3px solid #7059FF;
border-radius: 5px;
}
.rbtn:focus {
outline: none !important;
}
/*<== Modal conflit ==>*/
.modal {
display: none;
position: fixed;
z-index: 500;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.4);
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
height: 30%;
position: relative;
border-radius: 10px;
}
.close {
color: #111;
position: absolute;
right: 5px;
top: 0px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
/* Ajout de styles pour la frise chronologique */
.modal-timeline {
display: flex;
flex-direction: column;
align-items: stretch;
margin-bottom: 20px;
}
.time-labels,
.assiduites-container {
display: flex;
justify-content: space-between;
position: relative;
}
.time-label {
font-size: 14px;
margin-bottom: 4px;
}
.assiduite {
position: absolute;
top: 20px;
cursor: pointer;
border-radius: 4px;
z-index: 10;
height: 100px;
padding: 4px;
}
.assiduite-info {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.assiduite-id,
.assiduite-period,
.assiduite-state,
.assiduite-user_id {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.assiduites-container {
min-height: 20px;
height: calc(50% - 60px);
/* Augmentation de la hauteur du conteneur des assiduités */
position: relative;
margin-bottom: 10px;
}
.action-buttons {
position: absolute;
text-align: center;
display: flex;
justify-content: space-evenly;
align-items: center;
height: 60px;
width: 100%;
bottom: 5%;
}
/* Ajout de la classe CSS pour la bordure en pointillés */
.assiduite.selected {
border: 2px dashed black;
}
.assiduite-special {
height: 120px;
position: absolute;
z-index: 5;
border: 2px solid #000;
background-color: rgba(36, 36, 36, 0.25);
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, rgba(81, 81, 81, 0.61) 5px, rgba(81, 81, 81, 0.61) 10px);
border-radius: 5px;
}
/*<== Info sur l'assiduité sélectionnée ==>*/
.modal-assiduite-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: max-content;
height: 30%;
position: relative;
border-radius: 10px;
display: none;
}
.modal-assiduite-content.show {
display: block;
}
.modal-assiduite-content .infos {
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: flex-start;
}
/*<=== Mass Action ==>*/
.mass-selection {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
margin: 2% 0;
}
.mass-selection span {
margin: 0 1%;
}
.mass-selection .rbtn {
background-color: transparent;
cursor: pointer;
}
/*<== Loader ==> */
.loader-container {
display: none;
/* Cacher le loader par défaut */
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
/* Fond semi-transparent pour bloquer les clics */
z-index: 9999;
/* Placer le loader au-dessus de tout le contenu */
}
.loader {
border: 6px solid #f3f3f3;
border-radius: 50%;
border-top: 6px solid #3498db;
width: 60px;
height: 60px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
.fieldsplit {
display: flex;
justify-content: flex-start;
align-items: center;
flex-direction: column;
}
.fieldsplit legend {
margin: 0;
}
#page-assiduite-content {
display: flex;
flex-wrap: wrap;
gap: 5%;
flex-direction: column;
}
#page-assiduite-content>* {
margin: 1.5% 0;
}

11
app/static/icons/absent.svg Executable file
View File

@ -0,0 +1,11 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#F1A69C"/>
<g opacity="0.5" clip-path="url(#clip0_120_4425)">
<path d="M67.2116 70L43 45.707L18.7885 70L15.0809 66.3043L39.305 41.9995L15.0809 17.6939L18.7885 14L43 38.2922L67.2116 14L70.9191 17.6939L46.695 41.9995L70.9191 66.3043L67.2116 70Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_120_4425">
<rect width="56" height="56" fill="white" transform="matrix(1 0 0 -1 15 70)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 547 B

13
app/static/icons/present.svg Executable file
View File

@ -0,0 +1,13 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#9CF1AF"/>
<g clip-path="url(#clip0_120_4405)">
<g opacity="0.5">
<path d="M70.7713 27.5875L36.0497 62.3091C35.7438 62.6149 35.2487 62.6149 34.9435 62.3091L15.2286 42.5935C14.9235 42.2891 14.9235 41.7939 15.2286 41.488L20.0191 36.6976C20.3249 36.3924 20.8201 36.3924 21.1252 36.6976L35.4973 51.069L64.8754 21.6909C65.1819 21.3858 65.6757 21.3858 65.9815 21.6909L70.7713 26.4814C71.0771 26.7865 71.0771 27.281 70.7713 27.5875Z" fill="black"/>
</g>
</g>
<defs>
<clipPath id="clip0_120_4405">
<rect width="56" height="56" fill="white" transform="translate(15 14)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 729 B

12
app/static/icons/retard.svg Executable file
View File

@ -0,0 +1,12 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#F1D99C"/>
<g opacity="0.5" clip-path="url(#clip0_120_4407)">
<path d="M55.2901 49.1836L44.1475 41.3918V28C44.1475 27.3688 43.6311 26.8524 43 26.8524C42.3688 26.8524 41.8524 27.3688 41.8524 28V42C41.8524 42.3787 42.036 42.7229 42.3459 42.941L53.9819 51.077C54.177 51.2147 54.4065 51.2836 54.636 51.2836C54.9918 51.2836 55.3475 51.1115 55.577 50.7787C55.9327 50.2623 55.8065 49.5508 55.2901 49.1836Z" fill="black"/>
<path d="M62.7836 22.2164C57.482 16.9148 50.459 14 43 14C35.541 14 28.518 16.9148 23.2164 22.2164C17.9148 27.518 15 34.541 15 42C15 49.459 17.9148 56.482 23.2164 61.7836C28.518 67.0852 35.541 70 43 70C50.459 70 57.482 67.0852 62.7836 61.7836C68.0852 56.482 71 49.459 71 42C71 34.541 68.0852 27.518 62.7836 22.2164ZM44.1475 67.682V63C44.1475 62.3689 43.6311 61.8525 43 61.8525C42.3689 61.8525 41.8525 62.3689 41.8525 63V67.682C28.5869 67.0967 17.9033 56.4131 17.318 43.1475H22C22.6311 43.1475 23.1475 42.6311 23.1475 42C23.1475 41.3689 22.6311 40.8525 22 40.8525H17.318C17.9033 27.5869 28.5869 16.9033 41.8525 16.318V21C41.8525 21.6311 42.3689 22.1475 43 22.1475C43.6311 22.1475 44.1475 21.6311 44.1475 21V16.318C57.4131 16.9033 68.0967 27.5869 68.682 40.8525H64C63.3689 40.8525 62.8525 41.3689 62.8525 42C62.8525 42.6311 63.3689 43.1475 64 43.1475H68.682C68.0967 56.4131 57.4131 67.0967 44.1475 67.682Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_120_4407">
<rect width="56" height="56" fill="white" transform="translate(15 14)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

1792
app/static/js/assiduites.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3309
app/static/libjs/moment.new.min.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,156 @@
{% block alertmodal %}
<div id="alertModal" class="alertmodal">
<!-- alertModal content -->
<div class="alertmodal-content">
<div class="alertmodal-header">
<span class="alertmodal-close">&times;</span>
<h2 class="alertmodal-title">alertModal Header</h2>
</div>
<div class="alertmodal-body">
<p>Some text in the alertModal Body</p>
<p>Some other text...</p>
</div>
<div class="alertmodal-footer">
<h3>alertModal Footer</h3>
</div>
</div>
</div>
<style>
/* The alertModal (background) */
.alertmodal {
display: none;
/* Hidden by default */
position: fixed;
/* Stay in place */
z-index: 50;
/* Sit on top */
padding-top: 100px;
/* Location of the box */
left: 0;
top: 0;
width: 100%;
/* Full width */
height: 100%;
/* Full height */
overflow: auto;
/* Enable scroll if needed */
background-color: rgb(0, 0, 0);
/* Fallback color */
background-color: rgba(0, 0, 0, 0.4);
/* Black w/ opacity */
}
/* alertModal Content */
.alertmodal-content {
border-radius: 8px;
overflow: hidden;
position: relative;
background-color: #fefefe;
margin: auto;
padding: 0;
border: 1px solid #888;
width: 45%;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
-webkit-animation-name: animatetop;
-webkit-animation-duration: 0.4s;
animation-name: animatetop;
animation-duration: 0.4s
}
/* Add Animation */
@-webkit-keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
@keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
/* The Close Button */
.alertmodal-close {
color: white;
position: absolute;
right: 10px;
font-size: 28px;
font-weight: bold;
}
.alertmodal-close:hover,
.alertmodal-close:focus {
color: #000;
text-decoration: none;
cursor: pointer;
}
.alertmodal-header {
padding: 2px 16px;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.alertmodal-body {
padding: 2px 16px;
}
.alertmodal-footer {
padding: 2px 16px;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.alertmodal.is-active {
display: block;
}
</style>
<script>
const alertmodal = document.getElementById("alertModal");
function openAlertModal(titre, contenu, footer, color = "crimson") {
alertmodal.classList.add('is-active');
alertmodal.querySelector('.alertmodal-title').textContent = titre;
alertmodal.querySelector('.alertmodal-body').innerHTML = ""
alertmodal.querySelector('.alertmodal-body').appendChild(contenu);
alertmodal.querySelector('.alertmodal-footer').textContent = footer;
const banners = Array.from(alertmodal.querySelectorAll('.alertmodal-footer,.alertmodal-header'))
banners.forEach((ban) => {
ban.style.backgroundColor = color;
})
}
function closeAlertModal() {
alertmodal.classList.remove("is-active")
}
const alertClose = document.querySelector(".alertmodal-close");
alertClose.onclick = function () {
closeAlertModal()
}
window.onclick = function (event) {
if (event.target == alertmodal) {
alertmodal.classList.remove('is-active');
}
}
</script>
{% endblock alertmodal %}

View File

@ -0,0 +1,105 @@
<div class="assiduite-bubble">
</div>
<script>
</script>
<style>
.assiduite-bubble {
position: fixed;
display: none;
background-color: #f9f9f9;
border-radius: 5px;
padding: 8px;
border: 3px solid #ccc;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-size: 12px;
line-height: 1.4;
z-index: 500;
}
.assiduite-bubble.is-active {
display: block;
}
.assiduite-bubble::before {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 6px;
border-style: solid;
border-color: transparent transparent #f9f9f9 transparent;
}
.assiduite-bubble::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: transparent transparent #ccc transparent;
}
.assiduite-id,
.assiduite-period,
.assiduite-state,
.assiduite-user_id {
margin-bottom: 4px;
}
.assiduite-bubble.absent {
border-color: #ec5c49 !important;
}
.assiduite-bubble.present {
border-color: #37f05f !important;
}
.assiduite-bubble.retard {
border-color: #ecb52a !important;
}
.mini-timeline {
height: 7px;
width: 90%;
border: 1px solid black;
position: relative;
background-color: white;
overflow: hidden;
}
.mini-timeline.single {
height: 9px;
}
.mini-timeline-block {
position: absolute;
height: 100%;
z-index: 1;
}
#page-assiduite-content .mini-timeline-block {
cursor: pointer;
}
.mini_tick {
position: absolute;
text-align: center;
top: -16px;
left: 50%;
}
.mini_tick::after {
display: block;
content: "|";
position: absolute;
bottom: -2px;
z-index: 2;
}
</style>

View File

@ -0,0 +1,113 @@
<label for="moduleimpl_select">
Module
<select id="moduleimpl_select">
<option value="" selected> Non spécifié </option>
</select>
</label>
<script>
function getEtudFormSemestres() {
let semestre = {};
sync_get(getUrl() + `/api/etudiant/etudid/${etudid}/formsemestres`, (data) => {
semestre = data;
});
return semestre;
}
function filterFormSemestres(semestres) {
const date = new moment.tz(
document.querySelector("#tl_date").value,
TIMEZONE
);
semestres = semestres.filter((fm) => {
return date.isBetween(fm.date_debut_iso, fm.date_fin_iso)
})
return semestres;
}
function getFormSemestreProgramme(fm_id) {
let semestre = {};
sync_get(getUrl() + `/api/formsemestre/${fm_id}/programme`, (data) => {
semestre = data;
});
return semestre;
}
function getModulesImplByFormsemestre(semestres) {
const map = new Map();
semestres.forEach((fm) => {
const array = [];
const fm_p = getFormSemestreProgramme(fm.formsemestre_id);
["ressources", "saes", "modules"].forEach((r) => {
if (r in fm_p) {
fm_p[r].forEach((o) => {
array.push(getModuleInfos(o))
})
}
})
map.set(fm.titre_num, array)
})
return map;
}
function getModuleInfos(obj) {
return {
moduleimpl_id: obj.moduleimpl_id,
titre: obj.module.titre,
code: obj.module.code,
}
}
function populateSelect(sems, selected) {
const select = document.getElementById('moduleimpl_select');
select.innerHTML = `<option value="" selected> Non spécifié </option>`
sems.forEach((mods, label) => {
const optGrp = document.createElement('optgroup');
optGrp.label = label
mods.forEach((obj) => {
const opt = document.createElement('option');
opt.value = obj.moduleimpl_id;
opt.textContent = `${obj.code} ${obj.titre}`
if (obj.moduleimpl_id == selected) {
opt.setAttribute('selected', 'true');
}
optGrp.appendChild(opt);
})
select.appendChild(optGrp);
})
}
function updateSelect(moduleimpl_id) {
let sem = getEtudFormSemestres()
sem = filterFormSemestres(sem)
const mod = getModulesImplByFormsemestre(sem)
populateSelect(mod, moduleimpl_id);
}
function updateSelectedSelect(moduleimpl_id) {
document.getElementById('moduleimpl_select').value = moduleimpl_id;
}
</script>
<style>
#moduleimpl_select {
width: 125px;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,14 @@
<select name="moduleimpl_select" id="moduleimpl_select">
<option value="" {{selected}}> Non spécifié </option>
{% for mod in modules %}
{% if mod.moduleimpl_id == moduleimpl_id %}
<option value="{{mod.moduleimpl_id}}" selected> {{mod.name}} </option>
{% else %}
<option value="{{mod.moduleimpl_id}}"> {{mod.name}} </option>
{% endif %}
{% endfor %}
</select>

View File

@ -0,0 +1,211 @@
{% block promptModal %}
<div id="promptModal" class="promptModal">
<!-- promptModal content -->
<div class="promptModal-content">
<div class="promptModal-header">
<span class="promptModal-close">&times;</span>
<h2 class="promptModal-title">promptModal Header</h2>
</div>
<div class="promptModal-body">
<p>Some text in the promptModal Body</p>
<p>Some other text...</p>
</div>
<div class="promptModal-footer">
<h3>promptModal Footer</h3>
</div>
</div>
</div>
<style>
/* The promptModal (background) */
.promptModal {
display: none;
/* Hidden by default */
position: fixed;
/* Stay in place */
z-index: 50;
/* Sit on top */
padding-top: 100px;
/* Location of the box */
left: 0;
top: 0;
width: 100%;
/* Full width */
height: 100%;
/* Full height */
overflow: auto;
/* Enable scroll if needed */
background-color: rgb(0, 0, 0);
/* Fallback color */
background-color: rgba(0, 0, 0, 0.4);
/* Black w/ opacity */
}
/* promptModal Content */
.promptModal-content {
border-radius: 8px;
overflow: hidden;
position: relative;
background-color: #fefefe;
margin: auto;
padding: 0;
border: 1px solid #888;
width: 45%;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
-webkit-animation-name: animatetop;
-webkit-animation-duration: 0.4s;
animation-name: animatetop;
animation-duration: 0.4s
}
/* Add Animation */
@-webkit-keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
@keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
/* The Close Button */
.promptModal-close {
color: white;
position: absolute;
right: 10px;
font-size: 28px;
font-weight: bold;
}
.promptModal-close:hover,
.promptModal-close:focus {
color: #000;
text-decoration: none;
cursor: pointer;
}
.promptModal-header {
padding: 2px 16px;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.promptModal-body {
padding: 2px 16px;
}
.promptModal-footer {
padding: 2px 16px;
color: white;
display: flex;
justify-content: space-evenly;
align-items: center;
}
.promptModal.is-active {
display: block;
}
.btnPrompt {
display: inline-block;
padding: 6px 12px;
font-size: 14px;
font-weight: 500;
text-align: center;
text-decoration: none;
color: #ffffff;
background-color: #6c757d;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.2);
}
.btnPrompt:hover {
background-color: #5a6268;
}
.btnPrompt:active {
transform: translateY(1px);
}
.btnPrompt:disabled {
opacity: 0.7;
background-color: whitesmoke;
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, rgba(81, 81, 81, 0.61) 5px, rgba(81, 81, 81, 0.61) 10px)
}
</style>
<script>
const promptModal = document.getElementById("promptModal");
function openPromptModal(titre, contenu, success, cancel = () => { }, color = "crimson") {
promptModal.classList.add('is-active');
promptModal.querySelector('.promptModal-title').textContent = titre;
promptModal.querySelector('.promptModal-body').innerHTML = ""
promptModal.querySelector('.promptModal-body').appendChild(contenu);
promptModal.querySelector('.promptModal-footer').innerHTML = ""
promptModalButtonAction(success, cancel).forEach((btnPrompt) => {
promptModal.querySelector('.promptModal-footer').appendChild(btnPrompt)
})
const banners = Array.from(promptModal.querySelectorAll('.promptModal-footer,.promptModal-header'))
banners.forEach((ban) => {
ban.style.backgroundColor = color;
})
}
function promptModalButtonAction(success, cancel) {
const succBtn = document.createElement('button')
succBtn.classList.add("btnPrompt")
succBtn.textContent = "Valider"
succBtn.addEventListener('click', () => {
success();
closePromptModal();
})
const cancelBtn = document.createElement('button')
cancelBtn.classList.add("btnPrompt")
cancelBtn.textContent = "Annuler"
cancelBtn.addEventListener('click', () => {
cancel();
closePromptModal();
})
return [succBtn, cancelBtn]
}
function closePromptModal() {
promptModal.classList.remove("is-active")
}
const promptClose = document.querySelector(".promptModal-close");
promptClose.onclick = function () {
closePromptModal()
}
window.onclick = function (event) {
if (event.target == promptModal) {
promptModal.classList.remove('is-active');
}
}
</script>
{% endblock promptModal %}

View File

@ -0,0 +1,99 @@
{# -*- mode: jinja-html -*- #}
<div id="myModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Veuillez régler le conflit pour poursuivre</h2>
<!-- Ajout de la frise chronologique -->
<div class="modal-timeline">
<div class="time-labels"></div>
<div class="assiduites-container"></div>
</div>
<div class="action-buttons">
<button id="delete" class="btnPrompt" disabled>Supprimer</button>
<button id="split" class="btnPrompt" disabled>Séparer</button>
<button id="edit" class="btnPrompt" disabled>Modifier</button>
</div>
</div>
<div class="modal-assiduite-content">
<h2>Information de l'assiduité sélectionnée</h2>
<div class="infos">
<p>Assiduite id : <span id="modal-assiduite-id">A</span></p>
<p>Etat : <span id="modal-assiduite-etat">B</span></p>
<p>Date de début : <span id="modal-assiduite-deb">C</span></p>
<p>Date de fin: <span id="modal-assiduite-fin">D</span></p>
<p>Module : <span id="modal-assiduite-module">E</span></p>
<p><span id="modal-assiduite-user">F</span></p>
</div>
</div>
</div>
{% include "assiduites/toast.j2" %}
<div id="page-assiduite-content">
{% block content %}
<h2>Signalement de l'assiduité de <span class="rouge">{{sco.etud.nomprenom}}</span></h2>
<div class="infos">
Date: <span id="datestr"></span>
<input type="date" name="tl_date" id="tl_date" value="{{ date }}" onchange="updateDate()">
</div>
{% include "assiduites/timeline.j2" %}
<div>
{% include "assiduites/moduleimpl_dynamic_selector.j2" %}
<button class="btn" onclick="justify()" disabled id="justif-rapide">Justifier</button>
</div>
<div class="btn_group">
<button class="btn" onclick="setPeriodValues(8,18)">Journée</button>
<button class="btn" onclick="setPeriodValues(8,13)">Matin</button>
<button class="btn" onclick="setPeriodValues(13,18)">Après-midi</button>
</div>
<div class="etud_holder">
<div id="etud_row_{{sco.etud.id}}">
<div class="index"></div>
</div>
</div>
<!-- Ajout d'un conteneur pour le loader -->
<div class="loader-container" id="loaderContainer">
<div class="loader"></div>
</div>
{% include "assiduites/alert.j2" %}
{% include "assiduites/prompt.j2" %}
<script>
const etudid = {{ sco.etud.id }};
setupDate(() => {
actualizeEtud(etudid);
updateSelect()
});
updateDate();
getSingleEtud({{ sco.etud.id }});
actualizeEtud({{ sco.etud.id }});
updateSelect()
</script>
<style>
.rouge {
color: crimson;
}
.justifie {
background-color: rgb(104, 104, 252);
color: whitesmoke;
}
</style>
{% endblock %}
</div>

View File

@ -0,0 +1,76 @@
{% include "assiduites/toast.j2" %}
<section id="content">
<div class="no-display">
<span class="formsemestre_id">{{formsemestre_id}}</span>
<span id="formsemestre_date_debut">{{formsemestre_date_debut}}</span>
<span id="formsemestre_date_fin">{{formsemestre_date_fin}}</span>
</div>
<h2>
Saisie des assiduités {{gr_tit|safe}} {{sem}}
</h2>
<fieldset class="selectors">
<div>Groupes : {{grp|safe}}</div>
<div>Modules :{{moduleimpl_select|safe}}</div>
<div class="infos">
Date: <span id="datestr"></span>
<input type="date" name="tl_date" id="tl_date" value="{{ date }}" onchange="updateDate()">
</div>
<button id="validate_selectors" onclick="validateSelectors()">
Valider
</button>
</fieldset>
{{timeline|safe}}
<div class="etud_holder">
</div>
<div id="myModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Veuillez régler le conflit pour poursuivre</h2>
<!-- Ajout de la frise chronologique -->
<div class="modal-timeline">
<div class="time-labels"></div>
<div class="assiduites-container"></div>
</div>
<div class="action-buttons">
<button id="delete" class="btnPrompt" disabled>Supprimer</button>
<button id="split" class="btnPrompt" disabled>Séparer</button>
<button id="edit" class="btnPrompt" disabled>Modifier</button>
</div>
</div>
<div class="modal-assiduite-content">
<h2>Information de l'assiduité sélectionnée</h2>
<div class="infos">
<p>Assiduite id : <span id="modal-assiduite-id">A</span></p>
<p>Etat : <span id="modal-assiduite-etat">B</span></p>
<p>Date de début : <span id="modal-assiduite-deb">C</span></p>
<p>Date de fin: <span id="modal-assiduite-fin">D</span></p>
<p>Module : <span id="modal-assiduite-module">E</span></p>
<p><span id="modal-assiduite-user">F</span></p>
</div>
</div>
</div>
<!-- Ajout d'un conteneur pour le loader -->
<div class="loader-container" id="loaderContainer">
<div class="loader"></div>
</div>
{% include "assiduites/alert.j2" %}
{% include "assiduites/prompt.j2" %}
<script>
updateDate();
setupDate();
</script>
</section>

View File

@ -0,0 +1,212 @@
<div class="timeline-container">
<div class="period" style="left: 0%; width: 20%">
<div class="period-handle left"></div>
<div class="period-handle right"></div>
</div>
</div>
<script>
const timelineContainer = document.querySelector(".timeline-container");
const periodTimeLine = document.querySelector(".period");
function createTicks() {
for (let i = 8; i <= 18; i++) {
const hourTick = document.createElement("div");
hourTick.classList.add("tick", "hour");
hourTick.style.left = `${((i - 8) / 10) * 100}%`;
timelineContainer.appendChild(hourTick);
const tickLabel = document.createElement("div");
tickLabel.classList.add("tick-label");
tickLabel.style.left = `${((i - 8) / 10) * 100}%`;
tickLabel.textContent = i < 10 ? `0${i}:00` : `${i}:00`;
timelineContainer.appendChild(tickLabel);
if (i < 18) {
for (let j = 1; j < 4; j++) {
const quarterTick = document.createElement("div");
quarterTick.classList.add("tick", "quarter");
quarterTick.style.left = `${((i - 8 + j / 4) / 10) * 100}%`;
timelineContainer.appendChild(quarterTick);
}
}
}
}
function snapToQuarter(value) {
return Math.round(value * 4) / 4;
}
timelineContainer.addEventListener("mousedown", (event) => {
const startX = event.clientX;
if (event.target === periodTimeLine) {
const startLeft = parseFloat(periodTimeLine.style.left);
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const containerWidth = timelineContainer.clientWidth;
const newLeft = startLeft + (deltaX / containerWidth) * 100;
adjustPeriodPosition(newLeft, parseFloat(periodTimeLine.style.width));
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener(
"mouseup",
() => {
generateAllEtudRow();
snapHandlesToQuarters()
document.removeEventListener("mousemove", onMouseMove);
},
{ once: true }
);
} else if (event.target.classList.contains("period-handle")) {
const startWidth = parseFloat(periodTimeLine.style.width);
const startLeft = parseFloat(periodTimeLine.style.left);
const isLeftHandle = event.target.classList.contains("left");
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const containerWidth = timelineContainer.clientWidth;
const newWidth =
startWidth + ((isLeftHandle ? -deltaX : deltaX) / containerWidth) * 100;
if (isLeftHandle) {
const newLeft = startLeft + (deltaX / containerWidth) * 100;
adjustPeriodPosition(newLeft, newWidth);
} else {
adjustPeriodPosition(parseFloat(periodTimeLine.style.left), newWidth);
}
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener(
"mouseup",
() => {
snapHandlesToQuarters()
generateAllEtudRow();
document.removeEventListener("mousemove", onMouseMove);
},
{ once: true }
);
}
});
function adjustPeriodPosition(newLeft, newWidth) {
const snappedLeft = snapToQuarter(newLeft);
const snappedWidth = snapToQuarter(newWidth);
const minLeft = 0;
const maxLeft = 100 - snappedWidth;
const clampedLeft = Math.min(Math.max(snappedLeft, minLeft), maxLeft);
periodTimeLine.style.left = `${clampedLeft}%`;
periodTimeLine.style.width = `${snappedWidth}%`;
}
function getPeriodValues() {
const leftPercentage = parseFloat(periodTimeLine.style.left);
const widthPercentage = parseFloat(periodTimeLine.style.width);
const startHour = (leftPercentage / 100) * 10 + 8;
const endHour = ((leftPercentage + widthPercentage) / 100) * 10 + 8;
const startValue = Math.round(startHour * 4) / 4;
const endValue = Math.round(endHour * 4) / 4;
return [startValue, endValue]
}
function setPeriodValues(deb, fin) {
let leftPercentage = (deb - 8) / 10 * 100
let widthPercentage = (fin - deb) / 10 * 100
periodTimeLine.style.left = `${leftPercentage}%`
periodTimeLine.style.width = `${widthPercentage}%`
snapHandlesToQuarters()
generateAllEtudRow();
}
function snapHandlesToQuarters() {
const periodValues = getPeriodValues();
let lef = Math.min((periodValues[0] - 8) * 10, 97.5)
if (lef < 0) {
lef = 0;
}
const left = `${lef}%`
let wid = Math.max((periodValues[1] - periodValues[0]) * 10, 2.5)
if (wid > 100) {
wid = 100;
}
const width = `${wid}%`
periodTimeLine.style.left = left;
periodTimeLine.style.width = width;
}
createTicks();
</script>
<style>
.timeline-container {
width: 75%;
margin-left: 5%;
background-color: white;
border-radius: 15px;
position: relative;
height: 40px;
}
/* ... */
.tick {
position: absolute;
bottom: 0;
width: 1px;
background-color: rgba(0, 0, 0, 0.5);
}
.tick.hour {
height: 100%;
}
.tick.quarter {
height: 50%;
}
.tick-label {
position: absolute;
bottom: 0;
font-size: 12px;
text-align: center;
transform: translateY(100%) translateX(-50%);
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.period {
position: absolute;
height: 100%;
background-color: rgba(0, 183, 255, 0.5);
border-radius: 15px;
}
.period-handle {
position: absolute;
top: 0;
bottom: 0;
width: 8px;
border-radius: 0 4px 4px 0;
cursor: col-resize;
}
.period-handle.right {
right: 0;
border-radius: 4px 0 0 4px;
}
</style>

View File

@ -0,0 +1,88 @@
<div class="toast-holder">
</div>
<style>
.toast-holder {
position: fixed;
right: 1vw;
top: 5vh;
height: 80vh;
width: 20vw;
display: flex;
flex-direction: column;
flex-wrap: wrap;
transition: all 0.3s ease-in-out;
}
.toast {
margin: 0.5vh 0;
display: flex;
width: 100%;
height: fit-content;
justify-content: flex-start;
align-items: center;
border-radius: 10px;
padding: 7px;
z-index: 250;
transition: all 0.3s ease-in-out;
}
.fadeIn {
animation: fadeIn 0.5s ease-in;
}
.fadeOut {
animation: fadeOut 0.5s ease-in;
}
.toast-content {
color: whitesmoke;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
</style>
<script>
function generateToast(content, color = "#12d3a5", ttl = 5) {
const toast = document.createElement('div')
toast.classList.add('toast', 'fadeIn')
const toastContent = document.createElement('div')
toastContent.classList.add('toast-content')
toastContent.appendChild(content)
toast.style.backgroundColor = color;
setTimeout(() => { toast.classList.replace('fadeIn', 'fadeOut') }, Math.max(0, ttl * 1000 - 500))
setTimeout(() => { toast.remove() }, Math.max(0, ttl * 1000))
toast.appendChild(toastContent)
return toast
}
</script>

2
app/templates/sidebar.j2 Normal file → Executable file
View File

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

View File

@ -23,6 +23,7 @@ scolar_bp = Blueprint("scolar", __name__)
notes_bp = Blueprint("notes", __name__) notes_bp = Blueprint("notes", __name__)
users_bp = Blueprint("users", __name__) users_bp = Blueprint("users", __name__)
absences_bp = Blueprint("absences", __name__) absences_bp = Blueprint("absences", __name__)
assiduites_bp = Blueprint("assiduites", __name__)
# Cette fonction est bien appelée avant toutes les requêtes # Cette fonction est bien appelée avant toutes les requêtes
@ -108,6 +109,7 @@ class ScoData:
from app.views import ( from app.views import (
absences, absences,
but_formation, but_formation,
assiduites,
notes_formsemestre, notes_formsemestre,
notes, notes,
pn_modules, pn_modules,

377
app/views/assiduites.py Normal file
View File

@ -0,0 +1,377 @@
import datetime
from flask import g, request, render_template
from flask import abort, url_for
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.decorators import (
scodoc,
permission_required,
)
from app.models import FormSemestre, Identite
from app.views import assiduites_bp as bp
from app.views import ScoData
# ---------------
from app.scodoc.sco_permissions import Permission
from app.scodoc import html_sco_header
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences
from app.scodoc import sco_groups_view
from app.scodoc import sco_etud
from app.scodoc import sco_find_etud
from flask_login import current_user
CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS
# --- UTILS ---
class HTMLElement:
""""""
class HTMLElement:
"""Représentation d'un HTMLElement version Python"""
def __init__(self, tag: str, *attr, **kattr) -> None:
self.tag: str = tag
self.children: list[HTMLElement] = []
self.self_close: bool = kattr.get("self_close", False)
self.text_content: str = kattr.get("text_content", "")
self.key_attributes: dict[str, any] = kattr
self.attributes: list[str] = list(attr)
def add(self, *child: HTMLElement) -> None:
"""add child element to self"""
for kid in child:
self.children.append(kid)
def remove(self, child: HTMLElement) -> None:
"""Remove child element from self"""
if child in self.children:
self.children.remove(child)
def __str__(self) -> str:
attr: list[str] = self.attributes
for att, val in self.key_attributes.items():
if att in ("self_close", "text_content"):
continue
if att != "cls":
attr.append(f'{att}="{val}"')
else:
attr.append(f'class="{val}"')
if not self.self_close:
head: str = f"<{self.tag} {' '.join(attr)}>{self.text_content}"
body: str = "\n".join(map(str, self.children))
foot: str = f"</{self.tag}>"
return head + body + foot
return f"<{self.tag} {' '.join(attr)}/>"
def __add__(self, other: str):
return str(self) + other
def __radd__(self, other: str):
return other + str(self)
class HTMLStringElement(HTMLElement):
"""Utilisation d'une chaine de caracètres pour représenter un element"""
def __init__(self, text: str) -> None:
self.text: str = text
HTMLElement.__init__(self, "textnode")
def __str__(self) -> str:
return self.text
class HTMLBuilder:
def __init__(self, *content: HTMLElement or str) -> None:
self.content: list[HTMLElement or str] = list(content)
def add(self, *element: HTMLElement or str):
self.content.extend(element)
def remove(self, element: HTMLElement or str):
if element in self.content:
self.content.remove(element)
def __str__(self) -> str:
return "\n".join(map(str, self.content))
def build(self) -> str:
return self.__str__()
# --------------------------------------------------------------------
#
# Assiduités (/ScoDoc/<dept>/Scolarite/Assiduites/...)
#
# --------------------------------------------------------------------
@bp.route("/")
@bp.route("/index_html")
@scodoc
@permission_required(Permission.ScoView)
def index_html():
"""Gestionnaire assiduités, page principale"""
H = [
html_sco_header.sco_header(
page_title="Saisie des assiduités",
cssstyles=["css/calabs.css"],
javascripts=["js/calabs.js"],
),
"""<h2>Traitement des assiduités</h2>
<p class="help">
Pour saisir des assiduités ou consulter les états, il est recommandé par passer par
le semestre concerné (saisie par jours nommés ou par semaines).
</p>
""",
]
H.append(
"""<p class="help">Pour signaler, annuler ou justifier une assiduité pour un seul étudiant,
choisissez d'abord concerné:</p>"""
)
H.append(sco_find_etud.form_search_etud())
if current_user.has_permission(
Permission.ScoAbsChange
) and sco_preferences.get_preference("handle_billets_abs"):
H.append(
f"""
<h2 style="margin-top: 30px;">Billets d'absence</h2>
<ul><li><a href="{url_for("absences.list_billets", scodoc_dept=g.scodoc_dept)
}">Traitement des billets d'absence en attente</a>
</li></ul>
"""
)
H.append(html_sco_header.sco_footer())
return "\n".join(H)
@bp.route("/SignaleAssiduiteEtud")
@scodoc
@permission_required(Permission.ScoAbsChange)
def signal_assiduites_etud():
"""
signal_assiduites_etud Saisie de l'assiduité d'un étudiant
Args:
etudid (int): l'identifiant de l'étudiant
Returns:
str: l'html généré
"""
etudid = request.args.get("etudid", -1)
etud: Identite = Identite.query.get_or_404(etudid)
if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département")
header: str = html_sco_header.sco_header(
page_title="Saisie Assiduités",
init_qtip=True,
javascripts=[
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
)
return HTMLBuilder(
header,
render_template("assiduites/minitimeline.j2"),
render_template(
"assiduites/signal_assiduites_etud.j2",
sco=ScoData(etud),
date=datetime.date.today().isoformat(),
),
).build()
@bp.route("/SignalAssiduiteGr")
@scodoc
@permission_required(Permission.ScoAbsChange)
def signal_assiduites_group():
"""
signal_assiduites_group Saisie des assiduités des groupes pour le jour donnée
Returns:
str: l'html généré
"""
formsemestre_id: int = request.args.get("formsemestre_id", -1)
moduleimpl_id: int = request.args.get("moduleimpl_id")
group_ids: list[int] = request.args.get("group_ids", None)
formsemestre_id: int = request.args.get("formsemestre_id", -1)
moduleimpl_id: int = request.args.get("moduleimpl_id")
date: str = request.args.get("jour", datetime.date.today().isoformat())
group_ids: list[int] = request.args.get("group_ids", None)
if group_ids is None:
group_ids = []
else:
group_ids = group_ids.split(",")
map(str, group_ids)
# Vérification du moduleimpl_id
try:
moduleimpl_id = int(moduleimpl_id)
except (TypeError, ValueError):
moduleimpl_id = None
# Vérification du formsemestre_id
try:
formsemestre_id = int(formsemestre_id)
except (TypeError, ValueError):
formsemestre_id = None
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, moduleimpl_id=moduleimpl_id, formsemestre_id=formsemestre_id
)
# Aucun étudiant WIP
if not groups_infos.members:
return (
html_sco_header.sco_header(page_title="Saisie journalière des Assiduités")
+ "<h3>Aucun étudiant ! </h3>"
+ html_sco_header.sco_footer()
)
# --- URL DEFAULT ---
base_url: str = f"SignalAssiduiteGr?date={date}&{groups_infos.groups_query_args}"
# --- Filtrage par formsemestre ---
formsemestre_id = groups_infos.formsemestre_id
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if formsemestre.dept_id != g.scodoc_dept_id:
abort(404, "groupes inexistants dans ce département")
require_module = sco_preferences.get_preference(
"abs_require_module", formsemestre_id
)
etuds = [
sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0]
for m in groups_infos.members
]
# --- Restriction en fonction du moduleimpl_id ---
if moduleimpl_id:
mod_inscrits = {
x["etudid"]
for x in sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=moduleimpl_id
)
}
etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits]
if etuds_inscrits_module:
etuds = etuds_inscrits_module
else:
# Si aucun etudiant n'est inscrit au module choisi...
moduleimpl_id = None
# --- Génération de l'HTML ---
sem = formsemestre.to_dict()
if groups_infos.tous_les_etuds_du_sem:
gr_tit = "en"
else:
if len(groups_infos.group_ids) > 1:
grp = "des groupes"
else:
grp = "du groupe"
gr_tit = (
grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
)
header: str = html_sco_header.sco_header(
page_title="Saisie journalière des assiduités",
init_qtip=True,
javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS
+ [
# Voir fonctionnement JS
"js/etud_info.js",
"js/abs_ajax.js",
"js/groups_view.js",
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
no_side_bar=1,
)
return HTMLBuilder(
header,
render_template("assiduites/minitimeline.j2"),
render_template(
"assiduites/signal_assiduites_group.j2",
gr_tit=gr_tit,
sem=sem["titre_num"],
date=date,
formsemestre_id=formsemestre_id,
grp=sco_groups_view.menu_groups_choice(groups_infos),
moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
timeline=_timeline(),
formsemestre_date_debut=str(formsemestre.date_debut),
formsemestre_date_fin=str(formsemestre.date_fin),
),
html_sco_header.sco_footer(),
).build()
def _module_selector(
formsemestre: FormSemestre, moduleimpl_id: int = None
) -> HTMLElement:
"""
_module_selector Génère un HTMLSelectElement à partir des moduleimpl du formsemestre
Args:
formsemestre (FormSemestre): Le formsemestre d'où les moduleimpls seront pris.
Returns:
str: La représentation str d'un HTMLSelectElement
"""
ntc: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
modimpls_list: list[dict] = []
ues = ntc.get_ues_stat_dict()
for ue in ues:
modimpls_list += ntc.get_modimpls_dict(ue_id=ue["ue_id"])
selected = moduleimpl_id is not None
modules = []
for modimpl in modimpls_list:
modname: str = (
(modimpl["module"]["code"] or "")
+ " "
+ (modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or "")
)
modules.append({"moduleimpl_id": modimpl["moduleimpl_id"], "name": modname})
return render_template(
"assiduites/moduleimpl_selector.j2", selected=selected, modules=modules
)
def _timeline() -> HTMLElement:
return render_template("assiduites/timeline.j2")