Compare commits

...

10 Commits

26 changed files with 2695 additions and 314 deletions

View File

@ -6,18 +6,18 @@
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask_json import as_json
from flask import g, request
from flask_login import login_required, current_user
from flask import g, request
from flask_json import as_json
from flask_login import current_user, login_required
from app import db, log
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object
from app.api import api_web_bp, get_model_api_object, tools
from app.decorators import permission_required, scodoc
from app.models import Assiduite, FormSemestre, Identite, ModuleImpl
from app.models import Assiduite, FormSemestre, Identite, ModuleImpl, Scolog
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@ -47,15 +47,35 @@ def assiduite(assiduite_id: int = None):
return get_model_api_object(Assiduite, assiduite_id, Identite)
@bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
# etudid
@bp.route("/assiduites/<etudid>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<etudid>/count", defaults={"with_query": False})
@bp.route("/assiduites/<etudid>/count/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<etudid>/count/query", defaults={"with_query": True})
@bp.route("/assiduites/etudid/<etudid>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/etudid/<etudid>/count", defaults={"with_query": False})
@bp.route("/assiduites/etudid/<etudid>/count/query", defaults={"with_query": True})
@api_web_bp.route(
"/assiduites/etudid/<etudid>/count/query", defaults={"with_query": True}
)
# nip
@bp.route("/assiduites/nip/<nip>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/nip/<nip>/count", defaults={"with_query": False})
@bp.route("/assiduites/nip/<nip>/count/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/nip/<nip>/count/query", defaults={"with_query": True})
# ine
@bp.route("/assiduites/ine/<ine>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/ine/<ine>/count", defaults={"with_query": False})
@bp.route("/assiduites/ine/<ine>/count/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/ine/<ine>/count/query", defaults={"with_query": True})
#
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def count_assiduites(etudid: int = None, with_query: bool = False):
def count_assiduites(
etudid: int = None, nip: str = None, ine: str = None, with_query: bool = False
):
"""
Retourne le nombre d'assiduités d'un étudiant
chemin : /assiduites/<int:etudid>/count
@ -100,11 +120,20 @@ def count_assiduites(etudid: int = None, with_query: bool = False):
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
# query = Identite.query.filter_by(id=etudid)
# if g.scodoc_dept:
# query = query.filter_by(dept_id=g.scodoc_dept_id)
# etud: Identite = query.first_or_404(etudid)
etud: Identite = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
etud: Identite = query.first_or_404(etudid)
filtered: dict[str, object] = {}
metric: str = "all"
@ -116,15 +145,31 @@ def count_assiduites(etudid: int = None, with_query: bool = False):
)
@bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
# etudid
@bp.route("/assiduites/<etudid>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<etudid>", defaults={"with_query": False})
@bp.route("/assiduites/<etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<etudid>/query", defaults={"with_query": True})
@bp.route("/assiduites/etudid/<etudid>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/etudid/<etudid>", defaults={"with_query": False})
@bp.route("/assiduites/etudid/<etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/etudid/<etudid>/query", defaults={"with_query": True})
# nip
@bp.route("/assiduites/nip/<nip>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/nip/<nip>", defaults={"with_query": False})
@bp.route("/assiduites/nip/<nip>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/nip/<nip>/query", defaults={"with_query": True})
# ine
@bp.route("/assiduites/ine/<ine>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/ine/<ine>", defaults={"with_query": False})
@bp.route("/assiduites/ine/<ine>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/ine/<ine>/query", defaults={"with_query": True})
#
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def assiduites(etudid: int = None, with_query: bool = False):
def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /assiduites/<int:etudid>
@ -164,11 +209,18 @@ def assiduites(etudid: int = None, with_query: bool = False):
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
# query = Identite.query.filter_by(id=etudid)
# if g.scodoc_dept:
# query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
# etud: Identite = query.first_or_404(etudid)
etud: Identite = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
assiduites_query = etud.assiduites
if with_query:
@ -340,13 +392,23 @@ def count_assiduites_formsemestre(
return scass.get_assiduites_stats(assiduites_query, metric, filtered)
@bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
# etudid
@bp.route("/assiduite/<etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/<etudid>/create", methods=["POST"])
@bp.route("/assiduite/etudid/<etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/etudid/<etudid>/create", methods=["POST"])
# nip
@bp.route("/assiduite/nip/<nip>/create", methods=["POST"])
@api_web_bp.route("/assiduite/nip/<nip>/create", methods=["POST"])
# ine
@bp.route("/assiduite/ine/<ine>/create", methods=["POST"])
@api_web_bp.route("/assiduite/ine/<ine>/create", methods=["POST"])
#
@scodoc
@as_json
@login_required
@permission_required(Permission.ScoAbsChange)
def assiduite_create(etudid: int = None):
def assiduite_create(etudid: int = None, nip=None, ine=None):
"""
Création d'une assiduité pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
@ -367,21 +429,27 @@ def assiduite_create(etudid: int = None):
]
"""
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
etud: Identite = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
errors: list = []
success: list = []
for i, data in enumerate(create_list):
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
errors.append({"indice": i, "message": obj})
else:
success[i] = obj
success.append({"indice": i, "message": obj})
scass.simple_invalidate_cache(data, etud.id)
db.session.commit()
@ -425,19 +493,19 @@ def assiduites_create():
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
errors: list = []
success: list = []
for i, data in enumerate(create_list):
etud: Identite = Identite.query.filter_by(id=data["etudid"]).first()
if etud is None:
errors[i] = "Cet étudiant n'existe pas."
errors.append({"indice": i, "message": "Cet étudiant n'existe pas."})
continue
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
errors.append({"indice": i, "message": obj})
else:
success[i] = obj
success.append({"indice": i, "message": obj})
scass.simple_invalidate_cache(data)
return {"errors": errors, "success": success}
@ -539,14 +607,14 @@ def assiduite_delete():
if not isinstance(assiduites_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
output = {"errors": {}, "success": {}}
output = {"errors": [], "success": []}
for i, ass in enumerate(assiduites_list):
code, msg = _delete_singular(ass, db)
if code == 404:
output["errors"][f"{i}"] = msg
output["errors"].append({"indice": i, "message": msg})
else:
output["success"][f"{i}"] = {"OK": True}
output["success"].append({"indice": i, "message": "OK"})
db.session.commit()
return output
@ -556,8 +624,15 @@ def _delete_singular(assiduite_id: int, database):
assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first()
if assiduite_unique is None:
return (404, "Assiduite non existante")
scass.simple_invalidate_cache(assiduite_unique.to_dict())
ass_dict = assiduite_unique.to_dict()
log(f"delete_assiduite: {assiduite_unique.etudiant.id} {assiduite_unique}")
Scolog.logdb(
method="delete_assiduite",
etudid=assiduite_unique.etudiant.id,
msg=f"assiduité: {assiduite_unique}",
)
database.session.delete(assiduite_unique)
scass.simple_invalidate_cache(ass_dict)
return (200, "OK")
@ -630,6 +705,12 @@ def assiduite_edit(assiduite_id: int):
err: str = ", ".join(errors)
return json_error(404, err)
log(f"assiduite_edit: {assiduite_unique.etudiant.id} {assiduite_unique}")
Scolog.logdb(
"assiduite_edit",
assiduite_unique.etudiant.id,
msg=f"assiduite: modif {assiduite_unique}",
)
db.session.add(assiduite_unique)
db.session.commit()
scass.simple_invalidate_cache(assiduite_unique.to_dict())
@ -645,33 +726,45 @@ def assiduite_edit(assiduite_id: int):
@permission_required(Permission.ScoAbsChange)
def assiduites_edit():
"""
Edition d'une assiduité à partir de son id
Edition de plusieurs assiduités
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"moduleimpl_id"?: int
"desc"?: str
"est_just"?: bool
}
[
{
"assiduite_id" : int,
"etat"?: str,
"moduleimpl_id"?: int
"desc"?: str
"est_just"?: bool
}
]
"""
edit_list: list[object] = request.get_json(force=True)
if not isinstance(edit_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
errors: list[dict] = []
success: list[dict] = []
for i, data in enumerate(edit_list):
assi: Identite = Assiduite.query.filter_by(id=data["assiduite_id"]).first()
if assi is None:
errors[i] = "Cet assiduité n'existe pas."
errors.append(
{
"indice": i,
"message": f"assiduité {data['assiduite_id']} n'existe pas.",
}
)
continue
code, obj = _edit_singular(assi, data)
obj_retour = {
"indice": i,
"message": obj,
}
if code == 404:
errors[i] = obj
errors.append(obj_retour)
else:
success[i] = obj
success.append(obj_retour)
db.session.commit()
@ -727,6 +820,12 @@ def _edit_singular(assiduite_unique, data):
err: str = ", ".join(errors)
return (404, err)
log(f"_edit_singular: {assiduite_unique.etudiant.id} {assiduite_unique}")
Scolog.logdb(
"assiduite_edit",
assiduite_unique.etudiant.id,
msg=f"assiduite: modif {assiduite_unique}",
)
db.session.add(assiduite_unique)
scass.simple_invalidate_cache(assiduite_unique.to_dict())

View File

@ -16,7 +16,7 @@ import app.scodoc.sco_utils as scu
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object
from app.api import get_model_api_object, tools
from app.decorators import permission_required, scodoc
from app.models import Identite, Justificatif, Departement
from app.models.assiduites import compute_assiduites_justified
@ -52,15 +52,31 @@ def justificatif(justif_id: int = None):
return get_model_api_object(Justificatif, justif_id, Identite)
@bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
# etudid
@bp.route("/justificatifs/<etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/<etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/<etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/<etudid>/query", defaults={"with_query": True})
@bp.route("/justificatifs/etudid/<etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/etudid/<etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/etudid/<etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/etudid/<etudid>/query", defaults={"with_query": True})
# nip
@bp.route("/justificatifs/nip/<nip>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/nip/<nip>", defaults={"with_query": False})
@bp.route("/justificatifs/nip/<nip>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/nip/<nip>/query", defaults={"with_query": True})
# ine
@bp.route("/justificatifs/ine/<ine>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/ine/<ine>", defaults={"with_query": False})
@bp.route("/justificatifs/ine/<ine>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/ine/<ine>/query", defaults={"with_query": True})
#
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def justificatifs(etudid: int = None, with_query: bool = False):
def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /justificatifs/<int:etudid>
@ -87,11 +103,13 @@ def justificatifs(etudid: int = None, with_query: bool = False):
ex query?user_id=3
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = tools.get_etud(etudid, nip, ine)
etud: Identite = query.first_or_404(etudid)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
justificatifs_query = etud.justificatifs
if with_query:
@ -136,7 +154,7 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False):
@login_required
@as_json
@permission_required(Permission.ScoAbsChange)
def justif_create(etudid: int = None):
def justif_create(etudid: int = None, nip=None, ine=None):
"""
Création d'un justificatif pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
@ -156,21 +174,27 @@ def justif_create(etudid: int = None):
]
"""
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
etud: Identite = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
errors: list = []
success: list = []
for i, data in enumerate(create_list):
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
errors.append({"indice": i, "message": obj})
else:
success[i] = obj
success.append({"indice": i, "message": obj})
scass.simple_invalidate_cache(data, etud.id)
compute_assiduites_justified(Justificatif.query.filter_by(etudid=etudid), True)
return {"errors": errors, "success": success}
@ -359,14 +383,14 @@ def justif_delete():
if not isinstance(justificatifs_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
output = {"errors": {}, "success": {}}
output = {"errors": [], "success": []}
for i, ass in enumerate(justificatifs_list):
code, msg = _delete_singular(ass, db)
if code == 404:
output["errors"][f"{i}"] = msg
output["errors"].append({"indice": i, "message": msg})
else:
output["success"][f"{i}"] = {"OK": True}
output["success"].append({"indice": i, "message": "OK"})
db.session.commit()

View File

@ -110,7 +110,7 @@ def formsemestre_partitions(formsemestre_id: int):
def etud_in_group(group_id: int):
"""
Retourne la liste des étudiants dans un groupe
(inscrits au groupe et inscrits au semestre).
group_id : l'id d'un groupe
Exemple de résultat :
@ -133,7 +133,15 @@ def etud_in_group(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group = query.first_or_404()
return [etud.to_dict_short() for etud in group.etuds]
query = (
Identite.query.join(group_membership)
.filter_by(group_id=group_id)
.join(FormSemestreInscription)
.filter_by(formsemestre_id=group.partition.formsemestre_id)
)
return [etud.to_dict_short() for etud in query]
@bp.route("/group/<int:group_id>/etudiants/query")
@ -161,7 +169,6 @@ def etud_in_group_query(group_id: int):
query = query.filter_by(etat=etat)
query = query.join(group_membership).filter_by(group_id=group_id)
return [etud.to_dict_short() for etud in query]

445
app/but/prepajury_but.py Normal file
View File

@ -0,0 +1,445 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""Feuille excel pour préparation des jurys classiques (non BUT)
"""
import time
from flask import abort
from app.but import jury_but
from app.but.cursus_but import EtudCursusBUT
from app.but.prepajury_desc import ParcoursDesc, FormsemestreDesc
from app.models import (
FormSemestre,
ApcParcours,
Formation,
)
import app.scodoc.sco_utils as scu
from app.but.prepajury_xl import ScoExcelBook
class Element:
def __init__(self, etudiant):
self.etudiant = etudiant
self.note = None
self.resultat = None
self.format = 0
def set_note(self, note):
self.note = note
def set_res(self, res):
self.resultat = res
def get_res(self):
return self.resultat
def get_note(self):
return self.note
class ElementUE(Element):
def __init__(self, etudiant):
super().__init__(etudiant)
self.status = None
def set_status(self, status):
self.status = status
class ElementNiveau(Element):
def __init__(self, etudiant, competence_id):
super().__init__(etudiant)
self.competence_id = competence_id
self.validation = None
self.ues = {}
def get_elem(self, periode=None):
if periode is None:
return self
return self.ues.get(periode, None)
def compute(self, rcue):
self.set_note(rcue.moy_rcue)
class ElementFormsemestre(Element):
def __init__(self, etudiant, formsemestre_desc: FormsemestreDesc = None):
super().__init__(etudiant)
self.formsemestre_desc = formsemestre_desc
self.formsemestre_id = formsemestre_desc.formsemestre_id
self.deca = None
self.ues = {}
def get_elem(self, competence_id=None):
if competence_id is None:
return self
return self.ues.get(competence_id, None)
class ElementAnnee(Element):
def __init__(self, etudiant):
super().__init__(etudiant)
self.formsemestres = {}
self.niveaux = {}
self.last = None
self.deca = None
def get_elem(self, competence_id=None, periode=None):
if competence_id is None and periode is None:
return self
elif competence_id is None:
return self.formsemestres.get(periode, None)
elif competence_id in self.niveaux:
return self.niveaux[competence_id].get_elem(periode)
else:
return None
def set_periode(self, periode, formsemestre_desc):
self.formsemestres[periode] = formsemestre_desc
def set_niveau(self, competence_id, elem_niveau):
self.niveaux[competence_id] = elem_niveau
def add_validation(self, validation):
competence_id = validation.ue1.niveau_competence.competence_id
self.niveaux[competence_id].set_res(validation.code)
def create_structure(self):
self.last = self.formsemestres.get(2, self.formsemestres.get(1, None))
if self.last is not None:
self.deca = jury_but.DecisionsProposeesAnnee(
self.etudiant.ident, self.last.formsemestre_desc.formsemestre
)
for niveau in self.deca.niveaux_competences:
competence_id = niveau.competence_id
elem_niveau = ElementNiveau(self.etudiant, competence_id)
self.niveaux[competence_id] = elem_niveau
for ue in self.deca.ues_impair:
competence_id = ue.niveau_competence.competence_id
periode = 1
elem_ue = ElementUE(self.etudiant)
self.niveaux[competence_id].ues[periode] = elem_ue
self.formsemestres[periode].ues[competence_id] = elem_ue
for ue in self.deca.ues_pair:
competence_id = ue.niveau_competence.competence_id
periode = 2
elem_ue = ElementUE(self.etudiant)
self.niveaux[competence_id].ues[periode] = elem_ue
self.formsemestres[periode].ues[competence_id] = elem_ue
def compute(self):
if self.last is not None:
self.set_res(self.deca.code_valide)
self.set_note(
self.last.formsemestre_desc.get_resultats().get_etud_moy_gen(
self.etudiant.ident.id
)
)
class EtudiantJury:
"""
Structure:
ident
formation
parcour
cursus
inscriptions*
current_formsemestre
absences_tot
absences_just
Annees*
nb_rcues
note
resultat
niveaux*
note
resultat
ues*
note
resultat
DUT
resultat
BUT
resultat
"""
annee_periode = {
1: ("BUT1", 1),
2: ("BUT1", 2),
3: ("BUT2", 1),
4: ("BUT2", 2),
5: ("BUT3", 1),
6: ("BUT3", 2),
}
def __init__(self, ident, contexte: "_Compilation"):
self.ident = ident
self.contexte = contexte
self.formation = contexte.formation
self.current_formsemestre = contexte.formsemestre
self.current_formsemestre_id = contexte.formsemestre.formsemestre_id
self.current_formsemestre_desc = contexte.get_semestredesc(
self.current_formsemestre_id
)
self.nbabs, self.nbabsjust = self.current_formsemestre.get_abs_count(ident.id)
self.cursus: EtudCursusBUT = EtudCursusBUT(ident, self.formation)
self.parcour = self.cursus.inscriptions[-1].parcour
# donnes propres à l étudiant (à remplir par fill_in)
self.history = [] # description historique de l etudiant
self.formsemestres = [] # liste historique des formsemestres
self.formsemestre_by_semestre = (
{}
) # semetre_id -> dernier FormSemestreDesc pour chaque semestre
self.formsemestre_by_annee = (
{}
) # annee -> dernier FormSemestreDesc pour chaque semestre
self.annees = {}
self.DUT = None # Résultat au DUT
self.BUT = None # Résultat au BUT
def get_elem(self, annee=None, competence_id=None, periode=None):
if annee is None:
return self
if annee in self.annees:
return self.annees[annee].get_elem(competence_id, periode)
return None
def get_desc(self, annee=None, competence_id=None, periode=None):
return self.parcour.get_desc(annee, competence_id, periode)
def compute_history(self):
# calcul historique
for inscription in sorted(
self.cursus.inscriptions, key=lambda x: x.formsemestre.date_debut
):
formsemestre = inscription.formsemestre
semestre_id = None
# if formsemestre.formation == self.formation:
if formsemestre.formation.is_apc():
formsemestre_id = formsemestre.formsemestre_id
formsemestre_desc = self.contexte.get_semestredesc(formsemestre_id)
semestre_id = formsemestre.semestre_id
self.formsemestres.append(formsemestre)
annee, periode = EtudiantJury.annee_periode[semestre_id]
self.formsemestre_by_semestre[semestre_id] = formsemestre_desc
self.formsemestre_by_annee[annee] = formsemestre_desc
etat = inscription.etat
Sx = f"S{semestre_id}"
if etat != "I":
Sx += " (Dem)" if etat == "D" else f"({etat})"
self.history.append(Sx)
def create_structure(self):
for annee, formsemestre_desc in self.formsemestre_by_annee.items():
elem_annee = ElementAnnee(self)
self.annees[annee] = elem_annee
for semestre_id, formsemestre_desc in self.formsemestre_by_semestre.items():
annee, periode = EtudiantJury.annee_periode[semestre_id]
elem_formsemestre = ElementFormsemestre(self.ident, formsemestre_desc)
self.annees[annee].set_periode(periode, elem_formsemestre)
for annee in self.annees:
self.annees[annee].create_structure()
def compute(self):
for annee in self.annees:
self.annees[annee].compute()
for (
competence_id,
validations,
) in self.cursus.validation_par_competence_et_annee.items():
for annee, validation in validations.items():
self.annees[annee].add_validation(validation)
def fill_in(self):
"""
Creation des donnees propres à l'étudiant
"""
self.compute_history()
# self.create_structure()
# self.compute()
# for (
# competence_id,
# validations_par_competence,
# ) in self.cursus.validation_par_competence_et_annee.items():
# for annee, validation in validations_par_competence.items():
# elem_annee = self.annees[annee]
# elem_formsemestre = elem_annee.formsemestres.get(
# periode, ElementFormsemestre(self, formsemestre_desc)
# )
# resultats = formsemestre_desc.get_resultats()
# ues = resultats.etud_ues(self.ident.etudid)
# for ue in ues:
# niveau_id = ue.niveau_competence_id
# competence_id = ue.niveau_competence.competence_id
# status = resultats.get_etud_ue_status(self.ident.etudid, ue.id)
# if competence_id not in self.annees[annee].niveaux:
# elem_niveau = ElementNiveau(self, validation)
# self.annees[annee].niveaux[competence_id] = elem_niveau
# else:
# elem_niveau = self.annees[annee].niveaux[competence_id]
# elem_ue = ElementUE(self, status)
# elem_niveau.ues[periode] = elem_ue
#
#
#
# for (
# competence_id,
# validation,
# ) in self.cursus.validation_par_competence_et_annee.items():
# self.elements_par_competence_annee_et_periode[competence_id] = {}
# for annee in validation:
# self.elements_par_competence_annee_et_periode[competence_id][
# annee
# ] = []
# self.deca_by_semestre[semestre_idx] = jury_but.DecisionsProposeesAnnee(
# self.ident, self.formsemestre_by_semestre[semestre_idx]
# )
# # niveau.add_ue_status(semestre_idx, status)
# if annee in self.deca:
# for rcue in self.deca[annee].rcues_annee:
# ue_id1 = rcue.ue_1.id
# self.notes_ues[ue_id1] = rcue.moy_ue_1
# ue_id2 = rcue.ue_2.id
# self.notes_ues[ue_id2] = rcue.moy_ue_2
# rcue_id = rcue.ue_1.niveau_competence_id
# self.notes_rcues[rcue_id] = rcue.moy_rcue
def get_note(self, annee, competence_id=None, periode=None):
elem: Element = self.get_elem(annee, competence_id, periode)
if elem:
return elem.get_note()
return "-"
def get_res(self, annee, competence_id=None, periode=None):
elem: Element = self.get_elem(annee, competence_id, periode)
if elem:
return elem.get_res()
return "-"
def get_data(self):
result = [
self.ident.id,
self.ident.code_nip,
self.ident.civilite,
self.ident.nom,
self.ident.prenom,
self.ident.etat_civil_pv(with_paragraph=False),
self.parcour.code,
", ".join(self.history),
]
return result
class _Compilation:
"""
structure:
semestres: formsemestre_id -> FormSemestreDesc
parcours: parcour_code -> ParcoursDesc
formation
"""
def __init__(self, formsemestre: FormSemestre):
self.semestres = {}
self.parcours = {}
formsemestre_id = formsemestre.formsemestre_id
self.formation: Formation = formsemestre.formation
self.formsemestre = formsemestre
self.add_semestre(formsemestre_id, formsemestre)
self.current_semestre = self.semestres[formsemestre_id]
# inventaire des semestres et parcours
for ident in (
self.semestres[formsemestre_id].get_resultats().get_inscrits(order_by="moy")
):
etudiant: EtudiantJury = EtudiantJury(
ident, self
) # initialise etudiant.cursus et etudiant.parcour
for inscription in etudiant.cursus.inscriptions:
formsemestre = inscription.formsemestre
if (
formsemestre.formation.referentiel_competence
== self.formation.referentiel_competence
):
self.add_semestre(formsemestre.formsemestre_id, formsemestre)
scodocParcour = etudiant.parcour
if scodocParcour is None:
parcourCode = "TC"
else:
parcourCode = scodocParcour.code
if parcourCode in self.parcours:
parcoursDesc = self.parcours[parcourCode]
else:
parcoursDesc = ParcoursDesc(self.formation, scodocParcour)
self.parcours[parcourCode] = parcoursDesc
parcoursDesc.add_etudiant(etudiant)
etudiant.fill_in()
def get_semestredesc(self, formsemestre_id):
return self.semestres.get(formsemestre_id, None)
def add_semestre(self, formsemestre_id, formsemestre=None):
if formsemestre_id not in self.semestres:
self.semestres[formsemestre_id] = FormsemestreDesc(
formsemestre_id, formsemestre
)
def add_parcours(self, scodoc_parcour: ApcParcours, etudiant: EtudiantJury):
parcour_code = scodoc_parcour.get("code", "TC")
if parcour_code not in self.parcours:
self.parcours[parcour_code] = ParcoursDesc(self.formation, scodoc_parcour)
self.parcours[parcour_code].add(etudiant)
def computes_decision(self):
pass
def make_excel(self, filename: str):
workbook = ScoExcelBook()
for parcoursCode, parcours in self.parcours.items():
parcours.generate(workbook)
mime, suffix = scu.get_mime_suffix("xlsx")
xls = workbook.generate()
return scu.send_file(xls, filename=filename, mime=mime, suffix=suffix)
def feuille_preparation_jury_but(formsemestre_id):
if not isinstance(formsemestre_id, int):
abort(404)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
"""Feuille excel pour préparation des jurys adaptée pour le BUT."""
# breakpoint()
compilation = _Compilation(formsemestre)
# compilation.computes_decision()
filename = scu.sanitize_filename(
f"""{'jury'}-{formsemestre.titre_num()}-{time.strftime("%Y-%m-%d")}"""
)
return compilation.make_excel(filename)

522
app/but/prepajury_desc.py Normal file
View File

@ -0,0 +1,522 @@
import openpyxl
from openpyxl.worksheet.worksheet import Worksheet
from app.but.prepajury_xl_format import (
SCO_COLORS,
FMT,
SCO_FONTSIZE,
SCO_VALIGN,
SCO_HALIGN,
SCO_BORDERTHICKNESS,
Sco_Style,
HAIR_BLACK,
SCO_NUMBER_FORMAT,
)
from app.comp import res_sem
from app.models import ApcCompetence, ApcParcours, FormSemestre
from app.but.prepajury_xl import (
ScoExcelBook,
ScoExcelSheet,
base_signature,
Frame_Engine,
Merge_Engine,
)
UNUSED = "XXX"
liste_annees = ["BUT1", "BUT2", "BUT3"]
header_colors = {
"BUT1": {
"BUT": SCO_COLORS.BUT1,
"RCUE": SCO_COLORS.RCUE1,
"UE": SCO_COLORS.UE1,
},
"BUT2": {
"BUT": SCO_COLORS.BUT2,
"RCUE": SCO_COLORS.RCUE2,
"UE": SCO_COLORS.UE2,
},
"BUT3": {
"BUT": SCO_COLORS.BUT3,
"RCUE": SCO_COLORS.RCUE3,
"UE": SCO_COLORS.UE3,
},
}
def periode(semestre_idx):
return 1 + (semestre_idx + 1) % 2
class UeDesc:
def __init__(self, scodocUe, competence_id, periode):
self.fromScodoc = scodocUe
self.competence_id = competence_id
self.periode = periode
class NiveauDesc:
def __init__(self, scodocNiveau):
self.fromScodoc = scodocNiveau
self.ues = {1: None, 2: None}
for scodocUe in scodocNiveau.ues:
ue_desc = UeDesc(
scodocUe, scodocNiveau.competence_id, periode(scodocUe.semestre_idx)
)
self.ues[periode(scodocUe.semestre_idx)] = scodocUe
if not scodocUe.is_external:
self.ues[periode(scodocUe.semestre_idx)] = scodocUe
def get_desc(self, periode=None):
if periode is None:
return self
return self.ues.get(periode, None)
def generate_data(
self, ws: ScoExcelSheet, row: int, etudiant: "EtudiantJury", column: int
) -> int:
for periode in [1, 2]:
ue = self.ues[periode]
if ue is None:
ws.set_cell(row, column, UNUSED)
ws.set_cell(row, column + 1, UNUSED)
else:
ws.set_cell(
row,
column,
etudiant.get_note(
self.fromScodoc.annee, self.fromScodoc.competence_id, periode
),
base_signature,
)
ws.set_cell(
row,
column + 1,
etudiant.get_res(
self.fromScodoc.annee, self.fromScodoc.competence_id, periode
),
)
column += 2
ws.set_cell(
row,
column,
etudiant.get_note(self.fromScodoc.annee, self.fromScodoc.competence_id),
base_signature,
)
ws.set_cell(
row,
column + 1,
etudiant.get_res(self.fromScodoc.annee, self.fromScodoc.competence_id),
)
return column + 2
@staticmethod
def generate_blank_data(ws: ScoExcelSheet, row: int, column: int) -> int:
ws.set_cell(row, column + 0, "UN1")
ws.set_cell(row, column + 1, "UR1")
ws.set_cell(row, column + 2, "UN2")
ws.set_cell(row, column + 3, "UR2")
ws.set_cell(row, column + 4, "R1")
ws.set_cell(row, column + 5, "R2")
column += 6
return column
@staticmethod
def generate_blank_header(ws: ScoExcelSheet, column: int, annee: str):
rcue_signature = FMT.FILL_BGCOLOR.write(
value=header_colors[annee]["RCUE"].value,
signature=base_signature,
)
ue_signature = FMT.FILL_BGCOLOR.write(
value=header_colors[annee]["UE"].value,
signature=base_signature,
)
merge = ws.get_merge_engine(start_row=2, start_column=column)
frame = ws.get_frame_engine(
start_row=2, start_column=column, thickness=SCO_BORDERTHICKNESS.BORDER_THIN
)
ws.set_cell(2, column, UNUSED, from_signature=rcue_signature)
for ue in ["UE1", "UE2"]:
frame_ue = ws.get_frame_engine(
start_row=3,
start_column=column,
thickness=SCO_BORDERTHICKNESS.BORDER_THIN,
color=SCO_COLORS.GREY,
)
merge_ue = ws.get_merge_engine(start_row=3, start_column=column)
ws.set_cell(3, column, UNUSED, ue_signature)
ws.set_cell(4, column, "Note", ue_signature)
ws.set_column_dimension_hidden(ScoExcelSheet.i2col(column - 1), True)
column += 1
ws.set_cell(4, column, "Rés.", ue_signature)
ws.set_column_dimension_hidden(ScoExcelSheet.i2col(column - 1), True)
column += 1
merge_ue.close(end_row=3, end_column=column - 1)
frame_ue.close(end_row=4, end_column=column - 1)
merge_rcue = ws.get_merge_engine(start_row=3, start_column=column)
ws.set_cell(3, column, UNUSED, rcue_signature)
ws.set_cell(4, column, UNUSED, rcue_signature)
ws.set_column_dimension_hidden(ScoExcelSheet.i2col(column - 1), True)
column += 1
ws.set_cell(4, column, UNUSED, rcue_signature)
ws.set_column_dimension_hidden(ScoExcelSheet.i2col(column - 1), True)
column += 1
merge_rcue.close(end_row=3, end_column=column - 1)
frame.close(end_row=4, end_column=column - 1)
merge.close(end_row=2, end_column=column - 1)
return column
def generate_header(self, ws: ScoExcelSheet, column: int):
rcue_signature = FMT.FILL_BGCOLOR.write(
value=header_colors[self.fromScodoc.annee]["RCUE"].value,
signature=base_signature,
)
ue_signature = FMT.FILL_BGCOLOR.write(
value=header_colors[self.fromScodoc.annee]["UE"].value,
signature=base_signature,
)
merge = ws.get_merge_engine(start_row=2, start_column=column)
frame = ws.get_frame_engine(
start_row=2, start_column=column, thickness=SCO_BORDERTHICKNESS.BORDER_THIN
)
ws.set_cell(
2, column, self.fromScodoc.competence.titre, from_signature=rcue_signature
)
for periode in [1, 2]:
ue = self.ues[periode]
frame_ue = ws.get_frame_engine(
start_row=3,
start_column=column,
thickness=SCO_BORDERTHICKNESS.BORDER_THIN,
color=SCO_COLORS.GREY,
)
merge_ue = ws.get_merge_engine(start_row=3, start_column=column)
if ue is None:
ws.set_cell(3, column, "XXX", ue_signature)
else:
ws.set_cell(3, column, ue.acronyme, ue_signature)
ws.set_cell(4, column, "Note", ue_signature)
column += 1
ws.set_cell(4, column, "Rés.", ue_signature)
column += 1
merge_ue.close(end_row=3, end_column=column - 1)
frame_ue.close(end_row=4, end_column=column - 1)
merge_rcue = ws.get_merge_engine(start_row=3, start_column=column)
ws.set_cell(3, column, "Competence", rcue_signature)
ws.set_cell(4, column, "Note", rcue_signature)
column += 1
ws.set_cell(4, column, "Rés.", rcue_signature)
column += 1
merge_rcue.close(end_row=3, end_column=column - 1)
frame.close(end_row=4, end_column=column - 1)
merge.close(end_row=2, end_column=column - 1)
return column
def get_ues(self, etudiant):
"""get list of candidates UEs for Niveau"""
ues = [None, None]
for inscription in etudiant.cursus.inscriptions:
formation_id = inscription.formsemestre.formation_id
semestre_idx = inscription.formsemestre.semestre_id
if semestre_idx in self.ues:
# identifier les ues cocernées
ues[periode(semestre_idx)] = inscription.formsemestre
return ues
class CompetenceDesc:
def __init__(self, scodocCompetence):
self.fromScodoc: ApcCompetence = scodocCompetence
self.niveaux = {}
for scodocNiveau in scodocCompetence.niveaux.all():
self.niveaux[scodocNiveau.id] = NiveauDesc(scodocNiveau)
def getNiveauDesc(self, niveau_id):
return self.niveaux[niveau_id]
def getNiveaux(self, codeAnnee):
niveaux = []
for niveau_id, niveauDesc in self.niveaux.items():
if codeAnnee == niveauDesc.fromScodoc.annee:
niveaux.append(niveauDesc)
return niveaux
class FormsemestreDesc:
def __init__(self, formsemestre_id, formsemestre=None):
self.formsemestre_id = formsemestre_id
self.formsemestre = formsemestre
self.resultats = None
def get_desc(self, competence_id=None):
if competence_id is None:
return self
return self.ues.get(competence_id, None)
def get_formsemestre(self):
if self.formsemestre is None:
self.formsemestre = FormSemestre.get(self.formsemestre_id)
return self.formsemestre
def get_resultats(self):
if self.resultats is None:
self.resultats = res_sem.load_formsemestre_results(self.get_formsemestre())
return self.resultats
class AnneeDesc:
def __init__(self, codeAnnee):
self.codeAnnee = codeAnnee
self.niveaux = {}
def get_desc(self, competence_id=None, periode=None):
if competence_id is None and periode is None:
return self
elif competence_id is None:
return self.formsemestres.get(periode, None)
elif competence_id in self.niveaux:
return self.niveaux[competence_id].get_desc(periode)
else:
return None
def addNiveau(self, niveaux):
for niveau in niveaux:
competence_id = niveau.fromScodoc.competence_id
self.niveaux[competence_id] = niveau
def generate_blank_niveau(self, ws: ScoExcelSheet, column: int):
return column
def generate_data(
self, ws: ScoExcelSheet, row: int, etudiant: "EtudiantJury", column: int
) -> int:
for niveau in self.niveaux.values():
column = niveau.generate_data(ws, row, etudiant, column)
for i in range(len(self.niveaux), 6):
column = NiveauDesc.generate_blank_data(ws, row, column)
ws.set_cell(row, column + 0, "A1")
ws.set_cell(row, column + 1, etudiant.get_note(self.codeAnnee), base_signature)
ws.set_cell(row, column + 2, etudiant.get_res(self.codeAnnee))
column += 3
if self.codeAnnee == "BUT2":
# ws.set_cell(row, column, etudiant.getDipl("BUT2"))
column += 1
if self.codeAnnee == "BUT3":
# ws.set_cell(row, column, etudiant.getDipl("BUT3"))
column += 1
return column
def generate_header(self, ws: ScoExcelSheet, column: int):
start = column
but_signature = FMT.FILL_BGCOLOR.write(
signature=base_signature, value=header_colors[self.codeAnnee]["BUT"].value
)
merge = ws.get_merge_engine(start_row=1, start_column=column)
frame = ws.get_frame_engine(
start_row=1, start_column=column, thickness=SCO_BORDERTHICKNESS.BORDER_THICK
)
ws.set_cell(
1,
column,
text=self.codeAnnee,
from_signature=but_signature,
composition=[
(FMT.BORDER_LEFT_COLOR, SCO_COLORS.BLACK.value),
(FMT.BORDER_LEFT_STYLE, SCO_BORDERTHICKNESS.BORDER_MEDIUM.value),
],
)
for niveau in self.niveaux.values():
column = niveau.generate_header(ws, column)
for i in range(len(self.niveaux), 6):
column = NiveauDesc.generate_blank_header(ws, column, self.codeAnnee)
merge_annee = ws.get_merge_engine(start_row=2, start_column=column)
ws.set_cell(2, column, "Année", from_signature=but_signature)
# cell_format(ws.cell(2, column), but_signature, [(FMT.FONT_BOLD, True)])
ws.set_cell(3, column, "Nb", from_signature=but_signature)
ws.set_cell(4, column, "RCUE", from_signature=but_signature)
column += 1
ws.set_cell(3, column, from_signature=but_signature)
ws.set_cell(3, column, "Moy.", from_signature=but_signature)
ws.set_cell(4, column, f"Sem", from_signature=but_signature)
column += 1
ws.set_cell(3, column, from_signature=but_signature)
ws.set_cell(4, column, "Rés.", from_signature=but_signature)
column += 1
merge_annee.close(end_row=2, end_column=column - 1)
if self.codeAnnee == "BUT2":
ws.set_cell(2, column, "DUT", from_signature=but_signature)
ws.set_cell(3, column, from_signature=but_signature)
ws.set_cell(4, column, "Rés.", from_signature=but_signature)
column += 1
if self.codeAnnee == "BUT3":
ws.set_cell(2, column, "BUT", from_signature=but_signature)
ws.set_cell(3, column, from_signature=but_signature)
ws.set_cell(4, column, "Rés.", from_signature=but_signature)
column += 1
frame.close(end_row=4, end_column=column - 1)
merge.close(end_row=1, end_column=column - 1)
return column
class ParcoursDesc:
signature_header = FMT.compose(
[
(FMT.FILL_BGCOLOR, SCO_COLORS.LIGHT_YELLOW.value),
(FMT.NUMBER_FORMAT, SCO_NUMBER_FORMAT.NUMBER_GENERAL.value),
# (FMT.FONT_BOLD, True),
# (FMT.FONT_SIZE, SCO_FONTSIZE.FONTSIZE_13.value),
# (FMT.ALIGNMENT_VALIGN, SCO_VALIGN.VALIGN_CENTER.value),
# (FMT.ALIGNMENT_HALIGN, SCO_HALIGN.HALIGN_CENTER.value),
# (FMT.BORDER_RIGHT, HAIR_BLACK),
# (FMT.BORDER_LEFT, HAIR_BLACK),
# (FMT.BORDER_TOP, HAIR_BLACK),
# (FMT.BORDER_BOTTOM, HAIR_BLACK),
],
base_signature,
)
def __init__(self, formation, scodocParcour: ApcParcours = None):
self.fromScodoc: ApcParcours = scodocParcour # None pour le tronc commun 'TC'
self.etudiants = []
self.competences = {}
self.annees = {}
if scodocParcour is None:
for (
scodocCompetence
) in formation.referentiel_competence.get_competences_tronc_commun():
self.competences[scodocCompetence.id] = CompetenceDesc(scodocCompetence)
else:
query = formation.query_competences_parcour(scodocParcour)
if not query is None:
for scodocCompetence in query.all():
self.competences[scodocCompetence.id] = CompetenceDesc(
scodocCompetence
)
for codeAnnee in liste_annees:
annee_desc = AnneeDesc(codeAnnee)
for competence_id, competence in self.competences.items():
annee_desc.addNiveau(competence.getNiveaux(codeAnnee))
self.annees[codeAnnee] = annee_desc
def get_desc(self, annee=None, competence_id=None, periode=None):
if annee is None:
return self
if annee in self.annees:
return self.annees[annee].get_desc(competence_id, periode)
return None
def add_etudiant(self, etudiant):
if not etudiant in self.etudiants:
self.etudiants.append(etudiant)
def getNiveauDesc(self, competence_id, niveau_id):
return self.competences[competence_id].getNiveauDesc(niveau_id)
def getData(self):
data = []
for etudiant in self.etudiants:
data.append(etudiant.get_data())
return data
def append_title_column(self, worksheet, cells, val1, val2, val3, val4):
cells1, cells2, cells3, cells4 = cells
cells1.append(worksheet.make_cell(val1))
cells2.append(worksheet.make_cell(val2))
cells3.append(worksheet.make_cell(val3))
cells4.append(worksheet.make_cell(val4))
def handle_description(
self, ws: ScoExcelSheet, description: tuple, row: int, column: int
) -> int:
title, content_list = description
frame_thickness, frame_color = [
(SCO_BORDERTHICKNESS.BORDER_THICK, SCO_COLORS.BLACK),
(SCO_BORDERTHICKNESS.BORDER_HAIR, SCO_COLORS.BLACK),
(SCO_BORDERTHICKNESS.BORDER_HAIR, SCO_COLORS.BLACK),
(SCO_BORDERTHICKNESS.NONE, SCO_COLORS.NONE),
][row - 1]
frame = ws.get_frame_engine(
start_row=row,
start_column=column,
thickness=frame_thickness,
color=frame_color,
)
ws.set_cell(
row=row,
column=column,
text=title,
from_signature=self.signature_header,
)
merge_h = ws.get_merge_engine(start_row=row, start_column=column)
merge_v = ws.get_merge_engine(start_row=row, start_column=column)
if content_list is None:
merge_v.close(end_row=4)
column += 1
else:
for content in content_list:
column = self.handle_description(ws, content, row + 1, column)
merge_h.close(end_column=column - 1)
frame.close(4, column - 1)
return column
def generate_etudiant_header(self, ws: ScoExcelSheet) -> int:
titles = (
"ETUDIANT",
[
("id", None),
("nip", None),
("Civ", None),
("nom", None),
("prenom", None),
("parcours", None),
("cursus", None),
(
"absences",
[
("Tot.", None),
("Non", [("Just.", None)]),
],
),
],
)
column = self.handle_description(ws, titles, 1, 1)
return column
def generate_header(self, ws: ScoExcelSheet):
column: int = self.generate_etudiant_header(ws)
for codeAnnee in liste_annees:
column = self.annees[codeAnnee].generate_header(ws, column)
def generate(self, workbook: ScoExcelBook):
if self.fromScodoc:
sheet_name = self.fromScodoc.code
else:
sheet_name = "TC"
worksheet: ScoExcelSheet = workbook.create_sheet(sheet_name)
self.generate_header(worksheet)
self.generate_etudiants(worksheet)
def generate_data(
self, ws: ScoExcelSheet, row: int, column: int, etudiant: "EtudiantJury"
):
for codeAnnee in liste_annees:
column = self.annees[codeAnnee].generate_data(ws, row, etudiant, column)
def generate_etudiants(self, ws: ScoExcelSheet):
ligne = 5
for etudiant in self.etudiants:
ws.set_cell(ligne, 1, etudiant.ident.id)
ws.set_cell(ligne, 2, etudiant.ident.code_nip)
ws.set_cell(ligne, 3, etudiant.ident.civilite)
ws.set_cell(ligne, 4, etudiant.ident.nom)
ws.set_cell(ligne, 5, etudiant.ident.prenom)
if etudiant.parcour:
ws.set_cell(ligne, 6, etudiant.parcour.code)
else:
ws.set_cell(ligne, 6, "-")
cursus = ", ".join(etudiant.history)
ws.set_cell(ligne, 7, cursus)
ws.set_cell(ligne, 8, etudiant.nbabs)
ws.set_cell(ligne, 9, etudiant.nbabs - etudiant.nbabsjust)
# self.generate_data(ws, ligne, 10, etudiant)
ligne = ligne + 1

410
app/but/prepajury_xl.py Normal file
View File

@ -0,0 +1,410 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
from __future__ import annotations
from collections import defaultdict
from openpyxl.cell import WriteOnlyCell
from openpyxl.worksheet.worksheet import Worksheet
from app.but.prepajury_xl_format import (
Sco_Style,
FMT,
SCO_FONTNAME,
SCO_FONTSIZE,
SCO_HALIGN,
SCO_VALIGN,
SCO_NUMBER_FORMAT,
SCO_BORDERTHICKNESS,
SCO_COLORS,
fmt_atomics,
)
""" Excel file handling
"""
import datetime
from tempfile import NamedTemporaryFile
import openpyxl.utils.datetime
from openpyxl.styles.numbers import FORMAT_DATE_DDMMYY
from openpyxl.comments import Comment
from openpyxl import Workbook
import app.scodoc.sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError
# Un style est enregistré comme un dictionnaire qui précise la valeur d'un attribut dans la liste suivante:
# font, border, number_format, fill,...
# (cf https://openpyxl.readthedocs.io/en/stable/styles.html#working-with-styles)
base_signature = (
FMT.FONT_NAME.set(SCO_FONTNAME.FONTNAME_CALIBRI)
+ FMT.FONT_SIZE.set(SCO_FONTSIZE.FONTSIZE_13)
+ FMT.ALIGNMENT_HALIGN.set(SCO_HALIGN.HALIGN_CENTER)
+ FMT.ALIGNMENT_VALIGN.set(SCO_VALIGN.VALIGN_CENTER)
+ FMT.NUMBER_FORMAT.set(SCO_NUMBER_FORMAT.NUMBER_GENERAL)
)
class Sco_Cell:
def __init__(self, text: str = "", signature: int = 0):
self.text = text
self.signature = signature
def alter(self, signature: int):
for fmt in fmt_atomics:
value: int = fmt.composante.read(signature)
if value > 0:
self.signature = fmt.write(value, self.signature)
def build(self, ws: Worksheet, row: int, column: int):
cell = ws.cell(row, column)
cell.value = self.text
FMT.ALL.apply(cell=cell, signature=self.signature)
class Frame_Engine:
def __init__(
self,
ws: ScoExcelSheet,
start_row: int = 1,
start_column: int = 1,
thickness: SCO_BORDERTHICKNESS = SCO_BORDERTHICKNESS.NONE,
color: SCO_COLORS = SCO_COLORS.BLACK,
):
self.start_row: int = start_row
self.start_column: int = start_column
self.ws: ScoExcelSheet = ws
self.border_style = FMT.BORDER_LEFT.make_zero_based_constant([thickness, color])
def close(self, end_row: int, end_column: int):
left_signature: int = FMT.BORDER_LEFT.write(self.border_style)
right_signature: int = FMT.BORDER_RIGHT.write(self.border_style)
top_signature: int = FMT.BORDER_TOP.write(self.border_style)
bottom_signature: int = FMT.BORDER_BOTTOM.write(self.border_style)
for row in range(self.start_row, end_row + 1):
self.ws.cells[row][self.start_column].alter(left_signature)
self.ws.cells[row][end_column].alter(right_signature)
for column in range(self.start_column, end_column + 1):
self.ws.cells[self.start_row][column].alter(top_signature)
self.ws.cells[end_row][column].alter(bottom_signature)
class Merge_Engine:
def __init__(self, start_row: int = 1, start_column: int = 1):
self.start_row: int = start_row
self.start_column: int = start_column
self.end_row: int = None
self.end_column: int = None
self.closed: bool = False
def close(self, end_row=None, end_column=None):
if end_row is None:
self.end_row = self.start_row + 1
else:
self.end_row = end_row + 1
if end_column is None:
self.end_column = self.start_column + 1
else:
self.end_column = end_column + 1
self.closed = True
def write(self, ws: Worksheet):
if self.closed:
if (self.end_row - self.start_row > 0) and (
self.end_column - self.start_column > 0
):
ws.merge_cells(
start_row=self.start_row,
start_column=self.start_column,
end_row=self.end_row - 1,
end_column=self.end_column - 1,
)
def __repr__(self):
return f"( {self.start_row}:{self.start_column}-{self.end_row}:{self.end_column})[{self.closed}]"
def xldate_as_datetime(xldate, datemode=0):
"""Conversion d'une date Excel en datetime python
Deux formats de chaîne acceptés:
* JJ/MM/YYYY (chaîne naïve)
* Date ISO (valeur de type date lue dans la feuille)
Peut lever une ValueError
"""
try:
return datetime.datetime.strptime(xldate, "%d/%m/%Y")
except:
return openpyxl.utils.datetime.from_ISO8601(xldate)
def adjust_sheetname(sheet_name):
"""Renvoie un nom convenable pour une feuille excel: < 31 cars, sans caractères spéciaux
Le / n'est pas autorisé par exemple.
Voir https://xlsxwriter.readthedocs.io/workbook.html#add_worksheet
"""
sheet_name = scu.make_filename(sheet_name)
# Le nom de la feuille ne peut faire plus de 31 caractères.
# si la taille du nom de feuille est > 31 on tronque (on pourrait remplacer par 'feuille' ?)
return sheet_name[:31]
class ScoExcelBook:
"""Permet la génération d'un classeur xlsx composé de plusieurs feuilles.
usage:
wb = ScoExcelBook()
ws0 = wb.create_sheet('sheet name 0')
ws1 = wb.create_sheet('sheet name 1')
...
steam = wb.generate()
"""
def __init__(self):
self.sheets = [] # list of sheets
self.wb = Workbook()
def create_sheet(self, sheet_name="feuille", default_signature: int = 0):
"""Crée une nouvelle feuille dans ce classeur
sheet_name -- le nom de la feuille
default_style -- le style par défaut
"""
sheet_name = adjust_sheetname(sheet_name)
ws = self.wb.create_sheet(sheet_name)
sheet = ScoExcelSheet(sheet_name, default_signature=default_signature, ws=ws)
self.sheets.append(sheet)
return sheet
def generate(self):
"""génération d'un stream binaire représentant la totalité du classeur.
retourne le flux
"""
sheet: Worksheet = self.wb.get_sheet_by_name("Sheet")
self.wb.remove_sheet(sheet)
for sheet in self.sheets:
sheet.prepare()
# construction d'un flux
# (https://openpyxl.readthedocs.io/en/stable/tutorial.html#saving-as-a-stream)
with NamedTemporaryFile() as tmp:
self.wb.save(tmp.name)
tmp.seek(0)
return tmp.read()
class ScoExcelSheet:
"""Représente une feuille qui peut être indépendante ou intégrée dans un ScoExcelBook.
En application des directives de la bibliothèque sur l'écriture optimisée, l'ordre des opérations
est imposé:
* instructions globales (largeur/maquage des colonnes et ligne, ...)
* construction et ajout des cellules et ligne selon le sens de lecture (occidental)
ligne de haut en bas et cellules de gauche à droite (i.e. A1, A2, .. B1, B2, ..)
* pour finir appel de la méthode de génération
"""
def __init__(
self,
sheet_name: str = "feuille",
default_signature: int = 0,
ws: Worksheet = None,
):
"""Création de la feuille. sheet_name
-- le nom de la feuille default_style
-- le style par défaut des cellules ws
-- None si la feuille est autonome (dans ce cas elle crée son propre wb), sinon c'est la worksheet
créée par le workbook propriétaire un workbook est créé et associé à cette feuille.
"""
# Le nom de la feuille ne peut faire plus de 31 caractères.
# si la taille du nom de feuille est > 31 on tronque (on pourrait remplacer par 'feuille' ?)
self.sheet_name = adjust_sheetname(sheet_name)
self.default_signature = default_signature
self.merges: list[Merge_Engine] = []
if ws is None:
self.wb = Workbook()
self.ws = self.wb.active
self.ws.title = self.sheet_name
else:
self.wb = None
self.ws = ws
# internal data
self.cells = defaultdict(lambda: defaultdict(Sco_Cell))
self.column_dimensions = {}
self.row_dimensions = {}
def get_frame_engine(
self,
start_row: int,
start_column: int,
thickness: SCO_BORDERTHICKNESS = SCO_BORDERTHICKNESS.NONE,
color: SCO_COLORS = SCO_COLORS.NONE,
):
return Frame_Engine(
ws=self,
start_row=start_row,
start_column=start_column,
thickness=thickness,
color=color,
)
def get_merge_engine(self, start_row: int, start_column: int):
merge_engine: Merge_Engine = Merge_Engine(
start_row=start_row, start_column=start_column
)
self.merges.append(merge_engine)
return merge_engine
@staticmethod
def i2col(idx):
if idx < 26: # one letter key
return chr(idx + 65)
else: # two letters AA..ZZ
first = idx // 26
second = idx % 26
return "" + chr(first + 64) + chr(second + 65)
def set_cell(
self,
row: int,
column: int,
text: str = "",
from_signature: int = 0,
composition: list = [],
):
cell: Sco_Cell = self.cells[row][column]
cell.text = text
cell.alter(FMT.compose(composition, from_signature))
def set_column_dimension_width(self, cle=None, value=21):
"""Détermine la largeur d'une colonne. cle -- identifie la colonne ("A" "B", ... ou 0, 1, 2, ...) si None,
value donne la liste des largeurs de colonnes depuis A, B, C, ... value -- la dimension (unité : 7 pixels
comme affiché dans Excel)
"""
if cle is None:
for i, val in enumerate(value):
self.ws.column_dimensions[self.i2col(i)].width = val
# No keys: value is a list of widths
elif isinstance(cle, str): # accepts set_column_with("D", ...)
self.ws.column_dimensions[cle].width = value
else:
self.ws.column_dimensions[self.i2col(cle)].width = value
def set_row_dimension_height(self, cle=None, value=21):
"""Détermine la hauteur d'une ligne. cle -- identifie la ligne (1, 2, ...) si None,
value donne la liste des hauteurs de colonnes depuis 1, 2, 3, ... value -- la dimension
"""
if cle is None:
for i, val in enumerate(value, start=1):
self.ws.row_dimensions[i].height = val
# No keys: value is a list of widths
else:
self.ws.row_dimensions[cle].height = value
def set_row_dimension_hidden(self, cle, value):
"""Masque ou affiche une ligne.
cle -- identifie la colonne (1...)
value -- boolean (vrai = colonne cachée)
"""
self.ws.row_dimensions[cle].hidden = value
def set_column_dimension_hidden(self, cle, value):
"""Masque ou affiche une ligne.
cle -- identifie la colonne (1...)
value -- boolean (vrai = colonne cachée)
"""
self.ws.column_dimensions[cle].hidden = value
# def make_cell(self, value: any = None, style: Sco_Style = None, comment=None):
# """Construit une cellule.
# value -- contenu de la cellule (texte, numérique, booléen ou date)
# style -- style par défaut (dictionnaire cf. excel_make_style) de la feuille si non spécifié
# """
# # adaptation des valeurs si nécessaire
# if value is None:
# value = ""
# elif value is True:
# value = 1
# elif value is False:
# value = 0
# elif isinstance(value, datetime.datetime):
# value = value.replace(
# tzinfo=None
# ) # make date naive (cf https://openpyxl.readthedocs.io/en/latest/datetime.html#timezones)
#
# # création de la cellule
# cell = WriteOnlyCell(self.ws, value)
#
# if style is not None:
# style.apply(cell)
#
# if not comment is None:
# cell.comment = Comment(comment, "scodoc")
# lines = comment.splitlines()
# cell.comment.width = 7 * max([len(line) for line in lines]) if lines else 7
# cell.comment.height = 20 * len(lines) if lines else 20
#
# # test datatype to overwrite datetime format
# if isinstance(value, datetime.date):
# cell.data_type = "d"
# cell.number_format = FORMAT_DATE_DDMMYY
# elif isinstance(value, int) or isinstance(value, float):
# cell.data_type = "n"
# else:
# cell.data_type = "s"
#
# return cell
def prepare(self):
"""génére un flux décrivant la feuille.
Ce flux pourra ensuite être repris dans send_excel_file (classeur mono feille)
ou pour la génération d'un classeur multi-feuilles
"""
# for row in self.column_dimensions.keys():
# self.ws.column_dimensions[row] = self.column_dimensions[row]
# for row in self.row_dimensions.keys():
# self.ws.row_dimensions[row] = self.row_dimensions[row]
# for row in self.rows:
# self.ws.append(row)
for row in self.cells:
for column in self.cells[row]:
self.cells[row][column].build(self.ws, row, column)
for merge_engine in self.merges:
merge_engine.write(self.ws)
def generate(self):
"""génération d'un classeur mono-feuille"""
# this method makes sense only if it is a standalone worksheet (else call workbook.generate()
if self.wb is None: # embeded sheet
raise ScoValueError("can't generate a single sheet from a ScoWorkbook")
# construction d'un flux (https://openpyxl.readthedocs.io/en/stable/tutorial.html#saving-as-a-stream)
self.prepare()
with NamedTemporaryFile() as tmp:
self.wb.save(tmp.name)
tmp.seek(0)
return tmp.read()

View File

@ -0,0 +1,666 @@
import abc
from enum import Enum
import openpyxl.styles
from openpyxl.cell import Cell
from openpyxl.styles import Side, Border, Font, PatternFill, Alignment
from openpyxl.styles.numbers import FORMAT_GENERAL, FORMAT_NUMBER_00
# Formatting Enums
class SCO_COLORS(Enum):
def __new__(cls, value, argb):
obj = object.__new__(cls)
obj._value_ = value
obj.argb = argb
return obj
NONE = (0, None)
BLACK = (1, "FF000000")
WHITE = (2, "FFFFFFFF")
RED = (3, "FFFF0000")
BROWN = (4, "FF993300")
PURPLE = (5, "FF993366")
BLUE = (6, "FF0000FF")
ORANGE = (7, "FFFF3300")
LIGHT_YELLOW = (8, "FFFFFF99")
GREEN = (9, "FF00FF00")
GREY = (10, "FF101010")
BUT1 = (23, "FF95B3D7")
RCUE1 = (24, "FFB8CCE4")
UE1 = (25, "FFDCE6F1")
BUT2 = (26, "FFC4D79B")
RCUE2 = (27, "FFD8E4BC")
UE2 = (28, "FFEBF1DE")
BUT3 = (29, "FFFABF8F")
RCUE3 = (30, "FFFCD5B4")
UE3 = (31, "FFFDE9D9")
class SCO_BORDERTHICKNESS(Enum):
def __new__(cls, value, width):
obj = object.__new__(cls)
obj._value_ = value
obj.width = width
return obj
NONE = (0, None)
BORDER_HAIR = (1, "hair")
BORDER_THIN = (2, "thin")
BORDER_MEDIUM = (3, "medium")
BORDER_THICK = (4, "thick")
class SCO_FONTNAME(Enum):
def __new__(cls, value, fontname):
obj = object.__new__(cls)
obj._value_ = value
obj.fontname = fontname
return obj
NONE = (0, None)
FONTNAME_CALIBRI = (1, "Calibri")
FONTNAME_ARIAL = (2, "Arial")
FONTNAME_COURIER = (3, "Courier New")
FONTNAME_TIMES = (4, "Times New Roman")
class SCO_FONTSIZE(Enum):
def __new__(cls, value, fontsize):
obj = object.__new__(cls)
obj._value_ = value
obj.fontsize = fontsize
return obj
NONE = (0, None)
FONTSIZE_9 = (1, 9.0)
FONTSIZE_10 = (2, 10.0)
FONTSIZE_11 = (2, 11.0)
FONTSIZE_13 = (4, 13.0)
class SCO_NUMBER_FORMAT(Enum):
def __new__(cls, value, format):
obj = object.__new__(cls)
obj._value_ = value
obj.format = format
return obj
NONE = (0, None)
NUMBER_GENERAL = (0, FORMAT_GENERAL)
NUMBER_00 = (1, FORMAT_NUMBER_00)
NUMBER_0 = (2, "0.0")
class SCO_HALIGN(Enum):
def __new__(cls, value, position):
obj = object.__new__(cls)
obj._value_ = value
obj.position = position
return obj
NONE = (0, None)
HALIGN_LEFT = (1, "left")
HALIGN_CENTER = (2, "center")
HALIGN_RIGHT = (3, "right")
class SCO_VALIGN(Enum):
def __new__(cls, value, position):
obj = object.__new__(cls)
obj._value_ = value
obj.position = position
return obj
VALIGN_BOTTOM = (0, "bottom")
VALIGN_TOP = (1, "top")
VALIGN_CENTER = (2, "center")
# Composante (bitfield) atomique. Based on Enums
free = 0
class Composante(abc.ABC):
def __init__(self, base=None, width: int = 1):
global free
if base is None:
self.base = free
free += width
else:
self.base = base
self.width = width
self.end = self.base + self.width
self.mask = ((1 << width) - 1) << self.base
def read(self, signature: int) -> int:
return (signature & self.mask) >> self.base
def clear(self, signature: int) -> int:
return signature & ~self.mask
def write(self, index, signature=0) -> int:
return self.clear(signature) + (index << self.base)
def make_zero_based_constant(self, enums: list[Enum] = None):
return 0
@abc.abstractmethod
def build(self, value: int):
pass
class Composante_boolean(Composante):
def __init__(self):
super().__init__(width=1)
def set(self, data: bool, signature=0) -> int:
return self.write(1 if data else 0, signature)
def build(self, signature) -> bool:
value = self.read(signature)
assert value < (1 << self.width)
return value == 1
class Composante_number_format(Composante):
WIDTH: int = 2
def __init__(self):
assert (1 << self.WIDTH) > SCO_NUMBER_FORMAT.__len__()
super().__init__(width=self.WIDTH)
def set(self, data: SCO_NUMBER_FORMAT, signature=0) -> int:
return self.write(data.value, signature)
def build(self, signature: int) -> SCO_NUMBER_FORMAT:
value = self.read(signature)
assert value < (1 << self.width)
return SCO_NUMBER_FORMAT(value)
class Composante_Colors(Composante):
WIDTH: int = 5
def __init__(self, default: SCO_COLORS = SCO_COLORS.BLACK):
assert (1 << self.WIDTH) > SCO_COLORS.__len__()
super().__init__(width=self.WIDTH)
self.default: SCO_COLORS = default
def set(self, data: SCO_COLORS, signature=0) -> int:
return self.write(data.value, signature)
def build(self, signature: int) -> SCO_COLORS:
value = self.read(signature)
assert value < (1 << self.width)
if value == 0:
return None
try:
return SCO_COLORS(value)
except:
return self.default
class Composante_borderThickness(Composante):
WIDTH: int = 3
def __init__(self):
assert (1 << self.WIDTH) > SCO_BORDERTHICKNESS.__len__()
super().__init__(width=self.WIDTH)
def set(self, data: SCO_BORDERTHICKNESS, signature=0) -> int:
return self.write(data.value, signature)
def build(self, signature: int) -> SCO_BORDERTHICKNESS:
value = self.read(signature)
assert value < (1 << self.width)
try:
return SCO_BORDERTHICKNESS(value)
except:
return None
class Composante_fontname(Composante):
WIDTH: int = 3
def __init__(self):
assert (1 << self.WIDTH) > SCO_FONTNAME.__len__()
super().__init__(width=self.WIDTH)
def set(self, data: SCO_FONTNAME, signature=0) -> int:
return self.write(data.value, signature)
def build(self, signature: int) -> SCO_FONTNAME:
value = self.read(signature)
assert value < (1 << self.width)
try:
return SCO_FONTNAME(value)
except:
return SCO_FONTNAME.FONTNAME_CALIBRI
class Composante_fontsize(Composante):
WIDTH: int = 3
def __init__(self):
assert (1 << self.WIDTH) > SCO_FONTSIZE.__len__()
super().__init__(width=self.WIDTH)
def set(self, data: SCO_FONTSIZE, signature=0) -> int:
return self.write(data.value, signature)
def build(self, signature: int) -> SCO_FONTSIZE:
value = self.read(signature)
assert value < (1 << self.width)
return SCO_FONTSIZE(value) or None
class Composante_halign(Composante):
WIDTH: int = 3
def __init__(self):
assert (1 << self.WIDTH) > SCO_HALIGN.__len__()
super().__init__(width=self.WIDTH)
def set(self, data: SCO_HALIGN, signature=0) -> int:
return self.write(data.value, signature)
def build(self, signature: int) -> SCO_HALIGN:
value = self.read(signature)
assert value < (1 << self.width)
try:
return SCO_HALIGN(value)
except:
return SCO_HALIGN.HALIGN_LEFT
class Composante_valign(Composante):
WIDTH: int = 3
def __init__(self):
assert (1 << self.WIDTH) > SCO_VALIGN.__len__()
super().__init__(width=self.WIDTH)
def set(self, data: SCO_VALIGN, signature=0) -> int:
return self.write(data.value, signature)
def build(self, signature: int) -> SCO_VALIGN:
value = self.read(signature)
assert value < (1 << self.width)
try:
return SCO_VALIGN(value)
except:
return SCO_VALIGN.VALIGN_CENTER
# Formatting objects
class Sco_Fill:
def __init__(self, color: SCO_COLORS):
self.color = color
def to_openpyxl(self):
return PatternFill(
fill_type="solid",
fgColor=None if self.color is None else self.color.argb,
)
class Sco_BorderSide:
def __init__(
self,
thickness: SCO_BORDERTHICKNESS = None,
color: SCO_COLORS = SCO_COLORS.WHITE,
):
self.thickness = thickness
self.color: SCO_COLORS = color
def to_openpyxl(self):
return Side(
border_style=None if self.thickness is None else self.thickness.width,
color=None if self.color is None else self.color.argb,
)
class Sco_Borders:
def __init__(
self,
left: Sco_BorderSide = None,
right: Sco_BorderSide = None,
top: Sco_BorderSide = None,
bottom: Sco_BorderSide = None,
):
self.left = left
self.right = right
self.top = top
self.bottom = bottom
def to_openpyxl(self):
return Border(
left=self.left.to_openpyxl(),
right=self.right.to_openpyxl(),
top=self.top.to_openpyxl(),
bottom=self.bottom.to_openpyxl(),
)
class Sco_Alignment:
def __init__(
self,
halign: SCO_HALIGN = None,
valign: SCO_VALIGN = None,
):
self.halign = halign
self.valign = valign
def to_openpyxl(self):
return Alignment(
horizontal=None if self.halign is None else self.halign.position,
vertical=None if self.valign is None else self.valign.position,
)
class Sco_Font:
def __init__(
self,
name: SCO_FONTNAME = SCO_FONTNAME(0),
fontsize: SCO_FONTSIZE = SCO_FONTSIZE(0),
bold: bool = None,
italic: bool = None,
outline: bool = None,
color: "SCO_COLORS" = None,
):
self.name = name
self.bold = bold
self.italic = italic
self.outline = outline
self.color = color
self.fontsize = fontsize
def to_openpyxl(self):
return Font(
name=None if self.name is None else self.name.fontname,
size=None if self.fontsize is None else self.fontsize.fontsize,
bold=self.bold,
italic=self.italic,
outline=self.outline,
color=None if self.color is None else self.color.argb,
)
class Sco_Style:
from openpyxl.cell import Cell
def __init__(
self,
font: Sco_Font = None,
fill: Sco_Fill = None,
alignment: Sco_Alignment = None,
borders: Sco_Borders = None,
number_format: SCO_NUMBER_FORMAT = FORMAT_GENERAL,
):
self.font = font or None
self.fill = fill or None
self.alignment = alignment or None
self.borders = borders or None
self.number_format = number_format or SCO_NUMBER_FORMAT.NUMBER_GENERAL
def apply(self, cell: Cell):
if self.font:
cell.font = self.font.to_openpyxl()
if self.fill and self.fill.color:
cell.fill = self.fill.to_openpyxl()
if self.alignment:
cell.alignment = self.alignment.to_openpyxl()
if self.borders:
cell.border = self.borders.to_openpyxl()
cell.number_format = (
FORMAT_GENERAL
if self.number_format is None
or self.number_format == SCO_NUMBER_FORMAT.NONE
else self.number_format.format
)
# Composantes groupant d'autres composantes et dotées d'un mécanisme de cache
class Composante_group(Composante):
def __init__(self, composantes: list[Composante]):
self.composantes = composantes
self.cache = {}
mini = min([comp.base for comp in composantes])
maxi = max([comp.end for comp in composantes])
width = sum([comp.width for comp in composantes])
if not width == (maxi - mini):
raise Exception("Composante group non complete ou non connexe")
super().__init__(base=mini, width=width)
def lookup_or_cache(self, signature: int):
value = self.read(signature)
assert value < (1 << self.width)
if not value in self.cache:
self.cache[value] = self.build(signature)
return self.cache[value]
def make_zero_based_constant(self, enums: list[Enum] = None) -> int:
if enums is None:
return 0
signature = 0
for enum, composante in zip(enums, self.composantes):
signature += composante.write(enum.value)
return signature >> self.base
class Composante_fill(Composante_group):
def __init__(self, color: Composante_Colors):
super().__init__([color])
self.color = color
def build(self, signature: int) -> Sco_Fill:
return Sco_Fill(color=self.color.build(signature))
class Composante_font(Composante_group):
def __init__(
self,
name: Composante_fontname,
fontsize: Composante_fontsize,
color: Composante_Colors,
bold: Composante_boolean,
italic: Composante_boolean,
outline: Composante_boolean,
):
super().__init__([name, fontsize, color, bold, italic, outline])
self.name = name
self.fontsize = fontsize
self.color = color
self.bold = bold
self.italic = italic
self.outline = outline
def build(self, signature: int) -> Sco_Font:
return Sco_Font(
name=self.name.build(signature),
fontsize=self.fontsize.build(signature),
color=self.color.build(signature),
bold=self.bold.build(signature),
italic=self.italic.build(signature),
outline=self.outline.build(signature),
)
class Composante_border(Composante_group):
def __init__(self, thick: Composante_borderThickness, color: Composante_Colors):
super().__init__([thick, color])
self.thick = thick
self.color = color
def build(self, signature: int) -> Sco_BorderSide:
return Sco_BorderSide(
thickness=self.thick.build(signature),
color=self.color.build(signature),
)
class Composante_borders(Composante_group):
def __init__(
self,
left: Composante_border,
right: Composante_border,
top: Composante_border,
bottom: Composante_border,
):
super().__init__([left, right, top, bottom])
self.left = left
self.right = right
self.top = top
self.bottom = bottom
def build(self, signature: int) -> Sco_Borders:
return Sco_Borders(
left=self.left.lookup_or_cache(signature),
right=self.right.lookup_or_cache(signature),
top=self.top.lookup_or_cache(signature),
bottom=self.bottom.lookup_or_cache(signature),
)
class Composante_alignment(Composante_group):
def __init__(self, halign: Composante_halign, valign: Composante_valign):
super().__init__([halign, valign])
self.halign = halign
self.valign = valign
def build(self, signature: int) -> Sco_Alignment:
return Sco_Alignment(
halign=self.halign.build(signature),
valign=self.valign.build(signature),
)
class Composante_all(Composante_group):
def __init__(
self,
font: Composante_font,
fill: Composante_fill,
borders: Composante_borders,
alignment: Composante_alignment,
number_format: Composante_number_format,
):
super().__init__([font, fill, borders, alignment, number_format])
assert self.width < 64
self.font = font
self.fill = fill
self.borders = borders
self.alignment = alignment
self.number_format = number_format
def build(self, signature: int) -> Sco_Style:
return Sco_Style(
fill=self.fill.lookup_or_cache(signature),
font=self.font.lookup_or_cache(signature),
borders=self.borders.lookup_or_cache(signature),
alignment=self.alignment.lookup_or_cache(signature),
number_format=self.number_format.build(signature),
)
def get_style(self, signature: int):
return self.lookup_or_cache(signature)
class FMT(Enum):
def __init__(self, composante: Composante):
self.composante = composante
def write(self, value, signature=0) -> int:
return self.composante.write(value, signature)
def set(self, data, signature: int = 0) -> int:
return self.composante.set(data, signature)
def get_style(self, signature: int):
return self.composante.lookup_or_cache(signature)
def make_zero_based_constant(self, enums: list[Enum]) -> int:
return self.composante.make_zero_based_constant(enums=enums)
def apply(self, cell: Cell, signature: int):
self.composante.build(signature).apply(cell)
@classmethod
def compose(cls, composition: list[("FMT", int)], signature: int = 0) -> int:
for field, value in composition:
signature = field.write(value, field.composante.clear(signature))
return signature
@classmethod
def style(cls, signature: int = None) -> Sco_Style:
return FMT.ALL.get_style(signature)
FONT_NAME = Composante_fontname()
FONT_SIZE = Composante_fontsize()
FONT_COLOR = Composante_Colors()
FONT_BOLD = Composante_boolean()
FONT_ITALIC = Composante_boolean()
FONT_OUTLINE = Composante_boolean()
BORDER_LEFT_STYLE = Composante_borderThickness()
BORDER_LEFT_COLOR = Composante_Colors()
BORDER_RIGHT_STYLE = Composante_borderThickness()
BORDER_RIGHT_COLOR = Composante_Colors()
BORDER_TOP_STYLE = Composante_borderThickness()
BORDER_TOP_COLOR = Composante_Colors()
BORDER_BOTTOM_STYLE = Composante_borderThickness()
BORDER_BOTTOM_COLOR = Composante_Colors()
FILL_BGCOLOR = Composante_Colors(None)
ALIGNMENT_HALIGN = Composante_halign()
ALIGNMENT_VALIGN = Composante_valign()
NUMBER_FORMAT = Composante_number_format()
FONT = Composante_font(
FONT_NAME, FONT_SIZE, FONT_COLOR, FONT_BOLD, FONT_ITALIC, FONT_OUTLINE
)
FILL = Composante_fill(FILL_BGCOLOR)
BORDER_LEFT = Composante_border(BORDER_LEFT_STYLE, BORDER_LEFT_COLOR)
BORDER_RIGHT = Composante_border(BORDER_RIGHT_STYLE, BORDER_RIGHT_COLOR)
BORDER_TOP = Composante_border(BORDER_TOP_STYLE, BORDER_TOP_COLOR)
BORDER_BOTTOM = Composante_border(BORDER_BOTTOM_STYLE, BORDER_BOTTOM_COLOR)
BORDERS = Composante_borders(BORDER_LEFT, BORDER_RIGHT, BORDER_TOP, BORDER_BOTTOM)
ALIGNMENT = Composante_alignment(ALIGNMENT_HALIGN, ALIGNMENT_VALIGN)
ALL = Composante_all(FONT, FILL, BORDERS, ALIGNMENT, NUMBER_FORMAT)
fmt_atomics = {
FMT.FONT_NAME,
FMT.FONT_SIZE,
FMT.FONT_COLOR,
FMT.FONT_BOLD,
FMT.FONT_ITALIC,
FMT.FONT_OUTLINE,
FMT.BORDER_LEFT_STYLE,
FMT.BORDER_LEFT_COLOR,
FMT.BORDER_RIGHT_STYLE,
FMT.BORDER_RIGHT_COLOR,
FMT.BORDER_TOP_STYLE,
FMT.BORDER_TOP_COLOR,
FMT.BORDER_BOTTOM_STYLE,
FMT.BORDER_BOTTOM_COLOR,
FMT.FILL_BGCOLOR,
FMT.ALIGNMENT_HALIGN,
FMT.ALIGNMENT_VALIGN,
FMT.NUMBER_FORMAT,
}
HAIR_BLACK: int = FMT.BORDER_LEFT.make_zero_based_constant(
enums=[SCO_BORDERTHICKNESS.BORDER_HAIR, SCO_COLORS.BLACK]
)
THIN_BLACK: int = FMT.BORDER_LEFT.make_zero_based_constant(
enums=[SCO_BORDERTHICKNESS.BORDER_THIN, SCO_COLORS.BLACK]
)
MEDIUM_BLACK: int = FMT.BORDER_LEFT.make_zero_based_constant(
enums=[SCO_BORDERTHICKNESS.BORDER_MEDIUM, SCO_COLORS.BLACK]
)
THICK_BLACK: int = FMT.BORDER_LEFT.make_zero_based_constant(
enums=[SCO_BORDERTHICKNESS.BORDER_THICK, SCO_COLORS.BLACK]
)

View File

@ -3,16 +3,10 @@
"""
from datetime import datetime
from app import db
from app.models import ModuleImpl
from app import db, log
from app.models import ModuleImpl, Scolog
from app.models.etudiants import Identite
from app.auth.models import User
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
localize_datetime,
is_period_overlapping,
)
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import (
EtatAssiduite,
@ -92,6 +86,20 @@ class Assiduite(db.Model):
}
return data
def __str__(self) -> str:
"chaine pour journaux et debug (lisible par humain français)"
try:
etat_str = EtatAssiduite(self.etat).name.lower().capitalize()
except ValueError:
etat_str = "Invalide"
return f"""{etat_str} {
"just." if self.est_just else "non just."
} de {
self.date_debut.strftime("%d/%m/%Y %Hh%M")
} à {
self.date_fin.strftime("%d/%m/%Y %Hh%M")
}"""
@classmethod
def create_assiduite(
cls,
@ -140,33 +148,12 @@ class Assiduite(db.Model):
est_just=est_just,
)
return nouv_assiduite
@classmethod
def fast_create_assiduite(
cls,
etudid: int,
date_debut: datetime,
date_fin: datetime,
etat: EtatAssiduite,
moduleimpl_id: int = None,
description: str = None,
entry_date: datetime = None,
est_just: bool = False,
) -> object or int:
"""Créer une nouvelle assiduité pour l'étudiant"""
# Vérification de non duplication des périodes
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudid=etudid,
moduleimpl_id=moduleimpl_id,
description=description,
entry_date=entry_date,
est_just=est_just,
log(f"create_assiduite: {etud.id} {nouv_assiduite}")
Scolog.logdb(
method="create_assiduite",
etudid=etud.id,
msg=f"assiduité: {nouv_assiduite}",
)
return nouv_assiduite
@ -243,6 +230,18 @@ class Justificatif(db.Model):
}
return data
def __str__(self) -> str:
"chaine pour journaux et debug (lisible par humain français)"
try:
etat_str = EtatJustificatif(self.etat).name
except ValueError:
etat_str = "Invalide"
return f"""Justificatif {etat_str} de {
self.date_debut.strftime("%d/%m/%Y %Hh%M")
} à {
self.date_fin.strftime("%d/%m/%Y %Hh%M")
}"""
@classmethod
def create_justificatif(
cls,
@ -264,29 +263,12 @@ class Justificatif(db.Model):
entry_date=entry_date,
user_id=user_id,
)
return nouv_justificatif
@classmethod
def fast_create_justificatif(
cls,
etudid: int,
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
raison: str = None,
entry_date: datetime = None,
) -> object or int:
"""Créer un nouveau justificatif pour l'étudiant"""
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudid=etudid,
raison=raison,
entry_date=entry_date,
log(f"create_justificatif: {etud.id} {nouv_justificatif}")
Scolog.logdb(
method="create_justificatif",
etudid=etud.id,
msg=f"justificatif: {nouv_justificatif}",
)
return nouv_justificatif

View File

@ -93,7 +93,7 @@ _formsemestreEditor = ndb.EditableTable(
)
def get_formsemestre(formsemestre_id: int):
def get_formsemestre(formsemestre_id: int) -> dict:
"list ONE formsemestre"
if formsemestre_id is None:
raise ValueError("get_formsemestre: id manquant")

View File

@ -838,9 +838,6 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
weekday = datetime.datetime.today().weekday()
try:
if with_absences:
first_monday = sco_abs.ddmmyyyy(
formsemestre.date_debut.strftime("%d/%m/%Y")
).prev_monday()
form_abs_tmpl = f"""
<td>
<a class="btn" href="{
@ -857,7 +854,13 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
</td>
"""
else:
form_abs_tmpl = ""
form_abs_tmpl = f"""
<td>
<a class="btn" href="{
url_for("assiduites.visu_assiduites_group", scodoc_dept=g.scodoc_dept)
}?group_ids=%(group_id)s&jour={datetime.date.today().isoformat()}&formsemestre_id={formsemestre.id}"><button>Voir l'assiduité</button></a>
</td>
"""
except ScoInvalidDateError: # dates incorrectes dans semestres ?
form_abs_tmpl = ""
#
@ -904,8 +907,7 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
"""
)
if with_absences:
H.append(form_abs_tmpl % group)
H.append(form_abs_tmpl % group)
H.append("</tr>")
H.append("</table>")

View File

@ -287,7 +287,7 @@ if (group_id) {
return "\n".join(H)
class DisplayedGroupsInfos(object):
class DisplayedGroupsInfos:
"""Container with attributes describing groups to display in the page
.groups_query_args : 'group_ids=xxx&group_ids=yyy'
.base_url : url de la requete, avec les groupes, sans les autres paramètres
@ -348,7 +348,7 @@ class DisplayedGroupsInfos(object):
self.tous_les_etuds_du_sem = (
False # affiche tous les etuds du semestre ? (si un seul semestre)
)
self.sems = collections.OrderedDict() # formsemestre_id : sem
self.sems = {} # formsemestre_id : sem
self.formsemestre = None
self.formsemestre_id = formsemestre_id
self.nbdem = 0 # nombre d'étudiants démissionnaires en tout

View File

@ -11,7 +11,7 @@
}
#validate_selectors {
margin-top: 5vh;
margin: 15px 0;
}
.no-display {
@ -91,9 +91,6 @@
/* === Gestion des etuds row === */
.etud_holder {
margin-top: 5vh;
}
.etud_row {
display: grid;
@ -315,7 +312,7 @@
padding: 20px;
border: 1px solid #888;
width: 80%;
height: 40%;
height: 320px;
position: relative;
border-radius: 10px;

View File

@ -1328,6 +1328,13 @@ tr.etuddem td {
color: rgb(100, 100, 100);
font-style: italic;
}
table.gt_table tr.etuddem td a {
color: red;
}
table.gt_table tr.etuddem td.etudinfo:first-child::after {
color: red;
content: " (dém.)";
}
td.etudabs,
td.etudabs a.discretelink,

View File

@ -84,19 +84,19 @@ function validateSelectors(btn) {
);
});
if (getModuleImplId() == null && window.forceModule) {
const HTML = `
<p>Attention, le module doit obligatoirement être renseigné.</p>
<p>Cela vient de la configuration du semestre ou plus largement du département.</p>
<p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
`;
// if (getModuleImplId() == null && window.forceModule) {
// const HTML = `
// <p>Attention, le module doit obligatoirement être renseigné.</p>
// <p>Cela vient de la configuration du semestre ou plus largement du département.</p>
// <p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
// `;
const content = document.createElement("div");
content.innerHTML = HTML;
// const content = document.createElement("div");
// content.innerHTML = HTML;
openAlertModal("Sélection du module", content);
return;
}
// openAlertModal("Sélection du module", content);
// return;
// }
getAssiduitesFromEtuds(true);
@ -269,6 +269,15 @@ function executeMassActionQueue() {
};
assiduite = setModuleImplId(assiduite);
if (assiduite.moduleimpl_id == null && window.forceModule) {
const html = `
<h3>Aucun module n'a été spécifié</h3>
`;
const div = document.createElement("div");
div.innerHTML = html;
openAlertModal("Erreur Module", div);
return 0;
}
const createQueue = []; //liste des assiduités qui seront créées.
@ -311,6 +320,16 @@ function executeMassActionQueue() {
return assiduite;
});
if (getModuleImplId() == null && window.forceModule) {
const html = `
<h3>Aucun module n'a été spécifié</h3>
`;
const div = document.createElement("div");
div.innerHTML = html;
openAlertModal("Erreur Module", div);
return 0;
}
const path = getUrl() + `/api/assiduites/edit`;
sync_post(
path,
@ -496,7 +515,7 @@ function generateMassAssiduites() {
});
});
if (!verifyDateInSemester()) {
if (!verifyDateInSemester() || readOnly) {
content.querySelector(".btns_field.mass").setAttribute("disabled", "true");
}
}
@ -849,6 +868,16 @@ function createAssiduite(etat, etudid) {
assiduite = setModuleImplId(assiduite);
if (assiduite.moduleimpl_id == null && window.forceModule) {
const html = `
<h3>Aucun module n'a été spécifié</h3>
`;
const div = document.createElement("div");
div.innerHTML = html;
openAlertModal("Erreur Module", div);
return false;
}
const path = getUrl() + `/api/assiduite/${etudid}/create`;
sync_post(
path,
@ -865,6 +894,7 @@ function createAssiduite(etat, etudid) {
errorAlert();
}
);
return true;
}
/**
@ -889,6 +919,7 @@ function deleteAssiduite(assiduite_id) {
errorAlert();
}
);
return true;
}
/**
@ -905,6 +936,15 @@ function editAssiduite(assiduite_id, etat) {
};
assiduite = setModuleImplId(assiduite);
if (assiduite.moduleimpl_id == null && window.forceModule) {
const html = `
<h3>Aucun module n'a été spécifié</h3>
`;
const div = document.createElement("div");
div.innerHTML = html;
openAlertModal("Erreur Module", div);
return;
}
const path = getUrl() + `/api/assiduite/${assiduite_id}/edit`;
let bool = false;
sync_post(
@ -1072,15 +1112,16 @@ function assiduiteAction(element) {
}
} else {
// Cas normal -> mise à jour en base
let done = false;
switch (type) {
case "création":
createAssiduite(etat, etudid);
done = createAssiduite(etat, etudid);
break;
case "édition":
if (etat === "remove") {
deleteAssiduite(assiduite_id);
done = deleteAssiduite(assiduite_id);
} else {
editAssiduite(assiduite_id, etat);
done = editAssiduite(assiduite_id, etat);
}
break;
case "conflit":
@ -1105,7 +1146,7 @@ function assiduiteAction(element) {
return;
}
if (type != "conflit") {
if (type != "conflit" && done) {
let etatAffiche;
switch (etat.toUpperCase()) {
@ -1262,7 +1303,7 @@ function insertEtudRow(etud, index, output = false) {
bar.appendChild(createMiniTimeline(assiduites[etud.id]));
if (!verifyDateInSemester()) {
if (!verifyDateInSemester() || readOnly) {
row.querySelector(".btns_field.single").setAttribute("disabled", "true");
}
}
@ -1485,7 +1526,6 @@ function fastJustify(assiduite) {
if (justifs.length > 0) {
justifyAssiduite(assiduite.assiduite_id, !assiduite.est_just);
} else {
console.debug("WIP");
//créer un nouveau justificatif
// Afficher prompt -> demander raison et état
@ -1608,12 +1648,46 @@ function deleteJustificatif(justif_id) {
function errorAlert() {
const html = `
<h3>Avez vous les droits suffisant pour cette action ?</h3>
<p>Si c'est bien le cas : veuillez de l'aide sur le canal Assistance de ScoDoc</p>
<p>Si c'est bien le cas : demandez de l'aide sur le canal Assistance de ScoDoc</p>
<br>
<p><i>pour les développeurs : l'erreur est affichée dans la console JS</i></p>
`;
const div = document.createElement("div");
div.innerHTML = html;
openAlertModal("Une erreur s'est déclanchée", div);
openAlertModal("Une erreur s'est produite", div);
}
const moduleimpls = {};
function getModuleImpl(assiduite) {
const id = assiduite.moduleimpl_id;
if (id == null || id == undefined) {
if (
"desc" in assiduite &&
assiduite.desc != null &&
assiduite.desc.indexOf("Module:Autre") != -1
) {
return "Autre";
} else {
return "Pas de module";
}
}
if (id in moduleimpls) {
return moduleimpls[id];
}
const url_api = getUrl() + `/api/moduleimpl/${id}`;
sync_get(
url_api,
(data) => {
moduleimpls[id] = `${data.module.code} ${data.module.abbrev}`;
},
(data) => {
moduleimpls[id] = "Pas de module";
}
);
return moduleimpls[id];
}

View File

@ -3,59 +3,64 @@
// utilise jQuery / qTip
function get_etudid_from_elem(e) {
// renvoie l'etudid, obtenu a partir de l'id de l'element
// qui est soit de la forme xxxx-etudid, soit tout simplement etudid
var etudid = e.id.split("-")[1];
if (etudid == undefined) {
return e.id;
} else {
return etudid;
}
// renvoie l'etudid, obtenu a partir de l'id de l'element
// qui est soit de la forme xxxx-etudid, soit tout simplement etudid
var etudid = e.id.split("-")[1];
if (etudid == undefined) {
return e.id;
} else {
return etudid;
}
}
$().ready(function () {
var elems = $(".etudinfo:not(th)");
var elems = $(".etudinfo");
var q_args = get_query_args();
var args_to_pass = new Set(
["formsemestre_id", "group_ids", "group_id", "partition_id",
"moduleimpl_id", "evaluation_id"
]);
var qs = "";
for (var k in q_args) {
if (args_to_pass.has(k)) {
qs += '&' + k + '=' + q_args[k];
}
}
for (var i = 0; i < elems.length; i++) {
$(elems[i]).qtip({
content: {
ajax: {
url: SCO_URL + "/etud_info_html?etudid=" + get_etudid_from_elem(elems[i]) + qs,
type: "GET"
//success: function(data, status) {
// this.set('content.text', data);
// xxx called twice on each success ???
// console.log(status);
}
},
text: "Loading...",
position: {
at: "right bottom",
my: "left top"
},
style: {
classes: 'qtip-etud'
},
hide: {
fixed: true,
delay: 300
}
// utile pour debugguer le css:
// hide: { event: 'unfocus' }
});
var q_args = get_query_args();
var args_to_pass = new Set([
"formsemestre_id",
"group_ids",
"group_id",
"partition_id",
"moduleimpl_id",
"evaluation_id",
]);
var qs = "";
for (var k in q_args) {
if (args_to_pass.has(k)) {
qs += "&" + k + "=" + q_args[k];
}
}
for (var i = 0; i < elems.length; i++) {
$(elems[i]).qtip({
content: {
ajax: {
url:
SCO_URL +
"/etud_info_html?etudid=" +
get_etudid_from_elem(elems[i]) +
qs,
type: "GET",
//success: function(data, status) {
// this.set('content.text', data);
// xxx called twice on each success ???
// console.log(status);
},
},
text: "Loading...",
position: {
at: "right bottom",
my: "left top",
},
style: {
classes: "qtip-etud",
},
hide: {
fixed: true,
delay: 300,
},
// utile pour debugguer le css:
// hide: { event: 'unfocus' }
});
}
});

View File

@ -8,10 +8,12 @@
"""
from flask import g, url_for
from app.models import Identite, Justificatif
from app import log
from app.models import FormSemestre, Identite, Justificatif
from app.tables import table_builder as tb
import app.scodoc.sco_assiduites as scass
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
class TableAssi(tb.Table):
@ -23,12 +25,13 @@ class TableAssi(tb.Table):
self,
etuds: list[Identite] = None,
dates: tuple[str, str] = None,
formsemestre: FormSemestre = None,
**kwargs,
):
self.rows: list["RowEtud"] = [] # juste pour que VSCode nous aide sur .rows
classes = ["gt_table", "gt_left"]
self.dates = [str(dates[0]) + "T00:00", str(dates[1]) + "T23:59"]
self.formsemestre = formsemestre
super().__init__(
row_class=RowAssi,
classes=classes,
@ -50,6 +53,17 @@ class RowAssi(tb.Row):
# pour le moment très simple, extensible (codes, liens bulletins, ...)
def __init__(self, table: TableAssi, etud: Identite, *args, **kwargs):
# Etat de l'inscription au formsemestre
if "classes" not in kwargs:
kwargs["classes"] = []
try:
inscription = table.formsemestre.etuds_inscriptions[etud.id]
if inscription.etat == scu.DEMISSION:
kwargs["classes"].append("etuddem")
except KeyError:
log(f"RowAssi: etudid {etud.id} non inscrit à {table.formsemestre.id}")
kwargs["classes"].append("non_inscrit") # ne devrait pas arriver !
super().__init__(table, etud.id, *args, **kwargs)
self.etud = etud
self.dates = table.dates

View File

@ -2,34 +2,50 @@
<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>
{% if readonly == "true" %}
<h1 style="font-weight: bolder;color:crimson">La page est en lecture seule.</h1>
{% endif %}
<fieldset class="selectors">
<div>Groupes : {{grp|safe}}</div>
<div id="forcemodule" style="display: none;">Une préférence du semestre vous impose d'indiquer le module !</div>
<div>Module :{{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>
</fieldset>
{{timeline|safe}}
{% if readonly == "true" %}
<button id="validate_selectors" onclick="validateSelectors(this)">
Voir la saisie
</button>
{% else %}
<button id="validate_selectors" onclick="validateSelectors(this)">
Faire la saisie
</button>
{% endif %}
{{timeline|safe}}
{% if readonly == "false" %}
<div style="margin: 1vh 0;">
<div id="forcemodule" style="display: none; margin:10px 0px;">Une préférence du semestre vous impose d'indiquer
le module !</div>
<div>Module :{{moduleimpl_select|safe}}</div>
</div>
{% else %}
{% endif %}
<div class="etud_holder">
<p class="placeholder">
Veillez à choisir le groupe concerné par la saisie ainsi que la date de la saisie.
@ -75,6 +91,9 @@
const nonWorkDays = [{{ nonworkdays| safe }}];
const readOnly = {{ readonly }};
updateDate();
setupDate();
setupTimeLine();
@ -89,17 +108,14 @@
const select = document.getElementById("moduleimpl_select");
if (select.value == "") {
btn.disabled = true;
if (!readOnly && select.value == "") {
document.getElementById('forcemodule').style.display = "block";
}
select.addEventListener('change', (e) => {
select?.addEventListener('change', (e) => {
if (e.target.value != "") {
btn.disabled = false;
document.getElementById('forcemodule').style.display = "none";
} else {
btn.disabled = true;
document.getElementById('forcemodule').style.display = "block";
}
});

View File

@ -129,6 +129,7 @@
</div>
<div class="action-buttons">
<button id="finish" class="btnPrompt">Terminer la résolution</button>
<button id="delete" class="btnPrompt" disabled>Supprimer</button>
<button id="split" class="btnPrompt" disabled>Séparer</button>
<button id="edit" class="btnPrompt" disabled>Modifier l'état</button>
@ -157,6 +158,7 @@
this.deleteBtn.addEventListener('click', () => { this.deleteAssiduiteModal() });
this.editBtn.addEventListener('click', () => { this.editAssiduiteModal() });
this.splitBtn.addEventListener('click', () => { this.splitAssiduiteModal() });
document.querySelector("#myModal #finish").addEventListener('click', () => { this.close() })
document.querySelector('#myModal .close').addEventListener('click', () => { this.close() })
@ -311,7 +313,7 @@
}
};
openPromptModal("Entrée demandée", fieldSet, success, () => { }, "#37f05f");
openPromptModal("Séparation de l'assiduité sélectionnée", fieldSet, success, () => { }, "#23aa40");
}
/**
@ -355,7 +357,7 @@
};
//Affichage du prompt
openPromptModal("Entrée demandée", fieldSet, success, () => { }, "#37f05f");
openPromptModal("Modification de l'état de l'assiduité sélectionnée", fieldSet, success, () => { }, "#23aa40");
}
/**

View File

@ -38,15 +38,15 @@
}
array.forEach((assiduité) => {
const startDate = moment(assiduité.date_debut);
const endDate = moment(assiduité.date_fin);
let startDate = moment(assiduité.date_debut);
let endDate = moment(assiduité.date_fin);
if (startDate.isBefore(dayStart)) {
startDate.startOf("day").add(mt_start, "hours");
startDate = dayEnd.clone().startOf("day").add(mt_start, "hours");
}
if (endDate.isAfter(dayEnd)) {
endDate.startOf("day").add(mt_end, "hours");
endDate = dayEnd.clone().startOf("day").add(mt_end, "hours");
}
const block = document.createElement("div");
@ -140,7 +140,7 @@
const idDiv = document.createElement("div");
idDiv.className = "assiduite-id";
idDiv.textContent = `ID: ${assiduite.assiduite_id}`;
idDiv.textContent = `${getModuleImpl(assiduite)}`;
bubble.appendChild(idDiv);
const periodDivDeb = document.createElement("div");

View File

@ -149,9 +149,10 @@
}
.btnPrompt:disabled {
opacity: 0.7;
color: black;
opacity: 0.8;
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)
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, rgba(81, 81, 81, 0.337) 5px, rgba(81, 81, 81, 0.337) 10px)
}
</style>

View File

@ -63,30 +63,7 @@
try { stats() } catch (_) { }
}
const moduleimpls = {}
function getModuleImpl(assiduite) {
const id = assiduite.moduleimpl_id;
if (id == null || id == undefined) {
if ("desc" in assiduite && assiduite.desc != null && assiduite.desc.indexOf('Module:Autre') != -1) {
return "Autre"
} else {
return "Pas de module"
}
}
if (id in moduleimpls) {
return moduleimpls[id];
}
const url_api = getUrl() + `/api/moduleimpl/${id}`;
sync_get(url_api, (data) => {
moduleimpls[id] = `${data.module.code} ${data.module.abbrev}`;
}, (data) => { moduleimpls[id] = "Pas de module" });
return moduleimpls[id];
}
function renderTableAssiduites(page, assiduités) {

View File

@ -234,11 +234,12 @@
<style>
.timeline-container {
width: 75%;
margin-left: 5%;
margin-left: 25px;
background-color: white;
border-radius: 15px;
position: relative;
height: 40px;
margin-bottom: 25px;
}
/* ... */

View File

@ -4,6 +4,7 @@ from flask import g, request, render_template
from flask import abort, url_for
from app import db
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.decorators import (
@ -465,7 +466,6 @@ def signal_assiduites_group():
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, moduleimpl_id=moduleimpl_id, formsemestre_id=formsemestre_id
)
if not groups_infos.members:
return (
html_sco_header.sco_header(page_title="Saisie journalière des Assiduités")
@ -571,6 +571,153 @@ def signal_assiduites_group():
dept_id=g.scodoc_dept_id,
),
defdem=_get_etuds_dem_def(formsemestre),
readonly="false",
),
html_sco_header.sco_footer(),
).build()
@bp.route("/VisuAssiduiteGr")
@scodoc
@permission_required(Permission.ScoView)
def visu_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")
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
)
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
]
# --- Vérification de la date ---
real_date = scu.is_iso_formated(date, True).date()
if real_date < formsemestre.date_debut:
date = formsemestre.date_debut.isoformat()
elif real_date > formsemestre.date_fin:
date = formsemestre.date_fin.isoformat()
# --- 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",
],
)
return HTMLBuilder(
header,
_mini_timeline(),
render_template(
"assiduites/pages/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(),
nonworkdays=_non_work_days(),
formsemestre_date_debut=str(formsemestre.date_debut),
formsemestre_date_fin=str(formsemestre.date_fin),
forcer_module=sco_preferences.get_preference(
"forcer_module",
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
),
defdem=_get_etuds_dem_def(formsemestre),
readonly="true",
),
html_sco_header.sco_footer(),
).build()
@ -669,9 +816,11 @@ def visu_assi_group():
map(str, group_ids)
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
formsemestre = db.session.get(FormSemestre, groups_infos.formsemestre_id)
etuds = etuds_sorted_from_ids([m["etudid"] for m in groups_infos.members])
table: TableAssi = TableAssi(etuds=etuds, dates=list(dates.values()))
table: TableAssi = TableAssi(
etuds=etuds, dates=list(dates.values()), formsemestre=formsemestre
)
if fmt.startswith("xls"):
return scu.send_file(
@ -934,6 +1083,7 @@ def _get_etuds_dem_def(formsemestre):
for etud in etuds_dem_def:
json_str += template.replace("£", str(etud[0])).replace("$", etud[1])
json_str = json_str[:-1] + "}"
if json_str != "{":
json_str = json_str[:-1]
return json_str
return json_str + "}"

View File

@ -35,7 +35,7 @@ ASSIDUITES_FIELDS = {
}
CREATE_FIELD = {"assiduite_id": int}
BATCH_FIELD = {"errors": dict, "success": dict}
BATCH_FIELD = {"errors": list, "success": list}
COUNT_FIELDS = {"compte": int, "journee": int, "demi": int, "heure": float}
@ -262,14 +262,14 @@ def test_route_create(api_admin_headers):
check_fields(res, BATCH_FIELD)
assert len(res["success"]) == 1
TO_REMOVE.append(res["success"]["0"]["assiduite_id"])
TO_REMOVE.append(res["success"][0]["message"]["assiduite_id"])
data2 = create_data("absent", "02", MODULE, "desc")
res = POST_JSON(f"/assiduite/{ETUDID}/create", [data2], api_admin_headers)
check_fields(res, BATCH_FIELD)
assert len(res["success"]) == 1
TO_REMOVE.append(res["success"]["0"]["assiduite_id"])
TO_REMOVE.append(res["success"][0]["message"]["assiduite_id"])
# Mauvais fonctionnement
check_failure_post(f"/assiduite/{FAUX}/create", api_admin_headers, [data])
@ -278,7 +278,7 @@ def test_route_create(api_admin_headers):
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 1
assert (
res["errors"]["0"]
res["errors"][0]["message"]
== "Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)"
)
@ -289,7 +289,7 @@ def test_route_create(api_admin_headers):
)
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 1
assert res["errors"]["0"] == "param 'moduleimpl_id': invalide"
assert res["errors"][0]["message"] == "param 'moduleimpl_id': invalide"
# -== Multiple ==-
@ -304,8 +304,8 @@ def test_route_create(api_admin_headers):
res = POST_JSON(f"/assiduite/{ETUDID}/create", data, api_admin_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
check_fields(res["success"][dat], CREATE_FIELD)
TO_REMOVE.append(res["success"][dat]["assiduite_id"])
check_fields(dat["message"], CREATE_FIELD)
TO_REMOVE.append(dat["message"]["assiduite_id"])
# Mauvais Fonctionnement
@ -321,13 +321,13 @@ def test_route_create(api_admin_headers):
assert len(res["errors"]) == 4
assert (
res["errors"]["0"]
res["errors"][0]["message"]
== "Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)"
)
assert res["errors"]["1"] == "param 'moduleimpl_id': invalide"
assert res["errors"]["2"] == "param 'etat': invalide"
assert res["errors"][1]["message"] == "param 'moduleimpl_id': invalide"
assert res["errors"][2]["message"] == "param 'etat': invalide"
assert (
res["errors"]["3"]
res["errors"][3]["message"]
== "param 'date_debut': format invalide, param 'date_fin': format invalide"
)
@ -367,7 +367,7 @@ def test_route_delete(api_admin_headers):
res = POST_JSON("/assiduite/delete", [data], api_admin_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
assert res["success"][dat] == {"OK": True}
assert dat["message"] == "OK"
# Mauvais fonctionnement
res = POST_JSON("/assiduite/delete", [data], api_admin_headers)
@ -383,7 +383,7 @@ def test_route_delete(api_admin_headers):
res = POST_JSON("/assiduite/delete", data, api_admin_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
assert res["success"][dat] == {"OK": True}
assert dat["message"] == "OK"
# Mauvais Fonctionnement
@ -397,4 +397,4 @@ def test_route_delete(api_admin_headers):
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 3
assert all([res["errors"][i] == "Assiduite non existante" for i in res["errors"]])
assert all(i["message"] == "Assiduite non existante" for i in res["errors"])

View File

@ -35,7 +35,7 @@ JUSTIFICATIFS_FIELDS = {
}
CREATE_FIELD = {"justif_id": int, "couverture": list}
BATCH_FIELD = {"errors": dict, "success": dict}
BATCH_FIELD = {"errors": list, "success": list}
TO_REMOVE = []
@ -172,14 +172,14 @@ def test_route_create(api_admin_headers):
check_fields(res, BATCH_FIELD)
assert len(res["success"]) == 1
TO_REMOVE.append(res["success"]["0"]["justif_id"])
TO_REMOVE.append(res["success"][0]["message"]["justif_id"])
data2 = create_data("modifie", "02", "raison")
res = POST_JSON(f"/justificatif/{ETUDID}/create", [data2], api_admin_headers)
check_fields(res, BATCH_FIELD)
assert len(res["success"]) == 1
TO_REMOVE.append(res["success"]["0"]["justif_id"])
TO_REMOVE.append(res["success"][0]["message"]["justif_id"])
# Mauvais fonctionnement
check_failure_post(f"/justificatif/{FAUX}/create", api_admin_headers, [data])
@ -191,7 +191,7 @@ def test_route_create(api_admin_headers):
)
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 1
assert res["errors"]["0"] == "param 'etat': invalide"
assert res["errors"][0]["message"] == "param 'etat': invalide"
# -== Multiple ==-
@ -206,8 +206,8 @@ def test_route_create(api_admin_headers):
res = POST_JSON(f"/justificatif/{ETUDID}/create", data, api_admin_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
check_fields(res["success"][dat], CREATE_FIELD)
TO_REMOVE.append(res["success"][dat]["justif_id"])
check_fields(dat["message"], CREATE_FIELD)
TO_REMOVE.append(dat["message"]["justif_id"])
# Mauvais Fonctionnement
@ -221,10 +221,10 @@ def test_route_create(api_admin_headers):
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 3
assert res["errors"]["0"] == "param 'etat': manquant"
assert res["errors"]["1"] == "param 'etat': invalide"
assert res["errors"][0]["message"] == "param 'etat': manquant"
assert res["errors"][1]["message"] == "param 'etat': invalide"
assert (
res["errors"]["2"]
res["errors"][2]["message"]
== "param 'date_debut': format invalide, param 'date_fin': format invalide"
)
@ -263,7 +263,7 @@ def test_route_delete(api_admin_headers):
res = POST_JSON("/justificatif/delete", [data], api_admin_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
assert res["success"][dat] == {"OK": True}
assert dat["message"] == "OK"
# Mauvais fonctionnement
res = POST_JSON("/justificatif/delete", [data], api_admin_headers)
@ -279,7 +279,7 @@ def test_route_delete(api_admin_headers):
res = POST_JSON("/justificatif/delete", data, api_admin_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
assert res["success"][dat] == {"OK": True}
assert dat["message"] == "OK"
# Mauvais Fonctionnement
@ -293,7 +293,7 @@ def test_route_delete(api_admin_headers):
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 3
assert all([res["errors"][i] == "Justificatif non existant" for i in res["errors"]])
assert all(i["message"] == "Justificatif non existant" for i in res["errors"])
# Gestion de l'archivage

View File

@ -121,15 +121,6 @@ class _Merger:
"entry_date": self.entry_date,
},
)
# retour = Justificatif.fast_create_justificatif(
# etudid=self.etudid,
# date_debut=date_deb,
# date_fin=date_fin,
# etat=EtatJustificatif.VALIDE,
# raison=self.raison,
# entry_date=self.entry_date,
# )
# return retour
def _to_assi(self):
date_deb = _Merger._tuple_to_date(self.deb)
@ -161,17 +152,6 @@ class _Merger:
},
)
# retour = Assiduite.fast_create_assiduite(
# etudid=self.etudid,
# date_debut=date_deb,
# date_fin=date_fin,
# etat=EtatAssiduite.ABSENT,
# moduleimpl_id=self.moduleimpl,
# description=self.raison,
# entry_date=self.entry_date,
# )
# return retour
def export(self):
"""Génère un nouvel objet Assiduité ou Justificatif"""
obj: Assiduite or Justificatif = None