Compare commits

..

19 Commits

Author SHA1 Message Date
5cd495e33e ajout export fichier par NIP 2023-12-03 00:17:16 +01:00
a5e5ad6248 Améliorations partielles de SignaleAssiduiteEtud 2023-11-29 18:07:14 +01:00
7cda427cac Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2023-11-29 17:15:27 +01:00
a0316c22e7 Fix: identite.etat_civil 2023-11-29 17:13:21 +01:00
2d3490e7fa Fix: nom ref. comp. BUT Info-Comm. 2023-11-28 17:16:40 +01:00
dcea12f6fc Table etud courants + export xlsx, + code nip 2023-11-26 19:04:03 +01:00
568c8681ba Accepte codage boursiers O/N (USPN), fonction de resynchro globale, table de tous les étudiants courants avec état boursier. 2023-11-26 18:28:56 +01:00
Iziram
cbb11d0e8e Merge branch 'liste_assi' into main96 2023-11-24 18:08:12 +01:00
Iziram
7a80ec3ce5 Assiduites : Tableaux (sans QOL) 2023-11-24 18:07:30 +01:00
Iziram
b13e751e1a Assiduites : WIP tableaux actions (sauf modifier) 2023-11-24 13:58:03 +01:00
60109bb513 Import etudiant: accepte boursier=O|N (API USPN) 2023-11-24 13:55:53 +01:00
e634b50d56 API: create/edit etudiant, admission, adresse 2023-11-23 17:08:18 +01:00
2377918b54 API: etudiant/create (WIP), refactoring. 2023-11-22 23:31:16 +01:00
532fb3e701 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2023-11-22 17:59:10 +01:00
457a9ddf51 Améliore code et tests gestion User 2023-11-22 17:55:15 +01:00
ea1a03a654 API: enrichit création/édition GroupDescr 2023-11-22 17:54:16 +01:00
e41879a1e1 Upgrade ReportLab to 4.0.7 2023-11-22 16:23:07 +01:00
4d3cbf7e75 API: enrichit création/édition User 2023-11-21 22:28:50 +01:00
939371cff9 complete message aide 2023-11-21 12:19:35 +01:00
65 changed files with 2178 additions and 792 deletions

View File

@ -7,7 +7,7 @@
"""
ScoDoc 9 API : accès aux départements
Note: les routes /departement[s] sont publiées sur l'API (/ScoDoc/api/),
Note: les routes /departement[s] sont publiées sur l'API (/ScoDoc/api/),
mais évidemment pas sur l'API web (/ScoDoc/<dept>/api).
"""
from datetime import datetime
@ -271,23 +271,11 @@ def dept_formsemestres_courants(acronym: str):
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
date_courante = request.args.get("date_courante")
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = app.db.func.now()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,
FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= test_date,
)
date_courante = datetime.fromisoformat(date_courante) if date_courante else None
return [
d.to_dict_api()
for d in formsemestres.order_by(
FormSemestre.date_debut.desc(),
FormSemestre.modalite,
FormSemestre.semestre_id,
FormSemestre.titre,
formsemestre.to_dict_api()
for formsemestre in FormSemestre.get_dept_formsemestres_courants(
dept, date_courante
)
]

View File

@ -18,20 +18,24 @@ from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import VARCHAR
import app
from app import db
from app.api import api_bp as bp, api_web_bp
from app.api import tools
from app.but import bulletin_but_court
from app.decorators import scodoc, permission_required
from app.models import (
Admission,
Adresse,
Departement,
FormSemestreInscription,
FormSemestre,
Identite,
ScolarNews,
)
from app.scodoc import sco_bulletins
from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc import sco_etud
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error, suppress_accents
@ -475,3 +479,112 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
data = sco_groups.get_etud_groups(etud.id, formsemestre.id)
return data
@bp.route("/etudiant/create", methods=["POST"], defaults={"force": False})
@bp.route("/etudiant/create/force", methods=["POST"], defaults={"force": True})
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def etudiant_create(force=False):
"""Création d'un nouvel étudiant
Si force, crée même si homonymie détectée.
L'étudiant créé n'est pas inscrit à un semestre.
Champs requis: nom, prenom (sauf si config sans prénom), dept (string:acronyme)
"""
args = request.get_json(force=True) # may raise 400 Bad Request
dept = args.get("dept", None)
if not dept:
return scu.json_error(400, "dept requis")
dept_o = Departement.query.filter_by(acronym=dept).first()
if not dept_o:
return scu.json_error(400, "dept invalide")
app.set_sco_dept(dept)
args["dept_id"] = dept_o.id
# vérifie que le département de création est bien autorisé
if not current_user.has_permission(Permission.EtudInscrit, dept):
return json_error(403, "departement non autorisé")
nom = args.get("nom", None)
prenom = args.get("prenom", None)
ok, homonyms = sco_etud.check_nom_prenom_homonyms(nom=nom, prenom=prenom)
if not ok:
return scu.json_error(400, "nom ou prénom invalide")
if len(homonyms) > 0 and not force:
return scu.json_error(
400, f"{len(homonyms)} homonymes détectés. Vous pouvez utiliser /force."
)
etud = Identite.create_etud(**args)
db.session.flush()
# --- Données admission
admission_args = args.get("admission", None)
if admission_args:
etud.admission.from_dict(admission_args)
# --- Adresse
adresses = args.get("adresses", [])
if adresses:
# ne prend en compte que la première adresse
# car si la base est concue pour avoir plusieurs adresses par étudiant,
# l'application n'en gère plus qu'une seule.
adresse = etud.adresses.first()
adresse.from_dict(adresses[0])
# Poste une nouvelle dans le département concerné:
ScolarNews.add(
typ=ScolarNews.NEWS_INSCR,
text=f"Nouvel étudiant {etud.html_link_fiche()}",
url=etud.url_fiche(),
max_frequency=0,
dept_id=dept_o.id,
)
db.session.commit()
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
db.session.refresh(etud)
r = etud.to_dict_api()
return r
@bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
@scodoc
@permission_required(Permission.EtudInscrit)
def etudiant_edit(
code_type: str = "etudid",
code: str = None,
):
"""Edition des données étudiant (identité, admission, adresses)"""
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code)
else:
return json_error(404, "invalid code_type")
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first()
#
args = request.get_json(force=True) # may raise 400 Bad Request
etud.from_dict(args)
admission_args = args.get("admission", None)
if admission_args:
etud.admission.from_dict(admission_args)
# --- Adresse
adresses = args.get("adresses", [])
if adresses:
# ne prend en compte que la première adresse
# car si la base est concue pour avoir plusieurs adresses par étudiant,
# l'application n'en gère plus qu'une seule.
adresse = etud.adresses.first()
adresse.from_dict(adresses[0])
db.session.commit()
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
db.session.refresh(etud)
r = etud.to_dict_api()
return r

View File

@ -646,8 +646,8 @@ def justif_import(justif_id: int = None):
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"])
@scodoc
@login_required
@permission_required(Permission.AbsChange)

View File

@ -303,15 +303,19 @@ def group_create(partition_id: int): # partition-group-create
return json_error(403, "partition non editable")
if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is None:
return json_error(API_CLIENT_ERROR, "missing group name or invalid data format")
if not GroupDescr.check_name(partition, group_name):
return json_error(API_CLIENT_ERROR, "invalid group_name")
group_name = group_name.strip()
group = GroupDescr(group_name=group_name, partition_id=partition_id)
args = request.get_json(force=True) # may raise 400 Bad Request
group_name = args.get("group_name")
if not isinstance(group_name, str):
return json_error(API_CLIENT_ERROR, "missing group name or invalid data format")
args["group_name"] = args["group_name"].strip()
if not GroupDescr.check_name(partition, args["group_name"]):
return json_error(API_CLIENT_ERROR, "invalid group_name")
args["partition_id"] = partition_id
try:
group = GroupDescr(**args)
except TypeError:
return json_error(API_CLIENT_ERROR, "invalid arguments")
db.session.add(group)
db.session.commit()
log(f"created group {group}")
@ -369,16 +373,22 @@ def group_edit(group_id: int):
return json_error(403, "partition non editable")
if not group.partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is not None:
group_name = group_name.strip()
if not GroupDescr.check_name(group.partition, group_name, existing=True):
args = request.get_json(force=True) # may raise 400 Bad Request
if "group_name" in args:
if not isinstance(args["group_name"], str):
return json_error(API_CLIENT_ERROR, "invalid data format for group_name")
args["group_name"] = args["group_name"].strip() if args["group_name"] else ""
if not GroupDescr.check_name(
group.partition, args["group_name"], existing=True
):
return json_error(API_CLIENT_ERROR, "invalid group_name")
group.group_name = group_name
db.session.add(group)
db.session.commit()
log(f"modified {group}")
group.from_dict(args)
db.session.add(group)
db.session.commit()
log(f"modified {group}")
app.set_sco_dept(group.partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return group.to_dict(with_partition=True)

View File

@ -7,7 +7,7 @@
"""
ScoDoc 9 API : accès aux utilisateurs
"""
import datetime
from flask import g, request
from flask_json import as_json
@ -85,6 +85,20 @@ def users_info_query():
return [user.to_dict() for user in query]
def _is_allowed_user_edit(args: dict) -> tuple[bool, str]:
"Vrai si on peut"
if "cas_id" in args and not current_user.has_permission(
Permission.UsersChangeCASId
):
return False, "non autorise a changer cas_id"
if not current_user.is_administrator():
for field in ("cas_allow_login", "cas_allow_scodoc_login"):
if field in args:
return False, f"non autorise a changer {field}"
return True, ""
@bp.route("/user/create", methods=["POST"])
@api_web_bp.route("/user/create", methods=["POST"])
@login_required
@ -95,21 +109,22 @@ def user_create():
"""Création d'un utilisateur
The request content type should be "application/json":
{
"user_name": str,
"active":bool (default True),
"dept": str or null,
"nom": str,
"prenom": str,
"active":bool (default True)
"user_name": str,
...
}
"""
data = request.get_json(force=True) # may raise 400 Bad Request
user_name = data.get("user_name")
args = request.get_json(force=True) # may raise 400 Bad Request
user_name = args.get("user_name")
if not user_name:
return json_error(404, "empty user_name")
user = User.query.filter_by(user_name=user_name).first()
if user:
return json_error(404, f"user_create: user {user} already exists\n")
dept = data.get("dept")
dept = args.get("dept")
if dept == "@all":
dept = None
allowed_depts = current_user.get_depts_with_permission(Permission.UsersAdmin)
@ -119,10 +134,12 @@ def user_create():
Departement.query.filter_by(acronym=dept).first() is None
):
return json_error(404, "user_create: departement inexistant")
nom = data.get("nom")
prenom = data.get("prenom")
active = scu.to_bool(data.get("active", True))
user = User(user_name=user_name, active=active, dept=dept, nom=nom, prenom=prenom)
args["dept"] = dept
ok, msg = _is_allowed_user_edit(args)
if not ok:
return json_error(403, f"user_create: {msg}")
user = User(user_name=user_name)
user.from_dict(args, new_user=True)
db.session.add(user)
db.session.commit()
return user.to_dict()
@ -142,13 +159,14 @@ def user_edit(uid: int):
"nom": str,
"prenom": str,
"active":bool
...
}
"""
data = request.get_json(force=True) # may raise 400 Bad Request
args = request.get_json(force=True) # may raise 400 Bad Request
user: User = User.query.get_or_404(uid)
# L'utilisateur doit avoir le droit dans le département de départ et celui d'arrivée
orig_dept = user.dept
dest_dept = data.get("dept", False)
dest_dept = args.get("dept", False)
if dest_dept is not False:
if dest_dept == "@all":
dest_dept = None
@ -164,10 +182,11 @@ def user_edit(uid: int):
return json_error(404, "user_edit: departement inexistant")
user.dept = dest_dept
user.nom = data.get("nom", user.nom)
user.prenom = data.get("prenom", user.prenom)
user.active = scu.to_bool(data.get("active", user.active))
ok, msg = _is_allowed_user_edit(args)
if not ok:
return json_error(403, f"user_edit: {msg}")
user.from_dict(args)
db.session.add(user)
db.session.commit()
return user.to_dict()

View File

@ -12,7 +12,6 @@ from typing import Optional
import cracklib # pylint: disable=import-error
import flask
from flask import current_app, g
from flask_login import UserMixin, AnonymousUserMixin
@ -21,14 +20,13 @@ from werkzeug.security import generate_password_hash, check_password_hash
import jwt
from app import db, email, log, login
from app.models import Departement
from app.models import Departement, ScoDocModel
from app.models import SHORT_STR_LEN, USERNAME_STR_LEN
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
import app.scodoc.sco_utils as scu
from app.scodoc import sco_etud # a deplacer dans scu
VALID_LOGIN_EXP = re.compile(r"^[a-zA-Z0-9@\\\-_\.]+$")
@ -53,13 +51,14 @@ def is_valid_password(cleartxt) -> bool:
def invalid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is invalid"
return (
(len(user_name) < 2)
not user_name
or (len(user_name) < 2)
or (len(user_name) >= USERNAME_STR_LEN)
or not VALID_LOGIN_EXP.match(user_name)
)
class User(UserMixin, db.Model):
class User(UserMixin, db.Model, ScoDocModel):
"""ScoDoc users, handled by Flask / SQLAlchemy"""
id = db.Column(db.Integer, primary_key=True)
@ -116,12 +115,17 @@ class User(UserMixin, db.Model):
)
def __init__(self, **kwargs):
"user_name:str is mandatory"
self.roles = []
self.user_roles = []
# check login:
if kwargs.get("user_name") and invalid_user_name(kwargs["user_name"]):
if not "user_name" in kwargs:
raise ValueError("missing user_name argument")
if invalid_user_name(kwargs["user_name"]):
raise ValueError(f"invalid user_name: {kwargs['user_name']}")
super(User, self).__init__(**kwargs)
kwargs["nom"] = kwargs.get("nom", "") or ""
kwargs["prenom"] = kwargs.get("prenom", "") or ""
super().__init__(**kwargs)
# Ajoute roles:
if (
not self.roles
@ -251,12 +255,13 @@ class User(UserMixin, db.Model):
"cas_last_login": self.cas_last_login.isoformat() + "Z"
if self.cas_last_login
else None,
"edt_id": self.edt_id,
"status_txt": "actif" if self.active else "fermé",
"last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None,
"nom": (self.nom or ""), # sco8
"prenom": (self.prenom or ""), # sco8
"nom": self.nom or "",
"prenom": self.prenom or "",
"roles_string": self.get_roles_string(), # eg "Ens_RT, Ens_Info"
"user_name": self.user_name, # sco8
"user_name": self.user_name,
# Les champs calculés:
"nom_fmt": self.get_nom_fmt(),
"prenom_fmt": self.get_prenom_fmt(),
@ -270,37 +275,50 @@ class User(UserMixin, db.Model):
data["email_institutionnel"] = self.email_institutionnel or ""
return data
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect.
args: dict with args in application.
returns: dict to store in model's db.
Convert boolean values to bools.
"""
args_dict = args
# Dates
if "date_expiration" in args:
date_expiration = args.get("date_expiration")
if isinstance(date_expiration, str):
args["date_expiration"] = (
datetime.datetime.fromisoformat(date_expiration)
if date_expiration
else None
)
# booléens:
for field in ("active", "cas_allow_login", "cas_allow_scodoc_login"):
if field in args:
args_dict[field] = scu.to_bool(args.get(field))
# chaines ne devant pas être NULLs
for field in ("nom", "prenom"):
if field in args:
args[field] = args[field] or ""
return args_dict
def from_dict(self, data: dict, new_user=False):
"""Set users' attributes from given dict values.
Roles must be encoded as "roles_string", like "Ens_RT, Secr_CJ"
- roles_string : roles, encoded like "Ens_RT, Secr_CJ"
- date_expiration is a dateime object.
Does not check permissions here.
"""
for field in [
"nom",
"prenom",
"dept",
"active",
"email",
"email_institutionnel",
"date_expiration",
"cas_id",
]:
if field in data:
setattr(self, field, data[field] or None)
# required boolean fields
for field in [
"cas_allow_login",
"cas_allow_scodoc_login",
]:
setattr(self, field, scu.to_bool(data.get(field, False)))
if new_user:
if "user_name" in data:
# never change name of existing users
if invalid_user_name(data["user_name"]):
raise ValueError(f"invalid user_name: {data['user_name']}")
self.user_name = data["user_name"]
if "password" in data:
self.set_password(data["password"])
if invalid_user_name(self.user_name):
raise ValueError(f"invalid user_name: {self.user_name}")
# Roles: roles_string is "Ens_RT, Secr_RT, ..."
if "roles_string" in data:
self.user_roles = []
@ -309,6 +327,8 @@ class User(UserMixin, db.Model):
role, dept = UserRole.role_dept_from_string(r_d)
self.add_role(role, dept)
super().from_dict(data, excluded={"user_name", "roles_string", "roles"})
# Set cas_id using regexp if configured:
exp = ScoDocSiteConfig.get("cas_uid_from_mail_regexp")
if exp and self.email_institutionnel:
@ -441,8 +461,8 @@ class User(UserMixin, db.Model):
"""nomplogin est le nom en majuscules suivi du prénom et du login
e.g. Dupont Pierre (dupont)
"""
nom = sco_etud.format_nom(self.nom) if self.nom else self.user_name.upper()
return f"{nom} {sco_etud.format_prenom(self.prenom)} ({self.user_name})"
nom = scu.format_nom(self.nom) if self.nom else self.user_name.upper()
return f"{nom} {scu.format_prenom(self.prenom)} ({self.user_name})"
@staticmethod
def get_user_id_from_nomplogin(nomplogin: str) -> Optional[int]:
@ -460,29 +480,29 @@ class User(UserMixin, db.Model):
def get_nom_fmt(self):
"""Nom formaté: "Martin" """
if self.nom:
return sco_etud.format_nom(self.nom, uppercase=False)
return scu.format_nom(self.nom, uppercase=False)
else:
return self.user_name
def get_prenom_fmt(self):
"""Prénom formaté (minuscule capitalisées)"""
return sco_etud.format_prenom(self.prenom)
return scu.format_prenom(self.prenom)
def get_nomprenom(self):
"""Nom capitalisé suivi de l'initiale du prénom:
Viennet E.
"""
prenom_abbrv = scu.abbrev_prenom(sco_etud.format_prenom(self.prenom))
prenom_abbrv = scu.abbrev_prenom(scu.format_prenom(self.prenom))
return (self.get_nom_fmt() + " " + prenom_abbrv).strip()
def get_prenomnom(self):
"""L'initiale du prénom suivie du nom: "J.-C. Dupont" """
prenom_abbrv = scu.abbrev_prenom(sco_etud.format_prenom(self.prenom))
prenom_abbrv = scu.abbrev_prenom(scu.format_prenom(self.prenom))
return (prenom_abbrv + " " + self.get_nom_fmt()).strip()
def get_nomcomplet(self):
"Prénom et nom complets"
return sco_etud.format_prenom(self.prenom) + " " + self.get_nom_fmt()
return scu.format_prenom(self.prenom) + " " + self.get_nom_fmt()
# nomnoacc était le nom en minuscules sans accents (inutile)

View File

@ -6,6 +6,7 @@ from flask import Blueprint
from app.scodoc import sco_etud
from app.auth.models import User
from app.models import Departement
import app.scodoc.sco_utils as scu
bp = Blueprint("entreprises", __name__)
@ -15,12 +16,12 @@ SIRET_PROVISOIRE_START = "xx"
@bp.app_template_filter()
def format_prenom(s):
return sco_etud.format_prenom(s)
return scu.format_prenom(s)
@bp.app_template_filter()
def format_nom(s):
return sco_etud.format_nom(s)
return scu.format_nom(s)
@bp.app_template_filter()

View File

@ -1580,8 +1580,8 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
)
)
elif request.method == "GET":
form.etudiant.data = f"""{sco_etud.format_nom(etudiant.nom)} {
sco_etud.format_prenom(etudiant.prenom)}"""
form.etudiant.data = f"""{scu.format_nom(etudiant.nom)} {
scu.format_prenom(etudiant.prenom)}"""
form.etudid.data = etudiant.id
form.type_offre.data = stage_apprentissage.type_offre
form.date_debut.data = stage_apprentissage.date_debut
@ -1699,7 +1699,7 @@ def json_etudiants():
list = []
for etudiant in etudiants:
content = {}
value = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}"
value = f"{scu.format_nom(etudiant.nom)} {scu.format_prenom(etudiant.prenom)}"
if etudiant.inscription_courante() is not None:
content = {
"id": f"{etudiant.id}",

View File

@ -53,8 +53,9 @@ class ScoDocModel:
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
By default, excluded == { 'id' }"""
excluded = {"id"} if excluded is None else set()
Add 'id' to excluded."""
excluded = excluded or set()
excluded.add("id") # always exclude id
# Les attributs du modèle qui sont des variables: (élimine les __ et les alias comme adm_id)
my_attributes = [
a
@ -70,7 +71,7 @@ class ScoDocModel:
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No side effect.
"""Convert fields from the given dict to model's attributes values. No side effect.
By default, do nothing, but is overloaded by some subclasses.
args: dict with args in application.
returns: dict to store in model's db.
@ -78,9 +79,11 @@ class ScoDocModel:
# virtual, by default, do nothing
return args
def from_dict(self, args: dict):
def from_dict(self, args: dict, excluded: set[str] | None = None):
"Update object's fields given in dict. Add to session but don't commit."
args_dict = self.convert_dict_fields(self.filter_model_attributes(args))
args_dict = self.convert_dict_fields(
self.filter_model_attributes(args, excluded=excluded)
)
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
@ -130,7 +133,6 @@ from app.models.notes import (
NotesNotesLog,
)
from app.models.validations import (
ScolarEvent,
ScolarFormSemestreValidation,
ScolarAutorisationInscription,
)
@ -149,3 +151,4 @@ from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.config import ScoDocSiteConfig
from app.models.assiduites import Assiduite, Justificatif
from app.models.scolar_event import ScolarEvent

View File

@ -3,8 +3,8 @@
"""
from datetime import datetime
from app import db, log
from app.models import ModuleImpl, Scolog, FormSemestre, FormSemestreInscription
from app import db, log, g
from app.models import ModuleImpl, Module, Scolog, FormSemestre, FormSemestreInscription
from app.models.etudiants import Identite
from app.auth.models import User
from app.scodoc import sco_abs_notification
@ -204,6 +204,77 @@ class Assiduite(db.Model):
sco_abs_notification.abs_notify(etud.id, nouv_assiduite.date_debut)
return nouv_assiduite
def set_moduleimpl(self, moduleimpl_id: int | str) -> bool:
moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
if moduleimpl is not None:
# Vérification de l'inscription de l'étudiant
if moduleimpl.est_inscrit(self.etudiant):
self.moduleimpl_id = moduleimpl.id
else:
raise ScoValueError("L'étudiant n'est pas inscrit au module")
elif isinstance(moduleimpl_id, str):
if self.external_data is None:
self.external_data = {"module": moduleimpl_id}
else:
self.external_data["module"] = moduleimpl_id
self.moduleimpl_id = None
else:
# Vérification si module forcé
formsemestre: FormSemestre = get_formsemestre_from_data(
{
"etudid": self.etudid,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
}
)
force: bool
if formsemestre:
force = is_assiduites_module_forced(formsemestre_id=formsemestre.id)
else:
force = is_assiduites_module_forced(dept_id=etud.dept_id)
if force:
raise ScoValueError("Module non renseigné")
return True
def supprimer(self):
from app.scodoc import sco_assiduites as scass
if g.scodoc_dept is None and self.etudiant.dept_id is not None:
# route sans département
set_sco_dept(self.etudiant.departement.acronym)
obj_dict: dict = self.to_dict()
# Suppression de l'objet et LOG
log(f"delete_assidutite: {self.etudiant.id} {self}")
Scolog.logdb(
method=f"delete_assiduite",
etudid=self.etudiant.id,
msg=f"Assiduité: {self}",
)
db.session.delete(self)
# Invalidation du cache
scass.simple_invalidate_cache(obj_dict)
def get_formsemestre(self) -> FormSemestre:
return get_formsemestre_from_data(self.to_dict())
def get_module(self, traduire: bool = False) -> int | str:
if self.moduleimpl_id is not None:
if traduire:
modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id)
mod: Module = Module.query.get(modimpl.module_id)
return f"{mod.code} {mod.titre}"
elif self.external_data is not None and "module" in self.external_data:
return (
"Tout module"
if self.external_data["module"] == "Autre"
else self.external_data["module"]
)
return "Non spécifié" if traduire else None
class Justificatif(db.Model):
"""
@ -334,6 +405,39 @@ class Justificatif(db.Model):
)
return nouv_justificatif
def supprimer(self):
from app.scodoc import sco_assiduites as scass
# Récupération de l'archive du justificatif
archive_name: str = self.fichier
if archive_name is not None:
# Si elle existe : on essaye de la supprimer
archiver: JustificatifArchiver = JustificatifArchiver()
try:
archiver.delete_justificatif(self.etudiant, archive_name)
except ValueError:
pass
if g.scodoc_dept is None and self.etudiant.dept_id is not None:
# route sans département
set_sco_dept(self.etudiant.departement.acronym)
# On invalide le cache
scass.simple_invalidate_cache(self.to_dict())
# Suppression de l'objet et LOG
log(f"delete_justificatif: {self.etudiant.id} {self}")
Scolog.logdb(
method=f"delete_justificatif",
etudid=self.etudiant.id,
msg=f"Justificatif: {self}",
)
db.session.delete(self)
# On actualise les assiduités justifiées de l'étudiant concerné
compute_assiduites_justified(
self.etudid,
Justificatif.query.filter_by(etudid=self.etudid).all(),
True,
)
def is_period_conflicting(
date_debut: datetime,

View File

@ -15,7 +15,8 @@ from sqlalchemy import desc, text
from app import db, log
from app import models
from app.models.departements import Departement
from app.models.scolar_event import ScolarEvent
from app.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat
from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
@ -170,9 +171,13 @@ class Identite(db.Model, models.ScoDocModel):
def html_link_fiche(self) -> str:
"lien vers la fiche"
return f"""<a class="stdlink" href="{
url_for("scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id)
}">{self.nomprenom}</a>"""
return f"""<a class="stdlink" href="{self.url_fiche()}">{self.nomprenom}</a>"""
def url_fiche(self) -> str:
"url de la fiche étudiant"
return url_for(
"scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id
)
@classmethod
def from_request(cls, etudid=None, code_nip=None) -> "Identite":
@ -200,19 +205,39 @@ class Identite(db.Model, models.ScoDocModel):
return cls.create_from_dict(args)
@classmethod
def create_from_dict(cls, data) -> "Identite":
def create_from_dict(cls, args) -> "Identite":
"""Crée un étudiant à partir d'un dict, avec admission et adresse vides.
If required dept_id or dept are not specified, set it to the current dept.
args: dict with args in application.
Les clés adresses et admission ne SONT PAS utilisées.
(added to session but not flushed nor commited)
"""
etud: Identite = super(cls, cls).create_from_dict(data)
if (data.get("admission_id", None) is None) and (
data.get("admission", None) is None
):
if not "dept_id" in args:
if "dept" in args:
departement = Departement.query.filter_by(acronym=args["dept"]).first()
if departement:
args["dept_id"] = departement.id
if not "dept_id" in args:
args["dept_id"] = g.scodoc_dept_id
etud: Identite = super().create_from_dict(args)
if args.get("admission_id", None) is None:
etud.admission = Admission()
etud.adresses.append(Adresse(typeadresse="domicile"))
db.session.flush()
event = ScolarEvent(etud=etud, event_type="CREATION")
db.session.add(event)
log(f"Identite.create {etud}")
return etud
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded."""
return super().filter_model_attributes(
data,
excluded=(excluded or set()) | {"adresses", "admission", "departement"},
)
@property
def civilite_str(self) -> str:
"""returns civilité usuelle: 'M.' ou 'Mme' ou '' (pour le genre neutre,
@ -259,14 +284,13 @@ class Identite(db.Model, models.ScoDocModel):
def nomprenom(self, reverse=False) -> str:
"""Civilité/nom/prenom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
Prend l'identité courant et non celle de l'état civile si elles diffèrent.
"""
nom = self.nom_usuel or self.nom
prenom = self.prenom_str
if reverse:
fields = (nom, prenom)
else:
fields = (self.civilite_str, prenom, nom)
return " ".join([x for x in fields if x])
return f"{nom} {prenom}".strip()
return f"{self.civilite_str} {prenom} {nom}".strip()
@property
def prenom_str(self):
@ -282,12 +306,10 @@ class Identite(db.Model, models.ScoDocModel):
@property
def etat_civil(self) -> str:
"M. Prénom NOM, utilisant les données état civil si présentes, usuelles sinon."
if self.prenom_etat_civil:
civ = {"M": "M.", "F": "Mme", "X": ""}[self.civilite_etat_civil]
return f"{civ} {self.prenom_etat_civil} {self.nom}"
else:
return self.nomprenom
"M. PRÉNOM NOM, utilisant les données état civil si présentes, usuelles sinon."
return f"""{self.civilite_etat_civil_str} {
self.prenom_etat_civil or self.prenom or ''} {
self.nom or ''}""".strip()
@property
def nom_short(self):
@ -321,8 +343,6 @@ class Identite(db.Model, models.ScoDocModel):
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect.
If required dept_id is not specified, set it to the current dept.
args: dict with args in application.
returns: dict to store in model's db.
"""
# Les champs qui sont toujours stockés en majuscules:
@ -341,8 +361,6 @@ class Identite(db.Model, models.ScoDocModel):
"code_ine",
}
args_dict = {}
if not "dept_id" in args:
args["dept_id"] = g.scodoc_dept_id
for key, value in args.items():
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
# compat scodoc7 (mauvaise idée de l'époque)
@ -355,7 +373,7 @@ class Identite(db.Model, models.ScoDocModel):
elif key == "civilite_etat_civil":
value = input_civilite_etat_civil(value)
elif key == "boursier":
value = bool(value)
value = scu.to_bool(value)
elif key == "date_naissance":
value = ndb.DateDMYtoISO(value)
args_dict[key] = value

View File

@ -133,7 +133,7 @@ class ScolarNews(db.Model):
return query.order_by(cls.date.desc()).limit(n).all()
@classmethod
def add(cls, typ, obj=None, text="", url=None, max_frequency=600):
def add(cls, typ, obj=None, text="", url=None, max_frequency=600, dept_id=None):
"""Enregistre une nouvelle
Si max_frequency, ne génère pas 2 nouvelles "identiques"
à moins de max_frequency secondes d'intervalle (10 minutes par défaut).
@ -141,10 +141,11 @@ class ScolarNews(db.Model):
même (obj, typ, user).
La nouvelle enregistrée est aussi envoyée par mail.
"""
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
if max_frequency:
last_news = (
cls.query.filter_by(
dept_id=g.scodoc_dept_id,
dept_id=dept_id,
authenticated_user=current_user.user_name,
type=typ,
object=obj,
@ -163,7 +164,7 @@ class ScolarNews(db.Model):
return
news = ScolarNews(
dept_id=g.scodoc_dept_id,
dept_id=dept_id,
authenticated_user=current_user.user_name,
type=typ,
object=obj,

View File

@ -30,6 +30,7 @@ from app.models.but_refcomp import (
parcours_formsemestre,
)
from app.models.config import ScoDocSiteConfig
from app.models.departements import Departement
from app.models.etudiants import Identite
from app.models.evaluations import Evaluation
from app.models.formations import Formation
@ -521,7 +522,7 @@ class FormSemestre(db.Model):
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
jour_pivot_annee=1,
jour_pivot_periode=1,
):
) -> tuple[int, int]:
"""Calcule la session associée à un formsemestre commençant en date_debut
sous la forme (année, période)
année: première année de l'année scolaire
@ -571,6 +572,26 @@ class FormSemestre(db.Model):
mois_pivot_periode=ScoDocSiteConfig.get_month_debut_periode2(),
)
@classmethod
def get_dept_formsemestres_courants(
cls, dept: Departement, date_courante: datetime.datetime | None = None
) -> db.Query:
"""Liste (query) ordonnée des formsemestres courants, c'est
à dire contenant la date courant (si None, la date actuelle)"""
date_courante = date_courante or db.func.now()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,
FormSemestre.date_debut <= date_courante,
FormSemestre.date_fin >= date_courante,
)
return formsemestres.order_by(
FormSemestre.date_debut.desc(),
FormSemestre.modalite,
FormSemestre.semestre_id,
FormSemestre.titre,
)
def etapes_apo_vdi(self) -> list[ApoEtapeVDI]:
"Liste des vdis"
# was read_formsemestre_etapes

View File

@ -11,14 +11,14 @@ from operator import attrgetter
from sqlalchemy.exc import IntegrityError
from app import db, log
from app.models import Scolog, GROUPNAME_STR_LEN, SHORT_STR_LEN
from app.models import ScoDocModel, Scolog, GROUPNAME_STR_LEN, SHORT_STR_LEN
from app.models.etudiants import Identite
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
class Partition(db.Model):
class Partition(db.Model, ScoDocModel):
"""Partition: découpage d'une promotion en groupes"""
__table_args__ = (db.UniqueConstraint("formsemestre_id", "partition_name"),)
@ -204,7 +204,7 @@ class Partition(db.Model):
return group
class GroupDescr(db.Model):
class GroupDescr(db.Model, ScoDocModel):
"""Description d'un groupe d'une partition"""
__tablename__ = "group_descr"

View File

@ -0,0 +1,48 @@
"""évènements scolaires dans la vie d'un étudiant(inscription, ...)
"""
from app import db
from app.models import SHORT_STR_LEN
class ScolarEvent(db.Model):
"""Evenement dans le parcours scolaire d'un étudiant"""
__tablename__ = "scolar_events"
id = db.Column(db.Integer, primary_key=True)
event_id = db.synonym("id")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
)
event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id", ondelete="SET NULL"),
)
ue_id = db.Column(
db.Integer,
db.ForeignKey("notes_ue.id", ondelete="SET NULL"),
)
# 'CREATION', 'INSCRIPTION', 'DEMISSION',
# 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM'
# 'ECHEC_SEM'
# 'UTIL_COMPENSATION'
event_type = db.Column(db.String(SHORT_STR_LEN))
# Semestre compensé par formsemestre_id:
comp_formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
)
etud = db.relationship("Identite", lazy="select", backref="events", uselist=False)
formsemestre = db.relationship(
"FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
)
def to_dict(self) -> dict:
"as a dict"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
return d
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.event_type}, {self.event_date.isoformat()}, {self.formsemestre})"

View File

@ -1,6 +1,6 @@
# -*- coding: UTF-8 -*
"""Notes, décisions de jury, évènements scolaires
"""Notes, décisions de jury
"""
from app import db
@ -218,47 +218,3 @@ class ScolarAutorisationInscription(db.Model):
msg=f"Passage vers S{autorisation.semestre_id}: effacé",
)
db.session.flush()
class ScolarEvent(db.Model):
"""Evenement dans le parcours scolaire d'un étudiant"""
__tablename__ = "scolar_events"
id = db.Column(db.Integer, primary_key=True)
event_id = db.synonym("id")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
)
event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id", ondelete="SET NULL"),
)
ue_id = db.Column(
db.Integer,
db.ForeignKey("notes_ue.id", ondelete="SET NULL"),
)
# 'CREATION', 'INSCRIPTION', 'DEMISSION',
# 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM'
# 'ECHEC_SEM'
# 'UTIL_COMPENSATION'
event_type = db.Column(db.String(SHORT_STR_LEN))
# Semestre compensé par formsemestre_id:
comp_formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
)
etud = db.relationship("Identite", lazy="select", backref="events", uselist=False)
formsemestre = db.relationship(
"FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
)
def to_dict(self) -> dict:
"as a dict"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
return d
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.event_type}, {self.event_date.isoformat()}, {self.formsemestre})"

View File

@ -149,21 +149,49 @@ def index_html(showcodes=0, showsemtable=0):
</p>"""
)
#
if current_user.has_permission(Permission.EtudInscrit):
H.append(
"""<hr>
H.append(
"""<hr>
<h3>Gestion des étudiants</h3>
<ul>
<li><a class="stdlink" href="etudident_create_form">créer <em>un</em> nouvel étudiant</a>
"""
)
if current_user.has_permission(Permission.EtudInscrit):
H.append(
f"""
<li><a class="stdlink" href="{
url_for("scolar.etudident_create_form", scodoc_dept=g.scodoc_dept)
}">créer <em>un</em> nouvel étudiant</a>
</li>
<li><a class="stdlink" href="form_students_import_excel">importer de nouveaux étudiants</a>
(ne pas utiliser sauf cas particulier, utilisez plutôt le lien dans
<li><a class="stdlink" href="{
url_for("scolar.form_students_import_excel", scodoc_dept=g.scodoc_dept)
}">importer de nouveaux étudiants</a>
(<em>ne pas utiliser</em> sauf cas particulier&nbsp;: utilisez plutôt le lien dans
le tableau de bord semestre si vous souhaitez inscrire les
étudiants importés à un semestre)
</li>
</ul>
"""
)
H.append(
f"""
<li><a class="stdlink" href="{
url_for("scolar.export_etudiants_courants", scodoc_dept=g.scodoc_dept)
}">exporter tableau des étudiants des semestres en cours</a>
</li>
"""
)
if current_user.has_permission(
Permission.EtudInscrit
) and sco_preferences.get_preference("portal_url"):
H.append(
f"""
<li><a class="stdlink" href="{
url_for("scolar.formsemestre_import_etud_admission",
scodoc_dept=g.scodoc_dept, tous_courants=1)
}">resynchroniser les données étudiants des semestres en cours depuis le portail</a>
</li>
"""
)
H.append("</ul>")
#
if current_user.has_permission(Permission.EditApogee):
H.append(

View File

@ -45,6 +45,12 @@ from app.models.etudiants import (
pivot_year,
)
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import (
format_civilite,
format_nom,
format_nomprenom,
format_prenom,
)
import app.scodoc.notesdb as ndb
from app.scodoc.sco_exceptions import ScoGenError, ScoValueError
from app.scodoc import safehtml
@ -102,60 +108,6 @@ def force_uppercase(s):
return s.upper() if s else s
def format_nomprenom(etud, reverse=False):
"""Formatte civilité/nom/prenom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
DEPRECATED: utiliser Identite.nomprenom
"""
nom = etud.get("nom_disp", "") or etud.get("nom_usuel", "") or etud["nom"]
prenom = format_prenom(etud["prenom"])
civilite = format_civilite(etud["civilite"])
if reverse:
fs = [nom, prenom]
else:
fs = [civilite, prenom, nom]
return " ".join([x for x in fs if x])
def format_prenom(s):
"""Formatte prenom etudiant pour affichage
DEPRECATED: utiliser Identite.prenom_str
"""
if not s:
return ""
frags = s.split()
r = []
for frag in frags:
fs = frag.split("-")
r.append("-".join([x.lower().capitalize() for x in fs]))
return " ".join(r)
def format_nom(s, uppercase=True):
if not s:
return ""
if uppercase:
return s.upper()
else:
return format_prenom(s)
def format_civilite(civilite):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
personne ne souhaitant pas d'affichage).
Raises ScoValueError if conversion fails.
"""
try:
return {
"M": "M.",
"F": "Mme",
"X": "",
}[civilite]
except KeyError as exc:
raise ScoValueError(f"valeur invalide pour la civilité: {civilite}") from exc
def _format_etat_civil(etud: dict) -> str:
"Mme Béatrice DUPONT, en utilisant les données d'état civil si indiquées."
if etud["prenom_etat_civil"] or etud["civilite_etat_civil"]:
@ -657,16 +609,6 @@ def create_etud(cnx, args: dict = None):
db.session.commit()
etudid = etud.id
# event
scolar_events_create(
cnx,
args={
"etudid": etudid,
"event_date": time.strftime("%d/%m/%Y"),
"formsemestre_id": None,
"event_type": "CREATION",
},
)
# log
logdb(
cnx,
@ -674,16 +616,18 @@ def create_etud(cnx, args: dict = None):
etudid=etudid,
msg="creation initiale",
)
etud = etudident_list(cnx, {"etudid": etudid})[0]
fill_etuds_info([etud])
etud["url"] = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
etud_dict = etudident_list(cnx, {"etudid": etudid})[0]
fill_etuds_info([etud_dict])
etud_dict["url"] = url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid
)
ScolarNews.add(
typ=ScolarNews.NEWS_INSCR,
text='Nouvel étudiant <a href="%(url)s">%(nomprenom)s</a>' % etud,
url=etud["url"],
text=f"Nouvel étudiant {etud.html_link_fiche()}",
url=etud_dict["url"],
max_frequency=0,
)
return etud
return etud_dict
# ---------- "EVENTS"

View File

@ -40,6 +40,8 @@ from openpyxl.comments import Comment
from openpyxl import Workbook, load_workbook
from openpyxl.cell import WriteOnlyCell
from openpyxl.styles import Font, Border, Side, Alignment, PatternFill
from openpyxl.utils import quote_sheetname, absolute_coordinate
from openpyxl.workbook.defined_name import DefinedName
from openpyxl.worksheet.worksheet import Worksheet
import app.scodoc.sco_utils as scu
@ -218,6 +220,7 @@ class ScoExcelSheet:
self.rows = [] # list of list of cells
self.column_dimensions = {}
self.row_dimensions = {}
self.formulae = {}
def excel_make_composite_style(
self,
@ -363,6 +366,9 @@ class ScoExcelSheet:
"""ajoute une ligne déjà construite à la feuille."""
self.rows.append(row)
def set_formula(self, coord, formula):
self.formulae[coord] = formula
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)
@ -383,6 +389,8 @@ class ScoExcelSheet:
# construction d'un flux (https://openpyxl.readthedocs.io/en/stable/tutorial.html#saving-as-a-stream)
self.prepare()
for coord, formula in self.formulae.items():
self.ws[coord] = formula
with NamedTemporaryFile() as tmp:
self.wb.save(tmp.name)
tmp.seek(0)
@ -433,7 +441,12 @@ def excel_simple_table(
return ws.generate()
def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, lines):
from openpyxl.utils import absolute_coordinate
def excel_feuille_saisie(
evaluation: "Evaluation", titreannee, description, lines, withnips=False
):
"""Genere feuille excel pour saisie des notes.
E: evaluation (dict)
lines: liste de tuples
@ -450,6 +463,13 @@ def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, line
ws.set_column_dimension_width("D", 164.0 / 7) # groupes
ws.set_column_dimension_width("E", 115.0 / 7) # notes
ws.set_column_dimension_width("F", 355.0 / 7) # remarques
if withnips:
ws.set_column_dimension_width("G", 11.0 / 7) # colonne NIP cachée
ws.set_column_dimension_width(
"H", 105.0 / 7
) # Colonne blanche entre liste et zone de saisie
ws.set_column_dimension_width("I", 90.0 / 7) # Saisie: NIP
ws.set_column_dimension_width("J", 115.0 / 7) # Saisie: Note
# fontes
font_base = Font(name="Arial", size=12)
@ -497,15 +517,32 @@ def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, line
"font": font_blue,
"border": border_top,
}
style_input = {
"font": font_bold,
"border": border_top,
"fill": fill_light_yellow,
}
if withnips:
del style_notes["fill"]
list_top = 9
# ligne de titres
ws.append_single_cell_row(
"Feuille saisie note (à enregistrer au format excel)", style_titres
)
# lignes d'instructions
ws.append_single_cell_row(
"Saisir les notes dans la colonne E (cases jaunes)", style_expl
)
if withnips:
ws.append_single_cell_row(
"Saisir les (NIP, note) en colonne I et J (cases jaunes). ordre des NIP indifférent",
style_expl,
)
else:
ws.append_single_cell_row(
"Saisir les notes dans la colonne E (cases jaunes)", style_expl
)
ws.append_single_cell_row("Ne pas modifier les cases en mauve !", style_expl)
# Nom du semestre
ws.append_single_cell_row(scu.unescape_html(titreannee), style_titres)
@ -518,19 +555,33 @@ def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, line
# ligne blanche
ws.append_blank_row()
# code et titres colonnes
ws.append_row(
[
ws.make_cell("!%s" % evaluation.id, style_ro),
ws.make_cell("Nom", style_titres),
ws.make_cell("Prénom", style_titres),
ws.make_cell("Groupe", style_titres),
ws.make_cell("Note sur %g" % (evaluation.note_max or 0.0), style_titres),
ws.make_cell("Remarque", style_titres),
title_row = [
ws.make_cell("!%s" % evaluation.id, style_ro),
ws.make_cell("Nom", style_titres),
ws.make_cell("Prénom", style_titres),
ws.make_cell("Groupe", style_titres),
ws.make_cell("Note sur %g" % (evaluation.note_max or 0.0), style_titres),
ws.make_cell("Remarque", style_titres),
]
if withnips:
title_row += [
ws.make_cell("NIP", style_titres),
ws.make_cell(),
ws.make_cell("NIP", style_titres),
ws.make_cell("Note sur 20", style_titres),
]
)
ws.append_row(title_row)
# Calcul de la zone de saisie (au format $I$9:$J$45) pour intégration dans la formule
if withnips:
min_row = list_top
max_row = list_top + len(lines) - 1
min_col = "I"
max_col = "J"
input_range = absolute_coordinate(f"{min_col}{min_row}:{max_col}{max_row}")
# etudiants
for line in lines:
for line_number, line in enumerate(lines):
st = style_nom
if line[3] != "I":
st = style_dem
@ -543,17 +594,30 @@ def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, line
try:
val = float(line[5])
except ValueError:
val = line[5]
ws.append_row(
[
ws.make_cell("!" + line[0], style_ro), # code
ws.make_cell(line[1], st),
ws.make_cell(line[2], st),
ws.make_cell(s, st),
ws.make_cell(val, style_notes), # note
ws.make_cell(line[6], style_comment), # comment
if withnips and line[5] == "":
ws.set_formula(
f"E{list_top + line_number}",
f"=VLOOKUP(G{list_top + line_number},{input_range}, 2, FALSE)",
)
val = ""
else:
val = line[5]
row = [
ws.make_cell("!" + line[0], style_ro), # code
ws.make_cell(line[1], st),
ws.make_cell(line[2], st),
ws.make_cell(s, st),
ws.make_cell(val, style_notes), # note
ws.make_cell(line[6], style_comment), # comment
]
if withnips:
row += [
ws.make_cell(line[7], style_ro), # NIP
ws.make_cell(),
ws.make_cell("", style_input), # Saisie NIP
ws.make_cell("", style_input), # Saisie note
]
)
ws.append_row(row)
# explication en bas
ws.append_row([None, ws.make_cell("Code notes", style_titres)])

View File

@ -42,6 +42,7 @@ from app.scodoc import sco_groups
from app.scodoc.sco_exceptions import ScoException
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
def form_search_etud(
@ -271,7 +272,7 @@ def search_etud_by_name(term: str) -> list:
data = [
{
"label": "%s %s %s"
% (x["code_nip"], x["nom"], sco_etud.format_prenom(x["prenom"])),
% (x["code_nip"], x["nom"], scu.format_prenom(x["prenom"])),
"value": x["code_nip"],
}
for x in r
@ -290,7 +291,7 @@ def search_etud_by_name(term: str) -> list:
data = [
{
"label": "%s %s" % (x["nom"], sco_etud.format_prenom(x["prenom"])),
"label": "%s %s" % (x["nom"], scu.format_prenom(x["prenom"])),
"value": x["etudid"],
}
for x in r

View File

@ -39,7 +39,7 @@ from app.comp.res_compat import NotesTableCompat
from app.models import Formation, FormSemestre, FormSemestreInscription, Scolog
from app.models.etudiants import Identite
from app.models.groups import Partition, GroupDescr
from app.models.validations import ScolarEvent
from app.models.scolar_event import ScolarEvent
import app.scodoc.sco_utils as scu
from app import log
from app.scodoc.scolog import logdb
@ -222,10 +222,10 @@ def do_formsemestre_desinscription(etudid, formsemestre_id):
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(
"""SELECT Im.id AS moduleimpl_inscription_id
"""SELECT Im.id AS moduleimpl_inscription_id
FROM notes_moduleimpl_inscription Im, notes_moduleimpl M
WHERE Im.etudid=%(etudid)s
and Im.moduleimpl_id = M.id
and Im.moduleimpl_id = M.id
and M.formsemestre_id = %(formsemestre_id)s
""",
{"etudid": etudid, "formsemestre_id": formsemestre_id},
@ -253,7 +253,7 @@ def do_formsemestre_desinscription(etudid, formsemestre_id):
nbinscrits = len(inscrits)
if nbinscrits == 0:
log(
f"""do_formsemestre_desinscription:
f"""do_formsemestre_desinscription:
suppression du semestre extérieur {formsemestre}"""
)
flash("Semestre exterieur supprimé")
@ -436,7 +436,7 @@ def formsemestre_inscription_with_modules(
if inscr is not None:
H.append(
f"""
<p class="warning">{etud.nomprenom} est déjà inscrit
<p class="warning">{etud.nomprenom} est déjà inscrit
dans le semestre {formsemestre.titre_mois()}
</p>
<ul>
@ -482,8 +482,8 @@ def formsemestre_inscription_with_modules(
H.append("</ul>")
H.append(
f"""<p><a href="{ url_for( "notes.formsemestre_inscription_with_modules",
scodoc_dept=g.scodoc_dept, etudid=etudid, formsemestre_id=formsemestre_id,
multiple_ok=1,
scodoc_dept=g.scodoc_dept, etudid=etudid, formsemestre_id=formsemestre_id,
multiple_ok=1,
group_ids=group_ids )
}">Continuer quand même l'inscription</a>
</p>"""
@ -644,7 +644,7 @@ function chkbx_select(field_id, state) {
"""
<p>Voici la liste des modules du semestre choisi.</p>
<p>
Les modules cochés sont ceux dans lesquels l'étudiant est inscrit.
Les modules cochés sont ceux dans lesquels l'étudiant est inscrit.
Vous pouvez l'inscrire ou le désincrire d'un ou plusieurs modules.
</p>
<p>Attention: cette méthode ne devrait être utilisée que pour les modules

View File

@ -53,6 +53,7 @@ from app.scodoc import codes_cursus
from app.scodoc import sco_cursus
from app.scodoc import sco_etud
from app.scodoc.sco_etud import etud_sort_key
import app.scodoc.sco_utils as scu
from app.scodoc import sco_xml
from app.scodoc.sco_exceptions import ScoException, AccessDenied, ScoValueError
from app.scodoc.TrivialFormulator import TrivialFormulator
@ -573,8 +574,8 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
etudid=str(e["etudid"]),
civilite=etud["civilite_str"] or "",
sexe=etud["civilite_str"] or "", # compat
nom=sco_etud.format_nom(etud["nom"] or ""),
prenom=sco_etud.format_prenom(etud["prenom"] or ""),
nom=scu.format_nom(etud["nom"] or ""),
prenom=scu.format_prenom(etud["prenom"] or ""),
origin=_comp_etud_origin(etud, formsemestre),
)
)
@ -599,8 +600,8 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
"etud",
etudid=str(etud["etudid"]),
sexe=etud["civilite_str"] or "",
nom=sco_etud.format_nom(etud["nom"] or ""),
prenom=sco_etud.format_prenom(etud["prenom"] or ""),
nom=scu.format_nom(etud["nom"] or ""),
prenom=scu.format_prenom(etud["prenom"] or ""),
origin=_comp_etud_origin(etud, formsemestre),
)
)

View File

@ -101,7 +101,8 @@ def group_rename(group_id):
"allow_null": True,
"explanation": """optionnel : identifiant du groupe dans le logiciel
d'emploi du temps, pour le cas où les noms de groupes ne seraient pas
les mêmes dans ScoDoc et dans l'emploi du temps (si plusieurs ids,
les mêmes dans ScoDoc et dans l'emploi du temps (si plusieurs ids de
groupes EDT doivent correspondre au même groupe ScoDoc,
les séparer par des virgules).""",
},
),

View File

@ -254,7 +254,7 @@ def import_users(users, force="") -> tuple[bool, list[str], int]:
if import_ok:
for u in created.values():
# Création de l'utilisateur (via SQLAlchemy)
user = User()
user = User(user_name=u["user_name"])
user.from_dict(u, new_user=True)
db.session.add(user)
db.session.commit()

View File

@ -244,8 +244,8 @@ def feuille_preparation_jury(formsemestre_id):
[
etud.id,
etud.civilite_str,
sco_etud.format_nom(etud.nom),
sco_etud.format_prenom(etud.prenom),
scu.format_nom(etud.nom),
scu.format_prenom(etud.prenom),
etud.date_naissance,
etud.admission.bac if etud.admission else "",
etud.admission.specialite if etud.admission else "",

View File

@ -733,6 +733,8 @@ def saisie_notes_tableur(evaluation_id, group_ids=()):
<li><a class="stdlink" href="feuille_saisie_notes?evaluation_id={evaluation_id}&{
groups_infos.groups_query_args}"
id="lnk_feuille_saisie">obtenir le fichier tableur à remplir</a>
&nbsp;(<a class="stdlink" href="feuille_saisie_notes?evaluation_id={evaluation_id}&{
groups_infos.groups_query_args}&withnips=1">saisie par NIP</a>)
</li>
<li>ou <a class="stdlink" href="{url_for("notes.saisie_notes",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id)
@ -875,7 +877,7 @@ def saisie_notes_tableur(evaluation_id, group_ids=()):
return "\n".join(H)
def feuille_saisie_notes(evaluation_id, group_ids=[]):
def feuille_saisie_notes(evaluation_id, group_ids=[], withnips=0):
"""Document Excel pour saisie notes dans l'évaluation et les groupes indiqués"""
evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
if not evaluation:
@ -922,28 +924,31 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]):
# une liste de liste de chaines: lignes de la feuille de calcul
rows = []
etuds = _get_sorted_etuds(evaluation, etudids, formsemestre.id)
for e in etuds:
etudid = e["etudid"]
groups = sco_groups.get_etud_groups(etudid, formsemestre.id)
grc = sco_groups.listgroups_abbrev(groups)
rows.append(
[
str(etudid),
e["nom"].upper(),
e["prenom"].lower().capitalize(),
e["inscr"]["etat"],
grc,
e["val"],
e["explanation"],
]
)
row = [
str(etudid),
e["nom"].upper(),
e["prenom"].lower().capitalize(),
e["inscr"]["etat"],
grc,
e["val"],
e["explanation"],
]
if withnips == 1:
row.append(e["code_nip"])
rows.append(row)
filename = f"notes_{eval_name}_{gr_title_filename}"
xls = sco_excel.excel_feuille_saisie(
evaluation, formsemestre.titre_annee(), description, lines=rows
evaluation,
formsemestre.titre_annee(),
description,
lines=rows,
withnips=(withnips == 1),
)
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
@ -1244,7 +1249,7 @@ def _form_saisie_notes(
'<span class="%s">' % classdem
+ e["civilite_str"]
+ " "
+ sco_etud.format_nomprenom(e, reverse=True)
+ scu.format_nomprenom(e, reverse=True)
+ "</span>"
)

View File

@ -793,21 +793,25 @@ def update_etape_formsemestre_inscription(ins, etud):
def formsemestre_import_etud_admission(
formsemestre_id, import_identite=True, import_email=False
):
formsemestre_id: int, import_identite=True, import_email=False
) -> tuple[list[Identite], list[Identite], list[tuple[Identite, str]]]:
"""Tente d'importer les données admission depuis le portail
pour tous les étudiants du semestre.
Si import_identite==True, recopie l'identité (nom/prenom/sexe/date_naissance)
de chaque étudiant depuis le portail.
N'affecte pas les etudiants inconnus sur le portail.
Renvoie:
- etuds_no_nip: liste d'étudiants sans code NIP
- etuds_unknown: etudiants avec NIP mais inconnus du portail
- changed_mails: (etudiant, old_mail) pour ceux dont le mail a changé
"""
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
{"formsemestre_id": formsemestre_id}
)
log(f"formsemestre_import_etud_admission: {formsemestre_id} ({len(ins)} etuds)")
no_nip = [] # liste d'etudids sans code NIP
unknowns = [] # etudiants avec NIP mais inconnus du portail
etuds_no_nip: list[Identite] = []
etuds_unknown: list[Identite] = []
changed_mails: list[tuple[Identite, str]] = [] # modification d'adresse mails
# Essaie de recuperer les etudiants des étapes, car
@ -828,7 +832,7 @@ def formsemestre_import_etud_admission(
etud: Identite = Identite.query.get_or_404(etudid)
code_nip = etud.code_nip
if not code_nip:
no_nip.append(etudid)
etuds_no_nip.append(etud)
else:
data_apo = apo_etuds.get(code_nip)
if not data_apo:
@ -865,7 +869,7 @@ def formsemestre_import_etud_admission(
if adresse.email != data_apo["mail"]:
changed_mails.append((etud, old_mail))
else:
unknowns.append(code_nip)
etuds_unknown.append(etud)
db.session.commit()
sco_cache.invalidate_formsemestre(formsemestre_id=sem["formsemestre_id"])
return no_nip, unknowns, changed_mails
return etuds_no_nip, etuds_unknown, changed_mails

View File

@ -129,7 +129,7 @@ def trombino_html(groups_infos):
H = [
f"""<table style="padding-top: 10px; padding-bottom: 10px;">
<tr>
<td><span
<td><span
style="font-style: bold; font-size: 150%%; padding-right: 20px;"
>{group_txt}</span></td>"""
]
@ -164,9 +164,9 @@ def trombino_html(groups_infos):
H.append("</span>")
H.append(
'<span class="trombi_legend"><span class="trombi_prenom">'
+ sco_etud.format_prenom(t["prenom"])
+ scu.format_prenom(t["prenom"])
+ '</span><span class="trombi_nom">'
+ sco_etud.format_nom(t["nom"])
+ scu.format_nom(t["nom"])
+ (" <i>(dem.)</i>" if t["etat"] == "D" else "")
)
H.append("</span></span></span>")
@ -175,10 +175,10 @@ def trombino_html(groups_infos):
H.append("</div>")
H.append(
f"""<div style="margin-bottom:15px;">
<a class="stdlink" href="{url_for('scolar.trombino', scodoc_dept=g.scodoc_dept,
<a class="stdlink" href="{url_for('scolar.trombino', scodoc_dept=g.scodoc_dept,
fmt='pdf', group_ids=groups_infos.group_ids)}">Version PDF</a>
&nbsp;&nbsp;
<a class="stdlink" href="{url_for('scolar.trombino', scodoc_dept=g.scodoc_dept,
<a class="stdlink" href="{url_for('scolar.trombino', scodoc_dept=g.scodoc_dept,
fmt='doc', group_ids=groups_infos.group_ids)}">Version doc</a>
</div>"""
)
@ -202,9 +202,9 @@ def check_local_photos_availability(groups_infos, fmt=""):
return (
False,
scu.confirm_dialog(
f"""<p>Attention: {nb_missing} photos ne sont pas disponibles
f"""<p>Attention: {nb_missing} photos ne sont pas disponibles
et ne peuvent pas être exportées.</p>
<p>Vous pouvez <a class="stdlink"
<p>Vous pouvez <a class="stdlink"
href="{groups_infos.base_url}&dialog_confirmed=1&fmt={fmt}"
>exporter seulement les photos existantes</a>""",
dest_url="trombino",
@ -263,11 +263,11 @@ def trombino_copy_photos(group_ids=[], dialog_confirmed=False):
if not dialog_confirmed:
return scu.confirm_dialog(
f"""<h2>Copier les photos du portail vers ScoDoc ?</h2>
<p>Les photos du groupe {groups_infos.groups_titles} présentes
<p>Les photos du groupe {groups_infos.groups_titles} présentes
dans ScoDoc seront remplacées par celles du portail (si elles existent).
</p>
<p>(les photos sont normalement automatiquement copiées
lors de leur première utilisation, l'usage de cette fonction
<p>(les photos sont normalement automatiquement copiées
lors de leur première utilisation, l'usage de cette fonction
n'est nécessaire que si les photos du portail ont été modifiées)
</p>
""",
@ -349,7 +349,7 @@ def _trombino_pdf(groups_infos):
[img],
[
Paragraph(
SU(sco_etud.format_nomprenom(t)),
SU(scu.format_nomprenom(t)),
style_sheet["Normal"],
)
],
@ -428,7 +428,7 @@ def _listeappel_photos_pdf(groups_infos):
t = groups_infos.members[i]
img = _get_etud_platypus_image(t, image_width=PHOTO_WIDTH)
txt = Paragraph(
SU(sco_etud.format_nomprenom(t)),
SU(scu.format_nomprenom(t)),
style_sheet["Normal"],
)
if currow:

View File

@ -55,7 +55,7 @@ def trombino_doc(groups_infos):
cell = table.rows[2 * li + 1].cells[co]
cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP
cell_p, cell_f, cell_r = _paragraph_format_run(cell)
cell_r.add_text(sco_etud.format_nomprenom(t))
cell_r.add_text(scu.format_nomprenom(t))
cell_f.space_after = Mm(8)
return scu.send_docx(document, filename)

View File

@ -196,9 +196,9 @@ def pdf_trombino_tours(
Paragraph(
SU(
"<para align=center><font size=8>"
+ sco_etud.format_prenom(m["prenom"])
+ scu.format_prenom(m["prenom"])
+ " "
+ sco_etud.format_nom(m["nom"])
+ scu.format_nom(m["nom"])
+ text_group
+ "</font></para>"
),
@ -413,11 +413,7 @@ def pdf_feuille_releve_absences(
for m in members:
currow = [
Paragraph(
SU(
sco_etud.format_nom(m["nom"])
+ " "
+ sco_etud.format_prenom(m["prenom"])
),
SU(scu.format_nom(m["nom"]) + " " + scu.format_prenom(m["prenom"])),
StyleSheet["Normal"],
)
]

View File

@ -102,7 +102,7 @@ def index_html(
<option value="">--Choisir--</option>
{menu_roles}
</select>
</form>
"""
)
@ -204,7 +204,7 @@ def list_users(
"cas_allow_scodoc_login",
"cas_last_login",
]
columns_ids.append("email_institutionnel")
columns_ids += ["email_institutionnel", "edt_id"]
title = "Utilisateurs définis dans ScoDoc"
tab = GenTable(
@ -227,6 +227,7 @@ def list_users(
"cas_allow_login": "CAS autorisé",
"cas_allow_scodoc_login": "Cnx sans CAS",
"cas_last_login": "Dernier login CAS",
"edt_id": "Identifiant emploi du temps",
},
caption=title,
page_title="title",
@ -431,15 +432,3 @@ def check_modif_user(
)
# Roles ?
return True, ""
def user_edit(user_name, vals):
"""Edit the user specified by user_name
(ported from Zope to SQLAlchemy, hence strange !)
"""
u: User = User.query.filter_by(user_name=user_name).first()
if not u:
raise ScoValueError("Invalid user_name")
u.from_dict(vals)
db.session.add(u)
db.session.commit()

View File

@ -64,6 +64,7 @@ from config import Config
from app import log, ScoDocJSONEncoder
from app.scodoc.codes_cursus import NOTES_TOLERANCE, CODES_EXPL
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_xml
import sco_version
@ -204,6 +205,13 @@ class EtatAssiduite(int, BiDirectionalEnum):
RETARD = 1
ABSENT = 2
def version_lisible(self) -> str:
return {
EtatAssiduite.PRESENT: "Présence",
EtatAssiduite.ABSENT: "Absence",
EtatAssiduite.RETARD: "Retard",
}.get(self, "")
class EtatJustificatif(int, BiDirectionalEnum):
"""Code des états des justificatifs"""
@ -215,6 +223,14 @@ class EtatJustificatif(int, BiDirectionalEnum):
ATTENTE = 2
MODIFIE = 3
def version_lisible(self) -> str:
return {
EtatJustificatif.VALIDE: "valide",
EtatJustificatif.ATTENTE: "soumis",
EtatJustificatif.MODIFIE: "modifié",
EtatJustificatif.NON_VALIDE: "invalide",
}.get(self, "")
def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None:
"""
@ -1139,6 +1155,61 @@ def abbrev_prenom(prenom):
return abrv
def format_civilite(civilite):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
personne ne souhaitant pas d'affichage).
Raises ScoValueError if conversion fails.
"""
try:
return {
"M": "M.",
"F": "Mme",
"X": "",
}[civilite]
except KeyError as exc:
raise ScoValueError(f"valeur invalide pour la civilité: {civilite}") from exc
def format_nomprenom(etud, reverse=False):
"""Formatte civilité/nom/prenom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
DEPRECATED: utiliser Identite.nomprenom
"""
nom = etud.get("nom_disp", "") or etud.get("nom_usuel", "") or etud["nom"]
prenom = format_prenom(etud["prenom"])
civilite = format_civilite(etud["civilite"])
if reverse:
fs = [nom, prenom]
else:
fs = [civilite, prenom, nom]
return " ".join([x for x in fs if x])
def format_nom(s, uppercase=True):
"Formatte le nom"
if not s:
return ""
if uppercase:
return s.upper()
else:
return format_prenom(s)
def format_prenom(s):
"""Formatte prenom etudiant pour affichage
DEPRECATED: utiliser Identite.prenom_str
"""
if not s:
return ""
frags = s.split()
r = []
for frag in frags:
fs = frag.split("-")
r.append("-".join([x.lower().capitalize() for x in fs]))
return " ".join(r)
#
def timedate_human_repr():
"representation du temps courant pour utilisateur"
@ -1480,6 +1551,7 @@ def is_assiduites_module_forced(
def get_assiduites_time_config(config_type: str) -> str:
from app.models import ScoDocSiteConfig
match config_type:
case "matin":
return ScoDocSiteConfig.get("assi_morning_time", "08:00:00")

View File

@ -1,4 +1,4 @@
/*
/*
* DataTables style for ScoDoc gen_tables
* generated using https://datatables.net/manual/styling/theme-creator
* and customized by hand
@ -138,111 +138,111 @@ table.dataTable.display tbody tr:hover.selected {
background-color: #a9b7d1;
}
table.dataTable.order-column tbody tr>.sorting_1,
table.dataTable.order-column tbody tr>.sorting_2,
table.dataTable.order-column tbody tr>.sorting_3,
table.dataTable.display tbody tr>.sorting_1,
table.dataTable.display tbody tr>.sorting_2,
table.dataTable.display tbody tr>.sorting_3 {
table.dataTable.order-column tbody tr > .sorting_1,
table.dataTable.order-column tbody tr > .sorting_2,
table.dataTable.order-column tbody tr > .sorting_3,
table.dataTable.display tbody tr > .sorting_1,
table.dataTable.display tbody tr > .sorting_2,
table.dataTable.display tbody tr > .sorting_3 {
background-color: #f9f9f9;
}
table.dataTable.order-column tbody tr.selected>.sorting_1,
table.dataTable.order-column tbody tr.selected>.sorting_2,
table.dataTable.order-column tbody tr.selected>.sorting_3,
table.dataTable.display tbody tr.selected>.sorting_1,
table.dataTable.display tbody tr.selected>.sorting_2,
table.dataTable.display tbody tr.selected>.sorting_3 {
table.dataTable.order-column tbody tr.selected > .sorting_1,
table.dataTable.order-column tbody tr.selected > .sorting_2,
table.dataTable.order-column tbody tr.selected > .sorting_3,
table.dataTable.display tbody tr.selected > .sorting_1,
table.dataTable.display tbody tr.selected > .sorting_2,
table.dataTable.display tbody tr.selected > .sorting_3 {
background-color: #acbad4;
}
table.dataTable.display tbody tr.odd>.sorting_1,
table.dataTable.order-column.stripe tbody tr.odd>.sorting_1 {
table.dataTable.display tbody tr.odd > .sorting_1,
table.dataTable.order-column.stripe tbody tr.odd > .sorting_1 {
background-color: #f1f1f1;
}
table.dataTable.display tbody tr.odd>.sorting_2,
table.dataTable.order-column.stripe tbody tr.odd>.sorting_2 {
table.dataTable.display tbody tr.odd > .sorting_2,
table.dataTable.order-column.stripe tbody tr.odd > .sorting_2 {
background-color: #f3f3f3;
}
table.dataTable.display tbody tr.odd>.sorting_3,
table.dataTable.order-column.stripe tbody tr.odd>.sorting_3 {
table.dataTable.display tbody tr.odd > .sorting_3,
table.dataTable.order-column.stripe tbody tr.odd > .sorting_3 {
background-color: whitesmoke;
}
table.dataTable.display tbody tr.odd.selected>.sorting_1,
table.dataTable.order-column.stripe tbody tr.odd.selected>.sorting_1 {
table.dataTable.display tbody tr.odd.selected > .sorting_1,
table.dataTable.order-column.stripe tbody tr.odd.selected > .sorting_1 {
background-color: #a6b3cd;
}
table.dataTable.display tbody tr.odd.selected>.sorting_2,
table.dataTable.order-column.stripe tbody tr.odd.selected>.sorting_2 {
table.dataTable.display tbody tr.odd.selected > .sorting_2,
table.dataTable.order-column.stripe tbody tr.odd.selected > .sorting_2 {
background-color: #a7b5ce;
}
table.dataTable.display tbody tr.odd.selected>.sorting_3,
table.dataTable.order-column.stripe tbody tr.odd.selected>.sorting_3 {
table.dataTable.display tbody tr.odd.selected > .sorting_3,
table.dataTable.order-column.stripe tbody tr.odd.selected > .sorting_3 {
background-color: #a9b6d0;
}
table.dataTable.display tbody tr.even>.sorting_1,
table.dataTable.order-column.stripe tbody tr.even>.sorting_1 {
table.dataTable.display tbody tr.even > .sorting_1,
table.dataTable.order-column.stripe tbody tr.even > .sorting_1 {
background-color: #f9f9f9;
}
table.dataTable.display tbody tr.even>.sorting_2,
table.dataTable.order-column.stripe tbody tr.even>.sorting_2 {
table.dataTable.display tbody tr.even > .sorting_2,
table.dataTable.order-column.stripe tbody tr.even > .sorting_2 {
background-color: #fbfbfb;
}
table.dataTable.display tbody tr.even>.sorting_3,
table.dataTable.order-column.stripe tbody tr.even>.sorting_3 {
table.dataTable.display tbody tr.even > .sorting_3,
table.dataTable.order-column.stripe tbody tr.even > .sorting_3 {
background-color: #fdfdfd;
}
table.dataTable.display tbody tr.even.selected>.sorting_1,
table.dataTable.order-column.stripe tbody tr.even.selected>.sorting_1 {
table.dataTable.display tbody tr.even.selected > .sorting_1,
table.dataTable.order-column.stripe tbody tr.even.selected > .sorting_1 {
background-color: #acbad4;
}
table.dataTable.display tbody tr.even.selected>.sorting_2,
table.dataTable.order-column.stripe tbody tr.even.selected>.sorting_2 {
table.dataTable.display tbody tr.even.selected > .sorting_2,
table.dataTable.order-column.stripe tbody tr.even.selected > .sorting_2 {
background-color: #adbbd6;
}
table.dataTable.display tbody tr.even.selected>.sorting_3,
table.dataTable.order-column.stripe tbody tr.even.selected>.sorting_3 {
table.dataTable.display tbody tr.even.selected > .sorting_3,
table.dataTable.order-column.stripe tbody tr.even.selected > .sorting_3 {
background-color: #afbdd8;
}
table.dataTable.display tbody tr:hover>.sorting_1,
table.dataTable.order-column.hover tbody tr:hover>.sorting_1 {
table.dataTable.display tbody tr:hover > .sorting_1,
table.dataTable.order-column.hover tbody tr:hover > .sorting_1 {
background-color: #eaeaea;
}
table.dataTable.display tbody tr:hover>.sorting_2,
table.dataTable.order-column.hover tbody tr:hover>.sorting_2 {
table.dataTable.display tbody tr:hover > .sorting_2,
table.dataTable.order-column.hover tbody tr:hover > .sorting_2 {
background-color: #ebebeb;
}
table.dataTable.display tbody tr:hover>.sorting_3,
table.dataTable.order-column.hover tbody tr:hover>.sorting_3 {
table.dataTable.display tbody tr:hover > .sorting_3,
table.dataTable.order-column.hover tbody tr:hover > .sorting_3 {
background-color: #eeeeee;
}
table.dataTable.display tbody tr:hover.selected>.sorting_1,
table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_1 {
table.dataTable.display tbody tr:hover.selected > .sorting_1,
table.dataTable.order-column.hover tbody tr:hover.selected > .sorting_1 {
background-color: #a1aec7;
}
table.dataTable.display tbody tr:hover.selected>.sorting_2,
table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_2 {
table.dataTable.display tbody tr:hover.selected > .sorting_2,
table.dataTable.order-column.hover tbody tr:hover.selected > .sorting_2 {
background-color: #a2afc8;
}
table.dataTable.display tbody tr:hover.selected>.sorting_3,
table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_3 {
table.dataTable.display tbody tr:hover.selected > .sorting_3,
table.dataTable.order-column.hover tbody tr:hover.selected > .sorting_3 {
background-color: #a4b2cb;
}
@ -419,7 +419,13 @@ table.dataTable td {
color: #333333 !important;
border: 1px solid #979797;
background-color: white;
background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, white), color-stop(100%, gainsboro));
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0%, white),
color-stop(100%, gainsboro)
);
/* Chrome,Safari4+ */
background: -webkit-linear-gradient(top, white 0%, gainsboro 100%);
/* Chrome10+,Safari5.1+ */
@ -447,7 +453,13 @@ table.dataTable td {
color: white !important;
border: 1px solid #111111;
background-color: #585858;
background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #585858), color-stop(100%, #111111));
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0%, #585858),
color-stop(100%, #111111)
);
/* Chrome,Safari4+ */
background: -webkit-linear-gradient(top, #585858 0%, #111111 100%);
/* Chrome10+,Safari5.1+ */
@ -464,7 +476,13 @@ table.dataTable td {
.dataTables_wrapper .dataTables_paginate .paginate_button:active {
outline: none;
background-color: #2b2b2b;
background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #2b2b2b), color-stop(100%, #0c0c0c));
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0%, #2b2b2b),
color-stop(100%, #0c0c0c)
);
/* Chrome,Safari4+ */
background: -webkit-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%);
/* Chrome10+,Safari5.1+ */
@ -495,12 +513,50 @@ table.dataTable td {
text-align: center;
font-size: 1.2em;
background-color: white;
background: -webkit-gradient(linear, left top, right top, color-stop(0%, rgba(255, 255, 255, 0)), color-stop(25%, rgba(255, 255, 255, 0.9)), color-stop(75%, rgba(255, 255, 255, 0.9)), color-stop(100%, rgba(255, 255, 255, 0)));
background: -webkit-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.9) 25%, rgba(255, 255, 255, 0.9) 75%, rgba(255, 255, 255, 0) 100%);
background: -moz-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.9) 25%, rgba(255, 255, 255, 0.9) 75%, rgba(255, 255, 255, 0) 100%);
background: -ms-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.9) 25%, rgba(255, 255, 255, 0.9) 75%, rgba(255, 255, 255, 0) 100%);
background: -o-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.9) 25%, rgba(255, 255, 255, 0.9) 75%, rgba(255, 255, 255, 0) 100%);
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.9) 25%, rgba(255, 255, 255, 0.9) 75%, rgba(255, 255, 255, 0) 100%);
background: -webkit-gradient(
linear,
left top,
right top,
color-stop(0%, rgba(255, 255, 255, 0)),
color-stop(25%, rgba(255, 255, 255, 0.9)),
color-stop(75%, rgba(255, 255, 255, 0.9)),
color-stop(100%, rgba(255, 255, 255, 0))
);
background: -webkit-linear-gradient(
left,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.9) 25%,
rgba(255, 255, 255, 0.9) 75%,
rgba(255, 255, 255, 0) 100%
);
background: -moz-linear-gradient(
left,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.9) 25%,
rgba(255, 255, 255, 0.9) 75%,
rgba(255, 255, 255, 0) 100%
);
background: -ms-linear-gradient(
left,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.9) 25%,
rgba(255, 255, 255, 0.9) 75%,
rgba(255, 255, 255, 0) 100%
);
background: -o-linear-gradient(
left,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.9) 25%,
rgba(255, 255, 255, 0.9) 75%,
rgba(255, 255, 255, 0) 100%
);
background: linear-gradient(
to right,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.9) 25%,
rgba(255, 255, 255, 0.9) 75%,
rgba(255, 255, 255, 0) 100%
);
}
.dataTables_wrapper .dataTables_length,
@ -520,17 +576,69 @@ table.dataTable td {
-webkit-overflow-scrolling: touch;
}
.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>th,
.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>td,
.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>th,
.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>td {
.dataTables_wrapper
.dataTables_scroll
div.dataTables_scrollBody
> table
> thead
> tr
> th,
.dataTables_wrapper
.dataTables_scroll
div.dataTables_scrollBody
> table
> thead
> tr
> td,
.dataTables_wrapper
.dataTables_scroll
div.dataTables_scrollBody
> table
> tbody
> tr
> th,
.dataTables_wrapper
.dataTables_scroll
div.dataTables_scrollBody
> table
> tbody
> tr
> td {
vertical-align: middle;
}
.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>th>div.dataTables_sizing,
.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>td>div.dataTables_sizing,
.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>th>div.dataTables_sizing,
.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>td>div.dataTables_sizing {
.dataTables_wrapper
.dataTables_scroll
div.dataTables_scrollBody
> table
> thead
> tr
> th
> div.dataTables_sizing,
.dataTables_wrapper
.dataTables_scroll
div.dataTables_scrollBody
> table
> thead
> tr
> td
> div.dataTables_sizing,
.dataTables_wrapper
.dataTables_scroll
div.dataTables_scrollBody
> table
> tbody
> tr
> th
> div.dataTables_sizing,
.dataTables_wrapper
.dataTables_scroll
div.dataTables_scrollBody
> table
> tbody
> tr
> td
> div.dataTables_sizing {
height: 0;
overflow: hidden;
margin: 0 !important;
@ -541,8 +649,8 @@ table.dataTable td {
border-bottom: 1px solid #111111;
}
.dataTables_wrapper.no-footer div.dataTables_scrollHead>table,
.dataTables_wrapper.no-footer div.dataTables_scrollBody>table {
.dataTables_wrapper.no-footer div.dataTables_scrollHead > table,
.dataTables_wrapper.no-footer div.dataTables_scrollBody > table {
border-bottom: none;
}
@ -555,7 +663,6 @@ table.dataTable td {
}
@media screen and (max-width: 767px) {
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_paginate {
float: none;
@ -568,7 +675,6 @@ table.dataTable td {
}
@media screen and (max-width: 640px) {
.dataTables_wrapper .dataTables_length,
.dataTables_wrapper .dataTables_filter {
float: none;
@ -580,8 +686,7 @@ table.dataTable td {
}
}
/* ------ Ajouts spécifiques pour ScoDoc:
/* ------ Ajouts spécifiques pour ScoDoc:
*/
table.gt_table td {
@ -624,7 +729,6 @@ table.dataTable.stripe.hover tbody tr.odd:hover td,
table.dataTable.stripe.hover tbody tr.odd:hover td.sorting_1,
table.dataTable.order-column.stripe.hover tbody tr.odd:hover td.sorting_1 {
background-color: rgb(80%, 85%, 80%);
;
}
/* Lignes paires */
@ -638,7 +742,6 @@ table.dataTable.stripe.hover tbody tr.even:hover td,
table.dataTable.stripe.hover tbody tr.even:hover td.sorting_1,
table.dataTable.order-column.stripe.hover tbody tr.even:hover td.sorting_1 {
background-color: rgb(85%, 85%, 85%);
;
}
/* Reglage largeur de la table */
@ -652,4 +755,9 @@ table.dataTable.gt_table {
/* Tables non centrées (inutile) */
table.dataTable.gt_table.gt_left {
margin-left: 16px;
}
}
table.dataTable.gt_table.gt_left td,
table.dataTable.gt_table.gt_left th {
text-align: left;
}
scodoc;css

View File

@ -1133,7 +1133,8 @@ a.redlink:hover {
text-decoration: underline;
}
a.discretelink {
a.discretelink,
a:discretelink:visited {
color: black;
text-decoration: none;
}
@ -1567,6 +1568,7 @@ h2.formsemestre,
#gtrcontent h2 {
margin-top: 2px;
font-size: 130%;
font-weight: bold;
}
.formsemestre_page_title table.semtitle,
@ -4347,6 +4349,10 @@ table.dataTable td.group {
background: #fff;
}
#zonePartitions .edt_id {
color: rgb(85, 255, 24);
}
/* ------------- Nouveau tableau recap ------------ */
div.table_recap {
margin-top: 6px;
@ -4856,7 +4862,3 @@ div.cas_etat_certif_ssl {
font-style: italic;
color: rgb(231, 0, 0);
}
.edt_id {
color: rgb(85, 255, 24);
}

View File

@ -448,6 +448,13 @@ class ScoDocDateTimePicker extends HTMLElement {
// Ajouter le style au shadow DOM
shadow.appendChild(style);
//Si une value est donnée
let value = this.getAttribute("value");
if (value != null) {
this.value = value;
}
}
static get observedAttributes() {
@ -474,7 +481,7 @@ class ScoDocDateTimePicker extends HTMLElement {
} else {
// Mettre à jour la valeur de l'input caché avant la soumission
this.hiddenInput.value = this.isValid()
? this.valueAsDate.toIsoUtcString()
? this.valueAsDate.toFakeIso()
: "";
}
});

View File

@ -6,9 +6,10 @@
"""Liste simple d'étudiants
"""
import datetime
from flask import g, url_for
from app.models import Identite
from app.models import FormSemestre, FormSemestreInscription, Identite
from app.scodoc.sco_exceptions import ScoValueError
from app.tables import table_builder as tb
@ -26,6 +27,7 @@ class TableEtud(tb.Table):
with_foot_titles=False,
**kwargs,
):
etuds = etuds or []
self.rows: list["RowEtud"] = [] # juste pour que VSCode nous aide sur .rows
classes = classes or ["gt_table", "gt_left"]
super().__init__(
@ -46,10 +48,12 @@ class TableEtud(tb.Table):
class RowEtud(tb.Row):
"Ligne de la table d'étudiants"
# pour le moment très simple, extensible (codes, liens bulletins, ...)
def __init__(self, table: TableEtud, etud: Identite, *args, **kwargs):
super().__init__(table, etud.id, *args, **kwargs)
self.etud = etud
self.target_url = etud.url_fiche()
def add_etud_cols(self):
"""Ajoute colonnes étudiant: codes, noms"""
@ -77,7 +81,6 @@ class RowEtud(tb.Row):
# formsemestre_id=res.formsemestre.id,
# etudid=etud.id,
# )
url_bulletin = None # pour extension future
self.add_cell("civilite_str", "Civ.", etud.civilite_str, "identite_detail")
self.add_cell(
"nom_disp",
@ -85,23 +88,10 @@ class RowEtud(tb.Row):
etud.nom_disp(),
"identite_detail",
data={"order": etud.sort_key},
target=url_bulletin,
target=self.target_url,
target_attrs={"class": "etudinfo discretelink", "id": str(etud.id)},
)
self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
# self.add_cell(
# "nom_short",
# "Nom",
# etud.nom_short,
# "identite_court",
# data={
# "order": etud.sort_key,
# "etudid": etud.id,
# "nomprenom": etud.nomprenom,
# },
# target=url_bulletin,
# target_attrs={"class": "etudinfo", "id": str(etud.id)},
# )
def etuds_sorted_from_ids(etudids) -> list[Identite]:
@ -115,3 +105,73 @@ def html_table_etuds(etudids) -> str:
etuds = etuds_sorted_from_ids(etudids)
table = TableEtud(etuds)
return table.html()
class RowEtudWithInfos(RowEtud):
"""Ligne de la table d'étudiants avec plus d'informations:
département, formsemestre, codes, boursier
"""
def __init__(
self,
table: TableEtud,
etud: Identite,
formsemestre: FormSemestre,
inscription: FormSemestreInscription,
*args,
**kwargs,
):
super().__init__(table, etud, *args, **kwargs)
self.formsemestre = formsemestre
self.inscription = inscription
def add_etud_cols(self):
"""Ajoute colonnes étudiant: codes, noms"""
self.add_cell("dept", "Dépt.", self.etud.departement.acronym, "identite_detail")
self.add_cell(
"formsemestre",
"Semestre",
f"""{self.formsemestre.titre_formation()} {
('S'+str(self.formsemestre.semestre_id))
if self.formsemestre.semestre_id > 0 else ''}
""",
"identite_detail",
)
self.add_cell("code_nip", "NIP", self.etud.code_nip or "", "identite_detail")
super().add_etud_cols()
self.add_cell(
"etat",
"État",
self.inscription.etat,
"inscription",
)
self.add_cell(
"boursier",
"Boursier",
"O" if self.etud.boursier else "N",
"identite_infos",
)
class TableEtudWithInfos(TableEtud):
"""Table d'étudiants avec formsemestre et inscription"""
def add_formsemestre(self, formsemestre: FormSemestre):
"Ajoute les étudiants de ce semestre à la table"
etuds = formsemestre.get_inscrits(order=True, include_demdef=True)
for etud in etuds:
row = self.row_class(
self, etud, formsemestre, formsemestre.etuds_inscriptions.get(etud.id)
)
row.add_etud_cols()
self.add_row(row)
def table_etudiants_courants(formsemestres: list[FormSemestre]) -> TableEtud:
"""Table des étudiants des formsemestres indiqués"""
if not formsemestres:
raise ScoValueError("Aucun semestre en cours")
table = TableEtudWithInfos(row_class=RowEtudWithInfos)
for formsemestre in formsemestres:
table.add_formsemestre(formsemestre)
return table

View File

@ -5,7 +5,7 @@ from datetime import datetime
from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif
from flask_sqlalchemy.query import Query, Pagination
from sqlalchemy import union, literal, select, desc
from app import db
from app import db, g
from flask import url_for
from app import log
@ -16,14 +16,14 @@ class ListeAssiJusti(tb.Table):
L'affichage par défaut se fait par ordre de date de fin décroissante.
"""
NB_PAR_PAGE: int = 2
NB_PAR_PAGE: int = 25
MAX_PAR_PAGE: int = 200
def __init__(
self,
*etudiants: tuple[Identite],
table_data: "Data",
filtre: "Filtre" = None,
page: int = 1,
nb_par_page: int = None,
options: "Options" = None,
**kwargs,
) -> None:
"""
@ -33,14 +33,12 @@ class ListeAssiJusti(tb.Table):
filtre (Filtre, optional): Filtrage des objets à afficher. Defaults to None.
page (int, optional): numéro de page de la pagination. Defaults to 1.
"""
self.etudiants = etudiants
self.table_data: "Data" = table_data
# Gestion du filtre, par défaut un filtre vide
self.filtre = filtre if filtre is not None else Filtre()
# Gestion de la pagination (par défaut page 1)
self.page: int = page
self.nb_par_page: int = (
nb_par_page if nb_par_page is not None else ListeAssiJusti.NB_PAR_PAGE
)
# Gestion des options, par défaut un objet Options vide
self.options = options if options is not None else Options()
self.total_page: int = None
@ -57,9 +55,6 @@ class ListeAssiJusti(tb.Table):
self.ajouter_lignes()
def etudiant_seul(self) -> bool:
return len(self.etudiants) == 1
def ajouter_lignes(self):
# Générer les query assiduités et justificatifs
assiduites_query_etudiants: Query = None
@ -69,13 +64,21 @@ class ListeAssiJusti(tb.Table):
type_obj = self.filtre.type_obj()
if type_obj in [0, 1]:
assiduites_query_etudiants = Assiduite.query.filter(
Assiduite.etudid.in_([e.etudid for e in self.etudiants])
)
assiduites_query_etudiants = self.table_data.assiduites_query
# Non affichage des présences
if not self.options.show_pres:
assiduites_query_etudiants = assiduites_query_etudiants.filter(
Assiduite.etat != EtatAssiduite.PRESENT
)
# Non affichage des retards
if not self.options.show_reta:
assiduites_query_etudiants = assiduites_query_etudiants.filter(
Assiduite.etat != EtatAssiduite.RETARD
)
if type_obj in [0, 2]:
justificatifs_query_etudiants = Justificatif.query.filter(
Justificatif.etudid.in_([e.etudid for e in self.etudiants])
)
justificatifs_query_etudiants = self.table_data.justificatifs_query
# Combinaison des requêtes
@ -112,7 +115,7 @@ class ListeAssiJusti(tb.Table):
résultats paginés.
"""
return query.paginate(
page=self.page, per_page=self.nb_par_page, error_out=False
page=self.options.page, per_page=self.options.nb_ligne_page, error_out=False
)
def joindre(self, query_assiduite: Query = None, query_justificatif: Query = None):
@ -149,7 +152,7 @@ class ListeAssiJusti(tb.Table):
# Définir les colonnes pour la requête d'assiduité
if query_assiduite:
query_assiduite = query_assiduite.with_entities(
assiduites_entities: list = [
Assiduite.assiduite_id.label("obj_id"),
Assiduite.etudid.label("etudid"),
Assiduite.entry_date.label("entry_date"),
@ -159,12 +162,17 @@ class ListeAssiJusti(tb.Table):
literal("assiduite").label("type"),
Assiduite.est_just.label("est_just"),
Assiduite.user_id.label("user_id"),
)
]
if self.options.show_desc:
assiduites_entities.append(Assiduite.description.label("description"))
query_assiduite = query_assiduite.with_entities(*assiduites_entities)
queries.append(query_assiduite)
# Définir les colonnes pour la requête de justificatif
if query_justificatif:
query_justificatif = query_justificatif.with_entities(
justificatifs_entities: list = [
Justificatif.justif_id.label("obj_id"),
Justificatif.etudid.label("etudid"),
Justificatif.entry_date.label("entry_date"),
@ -176,6 +184,13 @@ class ListeAssiJusti(tb.Table):
# donc on la met en nul car un justifcatif ne peut être justifié
literal(None).label("est_just"),
Justificatif.user_id.label("user_id"),
]
if self.options.show_desc:
justificatifs_entities.append(Justificatif.raison.label("description"))
query_justificatif = query_justificatif.with_entities(
*justificatifs_entities
)
queries.append(query_justificatif)
@ -206,8 +221,8 @@ class RowAssiJusti(tb.Row):
def ajouter_colonnes(self, lien_redirection: str = None):
# Ajout de l'étudiant
self.table: ListeAssiJusti
if not self.table.etudiant_seul():
self._etud()
if self.table.options.show_etu:
self._etud(lien_redirection)
# Type d'objet
self._type()
@ -218,28 +233,37 @@ class RowAssiJusti(tb.Row):
"Date de début",
self.ligne["date_debut"].strftime("%d/%m/%y à %H:%M"),
data={"order": self.ligne["date_debut"]},
raw_content=self.ligne["date_debut"],
)
# Date de fin
self.add_cell(
"date_fin",
"Date de fin",
self.ligne["date_fin"].strftime("%d/%m/%y à %H:%M"),
raw_content=self.ligne["date_fin"],
data={"order": self.ligne["date_fin"]},
)
# Ajout des colonnes optionnelles
self._optionnelles()
# Ajout colonne actions
if self.table.options.show_actions:
self._actions()
# Ajout de l'utilisateur ayant saisie l'objet
self._utilisateur()
# Date de saisie
self.add_cell(
"entry_date",
"Saisie le",
self.ligne["entry_date"].strftime("%d/%m/%y à %H:%M"),
data={"order": self.ligne["entry_date"]},
raw_content=self.ligne["entry_date"],
classes=["small-font"],
)
# Ajout de l'utilisateur ayant saisie l'objet
self._utilisateur()
# Ajout colonne actions
self._actions()
def _type(self) -> None:
obj_type: str = ""
is_assiduite: bool = self.ligne["type"] == "assiduite"
@ -264,7 +288,7 @@ class RowAssiJusti(tb.Row):
self.add_cell("obj_type", "Type", obj_type)
def _etud(self) -> None:
def _etud(self, lien_redirection) -> None:
etud = self.etud
self.table.group_titles.update(
{
@ -297,6 +321,21 @@ class RowAssiJusti(tb.Row):
target_attrs={"class": "discretelink"},
)
def _optionnelles(self) -> None:
if self.table.options.show_desc:
self.add_cell(
"description",
"Description",
self.ligne["description"] if self.ligne["description"] else "",
)
if self.table.options.show_module:
if self.ligne["type"] == "assiduite":
assi: Assiduite = Assiduite.query.get(self.ligne["obj_id"])
mod: str = assi.get_module(True)
self.add_cell("module", "Module", mod, data={"order": mod})
else:
self.add_cell("module", "Module", "", data={"order": ""})
def _utilisateur(self) -> None:
utilisateur: User = User.query.get(self.ligne["user_id"])
@ -304,11 +343,44 @@ class RowAssiJusti(tb.Row):
"user",
"Saisie par",
"Inconnu" if utilisateur is None else utilisateur.get_nomprenom(),
classes=["small-font"],
)
def _actions(self) -> None:
# XXX Ajouter une colonne avec les liens d'action (supprimer, modifier)
pass
url: str
html: list[str] = []
# Détails
url = url_for(
"assiduites.tableau_assiduite_actions",
type=self.ligne["type"],
action="details",
obj_id=self.ligne["obj_id"],
scodoc_dept=g.scodoc_dept,
)
html.append(f'<a title="Détails" href="{url}"></a>') # utiliser url_for
# Modifier
url = url_for(
"assiduites.tableau_assiduite_actions",
type=self.ligne["type"],
action="modifier",
obj_id=self.ligne["obj_id"],
scodoc_dept=g.scodoc_dept,
)
html.append(f'<a title="Modifier" href="{url}">📝</a>') # utiliser url_for
# Supprimer
url = url_for(
"assiduites.tableau_assiduite_actions",
type=self.ligne["type"],
action="supprimer",
obj_id=self.ligne["obj_id"],
scodoc_dept=g.scodoc_dept,
)
html.append(f'<a title="Supprimer" href="{url}">❌</a>') # utiliser url_for
self.add_cell("actions", "Actions", "&ensp;".join(html), no_excel=True)
class Filtre:
@ -323,7 +395,6 @@ class Filtre:
entry_date: tuple[int, datetime] = None,
date_debut: tuple[int, datetime] = None,
date_fin: tuple[int, datetime] = None,
etats: list[EtatAssiduite | EtatJustificatif] = None,
) -> None:
"""
__init__ Instancie un nouvel objet filtre.
@ -336,7 +407,7 @@ class Filtre:
etats (list[int | EtatJustificatif | EtatAssiduite], optional): liste d'états valides (int | EtatJustificatif | EtatAssiduite). Defaults to None.
"""
self.filtres = {}
self.filtres = {"type_obj": type_obj}
if entry_date is not None:
self.filtres["entry_date"]: tuple[int, datetime] = entry_date
@ -347,9 +418,6 @@ class Filtre:
if date_fin is not None:
self.filtres["date_fin"]: tuple[int, datetime] = date_fin
if etats is not None:
self.filtres["etats"]: list[int | EtatJustificatif | EtatAssiduite] = etats
def filtrage(self, query: Query, obj_class: db.Model) -> Query:
"""
filtrage Filtre la query passée en paramètre et retourne l'objet filtré
@ -405,3 +473,64 @@ class Filtre:
int: le/les types d'objets à afficher
"""
return self.filtres.get("type_obj", 0)
class Options:
VRAI = ["on", "true", "t", "v", "vrai", True, 1]
def __init__(
self,
page: int = 1,
nb_ligne_page: int = None,
show_pres: str | bool = False,
show_reta: str | bool = False,
show_desc: str | bool = False,
show_etu: str | bool = True,
show_actions: str | bool = True,
show_module: str | bool = False,
):
self.page: int = page
self.nb_ligne_page: int = nb_ligne_page
if self.nb_ligne_page is not None:
self.nb_ligne_page = min(nb_ligne_page, ListeAssiJusti.MAX_PAR_PAGE)
self.show_pres: bool = show_pres in Options.VRAI
self.show_reta: bool = show_reta in Options.VRAI
self.show_desc: bool = show_desc in Options.VRAI
self.show_etu: bool = show_etu in Options.VRAI
self.show_actions: bool = show_actions in Options.VRAI
self.show_module: bool = show_module in Options.VRAI
def remplacer(self, **kwargs):
for k, v in kwargs.items():
if k.startswith("show_"):
setattr(self, k, v in Options.VRAI)
elif k in ["page", "nb_ligne_page"]:
setattr(self, k, int(v))
if k == "nb_ligne_page":
self.nb_ligne_page = min(
self.nb_ligne_page, ListeAssiJusti.MAX_PAR_PAGE
)
class Data:
def __init__(
self, assiduites_query: Query = None, justificatifs_query: Query = None
):
self.assiduites_query: Query = assiduites_query
self.justificatifs_query: Query = justificatifs_query
@staticmethod
def from_etudiants(*etudiants: Identite) -> "Data":
data = Data()
data.assiduites_query = Assiduite.query.filter(
Assiduite.etudid.in_([e.etudid for e in etudiants])
)
data.justificatifs_query = Justificatif.query.filter(
Justificatif.etudid.in_([e.etudid for e in etudiants])
)
return data
def get(self) -> tuple[Query, Query]:
return self.assiduites_query, self.justificatifs_query

View File

@ -84,6 +84,8 @@ class Table(Element):
self.row_by_id: dict[str, "Row"] = {}
self.column_ids = []
"ordered list of columns ids"
self.raw_column_ids = []
"ordered list of columns ids for excel"
self.groups = []
"ordered list of column groups names"
self.group_titles = {}
@ -360,6 +362,7 @@ class Row(Element):
target_attrs: dict = None,
target: str = None,
column_classes: set[str] = None,
no_excel: bool = False,
) -> "Cell":
"""Create cell and add it to the row.
group: groupe de colonnes
@ -380,10 +383,17 @@ class Row(Element):
target=target,
target_attrs=target_attrs,
)
return self.add_cell_instance(col_id, cell, column_group=group, title=title)
return self.add_cell_instance(
col_id, cell, column_group=group, title=title, no_excel=no_excel
)
def add_cell_instance(
self, col_id: str, cell: "Cell", column_group: str = None, title: str = None
self,
col_id: str,
cell: "Cell",
column_group: str = None,
title: str = None,
no_excel: bool = False,
) -> "Cell":
"""Add a cell to the row.
Si title est None, il doit avoir été ajouté avec table.add_title().
@ -392,6 +402,9 @@ class Row(Element):
self.cells[col_id] = cell
if col_id not in self.table.column_ids:
self.table.column_ids.append(col_id)
if not no_excel:
self.table.raw_column_ids.append(col_id)
self.table.insert_group(column_group)
if column_group is not None:
self.table.column_group[col_id] = column_group
@ -422,7 +435,7 @@ class Row(Element):
"""row as a dict, with only cell contents"""
return {
col_id: self.cells.get(col_id, self.table.empty_cell).raw_content
for col_id in self.table.column_ids
for col_id in self.table.raw_column_ids
}
def to_excel(self, sheet, style=None) -> list:

View File

@ -1,14 +1,15 @@
{% include "assiduites/widgets/toast.j2" %}
{% include "assiduites/widgets/alert.j2" %}
{% block pageContent %}
<div class="pageContent">
<h3>Ajouter une assiduité</h3>
{% include "assiduites/widgets/tableau_base.j2" %}
<h3>Signaler un évènement pour {{etud.html_link_fiche()|safe}}</h3>
{% if saisie_eval %}
<div id="saisie_eval">
<br>
<h3>
La saisie de l'assiduité a été préconfigurée en fonction de l'évaluation. <br>
Une fois la saisie finie, cliquez sur le lien si dessous pour revenir sur la gestion de l'évaluation
La saisie a été préconfigurée en fonction de l'évaluation. <br>
Une fois la saisie terminée, cliquez sur le lien ci-dessous
</h3>
<a href="{{redirect_url}}">retourner sur la page de l'évaluation</a>
</div>
@ -29,7 +30,7 @@
<div class="assi-row">
<div class="assi-label">
<legend for="assi_etat" required>Etat de l'assiduité</legend>
<legend for="assi_etat" required>État de l'assiduité</legend>
<select name="assi_etat" id="assi_etat">
<option value="absent" selected>Absent</option>
<option value="retard">Retard</option>
@ -54,7 +55,7 @@
</div>
<div class="assi-row">
<button onclick="validerFormulaire(this)">Créer l'assiduité</button>
<button onclick="validerFormulaire(this)">Enregistrer</button>
<button onclick="effacerFormulaire()">Remettre à zero</button>
</div>
@ -63,8 +64,7 @@
</section>
<section class="liste">
<a class="icon filter" onclick="filterAssi()"></a>
{% include "assiduites/widgets/tableau_assi.j2" %}
{{tableau | safe }}
</section>
</div>
@ -141,7 +141,7 @@
let assiduite_id = null;
createAssiduiteComplete(assiduite, etudid);
loadAll();
updateTableau();
btn.disabled = true;
setTimeout(() => {
btn.disabled = false;
@ -208,7 +208,6 @@
{% endif %}
window.addEventListener("load", () => {
loadAll();
document.getElementById('assi_journee').addEventListener('click', () => { dayOnly() });
dayOnly()
@ -231,4 +230,4 @@
});
</script>
{% endblock pageContent %}
{% endblock pageContent %}

View File

@ -2,8 +2,6 @@
{% block pageContent %}
<div class="pageContent">
<h3>Justifier des absences ou retards</h3>
{% include "assiduites/widgets/tableau_base.j2" %}
<section class="justi-form page">
@ -58,28 +56,9 @@
</section>
<section class="liste">
<a class="icon filter" onclick="filterJusti()"></a>
{% include "assiduites/widgets/tableau_justi.j2" %}
{{tableau | safe }}
</section>
<div class="legende">
<h3>Gestion des justificatifs</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu
contextuel :
<ul>
<li>Détails : Affiche les détails du justificatif sélectionné</li>
<li>Editer : Permet de modifier le justificatif (dates, etat, ajouter/supprimer fichier etc)</li>
<li>Supprimer : Permet de supprimer le justificatif (Action Irréversible)</li>
</ul>
</p>
<p>Cliquer sur l'icone d'entonoir afin de filtrer le tableau des justificatifs</p>
</div>
</div>
<style>
@ -167,17 +146,15 @@
processData: false,
success: () => {
pushToast(generateToast(document.createTextNode(`Importation du fichier : ${f.name} finie`)));
loadAll();
},
}
)
)
});
if (in_files.files.length == 0) {
loadAll();
}
$.when(...requests).done(() => {
location.reload();
})
}
function validerFormulaire(btn) {
@ -258,7 +235,6 @@
const assi_evening = '{{assi_evening}}';
window.onload = () => {
loadAll();
document.getElementById('justi_journee').addEventListener('click', () => { dayOnly() });
dayOnly()

View File

@ -2,95 +2,6 @@
<div class="pageContent">
<h2>Liste de l'assiduité et des justificatifs de <span class="rouge">{{sco.etud.nomprenom}}</span></h2>
{% include "assiduites/widgets/tableau_base.j2" %}
<h3>Assiduité :</h3>
<span class="iconline">
<a class="icon filter" onclick="filterAssi()"></a>
<a class="icon download" onclick="downloadAssi()"></a>
</span>
{% include "assiduites/widgets/tableau_assi.j2" %}
<h3>Justificatifs :</h3>
<span class="iconline">
<a class="icon filter" onclick="filterJusti()"></a>
<a class="icon download" onclick="downloadJusti()"></a>
</span>
{% include "assiduites/widgets/tableau_justi.j2" %}
<ul id="contextMenu" class="context-menu">
<li id="detailOption">Detail</li>
<li id="editOption">Editer</li>
<li id="deleteOption">Supprimer</li>
</ul>
<div class="legende">
<h3>Gestion des justificatifs</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu
contextuel :
</p>
<ul>
<li>Détails : Affiche les détails du justificatif sélectionné</li>
<li>Editer : Permet de modifier le justificatif (dates, etat, ajouter/supprimer fichier etc)</li>
<li>Supprimer : Permet de supprimer le justificatif (Action Irréversible)</li>
</ul>
<p>Vous pouvez filtrer le tableau en cliquant sur l'icone d'entonnoir sous le titre du tableau.</p>
<h3>Gestion de l'assiduité</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu
contextuel :
</p>
<ul>
<li>Détails : affiche les détails de l'assiduité sélectionnée</li>
<li>Éditer : modifier l'élément (module, état)</li>
<li>Supprimer : supprimer l'élément (action irréversible)</li>
</ul>
<p>Vous pouvez filtrer le tableau en cliquant sur l'icone d'entonnoir sous le titre du tableau.</p>
</div>
{{tableau | safe }}
</div>
{% endblock app_content %}
<script>
const etudid = {{ sco.etud.id }}
const assiduite_unique_id = {{ assi_id }};
const assi_limit_annee = "{{ assi_limit_annee }}" == "True" ? true : false;
function wayForFilter() {
if (typeof assiduites[etudid] !== "undefined") {
console.log("Done")
let assiduite = assiduites[etudid].filter((a) => { return a.assiduite_id == assiduite_unique_id });
if (assiduite) {
assiduite = assiduite[0]
filterAssiduites["filters"] = {
"obj_id": [
assiduite.assiduite_id,
]
}
const obj_ids = assiduite.justificatifs ? assiduite.justificatifs.map((j) => { return j.justif_id }) : []
filterJustificatifs["filters"] = {
"obj_id": obj_ids
}
loadAll();
}
} else {
setTimeout(wayForFilter, 250)
}
}
window.onload = () => {
loadAll();
if (assiduite_unique_id != -1) {
wayForFilter()
}
}
</script>
{% endblock app_content %}

View File

@ -0,0 +1,27 @@
{% extends "sco_page.j2" %}
{% block scripts %}
{{ super() }}
<script src="{{scu.STATIC_DIR}}/js/etud_info.js"></script>
<script src="{{scu.STATIC_DIR}}/js/date_utils.js"></script>
{% endblock %}
{% block app_content %}
{% if action == "modifier" %}
{% include "assiduites/widgets/tableau_actions/modifier.j2" %}
{% else%}
{% include "assiduites/widgets/tableau_actions/details.j2" %}
{% endif %}
<br>
<hr>
<br>
<a href="" id="lien-retour">Retour</a>
<script>
window.addEventListener('load', () => {
document.getElementById("lien-retour").href = document.referrer;
})
</script>
{% endblock %}

View File

@ -1,44 +0,0 @@
{% extends "sco_page.j2" %}
{% block scripts %}
{{ super() }}
<script src="{{scu.STATIC_DIR}}/js/etud_info.js"></script>
{% endblock %}
{% block app_content %}
<legend>
Options
<form action="" method="get">
<label for="show_pres">afficher les présences</label>
{% if show_pres %}
<input type="checkbox" id="show_pres" name="show_pres" checked>
{% else %}
<input type="checkbox" id="show_pres" name="show_pres">
{% endif %}
<label for="show_reta">afficher les retards</label>
{% if show_reta %}
<input type="checkbox" id="show_reta" name="show_reta" checked>
{% else %}
<input type="checkbox" id="show_reta" name="show_reta">
{% endif %}
<br>
<label for="nb_ligne_page">Nombre de ligne par page : </label>
<input type="number" name="nb_ligne_page" id="nb_ligne_page" value="{{nb_ligne_page}}">
<label for="n_page">Page n°</label>
<select name="n_page" id="n_page">
{% for n in range(1,total_pages+1) %}
<option value="{{n}}">{{n}}</option>
{% endfor %}
</select>
<br>
<input type="submit" value="valider">
</form>
</legend>
{{tableau | safe}}
{% endblock %}

View File

@ -3,6 +3,7 @@
{% block scripts %}
{{ super() }}
<script src="{{scu.STATIC_DIR}}/js/etud_info.js"></script>
<script src="{{scu.STATIC_DIR}}/js/date_utils.js"></script>
{% endblock %}
{% block app_content %}
@ -43,4 +44,4 @@
</script>
{% endblock %}
{% endblock %}

View File

@ -119,11 +119,17 @@
}
{% if moduleid %}
const moduleimpl_dynamic_selector_id = "{{moduleid}}"
{% else %}
const moduleimpl_dynamic_selector_id = "moduleimpl_select"
{% endif %}
window.addEventListener("load", () => {
document.getElementById('moduleimpl_select').addEventListener('change', (el) => {
document.getElementById(moduleimpl_dynamic_selector_id).addEventListener('change', (el) => {
const assi = getCurrentAssiduite(etudid);
if (assi) {
editAssiduite(assi.assiduite_id, assi.etat, [assi]);

View File

@ -1,6 +1,8 @@
<select name="moduleimpl_select" id="moduleimpl_select">
{% with moduleimpl_id=moduleimpl_id %}
{% include "assiduites/widgets/simplemoduleimpl_select.j2" %}
{% endwith %}
{% for mod in modules %}
{% if mod.moduleimpl_id == moduleimpl_id %}

View File

@ -1,6 +1,10 @@
{% if scu.is_assiduites_module_forced(request.args.get('formsemestre_id', None))%}
<option value="" selected disabled> Saisir Module</option>
<option value="" disabled> Saisir Module</option>
{% else %}
<option value="" selected> Non spécifié </option>
<option value=""> Non spécifié </option>
{% endif %}
<option value="autre"> Tout module </option>
{% if moduleimpl_id == "autre" %}
<option value="autre" selected> Tout module </option>
{% else %}
<option value="autre"> Tout module </option>
{% endif %}

View File

@ -0,0 +1,73 @@
<hr>
<div>
<h3>Options</h3>
<div id="options-tableau">
{% if afficher_options != false %}
<label for="show_pres">afficher les présences</label>
{% if options.show_pres %}
<input type="checkbox" id="show_pres" name="show_pres" checked>
{% else %}
<input type="checkbox" id="show_pres" name="show_pres">
{% endif %}
<label for="show_reta">afficher les retards</label>
{% if options.show_reta %}
<input type="checkbox" id="show_reta" name="show_reta" checked>
{% else %}
<input type="checkbox" id="show_reta" name="show_reta">
{% endif %}
<label for="with_desc">afficher les descriptions</label>
{% if options.show_desc %}
<input type="checkbox" id="show_desc" name="show_desc" checked>
{% else %}
<input type="checkbox" id="show_desc" name="show_desc">
{% endif %}
<br>
{% endif %}
<label for="nb_ligne_page">Nombre de ligne par page : </label>
<input type="number" name="nb_ligne_page" id="nb_ligne_page" value="{{options.nb_ligne_page}}">
<label for="n_page">Page n°</label>
<select name="n_page" id="n_page">
{% for n in range(1,total_pages+1) %}
{% if n == options.page %}
<option value="{{n}}" selected>{{n}}</option>
{% else %}
<option value="{{n}}">{{n}}</option>
{% endif %}
{% endfor %}
</select>
<br>
<button onclick="updateTableau()">valider</button>
<a style="margin-left:32px;" href="{{request.url}}&fmt=xlsx">{{scu.ICON_XLS|safe}}</a>
</div>
</div>
{{tableau | safe}}
<script>
function updateTableau() {
const url = new URL(location.href);
const form = document.getElementById("options-tableau");
const formValues = form.querySelectorAll("*[name]");
formValues.forEach((el) => {
if (el.type == "checkbox") {
url.searchParams.set(el.name, el.checked)
} else {
url.searchParams.set(el.name, el.value)
}
})
location.href = url.href;
}
</script>
<style>
.small-font {
font-size: 9pt;
}
</style>

View File

@ -0,0 +1,106 @@
<h1>Détails {{type}} </h1>
<div id="informations">
<div class="info-row">
<span class="info-label">Étudiant.e concerné.e:</span> <span class="etudinfo"
id="etudid-{{objet.etudid}}">{{objet.etud_nom}}</span>
</div>
<div class="info-row">
<span class="info-label">Période concernée :</span> {{objet.date_debut}} au {{objet.date_fin}}
</div>
{% if type == "Assiduité" %}
<div class="info-row">
<span class="info-label">Module concernée :</span> {{objet.module}}
</div>
{% else %}
{% endif %}
<div class="info-row">
{% if type == "Justificatif" %}
<span class="info-label">État du justificatif :</span>
{% else %}
<span class="info-label">État de l'assiduité :</span>
{% endif %}
{{objet.etat}}
</div>
<div class="info-row">
{% if type == "Justificatif" %}
<div class="info-label">Raison:</div>
{% if objet.raison != None %}
<div class="text">{{objet.raison}}</div>
{% else %}
<div class="text">/div>
{% endif %}
{% else %}
<div class="info-label">Description:</div>
{% if objet.description != None %}
<div class="text">{{objet.description}}</div>
{% else %}
<div class="text"></div>
{% endif %}
{% endif %}
</div>
</div>
{# Affichage des justificatifs si assiduité justifiée #}
{% if type == "Assiduité" and objet.etat != "Présence" %}
<div class="info-row">
<span class="info-label">Justifiée: </span>
{% if objet.justification.est_just %}
<span class="text">Oui</span>
<div>
{% for justi in objet.justification.justificatifs %}
<a href="{{url_for('assiduites.tableau_assiduite_actions', type='justificatif', action='details', obj_id=justi.justif_id, scodoc_dept=g.scodoc_dept)}}"
target="_blank" rel="noopener noreferrer">Justificatif du {{justi.date_debut}} au {{justi.date_fin}}</a>
{% endfor %}
</div>
{% else %}
<span class="text">Non</span>
{% endif %}
</div>
{% endif %}
{# Affichage des assiduités justifiées si justificatif valide #}
{% if type == "Justificatif" and objet.etat == "Valide" %}
<div class="info-row">
<span class="info-label">Assiduités concernées: </span>
{% if objet.justification.assiduites %}
<div>
{% for assi in objet.justification.assiduites %}
<a href="{{url_for('assiduites.tableau_assiduite_actions', type='assiduite', action='details', obj_id=assi.assiduite_id, scodoc_dept=g.scodoc_dept)}}"
target="_blank">Assiduité {{assi.etat}} du {{assi.date_debut}} au
{{assi.date_fin}}</a>
{% endfor %}
</div>
{% else %}
<span class="text">Aucune</span>
{% endif %}
</div>
{% endif %}
{# Affichage des fichiers des justificatifs #}
{% if type == "Justificatif"%}
<div class="info-row">
<span class="info-label">Fichiers enregistrés: </span>
{% if objet.justification.fichiers.total != 0 %}
<div>Total : {{objet.justification.fichiers.total}} </div>
<ul>
{% for filename in objet.justification.fichiers.filenames %}
<li><a
href="{{url_for('api.justif_export',justif_id=objet.justif_id,filename=filename, scodoc_dept=g.scodoc_dept)}}">{{filename}}</a>
</li>
{% endfor %}
</ul>
{% else %}
<span class="text">Aucun</span>
{% endif %}
</div>
{% endif %}
<div class="info-row">
<span>Saisie par {{objet.saisie_par}} le {{objet.entry_date}}</span>
</div>

View File

@ -0,0 +1,107 @@
<h1>Modifier {{type}} </h1>
<form action="" method="post" enctype="multipart/form-data">
<input type="hidden" name="obj_id" value="{{obj_id}}">
<input type="hidden" name="table_url" id="table_url" value="">
{% if type == "Assiduité" %}
<input type="hidden" name="obj_type" value="assiduite">
<legend for="etat">État</legend>
<select name="etat" id="etat">
<option value="absent">Absent</option>
<option value="retard">Retard</option>
<option value="present">Présent</option>
</select>
<legend for="moduleimpl_select">Module</legend>
{{moduleimpl | safe}}
<legend for="description">Description</legend>
<textarea name="description" id="description" cols="50" rows="5">{{objet.description}}</textarea>
{% else %}
<input type="hidden" name="obj_type" value="justificatif">
<legend for="date_debut">Date de début</legend>
<scodoc-datetime name="date_debut" id="date_debut" value="{{objet.real_date_debut}}"></scodoc-datetime>
<legend for="date_fin">Date de fin</legend>
<scodoc-datetime name="date_fin" id="date_fin" value="{{objet.real_date_fin}}"></scodoc-datetime>
<legend for="etat">État</legend>
<select name="etat" id="etat">
<option value="valide">Valide</option>
<option value="non_valide">Non Valide</option>
<option value="attente">En Attente</option>
<option value="modifie">Modifié</option>
</select>
<legend for="raison">Raison</legend>
<textarea name="raison" id="raison" cols="50" rows="5">{{objet.raison}}</textarea>
<legend>Fichiers</legend>
<div class="info-row">
<label class="info-label">Fichiers enregistrés: </label>
{% if objet.justification.fichiers.total != 0 %}
<div>Total : {{objet.justification.fichiers.total}} </div>
<ul>
{% for filename in objet.justification.fichiers.filenames %}
<li data-id="{{filename}}">
<a data-file="{{filename}}">❌</a>
<a data-link=""
href="{{url_for('api.justif_export',justif_id=objet.justif_id,filename=filename, scodoc_dept=g.scodoc_dept)}}"><span
data-file="{{filename}}">{{filename}}</span></a>
</li>
{% endfor %}
</ul>
{% else %}
<span class="text">Aucun</span>
{% endif %}
</div>
<br>
<label for="justi_fich">Ajouter des fichiers:</label>
<input type="file" name="justi_fich" id="justi_fich" multiple>
{% endif %}
<br>
<br>
<input type="submit" value="Valider">
</form>
<script>
function removeFile(element) {
const link = document.querySelector(`*[data-id="${element.getAttribute('data-file')}"] a[data-link] span`);
link?.toggleAttribute("data-remove")
}
function deleteFiles(justif_id) {
const filenames = Array.from(document.querySelectorAll("*[data-remove]")).map((el) => el.getAttribute("data-file"))
obj = {
"remove": "list",
"filenames": filenames
}
//faire un POST à l'api justificatifs
}
window.addEventListener('load', () => {
document.getElementById('etat').value = "{{objet.real_etat}}";
document.getElementById('table_url').value = document.referrer;
document.querySelectorAll("a[data-file]").forEach((e) => {
e.addEventListener('click', () => {
removeFile(e);
})
})
})
</script>
<style>
[data-remove] {
text-decoration: line-through;
}
[data-file] {
cursor: pointer;
user-select: none;
}
</style>

View File

@ -9,15 +9,18 @@
<div class="user_basics">
<b>Login :</b> {{user.user_name}}<br>
<b>CAS id:</b> {{user.cas_id or "(aucun)"}}
(CAS {{'autorisé' if user.cas_allow_login else 'interdit'}} pour cet utilisateur)
{% if user.cas_allow_scodoc_login %}
(connexion sans CAS autorisée)
{% if ScoDocSiteConfig.is_cas_enabled() %}
(CAS {{'autorisé' if user.cas_allow_login else 'interdit'}} pour cet utilisateur)
{% if user.cas_allow_scodoc_login %}
(connexion sans CAS autorisée)
{% endif %}
{% endif %}
<br>
<b>Nom :</b> {{user.nom or ""}}<br>
<b>Prénom :</b> {{user.prenom or ""}}<br>
<b>Mail :</b> {{user.email}}<br>
<b>Mail institutionnel:</b> {{user.email_institutionnel or ""}}<br>
<b>Identifiant EDT:</b> {{user.edt_id or ""}}<br>
<b>Rôles :</b> {{user.get_roles_string()}}<br>
<b>Dept :</b> {{user.dept or ""}}<br>
{% if user.passwd_temp or user.password_scodoc7 %}

View File

@ -0,0 +1,20 @@
{% extends "sco_page.j2" %}
{% block styles %}
{{super()}}
{% endblock %}
{% block app_content %}
<div class="tab-content">
<h2>Étudiants des semestres courants</h2>
<a href="{{
url_for('scolar.export_etudiants_courants', scodoc_dept=g.scodoc_dept, fmt='xls')
}}">{{scu.ICON_XLS|safe}}</a>
{{ table.html() | safe }}
</div>
{% endblock %}

View File

@ -617,7 +617,7 @@
listeGroupesAutoaffectation();
})
.catch(error => {
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données.</h2>";
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données (1).</h2>";
})
}
@ -665,13 +665,13 @@
document.querySelector(`#zoneGroupes .partition[data-idpartition="${idPartition}"]`).innerHTML += templateGroupe_zoneGroupes(r.id, name);
// Lancement de l'édition du nom
divGroupe.querySelector(".modif").click();
// divGroupe.querySelector(".modif").click();
listeGroupesAutoaffectation();
})
.catch(error => {
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données.</h2>";
})
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données (4).</h2>";
});
}
/********************/
@ -746,12 +746,12 @@
.then(r => { return r.json() })
.then(r => {
if (!r) {
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données.</h2>";
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données (2).</h2>";
}
listeGroupesAutoaffectation();
})
.catch(error => {
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données.</h2>";
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données (3).</h2>";
})
}
@ -802,7 +802,7 @@
.then(r => { return r.json() })
.then(r => {
if (r.OK != true) {
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données.</h2>";
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données (5).</h2>";
}
listeGroupesAutoaffectation();
})
@ -916,12 +916,12 @@
.then(r => { return r.json() })
.then(r => {
if (!r) {
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données.</h2>";
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données (6).</h2>";
}
listeGroupesAutoaffectation();
})
.catch(error => {
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données.</h2>";
document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors de la sauvegarde des données (7).</h2>";
})
}

View File

@ -32,6 +32,7 @@ from flask import abort, url_for, redirect
from flask_login import current_user
from app import db
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.decorators import (
@ -47,6 +48,10 @@ from app.models import (
Departement,
Evaluation,
)
from app.auth.models import User
from app.models.assiduites import get_assiduites_justif, compute_assiduites_justified
import app.tables.liste_assiduites as liste_assi
from app.views import assiduites_bp as bp
from app.views import ScoData
@ -65,6 +70,7 @@ from app.scodoc.sco_exceptions import ScoValueError
from app.tables.visu_assiduites import TableAssi, etuds_sorted_from_ids
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS
@ -260,13 +266,6 @@ def signal_assiduites_etud():
if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département")
# Récupération de la date (par défaut la date du jour)
date = request.args.get("date", datetime.date.today().isoformat())
heures: list[str] = [
request.args.get("heure_deb", ""),
request.args.get("heure_fin", ""),
]
# gestion évaluations (Appel à la page depuis les évaluations)
saisie_eval: bool = request.args.get("saisie_eval") is not None
@ -299,21 +298,17 @@ def signal_assiduites_etud():
],
)
# Gestion des horaires (journée, matin, soir)
morning = ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00")
lunch = ScoDocSiteConfig.assi_get_rounded_time("assi_lunch_time", "13:00:00")
afternoon = ScoDocSiteConfig.assi_get_rounded_time(
"assi_afternoon_time", "18:00:00"
tableau = _preparer_tableau(
liste_assi.Data.from_etudiants(
etud,
),
filename=f"assiduite-{etudid}",
afficher_etu=False,
filtre=liste_assi.Filtre(type_obj=1),
options=liste_assi.Options(show_module=True),
)
# Gestion du selecteur de moduleimpl (pour le tableau différé)
select = f"""
<select class="dynaSelect">
{render_template("assiduites/widgets/simplemoduleimpl_select.j2")}
</select>
"""
if not tableau[0]:
return tableau[1]
# Génération de la page
return HTMLBuilder(
header,
@ -330,8 +325,10 @@ def signal_assiduites_etud():
saisie_eval=saisie_eval,
date_deb=date_deb,
date_fin=date_fin,
etud=etud,
redirect_url=redirect_url,
moduleimpl_id=moduleimpl_id,
tableau=tableau[1],
),
# render_template(
# "assiduites/pages/signal_assiduites_etud.j2",
@ -377,7 +374,7 @@ def liste_assiduites_etud():
if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département")
# Gestion d'une assiduité unique (redirigé depuis le calendrier)
# Gestion d'une assiduité unique (redirigé depuis le calendrier) TODO-Assiduites
assiduite_id: int = request.args.get("assiduite_id", -1)
# Préparation de la page
@ -393,18 +390,25 @@ def liste_assiduites_etud():
"css/assiduites.css",
],
)
tableau = _preparer_tableau(
liste_assi.Data.from_etudiants(
etud,
),
filename=f"assiduites-justificatifs-{etudid}",
afficher_etu=False,
filtre=liste_assi.Filtre(type_obj=0),
options=liste_assi.Options(show_module=True),
)
if not tableau[0]:
return tableau[1]
# Peuplement du template jinja
return HTMLBuilder(
header,
render_template(
"assiduites/pages/liste_assiduites.j2",
sco=ScoData(etud),
date=datetime.date.today().isoformat(),
assi_id=assiduite_id,
assi_limit_annee=sco_preferences.get_preference(
"assi_limit_annee",
dept_id=g.scodoc_dept_id,
),
tableau=tableau[1],
),
).build()
@ -501,6 +505,19 @@ def ajout_justificatif_etud():
],
)
tableau = _preparer_tableau(
liste_assi.Data.from_etudiants(
etud,
),
filename=f"justificatifs-{etudid}",
afficher_etu=False,
filtre=liste_assi.Filtre(type_obj=2),
options=liste_assi.Options(show_module=False, show_desc=True),
afficher_options=False,
)
if not tableau[0]:
return tableau[1]
# Peuplement du template jinja
return HTMLBuilder(
header,
@ -513,6 +530,7 @@ def ajout_justificatif_etud():
),
assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"),
assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"),
tableau=tableau[1],
),
).build()
@ -1044,26 +1062,44 @@ def visu_assi_group():
)
@bp.route("/testTableau")
@scodoc
@permission_required(Permission.ScoView)
def testTableau():
"""Visualisation de l'assiduité d'un groupe entre deux dates"""
def _preparer_tableau(
data: liste_assi.Data,
filename: str = "tableau-assiduites",
afficher_etu: bool = True,
filtre: liste_assi.Filtre = None,
options: liste_assi.Options = None,
afficher_options: bool = True,
) -> tuple[bool, "Response"]:
"""
_preparer_tableau prépare un tableau d'assiduités / justificatifs
etudid = request.args.get(
"etudid", 18114
) # TODO retirer la valeur par défaut de test
Cette fontion récupère dans la requête les arguments :
valeurs possibles des booléens vrais ["on", "true", "t", "v", "vrai", True, 1]
toute autre valeur est considérée comme fausse.
show_pres : bool -> Affiche les présences, par défaut False
show_reta : bool -> Affiche les retard, par défaut False
show_desc : bool -> Affiche les descriptions, par défaut False
Returns:
tuple[bool | "Reponse" ]:
- bool : Vrai si la réponse est du Text/HTML
- Reponse : du Text/HTML ou Une Reponse (téléchargement fichier)
"""
fmt = request.args.get("fmt", "html")
show_pres: bool | str = request.args.get("show_pres", False)
show_reta: bool | str = request.args.get("show_reta", False)
show_desc: bool | str = request.args.get("show_desc", False)
nb_ligne_page: int = request.args.get("nb_ligne_page")
# Vérification de nb_ligne_page
try:
nb_ligne_page: int = int(nb_ligne_page)
except (ValueError, TypeError):
nb_ligne_page = None
nb_ligne_page = liste_assi.ListeAssiJusti.NB_PAR_PAGE
page_number: int = request.args.get("n_page", 1)
# Vérification de page_number
@ -1072,33 +1108,272 @@ def testTableau():
except (ValueError, TypeError):
page_number = 1
from app.tables.liste_assiduites import ListeAssiJusti
fmt = request.args.get("fmt", "html")
table: ListeAssiJusti = ListeAssiJusti(
Identite.get_etud(etudid), page=page_number, nb_par_page=nb_ligne_page
if options is None:
options: liste_assi.Options = liste_assi.Options()
options.remplacer(
page=page_number,
nb_ligne_page=nb_ligne_page,
show_pres=show_pres,
show_reta=show_reta,
show_desc=show_desc,
show_etu=afficher_etu,
)
table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti(
table_data=data,
options=options,
filtre=filtre,
)
if fmt.startswith("xls"):
return scu.send_file(
return False, scu.send_file(
table.excel(),
filename=f"assiduite-{groups_infos.groups_filename}",
filename=filename,
mime=scu.XLSX_MIMETYPE,
suffix=scu.XLSX_SUFFIX,
)
return render_template(
"assiduites/pages/test_assi.j2",
sco=ScoData(),
return True, render_template(
"assiduites/widgets/tableau.j2",
tableau=table.html(),
title=f"Test tableau",
total_pages=table.total_pages,
page_number=page_number,
show_pres=show_pres,
show_reta=show_reta,
nb_ligne_page=nb_ligne_page,
options=options,
afficher_options=afficher_options,
)
@bp.route("/TableauAssiduiteActions", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def tableau_assiduite_actions():
obj_type: str = request.args.get("type", "assiduite")
action: str = request.args.get("action", "details")
obj_id: str = int(request.args.get("obj_id", -1))
objet: Assiduite | Justificatif
if obj_type == "assiduite":
objet: Assiduite = Assiduite.query.get_or_404(obj_id)
else:
objet: Justificatif = Justificatif.query.get_or_404(obj_id)
if action == "supprimer":
objet.supprimer()
if obj_type == "assiduite":
flash("L'assiduité a bien été supprimée")
else:
flash("Le justificatif a bien été supprimé")
return redirect(request.referrer)
if request.method == "GET":
module = ""
if obj_type == "assiduite":
formsemestre = objet.get_formsemestre()
if objet.moduleimpl_id is not None:
module = objet.moduleimpl_id
elif objet.external_data is not None:
module = objet.external_data.get("module", "")
module = module.lower() if isinstance(module, str) else module
module = _module_selector(formsemestre, module)
return render_template(
"assiduites/pages/tableau_actions.j2",
sco=ScoData(etud=objet.etudiant),
type="Justificatif" if obj_type == "justificatif" else "Assiduité",
action=action,
objet=_preparer_objet(obj_type, objet),
obj_id=obj_id,
moduleimpl=module,
)
# Cas des POSTS
if obj_type == "assiduite":
try:
_action_modifier_assiduite(objet)
except ScoValueError as error:
raise ScoValueError(error.args[0], request.referrer) from error
flash("L'assiduité a bien été modifiée.")
else:
try:
_action_modifier_justificatif(objet)
except ScoValueError as error:
raise ScoValueError(error.args[0], request.referrer) from error
flash("Le justificatif a bien été modifié.")
return redirect(request.form["table_url"])
def _action_modifier_assiduite(assi: Assiduite):
form = request.form
# Gestion de l'état
etat = scu.EtatAssiduite.get(form["etat"])
if etat is not None:
assi.etat = etat
if etat == scu.EtatAssiduite.PRESENT:
assi.est_just = False
else:
assi.est_just = len(get_assiduites_justif(assi.assiduite_id, False)) > 0
# Gestion de la description
assi.description = form["description"]
module: str = form["moduleimpl_select"]
if module == "":
module = None
else:
try:
module = int(module)
except ValueError:
pass
assi.set_moduleimpl(module)
db.session.add(assi)
db.session.commit()
scass.simple_invalidate_cache(assi.to_dict(True), assi.etudid)
def _action_modifier_justificatif(justi: Justificatif):
form = request.form
# Gestion des Dates
date_debut: datetime = scu.is_iso_formated(form["date_debut"], True)
date_fin: datetime = scu.is_iso_formated(form["date_fin"], True)
if date_debut is None or date_fin is None or date_fin < date_debut:
raise ScoValueError("Dates invalides", request.referrer)
justi.date_debut = date_debut
justi.date_fin = date_fin
# Gestion de l'état
etat = scu.EtatJustificatif.get(form["etat"])
if etat is not None:
justi.etat = etat
else:
raise ScoValueError("État invalide", request.referrer)
# Gestion de la raison
justi.raison = form["raison"]
# Gestion des fichiers
files = request.files.getlist("justi_fich")
if len(files) != 0:
files = request.files.values()
archive_name: str = justi.fichier
# Utilisation de l'archiver de justificatifs
archiver: JustificatifArchiver = JustificatifArchiver()
for fich in files:
archive_name, _ = archiver.save_justificatif(
justi.etudiant,
filename=fich.filename,
data=fich.stream.read(),
archive_name=archive_name,
user_id=current_user.id,
)
justi.fichier = archive_name
db.session.add(justi)
db.session.commit()
scass.compute_assiduites_justified(justi.etudid, reset=True)
scass.simple_invalidate_cache(justi.to_dict(True), justi.etudid)
def _preparer_objet(
obj_type: str, objet: Assiduite | Justificatif, sans_gros_objet: bool = False
) -> dict:
# Préparation d'un objet pour simplifier l'affichage jinja
objet_prepare: dict = objet.to_dict()
if obj_type == "assiduite":
objet_prepare["etat"] = (
scu.EtatAssiduite(objet.etat).version_lisible().capitalize()
)
objet_prepare["real_etat"] = scu.EtatAssiduite(objet.etat).name.lower()
objet_prepare["description"] = (
"" if objet.description is None else objet.description
)
objet_prepare["description"] = objet_prepare["description"].strip()
# Gestion du module
objet_prepare["module"] = objet.get_module(True)
# Gestion justification
if not objet.est_just:
objet_prepare["justification"] = {"est_just": False}
else:
objet_prepare["justification"] = {"est_just": True, "justificatifs": []}
if not sans_gros_objet:
justificatifs: list[int] = get_assiduites_justif(
objet.assiduite_id, False
)
for justi_id in justificatifs:
justi: Justificatif = Justificatif.query.get(justi_id)
objet_prepare["justification"]["justificatifs"].append(
_preparer_objet("justificatif", justi, sans_gros_objet=True)
)
else:
objet_prepare["etat"] = (
scu.EtatJustificatif(objet.etat).version_lisible().capitalize()
)
objet_prepare["real_etat"] = scu.EtatJustificatif(objet.etat).name.lower()
objet_prepare["raison"] = "" if objet.raison is None else objet.raison
objet_prepare["raison"] = objet_prepare["raison"].strip()
objet_prepare["justification"] = {"assiduites": [], "fichiers": {}}
if not sans_gros_objet:
assiduites: list[int] = scass.justifies(objet)
for assi_id in assiduites:
assi: Assiduite = Assiduite.query.get(assi_id)
objet_prepare["justification"]["assiduites"].append(
_preparer_objet("assiduite", assi, sans_gros_objet=True)
)
# Récupération de l'archive avec l'archiver
archive_name: str = objet.fichier
filenames: list[str] = []
archiver: JustificatifArchiver = JustificatifArchiver()
if archive_name is not None:
filenames = archiver.list_justificatifs(archive_name, objet.etudiant)
objet_prepare["justification"]["fichiers"] = {
"total": len(filenames),
"filenames": [],
}
for filename in filenames:
if int(filename[1]) == current_user.id or current_user.has_permission(
Permission.AbsJustifView
):
objet_prepare["justification"]["fichiers"]["filenames"].append(
filename[0]
)
objet_prepare["date_fin"] = objet.date_fin.strftime("%d/%m/%y à %H:%M")
objet_prepare["real_date_fin"] = objet.date_fin.isoformat()
objet_prepare["date_debut"] = objet.date_debut.strftime("%d/%m/%y à %H:%M")
objet_prepare["real_date_debut"] = objet.date_debut.isoformat()
objet_prepare["entry_date"] = objet.entry_date.strftime("%d/%m/%y à %H:%M")
objet_prepare["etud_nom"] = objet.etudiant.nomprenom
if objet.user_id != None:
user: User = User.query.get(objet.user_id)
objet_prepare["saisie_par"] = user.get_nomprenom()
else:
objet_prepare["saisie_par"] = "Inconnu"
return objet_prepare
@bp.route("/SignalAssiduiteDifferee")
@scodoc
@permission_required(Permission.AbsChange)
@ -1534,12 +1809,6 @@ def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> s
# prévoie la sélection par défaut d'un moduleimpl s'il a été passé en paramètre
selected = "" if moduleimpl_id is not None else "selected"
# Vérification que le moduleimpl_id passé en paramètre est bien un entier
try:
moduleimpl_id = int(moduleimpl_id)
except (ValueError, TypeError):
moduleimpl_id = None
modules: list[dict[str, str | int]] = []
# Récupération de l'id et d'un nom lisible pour chaque moduleimpl
for modimpl in modimpls_list:

View File

@ -105,6 +105,7 @@ from app.scodoc import sco_synchro_etuds
from app.scodoc import sco_trombino
from app.scodoc import sco_trombino_tours
from app.scodoc import sco_up_to_date
from app.tables import list_etuds
def sco_publish(route, function, permission, methods=["GET"]):
@ -2071,6 +2072,33 @@ def check_group_apogee(group_id, etat=None, fix=False, fixmail=False):
return "\n".join(H) + html_sco_header.sco_footer()
@bp.route("/export_etudiants_courants")
@scodoc
@permission_required(Permission.ScoView)
def export_etudiants_courants():
"""Table export de tous les étudiants des formsemestres en cours."""
fmt = request.args.get("fmt", "html")
departement = Departement.query.get(g.scodoc_dept_id)
if not departement:
raise ScoValueError("département invalide")
formsemestres = FormSemestre.get_dept_formsemestres_courants(departement)
table = list_etuds.table_etudiants_courants(formsemestres)
if fmt.startswith("xls"):
return scu.send_file(
table.excel(),
f"""{formsemestres[0].departement.acronym}-etudiants-{
datetime.datetime.now().strftime("%Y-%m-%dT%Hh%M")}""",
scu.XLSX_SUFFIX,
mime=scu.XLSX_MIMETYPE,
)
elif fmt == "html":
return render_template(
"scolar/export_etudiants_courants.j2", sco=ScoData(), table=table
)
else:
raise ScoValueError("invalid fmt value")
@bp.route("/form_students_import_excel", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.EtudInscrit)
@ -2361,35 +2389,57 @@ def form_students_import_infos_admissions(formsemestre_id=None):
@scodoc
@permission_required(Permission.EtudChangeAdr)
@scodoc7func
def formsemestre_import_etud_admission(formsemestre_id, import_email=True):
"""Ré-importe donnees admissions par synchro Portail Apogée"""
(
no_nip,
unknowns,
changed_mails,
) = sco_synchro_etuds.formsemestre_import_etud_admission(
formsemestre_id, import_identite=True, import_email=import_email
)
H = [
html_sco_header.html_sem_header("Ré-import données admission"),
"<h3>Opération effectuée</h3>",
]
if no_nip:
H.append("<p>Attention: étudiants sans NIP: " + str(no_nip) + "</p>")
if unknowns:
H.append(
"<p>Attention: étudiants inconnus du portail: codes NIP="
+ str(unknowns)
+ "</p>"
def formsemestre_import_etud_admission(
formsemestre_id=None, import_email=True, tous_courants=False
):
"""Ré-importe donnees admissions par synchro Portail Apogée.
Si tous_courants, le fait pour tous les formsemestres courants du département
"""
if tous_courants:
departement = Departement.query.get(g.scodoc_dept_id)
formsemestres = FormSemestre.get_dept_formsemestres_courants(departement)
else:
formsemestres = [FormSemestre.get_formsemestre(formsemestre_id)]
diag_by_sem = {}
for formsemestre in formsemestres:
(
etuds_no_nip,
etuds_unknown,
changed_mails,
) = sco_synchro_etuds.formsemestre_import_etud_admission(
formsemestre.id, import_identite=True, import_email=import_email
)
if changed_mails:
H.append("<h3>Adresses mails modifiées:</h3><ul>")
for etud, old_mail in changed_mails:
H.append(
f"""<li>{etud.nom}: <tt>{old_mail}</tt> devient <tt>{etud.email}</tt></li>"""
)
H.append("</ul>")
return "\n".join(H) + html_sco_header.sco_footer()
diag = ""
if etuds_no_nip:
diag += f"""<p>Attention: étudiants sans NIP:
{', '.join([e.html_link_fiche() for e in etuds_no_nip])}
</p>"""
if etuds_unknown:
diag += f"""<p>Attention: étudiants inconnus du portail:
{', '.join([(e.html_link_fiche() + ' (nip= ' + e.code_nip + ')')
for e in etuds_unknown])}
</p>"""
if changed_mails:
diag += """<p>Adresses mails modifiées:</p><ul>"""
for etud, old_mail in changed_mails:
diag += f"""<li>{etud.nom}: <tt>{old_mail}</tt> devient <tt>{etud.email}</tt></li>"""
diag += "</ul>"
diag_by_sem[formsemestre.id] = diag
return f"""
{ html_sco_header.html_sem_header("Ré-import données admission") }
<h3>Opération effectuée</h3>
<p>Sur le(s) semestres(s):</p>
<ul>
<li>
{ '</li><li>'.join( [(s.html_link_status() + diag_by_sem[s.id]) for s in formsemestres ]) }
</li>
</ul>
{ html_sco_header.sco_footer() }
"""
sco_publish(

View File

@ -450,6 +450,17 @@ def create_user_form(user_name=None, edit=0, all_roles=True):
"readonly": edit_only_roles,
},
),
(
"edt_id",
{
"title": "Identifiant sur l'emploi du temps",
"input_type": "text",
"explanation": """id du compte utilisateur sur l'emploi du temps
ou l'annuaire de l'établissement (par défaut, l'e-mail institutionnel )""",
"size": 36,
"allow_null": True,
},
),
]
if not edit: # options création utilisateur
descr += [
@ -690,10 +701,12 @@ def create_user_form(user_name=None, edit=0, all_roles=True):
log(f"sco_users: editing {user_name} by {current_user.user_name}")
log(f"sco_users: previous_values={initvalues}")
log(f"sco_users: new_values={vals}")
sco_users.user_edit(user_name, vals)
flash(f"Utilisateur {user_name} modifié")
else:
sco_users.user_edit(user_name, {"roles_string": vals["roles_string"]})
vals = {"roles_string": vals["roles_string"]}
the_user.from_dict(vals)
db.session.add(the_user)
db.session.commit()
flash(f"Utilisateur {user_name} modifié")
return flask.redirect(
url_for(
"users.user_info_page",
@ -749,7 +762,7 @@ def create_user_form(user_name=None, edit=0, all_roles=True):
log(
f"""sco_users: new_user {vals["user_name"]} by {current_user.user_name}"""
)
the_user = User()
the_user = User(user_name=user_name)
the_user.from_dict(vals, new_user=True)
db.session.add(the_user)
db.session.commit()
@ -916,11 +929,12 @@ def user_info_page(user_name=None):
return render_template(
"auth/user_info_page.j2",
user=user,
title=f"Utilisateur {user.user_name}",
Permission=Permission,
dept=dept,
Permission=Permission,
ScoDocSiteConfig=ScoDocSiteConfig,
session_info=session_info,
title=f"Utilisateur {user.user_name}",
user=user,
)

View File

@ -1,4 +1,5 @@
[pytest]
norecursedirs = .git app/static
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
apo
@ -11,4 +12,4 @@ markers =
filterwarnings =
ignore:.*json.*:DeprecationWarning
# en attendant mise à jour de Flask-JSON
# en attendant mise à jour de Flask-JSON

View File

@ -88,7 +88,7 @@ python-editor==1.0.4
pytz==2023.3.post1
PyYAML==6.0.1
redis==5.0.1
reportlab==4.0.5
reportlab==4.0.7
requests==2.31.0
rq==1.15.1
six==1.16.0

View File

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

View File

@ -28,10 +28,12 @@ from tests.api.setup_test_api import (
API_URL,
API_USER_ADMIN,
CHECK_CERTIFICATE,
DEPT_ACRONYM,
GET,
POST_JSON,
api_headers,
get_auth_headers,
)
from tests.api.setup_test_api import api_headers # pylint: disable=unused-import
from tests.api.tools_test_api import (
BULLETIN_ETUDIANT_FIELDS,
BULLETIN_FIELDS,
@ -923,3 +925,85 @@ def test_etudiant_groups(api_headers):
group = groups[0]
fields_ok = verify_fields(group, fields)
assert fields_ok is True
def test_etudiant_create(api_headers):
"""/etudiant/create"""
admin_header = get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN)
args = {
"prenom": "Carl Philipp Emanuel",
"nom": "Bach",
"dept": DEPT_ACRONYM,
"civilite": "M",
"admission": {
"commentaire": "test",
"annee_bac": 2024,
},
"adresses": [
{
"villedomicile": "Santa Teresa",
"emailperso": "XXX@2666.mx",
}
],
}
etud = POST_JSON(
"/etudiant/create",
args,
headers=admin_header,
)
assert etud["nom"] == args["nom"].upper()
assert etud["admission"]["commentaire"] == args["admission"]["commentaire"]
assert etud["admission"]["annee_bac"] == args["admission"]["annee_bac"]
assert len(etud["adresses"]) == 1
assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"]
assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"]
etudid = etud["id"]
# On recommence avec une nouvelle requête:
etud = GET(f"/etudiant/etudid/{etudid}", headers=api_headers)
assert etud["nom"] == args["nom"].upper()
assert etud["admission"]["commentaire"] == args["admission"]["commentaire"]
assert etud["admission"]["annee_bac"] == args["admission"]["annee_bac"]
assert len(etud["adresses"]) == 1
assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"]
assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"]
# Edition
etud = POST_JSON(
f"/etudiant/etudid/{etudid}/edit",
{
"civilite": "F",
"boursier": "N",
},
headers=admin_header,
)
assert etud["civilite"] == "F"
assert not etud["boursier"]
assert etud["nom"] == args["nom"].upper()
assert etud["admission"]["commentaire"] == args["admission"]["commentaire"]
assert etud["admission"]["annee_bac"] == args["admission"]["annee_bac"]
assert len(etud["adresses"]) == 1
assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"]
assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"]
etud = POST_JSON(
f"/etudiant/etudid/{etudid}/edit",
{
"adresses": [
{
"villedomicile": "Barcelona",
},
],
},
headers=admin_header,
)
assert etud["adresses"][0]["villedomicile"] == "Barcelona"
etud = POST_JSON(
f"/etudiant/etudid/{etudid}/edit",
{
"admission": {
"commentaire": "un nouveau commentaire",
},
"boursier": "O", # "oui", should be True
},
headers=admin_header,
)
assert etud["admission"]["commentaire"] == "un nouveau commentaire"
assert etud["boursier"]

View File

@ -90,6 +90,7 @@ def test_formsemestre_partition(api_headers):
)
assert isinstance(group_r, dict)
assert group_r["group_name"] == group_d["group_name"]
assert group_r["edt_id"] is None
# --- Liste groupes de la partition
partition = GET(f"/partition/{partition_r['id']}", headers=headers)
assert isinstance(partition, dict)
@ -99,6 +100,26 @@ def test_formsemestre_partition(api_headers):
group = partition["groups"][str(group_r["id"])] # nb: str car clés json en string
assert group["group_name"] == group_d["group_name"]
# --- Ajout d'un groupe avec edt_id
group_d = {"group_name": "extra", "edt_id": "GEDT"}
group_r = POST_JSON(
f"/partition/{partition_r['id']}/group/create",
group_d,
headers=headers,
)
assert group_r["edt_id"] == "GEDT"
# Edit edt_id
group_r = POST_JSON(
f"/group/{group_r['id']}/edit",
{"edt_id": "GEDT2"},
headers=headers,
)
assert group_r["edt_id"] == "GEDT2"
partition = GET(f"/partition/{partition_r['id']}", headers=headers)
group = partition["groups"][str(group_r["id"])] # nb: str car clés json en string
assert group["group_name"] == group_d["group_name"]
assert group["edt_id"] == "GEDT2"
# Place un étudiant dans le groupe
etud = GET(f"/formsemestre/{formsemestre_id}/etudiants", headers=headers)[0]
repl = POST_JSON(f"/group/{group['id']}/set_etudiant/{etud['id']}", headers=headers)

View File

@ -88,15 +88,17 @@ def test_edit_users(api_admin_headers):
# Change le dept et rend inactif
user = POST_JSON(
f"/user/{user['id']}/edit",
{"active": False, "dept": "TAPI"},
{"active": False, "dept": "TAPI", "edt_id": "GGG"},
headers=admin_h,
)
assert user["dept"] == "TAPI"
assert user["active"] is False
assert user["edt_id"] == "GGG"
user = GET(f"/user/{user['id']}", headers=admin_h)
assert user["nom"] == "Toto"
assert user["dept"] == "TAPI"
assert user["active"] is False
assert user["edt_id"] == "GGG"
def test_roles(api_admin_headers):

View File

@ -47,6 +47,7 @@ def test_identite(test_client):
assert e.prenom == "PRENOM"
assert e.prenom_etat_civil == "PRENOM_ETAT_CIVIL"
assert e.dept_naissance == "dept_naissance"
assert e.etat_civil == "PRENOM_ETAT_CIVIL NOM"
#
admission_id = e.admission_id
admission = db.session.get(Admission, admission_id)
@ -81,32 +82,32 @@ def test_etat_civil(test_client):
dept = Departement.query.first()
args = {"nom": "nom", "prenom": "prénom", "civilite": "M", "dept_id": dept.id}
# Homme
e = Identite(**args)
db.session.add(e)
e = Identite.create_etud(**args)
db.session.flush()
assert e.civilite_etat_civil_str == "M."
assert e.e == ""
assert e.etat_civil == "M. PRÉNOM NOM"
# Femme
e = Identite(**args | {"civilite": "F"})
db.session.add(e)
e = Identite.create_etud(**args | {"civilite": "F"})
db.session.flush()
assert e.civilite_etat_civil_str == "Mme"
assert e.e == "e"
assert e.etat_civil == "Mme PRÉNOM NOM"
# Homme devenu femme
e = Identite(**(args | {"civilite_etat_civil": "F"}))
db.session.add(e)
e = Identite.create_etud(**(args | {"civilite_etat_civil": "F"}))
db.session.flush()
assert e.civilite_etat_civil_str == "Mme"
assert e.civilite_str == "M."
assert e.e == ""
assert e.etat_civil == "Mme PRÉNOM NOM"
# Femme devenue neutre
e = Identite(**(args | {"civilite": "X", "civilite_etat_civil": "F"}))
db.session.add(e)
e = Identite.create_etud(**(args | {"civilite": "X", "civilite_etat_civil": "F"}))
db.session.flush()
assert e.civilite_etat_civil_str == "Mme"
assert e.civilite_str == ""
assert e.e == "(e)"
assert e.prenom_etat_civil is None
assert e.etat_civil == "Mme PRÉNOM NOM"
# La version dict
e_d = e.to_dict_scodoc7()
assert e_d["civilite"] == "X"
@ -119,7 +120,7 @@ def test_etud_legacy(test_client):
dept = Departement.query.first()
args = {"nom": "nom", "prenom": "prénom", "civilite": "M", "dept_id": dept.id}
# Prénom état civil
e = Identite(**(args))
e = Identite.create_etud(**(args))
db.session.add(e)
db.session.flush()
e_dict = e.to_dict_bul()

View File

@ -123,3 +123,30 @@ def test_create_delete(test_client):
db.session.commit()
ul = User.query.filter_by(prenom="Pierre").all()
assert len(ul) == 1
def test_edit(test_client):
"test edition object utlisateur"
args = {
"prenom": "No Totoro",
"edt_id": "totorito",
"cas_allow_login": 1, # boolean
"irrelevant": "..", # intentionnellement en dehors des attributs
}
u = User(user_name="Tonari")
u.from_dict(args)
db.session.add(u)
db.session.commit()
db.session.refresh(u)
assert u.edt_id == "totorito"
assert u.nom == ""
assert u.cas_allow_login is True
d = u.to_dict()
assert d["nom"] == ""
args["cas_allow_login"] = 0
u.from_dict(args)
db.session.commit()
db.session.refresh(u)
assert u.cas_allow_login is False
db.session.delete(u)
db.session.commit()