Compare commits

...

36 Commits

Author SHA1 Message Date
50d2c91a54 Connexion CAS / non CAS: correctif 2024-11-08 21:48:39 +01:00
d98eb7dc6b Améliore formsemestre_list_saisies_notes 2024-11-08 10:58:05 +01:00
6cd28853bc Améliore formsemestre_list_saisies_notes 2024-11-08 00:15:39 +01:00
07423e08b6 CAS: change sémantique param. cas_allow_scodoc_login 2024-11-07 14:30:47 +01:00
815d5a71ae Table jury: en excel, ajoute les adresses mail 2024-11-06 18:27:24 +01:00
8135038edb Restreint accès aux saisies de notes (admin et self) 2024-11-05 09:19:27 +01:00
e5a3e3a5a0 Fix API unit tests for operations_user_notes 2024-11-04 22:34:33 +01:00
2642a25cf7 Liste des saisies de notes sur le user_board 2024-11-04 21:26:18 +01:00
d7f4209a5a Comptes utilisateur: option pour forcer modif mot de passe. 2024-11-01 11:58:58 +01:00
83a5855f3d linting 2024-11-01 09:47:35 +01:00
8d85edbf1f Assiduite: saisie sur mobile: ne montre pas la minitimeline 2024-10-31 22:36:54 +01:00
0ec9814ac4 Assiduité: cosmetic timeline (borders) 2024-10-31 11:18:00 +01:00
8331d4ff69 API: recap (/resultats): prénom à afficher (=> améliore lisibilité éditeur partitions) 2024-10-31 10:37:57 +01:00
dfca8b07be cosmetic 2024-10-31 09:54:11 +01:00
e772a29363 Page passage d'un semestre à l'autre: améliore listes 2024-10-31 09:20:45 +01:00
dc009856d6 Synchro Apogée: listes d'étudiants en tables triables 2024-10-30 23:08:35 +01:00
dcbf4f32c4 sco_inscr_passage: refactoring 2024-10-30 22:11:39 +01:00
28e794c089 Synchro Apogée: retire indication en double 2024-10-30 21:25:10 +01:00
1d9c36f8e2 Synchro Apogée: ajout information sur finalisation inscription 2024-10-30 12:18:53 +01:00
da93a99069 Fix: moduleimpl_status: lien saisie notes/par groupe 2024-10-30 10:28:15 +01:00
7aec0cd2f3 Models => ScoDocModel. Elimine warnings SQLAlechmy 2. 2024-10-29 19:18:36 +01:00
6aaacbe42e Remove js log 2024-10-29 16:42:49 +01:00
8b01df0b02 Assiduité: fix bug fuseau horaire dans l'UI JS. Ajout API et test. 2024-10-29 16:40:59 +01:00
d1a2e52fef Merge branch 'el' of https://scodoc.org/git/viennet/ScoDoc 2024-10-28 22:20:30 +01:00
d97cb6f309 Fix: assiduité: heures des assiduités crées dans log et journal étudiant 2024-10-28 22:16:06 +01:00
0895d7b195 Améliore heure_to_iso8601. Close #1004 2024-10-28 15:46:16 +01:00
cc7fcded98 Améliore heure_to_iso8601 (#1004) et ajoute unit tests 2024-10-28 13:48:54 +01:00
ccc0e58a50 Fix typo (affichage acronyme UE) 2024-10-24 12:17:54 +02:00
e2c0de56c3 Merge pull request 'Actualiser app/static/css/edt.css' (#1003) from pascal.bouron/ScoDoc:master into master
Reviewed-on: #1003
2024-10-24 12:00:40 +02:00
fba623a58f Actualiser app/static/css/edt.css
Correction de l'affichage fugace d'une "erreur de date" sur page edt
(erreur en none par défaut au lieu de inline)
2024-10-24 11:49:45 +02:00
a9bc28a49e Fix regression: cancel button in old forms 2024-10-24 11:22:48 +02:00
b5d5bb3b91 Merge branch 'el' of https://scodoc.org/git/viennet/ScoDoc 2024-10-24 10:49:42 +02:00
4aa5c0e277 Améliore translate_calendar 2024-10-24 10:49:22 +02:00
3c5cb3e517 Merge pull request 'Actualiser app/scodoc/sco_edt_cal.py' (#1002) from pascal.bouron/ScoDoc:master into master
Reviewed-on: #1002
2024-10-24 10:35:27 +02:00
cb13ea2e31 Actualiser app/scodoc/sco_edt_cal.py
Simplification du code pour rechercher formsemestre_id
2024-10-23 23:18:53 +02:00
eaa82f61a4 Actualiser app/scodoc/sco_edt_cal.py
Proposition #1001

Signed-off-by: pascal.bouron <pascal.bouron@noreply@scodoc.org>
2024-10-23 22:45:34 +02:00
123 changed files with 1740 additions and 754 deletions

View File

@ -122,6 +122,7 @@ from app.api import (
justificatifs,
logos,
moduleimpl,
operations,
partitions,
semset,
users,

View File

@ -5,7 +5,8 @@
##############################################################################
"""ScoDoc 9 API : Assiduités"""
from datetime import datetime
from datetime import datetime, timedelta
import re
from flask import g, request
from flask_json import as_json
@ -39,6 +40,24 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@bp.route("/assiduite/date_time_offset/<string:date_iso>")
@api_web_bp.route("/assiduite/date_time_offset/<string:date_iso>")
@scodoc
@permission_required(Permission.ScoView)
def date_time_offset(date_iso: str):
"""L'offset dans le fuseau horaire du serveur pour la date indiquée.
Renvoie une chaîne de la forme "+04:00" (ISO 8601)
Exemple: `/assiduite/date_time_offset/2024-10-01` renvoie `'+02:00'`
"""
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_iso):
json_error(
404,
message="date invalide",
)
return scu.get_local_timezone_offset(date_iso)
@bp.route("/assiduite/<int:assiduite_id>")
@api_web_bp.route("/assiduite/<int:assiduite_id>")
@scodoc
@ -650,7 +669,9 @@ def assiduites_formsemestre_count(
@permission_required(Permission.AbsChange)
def assiduite_create(etudid: int = None, nip=None, ine=None):
"""
Enregistrement d'assiduités pour un étudiant (etudid)
Enregistrement d'assiduités pour un étudiant (etudid).
Si les heures n'ont pas de timezone, elles sont exprimées dans celle du serveur.
DATA
----
@ -841,6 +862,10 @@ def _create_one(
elif fin.tzinfo is None:
fin: datetime = scu.localize_datetime(fin)
# check duration: min 1 minute
if (deb is not None) and (fin is not None) and (fin - deb) < timedelta(seconds=60):
errors.append("durée trop courte")
# cas 4 : desc
desc: str = data.get("desc", None)

View File

@ -96,7 +96,7 @@ def departement_get(dept_id: int):
/departement/id/1;
"""
dept = Departement.query.get_or_404(dept_id)
dept = Departement.get_or_404(dept_id)
return dept.to_dict()
@ -212,7 +212,7 @@ def departement_etudiants_by_id(dept_id: int):
"""
Retourne la liste des étudiants d'un département d'id donné.
"""
dept = Departement.query.get_or_404(dept_id)
dept = Departement.get_or_404(dept_id)
return [etud.to_dict_short() for etud in dept.etudiants]
@ -246,7 +246,7 @@ def departement_formsemestres_ids_by_id(dept_id: int):
/departement/id/1/formsemestres_ids;
"""
dept = Departement.query.get_or_404(dept_id)
dept = Departement.get_or_404(dept_id)
return [formsemestre.id for formsemestre in dept.formsemestres]
@ -273,7 +273,7 @@ def departement_formsemestres_courants(acronym: str = "", dept_id: int | None =
dept = (
Departement.query.filter_by(acronym=acronym).first_or_404()
if acronym
else Departement.query.get_or_404(dept_id)
else Departement.get_or_404(dept_id)
)
date_courante = request.args.get("date_courante")
date_courante = datetime.fromisoformat(date_courante) if date_courante else None

View File

@ -215,7 +215,7 @@ def evaluation_create(moduleimpl_id: int):
/moduleimpl/1/evaluation/create;{""description"":""Exemple éval.""}
"""
moduleimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id)
moduleimpl: ModuleImpl = ModuleImpl.get_or_404(moduleimpl_id)
if not moduleimpl.can_edit_evaluation(current_user):
return scu.json_error(403, "opération non autorisée")
data = request.get_json(force=True) # may raise 400 Bad Request

View File

@ -199,7 +199,7 @@ def ue_set_parcours(ue_id: int):
parcours = []
else:
parcours = [
ApcParcours.query.get_or_404(int(parcour_id)) for parcour_id in parcours_ids
ApcParcours.get_or_404(int(parcour_id)) for parcour_id in parcours_ids
]
log(f"ue_set_parcours: ue_id={ue.id} parcours_ids={parcours_ids}")
ok, error_message = ue.set_parcours(parcours)
@ -226,7 +226,7 @@ def ue_assoc_niveau(ue_id: int, niveau_id: int):
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
niveau: ApcNiveau = ApcNiveau.query.get_or_404(niveau_id)
niveau: ApcNiveau = ApcNiveau.get_or_404(niveau_id)
ok, error_message = ue.set_niveau_competence(niveau)
if not ok:
if g.scodoc_dept: # "usage web"

View File

@ -615,13 +615,13 @@ def formsemestre_etat_evaluations(formsemestre_id: int):
result = []
for modimpl_id in nt.modimpls_results:
modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl_id]
modimpl: ModuleImpl = ModuleImpl.query.get_or_404(modimpl_id)
modimpl: ModuleImpl = ModuleImpl.get_or_404(modimpl_id)
modimpl_dict = modimpl.to_dict(convert_objects=True, with_module=False)
list_eval = []
for evaluation_id in modimpl_results.evaluations_etat:
eval_etat = modimpl_results.evaluations_etat[evaluation_id]
evaluation = Evaluation.query.get_or_404(evaluation_id)
evaluation = Evaluation.get_or_404(evaluation_id)
eval_dict = evaluation.to_dict_api()
eval_dict["etat"] = eval_etat.to_dict()

View File

@ -277,7 +277,7 @@ def validation_rcue_record(etudid: int):
except ValueError:
return json_error(API_CLIENT_ERROR, "invalid date string")
if parcours_id is not None:
parcours: ApcParcours = ApcParcours.query.get_or_404(parcours_id)
parcours: ApcParcours = ApcParcours.get_or_404(parcours_id)
if parcours.referentiel_id != ue1.niveau_competence.competence.referentiel_id:
return json_error(API_CLIENT_ERROR, "niveau et parcours incompatibles")

103
app/api/operations.py Normal file
View File

@ -0,0 +1,103 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
ScoDoc 9 API : liste opérations effectuées par un utilisateur
CATEGORY
--------
Operations
"""
from flask import url_for
from flask_json import as_json
from flask_login import login_required
import app
from app import db
from app.api import api_bp as bp, api_web_bp
from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.models import NotesNotes
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
MAX_QUERY_LENGTH = 10000
@bp.route("/operations/user/<int:uid>/notes")
@api_web_bp.route("/operations/user/<int:uid>/notes")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def operations_user_notes(uid: int):
"""Liste les opérations de saisie de notes effectuées par utilisateur.
QUERY
-----
start: indice de début de la liste
length: nombre d'éléments à retourner
draw: numéro de la requête (pour pagination, renvoyé tel quel)
order[dir]: desc ou asc
search[value]: chaîne à chercher (dans évaluation et étudiant)
PARAMS
-----
uid: l'id de l'utilisateur
"""
# --- Permission: restreint au superadmin ou à l'utilisateur lui-même
if not app.current_user.is_administrator() and app.current_user.id != uid:
return {"error": "Permission denied"}, 403
start = int(app.request.args.get("start", 0))
length = min(int(app.request.args.get("length", 10)), MAX_QUERY_LENGTH)
order = app.request.args.get("order[dir]", "desc")
draw = int(app.request.args.get("draw", 1))
search = app.request.args.get("search[value]", "")
query = db.session.query(NotesNotes).filter(NotesNotes.uid == uid)
if order == "asc":
query = query.order_by(NotesNotes.date.asc())
else:
query = query.order_by(NotesNotes.date.desc())
# Pour l'efficacité, limite si pas de recherche en python
limited_query = query.offset(start).limit(length) if not search else query
data = []
for note in limited_query:
obj = {
"date": note.date.isoformat(),
"date_dmy": note.date.strftime(scu.DATEATIME_FMT),
"operation": "Saisie de note",
"value": scu.fmt_note(note.value),
"id": note.id,
"uid": note.uid,
"etudiant": note.etudiant.to_dict_short(),
"etudiant_link": note.etudiant.html_link_fiche(),
"evaluation": note.evaluation.to_dict_api(),
"evaluation_link": f"""<a href="{
url_for('notes.evaluation_listenotes',
scodoc_dept=note.evaluation.moduleimpl.formsemestre.departement.acronym,
evaluation_id=note.evaluation_id)
}">{note.evaluation.descr()}</a>""",
}
if search:
search = search.lower()
if (
search not in note.etudiant.nomprenom.lower()
and search not in note.evaluation.descr().lower()
and search not in obj["date_dmy"]
):
continue # skip
data.append(obj)
result = data[start : start + length] if search else data
return {
"draw": draw,
"recordsTotal": query.count(), # unfiltered
"recordsFiltered": len(data) if search else query.count(),
"data": result,
}

View File

@ -159,7 +159,7 @@ def group_etudiants_query(group_id: int):
@as_json
def group_set_etudiant(group_id: int, etudid: int):
"""Affecte l'étudiant au groupe indiqué."""
etud = Identite.query.get_or_404(etudid)
etud = Identite.get_or_404(etudid)
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
query = (
@ -192,7 +192,7 @@ def group_set_etudiant(group_id: int, etudid: int):
@as_json
def group_remove_etud(group_id: int, etudid: int):
"""Retire l'étudiant de ce groupe. S'il n'y est pas, ne fait rien."""
etud = Identite.query.get_or_404(etudid)
etud = Identite.get_or_404(etudid)
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
query = (
@ -224,7 +224,7 @@ def partition_remove_etud(partition_id: int, etudid: int):
(NB: en principe, un étudiant ne doit être que dans 0 ou 1 groupe d'une partition)
"""
etud = Identite.query.get_or_404(etudid)
etud = Identite.get_or_404(etudid)
query = Partition.query.filter_by(id=partition_id)
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
@ -533,7 +533,7 @@ def formsemestre_set_partitions_order(formsemestre_id: int):
message="paramètre liste des partitions invalide",
)
for p_id, numero in zip(partition_ids, range(len(partition_ids))):
partition = Partition.query.get_or_404(p_id)
partition = Partition.get_or_404(p_id)
partition.numero = numero
db.session.add(partition)
db.session.commit()
@ -579,7 +579,7 @@ def partition_order_groups(partition_id: int):
message="paramètre liste de groupe invalide",
)
for group_id, numero in zip(group_ids, range(len(group_ids))):
group = GroupDescr.query.get_or_404(group_id)
group = GroupDescr.get_or_404(group_id)
group.numero = numero
db.session.add(group)
db.session.commit()

View File

@ -188,7 +188,7 @@ def user_edit(uid: int):
```
"""
args = request.get_json(force=True) # may raise 400 Bad Request
user: User = User.query.get_or_404(uid)
user: User = User.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 = args.get("dept", False)
@ -241,7 +241,7 @@ def user_password(uid: int):
/user/3/password;{""password"" : ""rePlaCemeNT456averylongandcomplicated""}
"""
data = request.get_json(force=True) # may raise 400 Bad Request
user: User = User.query.get_or_404(uid)
user: User = User.get_or_404(uid)
password = data.get("password")
if not password:
return json_error(404, "user_password: missing password")
@ -272,7 +272,7 @@ def user_password(uid: int):
@as_json
def user_role_add(uid: int, role_name: str, dept: str = None):
"""Ajoute un rôle à l'utilisateur dans le département donné."""
user: User = User.query.get_or_404(uid)
user: User = User.get_or_404(uid)
role: Role = Role.query.filter_by(name=role_name).first_or_404()
if dept is not None: # check
_ = Departement.query.filter_by(acronym=dept).first_or_404()
@ -301,7 +301,7 @@ def user_role_add(uid: int, role_name: str, dept: str = None):
@as_json
def user_role_remove(uid: int, role_name: str, dept: str = None):
"""Retire le rôle (dans le département donné) à cet utilisateur."""
user: User = User.query.get_or_404(uid)
user: User = User.get_or_404(uid)
role: Role = Role.query.filter_by(name=role_name).first_or_404()
if dept is not None: # check
_ = Departement.query.filter_by(acronym=dept).first_or_404()

View File

@ -38,7 +38,7 @@ def after_cas_login():
flask.session["scodoc_cas_login_date"] = (
datetime.datetime.now().isoformat()
)
user.cas_last_login = datetime.datetime.utcnow()
user.cas_last_login = datetime.datetime.now()
if flask.session.get("CAS_EDT_ID"):
# essaie de récupérer l'edt_id s'il est présent
# cet ID peut être renvoyé par le CAS et extrait par ScoDoc
@ -158,7 +158,7 @@ CAS_USER_INFO_COMMENTS = (
autorise la connexion via CAS (optionnel, faux par défaut)
""",
"""cas_allow_scodoc_login
autorise connexion via ScoDoc même si CAS obligatoire (optionnel, faux par défaut)
autorise connexion via ScoDoc même si CAS activé (optionnel, vrai par défaut)
""",
"""email_institutionnel
optionnel, le mail officiel de l'utilisateur.

View File

@ -9,8 +9,8 @@ from flask import current_app, g, redirect, request, url_for
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
import flask_login
from app import db, login
from app.auth.models import User
from app import db, log, login
from app.auth.models import User, Role
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_utils import json_error
@ -19,7 +19,7 @@ token_auth = HTTPTokenAuth()
@basic_auth.verify_password
def verify_password(username, password):
def verify_password(username, password) -> User | None:
"""Verify password for this user
Appelé lors d'une demande de jeton (normalement via la route /tokens)
"""
@ -28,6 +28,7 @@ def verify_password(username, password):
g.current_user = user
# note: est aussi basic_auth.current_user()
return user
return None
@basic_auth.error_handler
@ -61,7 +62,8 @@ def token_auth_error(status):
@token_auth.get_user_roles
def get_user_roles(user):
def get_user_roles(user) -> list[Role]:
"list roles"
return user.roles
@ -82,7 +84,7 @@ def load_user_from_request(req: flask.Request) -> User:
@login.unauthorized_handler
def unauthorized():
"flask-login: si pas autorisé, redirige vers page login, sauf si API"
if request.blueprint == "api" or request.blueprint == "apiweb":
if request.blueprint in ("api", "apiweb"):
return json_error(http.HTTPStatus.UNAUTHORIZED, "Non autorise (logic)")
return redirect(url_for("auth.login"))

View File

@ -92,7 +92,7 @@ class User(UserMixin, ScoDocModel):
cas_allow_scodoc_login = db.Column(
db.Boolean, default=False, server_default="false", nullable=False
)
"""Si CAS forcé (cas_force), peut-on se logguer sur ScoDoc directement ?
"""Si CAS activé et cas_id renseigné, peut-on se logguer sur ScoDoc directement ?
(le rôle ScoSuperAdmin peut toujours, mettre à True pour les utilisateur API)
"""
cas_last_login = db.Column(db.DateTime, nullable=True)
@ -101,10 +101,13 @@ class User(UserMixin, ScoDocModel):
"identifiant emplois du temps (unicité non imposée)"
password_hash = db.Column(db.Text()) # les hashs modernes peuvent être très longs
password_scodoc7 = db.Column(db.String(42))
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
date_modif_passwd = db.Column(db.DateTime, default=datetime.utcnow)
date_created = db.Column(db.DateTime, default=datetime.utcnow)
last_seen = db.Column(db.DateTime, default=datetime.now)
date_modif_passwd = db.Column(db.DateTime, default=datetime.now)
date_created = db.Column(db.DateTime, default=datetime.now)
date_expiration = db.Column(db.DateTime, default=None)
passwd_must_be_changed = db.Column(
db.Boolean, nullable=False, server_default="false", default=False
)
passwd_temp = db.Column(db.Boolean, default=False)
"""champ obsolete. Si connexion alors que passwd_temp est vrai,
efface mot de passe et redirige vers accueil."""
@ -130,7 +133,7 @@ class User(UserMixin, ScoDocModel):
self.roles = []
self.user_roles = []
# check login:
if not "user_name" in kwargs:
if "user_name" not in kwargs:
raise ValueError("missing user_name argument")
if not is_valid_user_name(kwargs["user_name"]):
raise ValueError(f"invalid user_name: {kwargs['user_name']}")
@ -177,7 +180,7 @@ class User(UserMixin, ScoDocModel):
def set_password(self, password):
"Set password"
current_app.logger.info(f"set_password({self})")
log(f"set_password({self})")
if password:
self.password_hash = generate_password_hash(password)
else:
@ -185,6 +188,8 @@ class User(UserMixin, ScoDocModel):
# La création d'un mot de passe efface l'éventuel mot de passe historique
self.password_scodoc7 = None
self.passwd_temp = False
# Retire le flag
self.passwd_must_be_changed = False
def check_password(self, password: str) -> bool:
"""Check given password vs current one.
@ -206,10 +211,23 @@ class User(UserMixin, ScoDocModel):
send_notif_desactivation_user(self)
return False
# if CAS activated and forced, allow only super-user and users with cas_allow_scodoc_login
# if CAS activated and cas_id, allow only super-user and users with cas_allow_scodoc_login
cas_enabled = ScoDocSiteConfig.is_cas_enabled()
if cas_enabled and ScoDocSiteConfig.get("cas_force"):
if (not self.is_administrator()) and not self.cas_allow_scodoc_login:
if cas_enabled and not self.is_administrator():
if not self.cas_allow_scodoc_login:
# CAS activé et compte non autorisé à se logguer sur ScoDoc
log(
f"""auth: login attempt for user {self.user_name}: scodoc login not allowed
"""
)
return False
# si CAS activé et forcé et cas_id renseigné, on ne peut pas se logguer
if self.cas_id and ScoDocSiteConfig.get("cas_force"):
log(
f"""auth: login attempt for user {self.user_name
} (cas_id='{
self.cas_id}'): cas forced and cas_id set: scodoc login not allowed"""
)
return False
if not self.password_hash: # user without password can't login
@ -250,7 +268,7 @@ class User(UserMixin, ScoDocModel):
)
except jwt.exceptions.ExpiredSignatureError:
log("verify_reset_password_token: token expired")
except:
except: # pylint: disable=bare-except
return None
try:
user_id = token["reset_password"]
@ -282,6 +300,7 @@ class User(UserMixin, ScoDocModel):
if self.date_modif_passwd
else None
),
"passwd_must_be_changed": self.passwd_must_be_changed,
"date_created": (
self.date_created.isoformat() + "Z" if self.date_created else None
),
@ -347,31 +366,31 @@ class User(UserMixin, ScoDocModel):
return args_dict
def from_dict(self, data: dict, new_user=False):
def from_dict(self, args: dict, new_user=False):
"""Set users' attributes from given dict values.
- roles_string : roles, encoded like "Ens_RT, Secr_CJ"
- date_expiration is a dateime object.
Does not check permissions here.
"""
if new_user:
if "user_name" in data:
if "user_name" in args:
# never change name of existing users
# (see change_user_name method to do that)
if not is_valid_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 not is_valid_user_name(args["user_name"]):
raise ValueError(f"invalid user_name: {args['user_name']}")
self.user_name = args["user_name"]
if "password" in args:
self.set_password(args["password"])
# Roles: roles_string is "Ens_RT, Secr_RT, ..."
if "roles_string" in data:
if "roles_string" in args:
self.user_roles = []
for r_d in data["roles_string"].split(","):
for r_d in args["roles_string"].split(","):
if r_d:
role, dept = UserRole.role_dept_from_string(r_d)
self.add_role(role, dept)
super().from_dict(data, excluded={"user_name", "roles_string", "roles"})
super().from_dict(args, excluded={"user_name", "roles_string", "roles"})
if ScoDocSiteConfig.cas_uid_use_scodoc():
self.cas_id = self.user_name
@ -385,7 +404,7 @@ class User(UserMixin, ScoDocModel):
def get_token(self, expires_in=3600):
"Un jeton pour cet user. Stocké en base, non commité."
now = datetime.utcnow()
now = datetime.now()
if self.token and self.token_expiration > now + timedelta(seconds=60):
return self.token
self.token = base64.b64encode(os.urandom(24)).decode("utf-8")
@ -395,7 +414,7 @@ class User(UserMixin, ScoDocModel):
def revoke_token(self):
"Révoque le jeton de cet utilisateur"
self.token_expiration = datetime.utcnow() - timedelta(seconds=1)
self.token_expiration = datetime.now() - timedelta(seconds=1)
@staticmethod
def check_token(token):
@ -403,7 +422,7 @@ class User(UserMixin, ScoDocModel):
and returns the user object.
"""
user = User.query.filter_by(token=token).first()
if user is None or user.token_expiration < datetime.utcnow():
if user is None or user.token_expiration < datetime.now():
return None
return user
@ -438,7 +457,10 @@ class User(UserMixin, ScoDocModel):
# les role liés à ce département, et les roles avec dept=None (super-admin)
roles_in_dept = (
UserRole.query.filter_by(user_id=self.id)
.filter((UserRole.dept == dept) | (UserRole.dept == None))
.filter(
(UserRole.dept == dept)
| (UserRole.dept == None) # pylint: disable=C0121
)
.all()
)
for user_role in roles_in_dept:
@ -616,17 +638,19 @@ class User(UserMixin, ScoDocModel):
class AnonymousUser(AnonymousUserMixin):
"Notre utilisateur anonyme"
def has_permission(self, perm, dept=None):
def has_permission(self, perm, dept=None): # pylint: disable=unused-argument
"always false, anonymous has no permission"
return False
def is_administrator(self):
"always false, anonymous is not admin"
return False
login.anonymous_user = AnonymousUser
class Role(db.Model):
class Role(ScoDocModel):
"""Roles for ScoDoc"""
id = db.Column(db.Integer, primary_key=True)
@ -730,7 +754,7 @@ class Role(db.Model):
return Role.query.filter_by(name=name).first()
class UserRole(db.Model):
class UserRole(ScoDocModel):
"""Associate user to role, in a dept.
If dept is None, the role applies to all departments (eg super admin).
"""

View File

@ -4,7 +4,7 @@ auth.routes.py
"""
import flask
from flask import current_app, flash, render_template
from flask import current_app, flash, g, render_template
from flask import redirect, url_for, request
from flask_login import login_user, current_user
from sqlalchemy import func
@ -23,6 +23,7 @@ from app.auth.email import send_password_reset_email
from app.decorators import admin_required
from app.forms.generic import SimpleConfirmationForm
from app.models.config import ScoDocSiteConfig
from app.models.departements import Departement
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
from app.scodoc import sco_utils as scu
@ -49,6 +50,24 @@ def _login_form():
login_user(user, remember=form.remember_me.data)
current_app.logger.info("login: success (%s)", form.user_name.data)
if user.passwd_must_be_changed:
# Mot de passe à changer à la première connexion
dept = user.dept or getattr(g, "scodoc_dept", None)
if not dept:
departement = db.session.query(Departement).first()
dept = departement.acronym
if dept:
# Redirect to the password change page
flash("Votre mot de passe doit être changé")
return redirect(
url_for(
"users.form_change_password",
scodoc_dept=dept,
user_name=user.user_name,
)
)
return form.redirect("scodoc.index")
return render_template(

View File

@ -407,7 +407,9 @@ class BulletinBUT:
d = {
"version": "0",
"type": "BUT",
"date": datetime.datetime.utcnow().isoformat() + "Z",
"date": datetime.datetime.now(datetime.timezone.utc)
.astimezone()
.isoformat(),
"publie": not formsemestre.bul_hide_xml,
"etat_inscription": etud.inscription_etat(formsemestre.id),
"etudiant": etud.to_dict_bul(),

View File

@ -57,7 +57,7 @@ from app.views import ScoData
@permission_required(Permission.ScoView)
def bulletin_but(formsemestre_id: int, etudid: int = None, fmt="html"):
"""Page HTML affichant le bulletin BUT simplifié"""
etud: Identite = Identite.query.get_or_404(etudid)
etud: Identite = Identite.get_or_404(etudid)
formsemestre: FormSemestre = (
FormSemestre.query.filter_by(id=formsemestre_id)
.join(FormSemestreInscription)

View File

@ -508,7 +508,7 @@ def but_validations_ues_parcours(
# Les UEs associées au tronc commun (à aucun parcours)
# UniteEns.query.filter(~UniteEns.id.in_(UEParcours.query.with_entities(UEParcours.ue_id)))
parcour = ApcParcours.query.get(parcour_id)
parcour: ApcParcours = db.session.get(ApcParcours, parcour_id)
if not parcour:
raise ScoValueError(f"but_validations_ues_parcours: {parcour_id} inexistant")
# Les validations d'UE de ce parcours ou du tronc commun pour cet étudiant:

View File

@ -1,7 +1,7 @@
from app import db
from app import db, models
class Entreprise(db.Model):
class Entreprise(models.ScoDocModel):
__tablename__ = "are_entreprises"
id = db.Column(db.Integer, primary_key=True)
siret = db.Column(db.Text, index=True, unique=True)
@ -45,7 +45,7 @@ class Entreprise(db.Model):
}
class EntrepriseSite(db.Model):
class EntrepriseSite(models.ScoDocModel):
__tablename__ = "are_sites"
id = db.Column(db.Integer, primary_key=True)
entreprise_id = db.Column(
@ -65,7 +65,7 @@ class EntrepriseSite(db.Model):
)
def to_dict(self):
entreprise = Entreprise.query.get_or_404(self.entreprise_id)
entreprise = Entreprise.get_or_404(self.entreprise_id)
return {
"siret_entreprise": entreprise.siret,
"id_site": self.id,
@ -77,7 +77,7 @@ class EntrepriseSite(db.Model):
}
class EntrepriseCorrespondant(db.Model):
class EntrepriseCorrespondant(models.ScoDocModel):
__tablename__ = "are_correspondants"
id = db.Column(db.Integer, primary_key=True)
site_id = db.Column(db.Integer, db.ForeignKey("are_sites.id", ondelete="cascade"))
@ -92,7 +92,7 @@ class EntrepriseCorrespondant(db.Model):
notes = db.Column(db.Text)
def to_dict(self):
site = EntrepriseSite.query.get_or_404(self.site_id)
site = EntrepriseSite.get_or_404(self.site_id)
return {
"id": self.id,
"civilite": self.civilite,
@ -108,7 +108,7 @@ class EntrepriseCorrespondant(db.Model):
}
class EntrepriseContact(db.Model):
class EntrepriseContact(models.ScoDocModel):
__tablename__ = "are_contacts"
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True))
@ -119,7 +119,7 @@ class EntrepriseContact(db.Model):
notes = db.Column(db.Text)
class EntrepriseOffre(db.Model):
class EntrepriseOffre(models.ScoDocModel):
__tablename__ = "are_offres"
id = db.Column(db.Integer, primary_key=True)
entreprise_id = db.Column(
@ -147,7 +147,7 @@ class EntrepriseOffre(db.Model):
}
class EntrepriseHistorique(db.Model):
class EntrepriseHistorique(models.ScoDocModel):
__tablename__ = "are_historique"
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
@ -158,7 +158,7 @@ class EntrepriseHistorique(db.Model):
text = db.Column(db.Text)
class EntrepriseStageApprentissage(db.Model):
class EntrepriseStageApprentissage(models.ScoDocModel):
__tablename__ = "are_stages_apprentissages"
id = db.Column(db.Integer, primary_key=True)
entreprise_id = db.Column(
@ -176,7 +176,7 @@ class EntrepriseStageApprentissage(db.Model):
notes = db.Column(db.Text)
class EntrepriseTaxeApprentissage(db.Model):
class EntrepriseTaxeApprentissage(models.ScoDocModel):
__tablename__ = "are_taxe_apprentissage"
id = db.Column(db.Integer, primary_key=True)
entreprise_id = db.Column(
@ -187,7 +187,7 @@ class EntrepriseTaxeApprentissage(db.Model):
notes = db.Column(db.Text)
class EntrepriseEnvoiOffre(db.Model):
class EntrepriseEnvoiOffre(models.ScoDocModel):
__tablename__ = "are_envoi_offre"
id = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="cascade"))
@ -196,7 +196,7 @@ class EntrepriseEnvoiOffre(db.Model):
date_envoi = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
class EntrepriseEnvoiOffreEtudiant(db.Model):
class EntrepriseEnvoiOffreEtudiant(models.ScoDocModel):
__tablename__ = "are_envoi_offre_etudiant"
id = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="cascade"))
@ -207,14 +207,14 @@ class EntrepriseEnvoiOffreEtudiant(db.Model):
date_envoi = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
class EntrepriseOffreDepartement(db.Model):
class EntrepriseOffreDepartement(models.ScoDocModel):
__tablename__ = "are_offre_departement"
id = db.Column(db.Integer, primary_key=True)
offre_id = db.Column(db.Integer, db.ForeignKey("are_offres.id", ondelete="cascade"))
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id", ondelete="cascade"))
class EntreprisePreferences(db.Model):
class EntreprisePreferences(models.ScoDocModel):
__tablename__ = "are_preferences"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Text)

View File

@ -1489,7 +1489,7 @@ def add_stage_apprentissage(entreprise_id):
)
if form.validate_on_submit():
etudid = form.etudid.data
etudiant = Identite.query.get_or_404(etudid)
etudiant = Identite.get_or_404(etudid)
formation = etudiant.inscription_courante_date(
form.date_debut.data, form.date_fin.data
)
@ -1552,7 +1552,7 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
)
if form.validate_on_submit():
etudid = form.etudid.data
etudiant = Identite.query.get_or_404(etudid)
etudiant = Identite.get_or_404(etudid)
formation = etudiant.inscription_courante_date(
form.date_debut.data, form.date_fin.data
)

View File

@ -50,7 +50,7 @@ from app.scodoc import codes_cursus
def formation_delete(formation_id=None, dialog_confirmed=False):
"""Delete a formation"""
formation: Formation = Formation.query.get_or_404(formation_id)
formation: Formation = Formation.get_or_404(formation_id)
H = [
f"""<h2>Suppression de la formation {formation.titre} ({formation.acronyme})</h2>""",
@ -159,7 +159,7 @@ def formation_edit(formation_id=None, create=False):
is_locked = False
else:
# edit an existing formation
formation: Formation = Formation.query.get_or_404(formation_id)
formation: Formation = Formation.get_or_404(formation_id)
form_dict = formation.to_dict()
form_dict["commentaire"] = form_dict["commentaire"] or ""
initvalues = form_dict
@ -347,7 +347,7 @@ def do_formation_edit(args) -> bool:
if "formation_code" in args and not args["formation_code"]:
del args["formation_code"]
formation: Formation = Formation.query.get_or_404(args["formation_id"])
formation: Formation = Formation.get_or_404(args["formation_id"])
# On autorise la modif de la formation meme si elle est verrouillee
# car cela ne change que du cosmetique, (sauf eventuellement le code formation ?)
# mais si verrouillée on ne peut changer le type de parcours
@ -386,7 +386,7 @@ def do_formation_edit(args) -> bool:
def module_move(module_id, after=0, redirect=True):
"""Move before/after previous one (decrement/increment numero)"""
redirect = bool(redirect)
module = Module.query.get_or_404(module_id)
module = Module.get_or_404(module_id)
after = int(after) # 0: deplace avant, 1 deplace apres
if after not in (0, 1):
raise ValueError(f'invalid value for "after" ({after})')
@ -430,7 +430,7 @@ def module_move(module_id, after=0, redirect=True):
def ue_move(ue_id, after=0, redirect=1):
"""Move UE before/after previous one (decrement/increment numero)"""
ue = UniteEns.query.get_or_404(ue_id)
ue = UniteEns.get_or_404(ue_id)
redirect = int(redirect)
after = int(after) # 0: deplace avant, 1 deplace apres
if after not in (0, 1):

View File

@ -45,7 +45,7 @@ from app.scodoc.sco_exceptions import (
def matiere_create(ue_id=None):
"""Formulaire création d'une matiere"""
ue: UniteEns = UniteEns.query.get_or_404(ue_id)
ue: UniteEns = UniteEns.get_or_404(ue_id)
default_numero = max([mat.numero for mat in ue.matieres] or [9]) + 1
H = [
f"""<h2>Création d'une matière dans l'UE {ue.titre or ''} ({ue.acronyme})</h2>

View File

@ -52,7 +52,7 @@ from app.scodoc import codes_cursus
def module_delete(module_id=None):
"""Formulaire suppression d'un module"""
module = Module.query.get_or_404(module_id)
module = Module.get_or_404(module_id)
if not module.can_be_deleted():
raise ScoNonEmptyFormationObject(
@ -149,13 +149,13 @@ def module_edit(
formation = ue.formation
orig_semestre_idx = ue.semestre_idx if semestre_id is None else semestre_id
else:
formation = Formation.query.get_or_404(formation_id)
formation = Formation.get_or_404(formation_id)
module = None
unlocked = True
else:
if not module_id:
raise ValueError("missing module_id !")
module = models.Module.query.get_or_404(module_id)
module = models.Module.get_or_404(module_id)
ue = module.ue
module_dict = module.to_dict()
formation = module.formation
@ -714,7 +714,7 @@ def module_edit(
old_ue_id = module.ue.id
new_ue_id = tf[2]["ue_id"]
if (old_ue_id != new_ue_id) and in_use:
new_ue = UniteEns.query.get_or_404(new_ue_id)
new_ue = UniteEns.get_or_404(new_ue_id)
if new_ue.semestre_idx != module.ue.semestre_idx:
# pas changer de semestre un module utilisé !
raise ScoValueError(
@ -808,7 +808,7 @@ def formation_add_malus_modules(
):
"""Création d'un module de "malus" dans chaque UE d'une formation"""
formation = Formation.query.get_or_404(formation_id)
formation = Formation.get_or_404(formation_id)
nb = 0
ues = formation.ues

View File

@ -219,7 +219,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"""Formulaire modification ou création d'une UE"""
create = int(create)
if not create:
ue: UniteEns = UniteEns.query.get_or_404(ue_id)
ue: UniteEns = UniteEns.get_or_404(ue_id)
ue_dict = ue.to_dict()
formation_id = ue.formation_id
title = f"Modification de l'UE {ue.acronyme} {ue.titre}"
@ -573,7 +573,7 @@ def next_ue_numero(formation_id, semestre_id=None) -> int:
def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False):
"""Delete an UE"""
ue = UniteEns.query.get_or_404(ue_id)
ue = UniteEns.get_or_404(ue_id)
if ue.modules.all():
raise ScoValueError(
f"""Suppression de l'UE {ue.titre} impossible car
@ -1108,7 +1108,7 @@ def _ue_table_ues(
if ue.titre != ue.acronyme:
acro_titre += " " + (ue.titre or "")
H.append(
f"""acro_titre <span class="ue_code">(code {ue.ue_code}{ects_str}, coef. {
f"""{acro_titre} <span class="ue_code">(code {ue.ue_code}{ects_str}, coef. {
(ue.coefficient or 0):3.2f}{code_apogee_str})</span>
<span class="ue_coef"></span>
"""
@ -1370,7 +1370,7 @@ def ue_sharing_code(ue_code: str = "", ue_id: int = None, hide_ue_id: int = None
hide_ue_id spécifie un id à retirer de la liste.
"""
if ue_id is not None:
ue = UniteEns.query.get_or_404(ue_id)
ue = UniteEns.get_or_404(ue_id)
if not ue_code:
ue_code = ue.ue_code
formation_code = ue.formation.formation_code

View File

@ -653,7 +653,7 @@ def formation_list_table(detail: bool) -> GenTable:
def formation_create_new_version(formation_id, redirect=True):
"duplicate formation, with new version number"
formation = Formation.query.get_or_404(formation_id)
formation = Formation.get_or_404(formation_id)
resp = formation_export(
formation_id, export_ids=True, export_external_ues=True, fmt="xml"
)

View File

@ -60,7 +60,7 @@ def formsemestre_associate_new_version(
formsemestre_id: optionnel, formsemestre de départ, qui sera associé à la nouvelle version
"""
formsemestre_id = int(formsemestre_id) if formsemestre_id else None
formation: Formation = Formation.query.get_or_404(formation_id)
formation: Formation = Formation.get_or_404(formation_id)
other_formsemestre_ids = {int(x) for x in (other_formsemestre_ids or [])}
if request.method == "GET":
# dresse la liste des semestres non verrouillés de la même formation
@ -191,7 +191,7 @@ def do_formsemestres_associate_new_version(
log(f"do_formsemestres_associate_new_version {formation_id} {formsemestre_ids}")
# Check: tous les semestres de la formation
formsemestres = [FormSemestre.query.get_or_404(i) for i in formsemestre_ids]
formsemestres = [FormSemestre.get_or_404(i) for i in formsemestre_ids]
if not all(
[formsemestre.formation_id == formation_id for formsemestre in formsemestres]
):

View File

@ -5,6 +5,8 @@
from flask import abort, g
import sqlalchemy
from sqlalchemy import select
from sqlalchemy.exc import NoResultFound
import app
from app import db
@ -164,6 +166,16 @@ class ScoDocModel(db.Model):
return query.first()
return query.first_or_404()
# Compatibilité avec SQLAlchemy 2.0
@classmethod
def get_or_404(cls, oid: int | str):
"""Get instance or abort 404"""
stmt = select(cls).where(cls.id == oid)
try:
return db.session.execute(stmt).scalar_one()
except NoResultFound:
abort(404)
from app.models.absences import Absence, AbsenceNotification, BilletAbsence
from app.models.departements import Departement

View File

@ -3,10 +3,10 @@
"""Gestion des absences
"""
from app import db
from app import db, models
class Absence(db.Model):
class Absence(models.ScoDocModel):
"""LEGACY
Ce modèle n'est PLUS UTILISE depuis ScoDoc 9.6 et remplacé par assiduité.
une absence (sur une demi-journée)
@ -45,7 +45,7 @@ class Absence(db.Model):
return data
class AbsenceNotification(db.Model):
class AbsenceNotification(models.ScoDocModel):
"""Notification d'absence émise"""
__tablename__ = "absences_notifications"
@ -67,7 +67,7 @@ class AbsenceNotification(db.Model):
)
class BilletAbsence(db.Model):
class BilletAbsence(models.ScoDocModel):
"""Billet d'absence (signalement par l'étudiant)"""
__tablename__ = "billet_absence"

View File

@ -124,19 +124,25 @@ class Assiduite(ScoDocModel):
return data
def __str__(self) -> str:
"chaine pour journaux et debug (lisible par humain français)"
"chaine pour journaux et debug (lisible par humain français, en timezone serveur)"
try:
etat_str = EtatAssiduite(self.etat).name.lower().capitalize()
except ValueError:
etat_str = "Invalide"
# passe en timezone serveur
d_deb = self.date_debut.astimezone(scu.TIME_ZONE)
d_fin = self.date_fin.astimezone(scu.TIME_ZONE)
return f"""{etat_str} {
"just." if self.est_just else "non just."
} de {
self.date_debut.strftime("%d/%m/%Y %Hh%M")
d_deb.strftime("%d/%m/%Y %Hh%M")
} à {
self.date_fin.strftime("%d/%m/%Y %Hh%M")
d_fin.strftime("%d/%m/%Y %Hh%M")
}"""
def __repr__(self) -> str:
return f"<Assiduite {self.id}: {self.etudiant.nom}: {self.__str__()}>"
@classmethod
def create_assiduite(
cls,
@ -746,7 +752,7 @@ def get_assiduites_justif(assiduite_id: int, long: bool) -> list[int | dict]:
list[int | dict]: La liste des justificatifs (par défaut uniquement
les identifiants, sinon les dict si long est vrai)
"""
assi: Assiduite = Assiduite.query.get_or_404(assiduite_id)
assi: Assiduite = Assiduite.get_or_404(assiduite_id)
return get_justifs_from_date(assi.etudid, assi.date_debut, assi.date_fin, long)

View File

@ -15,7 +15,7 @@ from flask_sqlalchemy.query import Query
from sqlalchemy.orm import class_mapper
import sqlalchemy
from app import db, log
from app import db, log, models
from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
@ -56,7 +56,7 @@ class XMLModel:
return f'<{self.__class__.__name__} {self.id} "{self.titre if hasattr(self, "titre") else ""}">'
class ApcReferentielCompetences(db.Model, XMLModel):
class ApcReferentielCompetences(models.ScoDocModel, XMLModel):
"Référentiel de compétence d'une spécialité"
id = db.Column(db.Integer, primary_key=True)
dept_id = db.Column(
@ -76,7 +76,7 @@ class ApcReferentielCompetences(db.Model, XMLModel):
"version": "version_orebut",
}
# ScoDoc specific fields:
scodoc_date_loaded = db.Column(db.DateTime, default=datetime.utcnow)
scodoc_date_loaded = db.Column(db.DateTime, default=datetime.now)
scodoc_orig_filename = db.Column(db.Text())
# Relations:
competences = db.relationship(
@ -339,7 +339,7 @@ class ApcReferentielCompetences(db.Model, XMLModel):
return doc.get(self.specialite, {})
class ApcCompetence(db.Model, XMLModel):
class ApcCompetence(models.ScoDocModel, XMLModel):
"Compétence"
id = db.Column(db.Integer, primary_key=True)
referentiel_id = db.Column(
@ -410,7 +410,7 @@ class ApcCompetence(db.Model, XMLModel):
}
class ApcSituationPro(db.Model, XMLModel):
class ApcSituationPro(models.ScoDocModel, XMLModel):
"Situation professionnelle"
id = db.Column(db.Integer, primary_key=True)
competence_id = db.Column(
@ -425,7 +425,7 @@ class ApcSituationPro(db.Model, XMLModel):
return {"libelle": self.libelle}
class ApcComposanteEssentielle(db.Model, XMLModel):
class ApcComposanteEssentielle(models.ScoDocModel, XMLModel):
"Composante essentielle"
id = db.Column(db.Integer, primary_key=True)
competence_id = db.Column(
@ -439,7 +439,7 @@ class ApcComposanteEssentielle(db.Model, XMLModel):
return {"libelle": self.libelle}
class ApcNiveau(db.Model, XMLModel):
class ApcNiveau(models.ScoDocModel, XMLModel):
"""Niveau de compétence
Chaque niveau peut être associé à deux UE,
des semestres impair et pair de la même année.
@ -608,7 +608,7 @@ app_critiques_modules = db.Table(
)
class ApcAppCritique(db.Model, XMLModel):
class ApcAppCritique(models.ScoDocModel, XMLModel):
"Apprentissage Critique BUT"
id = db.Column(db.Integer, primary_key=True)
niveau_id = db.Column(
@ -694,7 +694,7 @@ parcours_formsemestre = db.Table(
"""Association parcours <-> formsemestre (many-to-many)"""
class ApcParcours(db.Model, XMLModel):
class ApcParcours(models.ScoDocModel, XMLModel):
"Un parcours BUT"
id = db.Column(db.Integer, primary_key=True)
referentiel_id = db.Column(
@ -749,7 +749,7 @@ class ApcParcours(db.Model, XMLModel):
)
class ApcAnneeParcours(db.Model, XMLModel):
class ApcAnneeParcours(models.ScoDocModel, XMLModel):
id = db.Column(db.Integer, primary_key=True)
parcours_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="CASCADE"), nullable=False
@ -774,7 +774,7 @@ class ApcAnneeParcours(db.Model, XMLModel):
}
class ApcParcoursNiveauCompetence(db.Model):
class ApcParcoursNiveauCompetence(models.ScoDocModel):
"""Association entre année de parcours et compétence.
Le "niveau" de la compétence est donné ici
(convention Orébut)

View File

@ -8,7 +8,7 @@ import re
import urllib.parse
from flask import flash
from app import current_app, db, log
from app import current_app, db, log, models
from app.comp import bonus_spo
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu
@ -68,7 +68,7 @@ def code_scodoc_to_apo_default(code):
return CODES_SCODOC_TO_APO.get(code, "DEF")
class ScoDocSiteConfig(db.Model):
class ScoDocSiteConfig(models.ScoDocModel):
"""Config. d'un site
Nouveau en ScoDoc 9: va regrouper les paramètres qui dans les versions
antérieures étaient dans scodoc_config.py

View File

@ -4,7 +4,7 @@
"""
import re
from app import db
from app import db, models
from app.models import SHORT_STR_LEN
from app.models.preferences import ScoPreference
from app.scodoc.sco_exceptions import ScoValueError
@ -12,7 +12,7 @@ from app.scodoc.sco_exceptions import ScoValueError
VALID_DEPT_EXP = re.compile(r"^[\w@\\\-\.]+$")
class Departement(db.Model):
class Departement(models.ScoDocModel):
"""Un département ScoDoc"""
id = db.Column(db.Integer, primary_key=True)
@ -61,7 +61,7 @@ class Departement(db.Model):
dept_id = None
if dept_id is None:
return cls.query.filter_by(acronym=dept_ident).first_or_404()
return cls.query.get_or_404(dept_id)
return cls.get_or_404(dept_id)
def to_dict(self, with_dept_name=True, with_dept_preferences=False):
data = {

View File

@ -121,6 +121,18 @@ class Identite(models.ScoDocModel):
cascade="all, delete-orphan",
lazy="dynamic",
)
notes = db.relationship(
"NotesNotes",
backref="etudiant",
cascade="all, delete-orphan",
lazy="dynamic",
)
notes_log = db.relationship(
"NotesNotesLog",
backref="etudiant",
cascade="all, delete-orphan",
lazy="dynamic",
)
# Relations avec les assiduites et les justificatifs
assiduites = db.relationship(
"Assiduite", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
@ -516,7 +528,7 @@ class Identite(models.ScoDocModel):
e_dict["date_naissance"] = ndb.DateISOtoDMY(e_dict.get("date_naissance", ""))
e_dict["ne"] = self.e
e_dict["nomprenom"] = self.nomprenom
adresse = self.adresses.first()
adresse: Adresse = self.adresses.first()
if adresse:
e_dict.update(adresse.to_dict(restrict=restrict))
if with_inscriptions:

View File

@ -58,6 +58,19 @@ class Evaluation(models.ScoDocModel):
# ordre de presentation (par défaut, le plus petit numero
# est la plus ancienne eval):
numero = db.Column(db.Integer, nullable=False, default=0)
notes = db.relationship(
"NotesNotes",
backref="evaluation",
cascade="all, delete-orphan",
lazy="dynamic",
)
notes_log = db.relationship(
"NotesNotesLog",
backref="evaluation",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="Evaluation.id == foreign(NotesNotesLog.evaluation_id)",
)
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
_sco_dept_relations = ("ModuleImpl", "FormSemestre") # accès au dept_id
@ -380,6 +393,13 @@ class Evaluation(models.ScoDocModel):
return f"""du {self.date_debut.strftime('%d/%m/%Y')} à {
_h(self.date_debut)} au {self.date_fin.strftime('%d/%m/%Y')} à {_h(self.date_fin)}"""
def descr(self) -> str:
"Description de l'évaluation pour affichage (avec module et semestre)"
return f"""{self.description} {self.descr_date()} en {
self.moduleimpl.module.titre_str()} du {
self.moduleimpl.formsemestre.titre_formation(with_sem_idx=True)
}"""
def heure_debut(self) -> str:
"""L'heure de début (sans la date), en ISO.
Chaine vide si non renseignée."""
@ -526,7 +546,7 @@ class Evaluation(models.ScoDocModel):
)
class EvaluationUEPoids(db.Model):
class EvaluationUEPoids(models.ScoDocModel):
"""Poids des évaluations (BUT)
association many to many
"""

View File

@ -62,7 +62,7 @@ class Scolog(ScoDocModel):
}
class ScolarNews(db.Model):
class ScolarNews(ScoDocModel):
"""Nouvelles pour page d'accueil"""
NEWS_ABS = "ABS" # saisie absence

View File

@ -646,9 +646,11 @@ class FormSemestre(models.ScoDocModel):
)
return [db.session.get(ModuleImpl, modimpl_id) for modimpl_id in cursor]
def can_be_edited_by(self, user):
def can_be_edited_by(self, user: User):
"""Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)"""
if not user.has_permission(Permission.EditFormSemestre): # pas chef
if user.passwd_must_be_changed or not user.has_permission(
Permission.EditFormSemestre
): # pas chef
if not self.resp_can_edit or user.id not in [
resp.id for resp in self.responsables
]:
@ -897,6 +899,8 @@ class FormSemestre(models.ScoDocModel):
if not self.etat:
return False # semestre verrouillé
user = user or current_user
if user.passwd_must_be_changed:
return False
if user.has_permission(Permission.EtudChangeGroups):
return True # typiquement admin, chef dept
return self.est_responsable(user)
@ -906,11 +910,15 @@ class FormSemestre(models.ScoDocModel):
dans ce semestre: vérifie permission et verrouillage.
"""
user = user or current_user
if user.passwd_must_be_changed:
return False
return self.etat and self.est_chef_or_diretud(user)
def can_edit_pv(self, user: User = None):
"Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre"
user = user or current_user
if user.passwd_must_be_changed:
return False
# Autorise les secrétariats, repérés via la permission EtudChangeAdr
return self.est_chef_or_diretud(user) or user.has_permission(
Permission.EtudChangeAdr
@ -1575,7 +1583,7 @@ class FormSemestreUECoef(models.ScoDocModel):
coefficient = db.Column(db.Float, nullable=False)
class FormSemestreUEComputationExpr(db.Model):
class FormSemestreUEComputationExpr(models.ScoDocModel):
"""Formules utilisateurs pour calcul moyenne UE (désactivées en 9.2+)."""
__tablename__ = "notes_formsemestre_ue_computation_expr"

View File

@ -372,11 +372,3 @@ group_membership = db.Table(
db.Column("group_id", db.Integer, db.ForeignKey("group_descr.id")),
db.UniqueConstraint("etudid", "group_id"),
)
# class GroupMembership(db.Model):
# """Association groupe / étudiant"""
# __tablename__ = "group_membership"
# __table_args__ = (db.UniqueConstraint("etudid", "group_id"),)
# id = db.Column(db.Integer, primary_key=True)
# etudid = db.Column(db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"))
# group_id = db.Column(db.Integer, db.ForeignKey("group_descr.id"))

View File

@ -199,6 +199,8 @@ class ModuleImpl(ScoDocModel):
"""True if this user can create, delete or edit and evaluation in this modimpl
(nb: n'implique pas le droit de saisir ou modifier des notes)
"""
if user.passwd_must_be_changed:
return False
# acces pour resp. moduleimpl et resp. form semestre (dir etud)
if (
user.has_permission(Permission.EditAllEvals)
@ -222,6 +224,8 @@ class ModuleImpl(ScoDocModel):
# was sco_permissions_check.can_edit_notes
from app.scodoc import sco_cursus_dut
if user.passwd_must_be_changed:
return False
if not self.formsemestre.etat:
return False # semestre verrouillé
is_dir_etud = user.id in (u.id for u in self.formsemestre.responsables)
@ -247,6 +251,8 @@ class ModuleImpl(ScoDocModel):
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")
return False
if user.passwd_must_be_changed:
return False
# -- check access
# admin ou resp. semestre avec flag resp_can_change_resp
if user.has_permission(Permission.EditFormSemestre):
@ -264,6 +270,8 @@ class ModuleImpl(ScoDocModel):
if user is None, current user.
"""
user = current_user if user is None else user
if user.passwd_must_be_changed:
return False
if not self.formsemestre.etat:
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")
@ -285,6 +293,8 @@ class ModuleImpl(ScoDocModel):
Autorise ScoEtudInscrit ou responsables semestre.
"""
user = current_user if user is None else user
if user.passwd_must_be_changed:
return False
if not self.formsemestre.etat:
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")

View File

@ -624,7 +624,7 @@ class Module(models.ScoDocModel):
return "", http.HTTPStatus.NO_CONTENT
class ModuleUECoef(db.Model):
class ModuleUECoef(models.ScoDocModel):
"""Coefficients des modules vers les UE (APC, BUT)
En mode APC, ces coefs remplacent le coefficient "PPN" du module.
"""

View File

@ -83,7 +83,7 @@ class NotesNotes(models.ScoDocModel):
} {db.session.get(Evaluation, self.evaluation_id) if self.evaluation_id else "X" }>"""
class NotesNotesLog(db.Model):
class NotesNotesLog(models.ScoDocModel):
"""Historique des modifs sur notes (anciennes entrees de notes_notes)"""
__tablename__ = "notes_notes_log"

View File

@ -1,10 +1,11 @@
"""évènements scolaires dans la vie d'un étudiant(inscription, ...)
"""
from app import db
from app import db, models
from app.models import SHORT_STR_LEN
class ScolarEvent(db.Model):
class ScolarEvent(models.ScoDocModel):
"""Evenement dans le parcours scolaire d'un étudiant"""
__tablename__ = "scolar_events"

View File

@ -573,7 +573,7 @@ class UniteEns(models.ScoDocModel):
return self.set_parcours(self.parcours + [parcour])
class UEParcours(db.Model):
class UEParcours(models.ScoDocModel):
"""Association ue <-> parcours, indiquant les ECTS"""
__tablename__ = "ue_parcours"
@ -593,7 +593,7 @@ class UEParcours(db.Model):
return f"<UEParcours( ue_id={self.ue_id}, parcours_id={self.parcours_id}, ects={self.ects})>"
class DispenseUE(db.Model):
class DispenseUE(models.ScoDocModel):
"""Dispense d'UE
Utilisé en APC (BUT) pour indiquer
- les étudiants redoublants avec une UE capitalisée qu'ils ne refont pas.

View File

@ -4,8 +4,7 @@
"""
from flask_sqlalchemy.query import Query
from app import db
from app import log
from app import db, log, models
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN
from app.models.events import Scolog
@ -16,7 +15,7 @@ from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import CODES_UE_VALIDES
class ScolarFormSemestreValidation(db.Model):
class ScolarFormSemestreValidation(models.ScoDocModel):
"""Décisions de jury (sur semestre ou UEs)"""
__tablename__ = "scolar_formsemestre_validation"
@ -158,7 +157,7 @@ class ScolarFormSemestreValidation(db.Model):
)
class ScolarAutorisationInscription(db.Model):
class ScolarAutorisationInscription(models.ScoDocModel):
"""Autorisation d'inscription dans un semestre"""
__tablename__ = "scolar_autorisation_inscription"

View File

@ -442,8 +442,9 @@ class TF(object):
self.formid}_submit" id="{self.formid}_submit" value="{self.submitlabel
}" {' '.join(self.submitbuttonattributes)}/>"""
if self.cancelbutton:
buttons_markup += f""" <input class="btn btn-default" type="submit" name="cancel" id="{
self.formid}_cancel" value="{self.cancelbutton}">"""
buttons_markup += f""" <input class="btn btn-default" type="submit"
name="{self.formid}_cancel" id="{self.formid}_cancel"
value="{self.cancelbutton}">"""
buttons_markup += "</div>"
R = []

View File

@ -499,11 +499,7 @@ class GenTable:
H.append(caption)
if self.base_url:
H.append('<span class="gt_export_icons">')
if self.xls_link:
H.append(
f""" <a href="{add_query_param(self.base_url, "fmt", "xls")
}">{scu.ICON_XLS}</a>"""
)
H.append(self.xls_export_button())
if self.xls_link and self.pdf_link:
H.append("&nbsp;")
if self.pdf_link:
@ -517,6 +513,15 @@ class GenTable:
H.append(self.html_next_section)
return "\n".join(H)
def xls_export_button(self) -> str:
"markup pour export excel"
return (
f""" <a href="{add_query_param(self.base_url, "fmt", "xls")
}">{scu.ICON_XLS}</a>"""
if self.xls_link
else ""
)
def excel(self, wb=None):
"""Simple Excel representation of the table"""
if wb is None:

View File

@ -514,32 +514,6 @@ def DateISOtoDMY(isodate):
return "%02d/%02d/%04d" % (day, month, year)
def TimetoISO8601(t, null_is_empty=False) -> str:
"convert time string to ISO 8601 (allow 16:03, 16h03, 16)"
if isinstance(t, datetime.time):
return t.isoformat()
if not t and null_is_empty:
return ""
t = t.strip().upper().replace("H", ":")
if t and t.count(":") == 0 and len(t) < 3:
t = t + ":00"
return t
def TimefromISO8601(t) -> str:
"convert time string from ISO 8601 to our display format"
if not t:
return t
# XXX strange bug turnaround...
try:
t = "%s:%s" % (t.hour(), t.minute())
# log('TimefromISO8601: converted isotime to iso !')
except:
pass
fs = str(t).split(":")
return fs[0] + "h" + fs[1] # discard seconds
def float_null_is_zero(x):
if x is None or x == "":
return 0.0

View File

@ -282,7 +282,7 @@ class ApoEtud(dict):
):
res = self.autre_res
else:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
formsemestre = FormSemestre.get_or_404(sem["formsemestre_id"])
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if etudid not in res.identdict:
@ -761,7 +761,7 @@ class ApoData:
if not self.sems_etape:
raise ScoValueError("aucun semestre trouvé !")
self.formsemestres_etape = [
FormSemestre.query.get_or_404(s["formsemestre_id"]) for s in self.sems_etape
FormSemestre.get_or_404(s["formsemestre_id"]) for s in self.sems_etape
]
apcs = {
formsemestre.formation.is_apc() for formsemestre in self.formsemestres_etape
@ -903,9 +903,7 @@ class ApoData:
"""
codes_by_sem = {}
for sem in self.sems_etape:
formsemestre: FormSemestre = FormSemestre.query.get_or_404(
sem["formsemestre_id"]
)
formsemestre: FormSemestre = FormSemestre.get_or_404(sem["formsemestre_id"])
# L'ensemble des codes apo associés aux éléments:
codes_semestre = formsemestre.get_codes_apogee()
codes_modules = set().union(

View File

@ -54,9 +54,11 @@ class EtudsArchiver(sco_archives.BaseArchiver):
ETUDS_ARCHIVER = EtudsArchiver()
def can_edit_etud_archive(authuser):
def can_edit_etud_archive(user):
"""True si l'utilisateur peut modifier les archives etudiantes"""
return authuser.has_permission(Permission.EtudAddAnnotations)
if user.passwd_must_be_changed:
return False
return user.has_permission(Permission.EtudAddAnnotations)
def etud_list_archives_html(etud: Identite):

View File

@ -416,7 +416,7 @@ def formsemestre_get_archived_file(formsemestre_id, archive_name, filename):
def formsemestre_delete_archive(formsemestre_id, archive_name, dialog_confirmed=False):
"""Delete an archive"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
if not formsemestre.can_edit_pv():
raise ScoPermissionDenied(
dest_url=url_for(

View File

@ -159,7 +159,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
# Formation et parcours
if I["sem"]["formation_id"]:
formation_dict = Formation.query.get_or_404(I["sem"]["formation_id"]).to_dict()
formation_dict = Formation.get_or_404(I["sem"]["formation_id"]).to_dict()
else: # what's the fuck ?
formation_dict = {
"acronyme": "?",
@ -1001,6 +1001,8 @@ def formsemestre_bulletinetud(
def can_send_bulletin_by_mail(formsemestre_id):
"""True if current user is allowed to send a bulletin (pdf) by mail"""
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
if current_user.passwd_must_be_changed:
return False
return (
sco_preferences.get_preference("bul_mail_allowed_for_all", formsemestre_id)
or current_user.has_permission(Permission.EditFormSemestre)

View File

@ -225,7 +225,7 @@ def get_formsemestre_bulletins_pdf(
from app.but import bulletin_but_court
from app.scodoc import sco_bulletins
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
versions = (
scu.BULLETINS_VERSIONS_BUT
if formsemestre.formation.is_apc()

View File

@ -313,7 +313,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
sont validés. En sortie, sem_idx_set contient ceux qui n'ont pas été validés."""
for sem in self.get_semestres():
if sem["formation_code"] == self.formation.formation_code:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
formsemestre = FormSemestre.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
decision = nt.get_etud_decision_sem(self.etudid)
if decision and code_semestre_validant(decision["code"]):
@ -408,7 +408,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
if not sem:
code = "" # non inscrit à ce semestre
else:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
formsemestre = FormSemestre.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
decision = nt.get_etud_decision_sem(self.etudid)
if decision:
@ -949,7 +949,7 @@ def do_formsemestre_validate_ue(
):
"""Ajoute ou change validation UE"""
if semestre_id is None:
ue = UniteEns.query.get_or_404(ue_id)
ue = UniteEns.get_or_404(ue_id)
semestre_id = ue.semestre_idx
args = {
"formsemestre_id": formsemestre_id,

View File

@ -222,18 +222,21 @@ def translate_calendar(
if group and group_ids_set and group.id not in group_ids_set:
continue # ignore cet évènement
modimpl: ModuleImpl | bool = event["modimpl"]
params = {
"scodoc_dept": g.scodoc_dept,
"heure_deb": scu.heure_to_iso8601(event["heure_deb"]),
"heure_fin": scu.heure_to_iso8601(event["heure_fin"]),
"day": event["jour"],
}
if group:
params["group_ids"] = group.id
if modimpl:
params["moduleimpl_id"] = modimpl.id
elif group:
params["formsemestre_id"] = group.partition.formsemestre_id
url_abs = (
url_for(
"assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
group_ids=group.id,
heure_deb=event["heure_deb"],
heure_fin=event["heure_fin"],
moduleimpl_id=modimpl.id,
day=event["jour"],
)
if modimpl and group
else None
url_for("assiduites.signal_assiduites_group", **params) if group else None
)
match modimpl:
case False: # EDT non configuré

View File

@ -347,7 +347,7 @@ def apo_semset_maq_status(
if missing:
formation_ids = {sem["formation_id"] for sem in semset.sems}
formations = [
Formation.query.get_or_404(formation_id) for formation_id in formation_ids
Formation.get_or_404(formation_id) for formation_id in formation_ids
]
H.append(
f"""<div class="apo_csv_status_missing_elems">

View File

@ -137,9 +137,6 @@ _identiteEditor = ndb.EditableTable(
(
"admission_id",
"boursier",
"cas_allow_login",
"cas_allow_scodoc_login",
"cas_id",
"civilite_etat_civil",
"civilite", # 'M", "F", or "X"
"code_ine",

View File

@ -64,7 +64,7 @@ def _build_results_table(start_date=None, end_date=None, types_parcours=()):
semlist = [dpv["formsemestre"] for dpv in dpv_by_sem.values() if dpv]
semlist_parcours = []
for sem in semlist:
sem["formation"] = Formation.query.get_or_404(sem["formation_id"]).to_dict()
sem["formation"] = Formation.get_or_404(sem["formation_id"]).to_dict()
sem["parcours"] = codes_cursus.get_cursus_from_code(
sem["formation"]["type_parcours"]
)

View File

@ -148,7 +148,7 @@ def _formsemestre_enrich(sem):
# imports ici pour eviter refs circulaires
from app.scodoc import sco_formsemestre_edit
formation: Formation = Formation.query.get_or_404(sem["formation_id"])
formation: Formation = Formation.get_or_404(sem["formation_id"])
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
# 'S1', 'S2', ... ou '' pour les monosemestres
if sem["semestre_id"] != NO_SEMESTRE_ID:

View File

@ -134,15 +134,6 @@ def formsemestre_editwithmodules(formsemestre_id: int):
)
def can_edit_sem(formsemestre_id: int = None, sem=None):
"""Return sem if user can edit it, False otherwise"""
sem = sem or sco_formsemestre.get_formsemestre(formsemestre_id)
if not current_user.has_permission(Permission.EditFormSemestre): # pas chef
if not sem["resp_can_edit"] or current_user.id not in sem["responsables"]:
return False
return sem
RESP_FIELDS = [
"responsable_id",
"responsable_id2",
@ -181,7 +172,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
formation = formsemestre.formation
else:
formation_id = int(vals["formation_id"])
formation = Formation.query.get_or_404(formation_id)
formation = Formation.get_or_404(formation_id)
is_apc = formation.is_apc()
if not edit:
@ -706,8 +697,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
def opt_selected(gid):
if gid == vals.get(select_name):
return "selected"
else:
return ""
return ""
if mod.id in module_ids_set:
# pas de menu inscription si le module est déjà présent
@ -866,7 +856,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
{msg}
{tf[1]}
"""
elif tf[0] == -1:
if tf[0] == -1: # Annulation
if formsemestre:
return redirect(
url_for(
@ -876,6 +866,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
)
)
return redirect(url_for("notes.index_html", scodoc_dept=g.scodoc_dept))
# Edition ou modification du semestre
tf[2]["gestion_compensation"] = bool(tf[2]["gestion_compensation_lst"])
tf[2]["gestion_semestrielle"] = bool(tf[2]["gestion_semestrielle_lst"])
@ -890,6 +881,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
# (retire le "MI" du début du nom de champs)
module_ids_checked = [int(x[2:]) for x in tf[2]["tf-checked"]]
_formsemestre_check_ue_bonus_unicity(module_ids_checked)
ok = False
if not edit:
if is_apc:
_formsemestre_check_module_list(module_ids_checked, tf[2]["semestre_id"])
@ -1075,10 +1067,10 @@ def _formsemestre_check_module_list(module_ids, semestre_idx):
"""
# vérification de la cohérence / modules / semestre
mod_sems_idx = {
Module.query.get_or_404(module_id).ue.semestre_idx for module_id in module_ids
Module.get_or_404(module_id).ue.semestre_idx for module_id in module_ids
}
if mod_sems_idx and mod_sems_idx != {semestre_idx}:
modules = [Module.query.get_or_404(module_id) for module_id in module_ids]
modules = [Module.get_or_404(module_id) for module_id in module_ids]
log(
f"""_formsemestre_check_module_list:
{chr(10).join( str(module) + " " + str(module.ue) for module in modules )}
@ -1096,7 +1088,7 @@ def _formsemestre_check_module_list(module_ids, semestre_idx):
def _formsemestre_check_ue_bonus_unicity(module_ids):
"""Vérifie qu'il n'y a qu'une seule UE bonus associée aux modules choisis"""
ues = [Module.query.get_or_404(module_id).ue for module_id in module_ids]
ues = [Module.get_or_404(module_id).ue for module_id in module_ids]
ues_bonus = {ue.id for ue in ues if ue.type == codes_cursus.UE_SPORT}
if len(ues_bonus) > 1:
raise ScoValueError(
@ -1293,9 +1285,7 @@ def do_formsemestre_clone(
New dates, responsable_id
"""
log(f"do_formsemestre_clone: {orig_formsemestre_id}")
formsemestre_orig: FormSemestre = FormSemestre.query.get_or_404(
orig_formsemestre_id
)
formsemestre_orig: FormSemestre = FormSemestre.get_or_404(orig_formsemestre_id)
# 1- create sem
args = formsemestre_orig.to_dict()
del args["formsemestre_id"]

View File

@ -61,7 +61,7 @@ def formsemestre_ext_create(etud: Identite | None, sem_params: dict) -> FormSeme
sem_params: dict nécessaire à la création du formsemestre
"""
# Check args
_ = Formation.query.get_or_404(sem_params["formation_id"])
_ = Formation.get_or_404(sem_params["formation_id"])
# Create formsemestre
sem_params["modalite"] = "EXT"
@ -230,7 +230,7 @@ def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid):
La moyenne générale indicative du semestre est calculée et affichée,
mais pas enregistrée.
"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
etud = Identite.get_etud(etudid)
ues = formsemestre.formation.ues.filter(UniteEns.type != UE_SPORT).order_by(
UniteEns.semestre_idx, UniteEns.numero

View File

@ -248,9 +248,7 @@ def do_formsemestre_inscription_with_modules(
group_ids = [group_ids]
# Check that all groups exist before creating the inscription
groups = [
GroupDescr.query.get_or_404(group_id)
for group_id in group_ids
if group_id != ""
GroupDescr.get_or_404(group_id) for group_id in group_ids if group_id != ""
]
formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id)
# Inscription au semestre
@ -377,7 +375,7 @@ def formsemestre_inscription_with_modules(
)
if multiple_ok:
multiple_ok = int(multiple_ok)
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
etud = Identite.get_etud(etudid)
if etud.dept_id != formsemestre.dept_id:
raise ScoValueError("l'étudiant n'est pas dans ce département")

View File

@ -1079,7 +1079,7 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
raise ScoInvalidIdType(
"formsemestre_bulletinetud: formsemestre_id must be an integer !"
)
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
# S'assure que les groupes de parcours sont à jour:
if int(check_parcours):
formsemestre.setup_parcours_groups()

View File

@ -1383,7 +1383,7 @@ def do_formsemestre_validate_previous_ue(
cette UE (utile seulement pour les semestres extérieurs).
"""
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
ue: UniteEns = UniteEns.query.get_or_404(ue_id)
ue: UniteEns = UniteEns.get_or_404(ue_id)
cnx = ndb.GetDBConnexion()
if ue_coefficient is not None:

View File

@ -663,7 +663,7 @@ def change_etud_group_in_partition(etudid: int, group: GroupDescr) -> bool:
(et le désinscrit d'autres groupes de cette partition)
Return True si changement, False s'il était déjà dans ce groupe.
"""
etud: Identite = Identite.query.get_or_404(etudid)
etud: Identite = Identite.get_or_404(etudid)
if not group.partition.set_etud_group(etud, group):
return # pas de changement
@ -742,7 +742,7 @@ groupsToDelete={groupsToDelete}
except ValueError:
log(f"setGroups: ignoring invalid group_id={group_id}")
continue
group: GroupDescr = GroupDescr.query.get_or_404(group_id)
group: GroupDescr = GroupDescr.get_or_404(group_id)
# Anciens membres du groupe:
old_members_set = {etud.id for etud in group.etuds}
# Place dans ce groupe les etudiants indiqués:
@ -807,7 +807,7 @@ def create_group(partition_id, group_name="", default=False) -> GroupDescr:
If default, create default partition (with no name)
Obsolete: utiliser Partition.create_group
"""
partition = Partition.query.get_or_404(partition_id)
partition = Partition.get_or_404(partition_id)
if not partition.formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
#
@ -840,7 +840,7 @@ def delete_group(group_id, partition_id=None):
est bien dans cette partition.
S'il s'agit d'un groupe de parcours, affecte l'inscription des étudiants aux parcours.
"""
group = GroupDescr.query.get_or_404(group_id)
group = GroupDescr.get_or_404(group_id)
if partition_id:
if partition_id != group.partition_id:
raise ValueError("inconsistent partition/group")
@ -1096,7 +1096,7 @@ def partition_set_attr(partition_id, attr, value):
if attr not in {"bul_show_rank", "show_in_lists"}:
raise ValueError(f"invalid partition attribute: {attr}")
partition = Partition.query.get_or_404(partition_id)
partition = Partition.get_or_404(partition_id)
if not partition.formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
@ -1583,6 +1583,7 @@ def listgroups_abbrev(groups):
# form_group_choice replaces formChoixGroupe
# obsolete, utilisé seulement par formsemestre_inscription_with_modules
def form_group_choice(
formsemestre_id,
allow_none=True, # offre un choix vide dans chaque partition

View File

@ -42,7 +42,7 @@ def affect_groups(partition_id):
Permet aussi la creation et la suppression de groupes.
"""
# réécrit pour 9.0.47 avec un template
partition = Partition.query.get_or_404(partition_id)
partition = Partition.get_or_404(partition_id)
formsemestre = partition.formsemestre
if not formsemestre.can_change_groups():
raise AccessDenied("vous n'avez pas la permission de modifier les groupes")
@ -63,7 +63,7 @@ def affect_groups(partition_id):
def group_rename(group_id):
"""Form to rename a group"""
group: GroupDescr = GroupDescr.query.get_or_404(group_id)
group: GroupDescr = GroupDescr.get_or_404(group_id)
formsemestre_id = group.partition.formsemestre_id
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if not formsemestre.can_change_groups():

View File

@ -370,7 +370,7 @@ class DisplayedGroupsInfos:
if not group_ids: # appel sans groupe (eg page accueil)
if not formsemestre_id:
raise ValueError("missing parameter formsemestre_id or group_ids")
raise ScoValueError("missing parameter formsemestre_id or group_ids")
if empty_list_select_all:
if select_all_when_unspecified:
group_ids = [

View File

@ -85,7 +85,7 @@ COMMENTS = (
autorise la connexion via CAS (optionnel, faux par défaut)
""",
"""cas_allow_scodoc_login
autorise connexion via ScoDoc même si CAS obligatoire (optionnel, faux par défaut)
autorise connexion via ScoDoc même si CAS activé (optionnel, faux par défaut)
""",
"""email_institutionnel
optionnel, le mail officiel de l'utilisateur.

View File

@ -349,6 +349,7 @@ def formsemestre_inscr_passage(
inscrit_groupes=inscrit_groupes,
inscrit_parcours=inscrit_parcours,
ignore_jury=ignore_jury,
with_apo_cols=False,
)
else:
if not dialog_confirmed:
@ -447,9 +448,10 @@ def formsemestre_inscr_passage(
"""
)
#
return render_template(
"sco_page.j2", title="Passage des étudiants", content="\n".join(H)
"formsemestre/synchro_etuds.j2",
title="Passage des étudiants",
content="\n".join(H),
)
@ -462,6 +464,7 @@ def _build_page(
inscrit_groupes=False,
inscrit_parcours=False,
ignore_jury=False,
with_apo_cols: bool = True,
):
inscrit_groupes = int(inscrit_groupes)
inscrit_parcours = int(inscrit_parcours)
@ -506,7 +509,7 @@ def _build_page(
de ce semestre ({formsemestre.date_debut.strftime(scu.DATE_FMT)}) sont pris en
compte.</em>
</div>
{etuds_select_boxes(auth_etuds_by_sem, inscrits_ailleurs)}
{etuds_select_boxes(auth_etuds_by_sem, inscrits_ailleurs, with_apo_cols=with_apo_cols)}
<input type="submit" name="submitted" value="Appliquer les modifications"/>
@ -586,6 +589,7 @@ def etuds_select_boxes(
export_cat_xls=None,
base_url="",
read_only=False,
with_apo_cols: bool = True,
):
"""Boites pour selection étudiants par catégorie
auth_etuds_by_cat = { category : { 'info' : {}, 'etuds' : ... }
@ -598,22 +602,7 @@ def etuds_select_boxes(
return etuds_select_box_xls(auth_etuds_by_cat[export_cat_xls])
H = [
"""<script type="text/javascript">
function sem_select(formsemestre_id, state) {
var elems = document.getElementById(formsemestre_id).getElementsByTagName("input");
for (var i =0; i < elems.length; i++) { elems[i].checked=state; }
}
function sem_select_inscrits(formsemestre_id) {
var elems = document.getElementById(formsemestre_id).getElementsByTagName("input");
for (var i =0; i < elems.length; i++) {
if (elems[i].parentNode.className.indexOf('inscrit') >= 0) {
elems[i].checked=true;
} else {
elems[i].checked=false;
}
}
}
</script>
"""
<div class="etuds_select_boxes">"""
] # "
# Élimine les boites vides:
@ -625,118 +614,218 @@ def etuds_select_boxes(
for src_cat in auth_etuds_by_cat.keys():
infos = auth_etuds_by_cat[src_cat]["infos"]
infos["comment"] = infos.get("comment", "") # commentaire dans sous-titre boite
help_txt = infos.get("help", "")
etuds = auth_etuds_by_cat[src_cat]["etuds"]
etuds.sort(key=itemgetter("nom"))
with_checkbox = (not read_only) and auth_etuds_by_cat[src_cat]["infos"].get(
"with_checkbox", True
)
checkbox_name = auth_etuds_by_cat[src_cat]["infos"].get(
"checkbox_name", "etuds"
)
etud_key = auth_etuds_by_cat[src_cat]["infos"].get("etud_key", "etudid")
if etuds or show_empty_boxes:
infos["nbetuds"] = len(etuds)
H.append(
"""<div class="pas_sembox" id="%(id)s">
<div class="pas_sembox_title"><a href="%(title_target)s" """
% infos
xls_url = (
scu.build_url_query(base_url, export_cat_xls=src_cat)
if base_url and etuds
else ""
)
if help_txt: # bubble
H.append('title="%s"' % help_txt)
H.append(
""">%(title)s</a></div>
<div class="pas_sembox_subtitle">(%(nbetuds)d étudiants%(comment)s)"""
% infos
etuds_select_box(
etuds,
infos,
with_checkbox=with_checkbox,
sel_inscrits=sel_inscrits,
xls_url=xls_url,
inscrits_ailleurs=inscrits_ailleurs,
with_apo_cols=with_apo_cols,
)
)
if with_checkbox:
H.append(
""" (Select.
<a href="#" class="stdlink" onclick="sem_select('%(id)s', true);">tous</a>
<a href="#" class="stdlink" onclick="sem_select('%(id)s', false );">aucun</a>""" # "
% infos
)
if sel_inscrits:
H.append(
"""<a href="#" class="stdlink" onclick="sem_select_inscrits('%(id)s');">inscrits</a>"""
% infos
)
if with_checkbox or sel_inscrits:
H.append(")")
if base_url and etuds:
url = scu.build_url_query(base_url, export_cat_xls=src_cat)
H.append(f'<a href="{url}">{scu.ICON_XLS}</a>&nbsp;')
H.append("</div>")
for etud in etuds:
if etud.get("inscrit", False):
c = " deja-inscrit"
checked = 'checked="checked"'
else:
checked = ""
if etud["etudid"] in inscrits_ailleurs:
c = " inscrit-ailleurs"
else:
c = ""
sco_etud.format_etud_ident(etud)
if etud["etudid"]:
elink = f"""<a id="{etud['etudid']}" class="discretelink etudinfo {c}"
href="{ url_for(
'scolar.fiche_etud',
scodoc_dept=g.scodoc_dept,
etudid=etud['etudid'],
)
}">{etud['nomprenom']}</a>
"""
else:
# ce n'est pas un etudiant ScoDoc
elink = etud["nomprenom"]
if etud.get("datefinalisationinscription", None):
elink += (
'<span class="finalisationinscription">'
+ " : inscription finalisée le "
+ etud["datefinalisationinscription"].strftime(scu.DATE_FMT)
+ "</span>"
)
if not etud.get("paiementinscription", True):
elink += '<span class="paspaye"> (non paiement)</span>'
H.append("""<div class="pas_etud%s">""" % c)
if "etape" in etud:
etape_str = etud["etape"] or ""
else:
etape_str = ""
H.append("""<span class="sp_etape">%s</span>""" % etape_str)
if with_checkbox:
H.append(
"""<input type="checkbox" name="%s:list" value="%s" %s>"""
% (checkbox_name, etud[etud_key], checked)
)
H.append(elink)
if with_checkbox:
H.append("""</input>""")
H.append("</div>")
H.append("</div>")
H.append("</div>")
return "\n".join(H)
def etuds_select_box(
etuds: list[dict],
infos: dict,
with_checkbox: bool = True,
sel_inscrits: bool = True,
xls_url: str = "",
inscrits_ailleurs: set = None,
with_apo_cols: bool = True,
) -> str:
"""HTML pour une "boite" avec une liste d'étudiants à sélectionner"""
inscrits_ailleurs = inscrits_ailleurs or {}
box_id = infos["id"]
H = []
H.append(
f"""<div class="pas_sembox" id="{box_id}">
<div class="pas_sembox_title"><a href="{infos['title_target']}" """
)
help_txt = infos.get("help")
if help_txt: # bubble
H.append(f'title="{help_txt}"')
H.append(
""">%(title)s</a></div>
<div class="pas_sembox_subtitle">(%(nbetuds)d étudiants%(comment)s)"""
% infos
)
if with_checkbox:
H.append(
f""" (Select.
<a href="#" class="stdlink" onclick="sem_select('{box_id}', true);">tous</a>
<a href="#" class="stdlink" onclick="sem_select('{box_id}', false );">aucun</a>"""
)
if sel_inscrits:
H.append(
f"""<a href="#"
class="stdlink" onclick="sem_select_inscrits('{box_id}');"
>inscrits</a>"""
)
if with_checkbox or sel_inscrits:
H.append(")")
if xls_url:
H.append(f'<a href="{xls_url}">{scu.ICON_XLS}</a>&nbsp;')
H.append("</div>")
checkbox_title = """<th class="no-sort"></th>""" if with_checkbox else ""
ths = (
f"<th>Étape</th>{checkbox_title}<th>Nom</th><th>Paiement</th><th>Finalisé</th>"
if with_apo_cols
else f"{checkbox_title}<th>Nom</th>"
)
H.append(
f"""<table class="etuds-box">
<thead>
<tr>
{ths}
</tr>
</thead>
<tbody>
"""
)
for etud in etuds:
is_inscrit = etud.get("inscrit", False)
extra_class = (
"deja-inscrit"
if is_inscrit
else ("inscrit-ailleurs" if etud["etudid"] in inscrits_ailleurs else "")
)
H.append(
_etud_row(
etud,
with_checkbox=with_checkbox,
checkbox_name=infos.get("checkbox_name", "etuds"),
etud_key=infos.get("etud_key", "etudid"),
is_inscrit=is_inscrit,
extra_class=extra_class,
with_apo_cols=with_apo_cols,
)
)
H.append(
"""</tbody>
</table>"""
)
H.append("</div>")
return "\n".join(H)
def _etud_row(
etud: dict,
with_checkbox: bool = True,
checkbox_name: str = "etuds",
etud_key: str = "",
is_inscrit: bool = False,
extra_class: str = "",
with_apo_cols: bool = True,
) -> str:
"""HTML 'row' for this etud"""
H = []
nomprenom = scu.format_nomprenom(etud)
if etud["etudid"]:
elink = f"""<a id="{etud['etudid']}" class="discretelink etudinfo {extra_class}"
href="{ url_for(
'scolar.fiche_etud',
scodoc_dept=g.scodoc_dept,
etudid=etud['etudid'],
)
}">{nomprenom}</a>
"""
else:
# ce n'est pas un etudiant ScoDoc
elink = nomprenom
checkbox_cell = (
f"""<td><input type="checkbox" name="{checkbox_name}:list"
value="{etud[etud_key]}" {'checked="checked"' if is_inscrit else ''}>
</td>
"""
if with_checkbox
else ""
)
paiement = etud.get("paiementinscription", True)
datefinalisation = etud.get("datefinalisationinscription")
if with_apo_cols:
H.append(
f"""
<tr class="{extra_class}">
<td class="etape">{etud.get("etape", "") or ""}</td>
{checkbox_cell}
<td data-order="{etud.get("nom", "").upper()}">{elink}</td>
<td class="paiement {'' if paiement else 'paspaye'}">{
'' if paiement else 'non paiement'
}</td>
<td class="finalise" data-order="{
datefinalisation.isoformat() if datefinalisation else ''
}">{"inscription finalisée le " + datefinalisation.strftime(scu.DATE_FMT)
if datefinalisation else "" }
</td>
</tr>
"""
)
else: # juste checkbox et nom
H.append(
f"""
<tr class="{extra_class}">
{checkbox_cell}
<td data-order="{etud.get("nom", "").upper()}">{elink}</td>
</tr>
"""
)
return "\n".join(H)
def etuds_select_box_xls(src_cat):
"export a box to excel"
etuds = src_cat["etuds"]
columns_ids = ["etudid", "civilite_str", "nom", "prenom", "etape"]
titles = {x: x for x in columns_ids}
# Ajoute colonne paiement inscription
columns_ids.append("paiementinscription_str")
titles["paiementinscription_str"] = "paiement inscription"
columns_ids = [
"etudid",
"ine",
"nip",
"civilite_str",
"nom",
"prenom",
"etape",
"paiementinscription_str",
"datefinalisationinscription",
]
titles = {x: x for x in columns_ids} | {
"paiementinscription_str": "Paiement inscr.",
"datefinalisationinscription": "Finalisation inscr.",
}
for e in etuds:
if not e.get("paiementinscription", True):
e["paiementinscription_str"] = "NON"
else:
e["paiementinscription_str"] = "-"
# si e est un étudiant Apo, on a nip et ine
# mais si e est ScoDoc, on a code_nip et code_ine:
e["nip"] = e.get("nip", e.get("code_nip"))
e["ine"] = e.get("ine", e.get("code_ine"))
# Pour excel, datefinalisationinscription doit être datetime
dat = e.get("datefinalisationinscription")
if isinstance(dat, datetime.date):
e["datefinalisationinscription"] = datetime.datetime.combine(
dat, datetime.time.min
)
tab = GenTable(
caption="%(title)s. %(help)s" % src_cat["infos"],
columns_ids=columns_ids,

View File

@ -399,7 +399,7 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
H.append(
'<h3>Étudiants avec UEs capitalisées (ADM):</h3><ul class="ue_inscr_list">'
)
ues = [UniteEns.query.get_or_404(ue_id) for ue_id in ues_cap_info.keys()]
ues = [UniteEns.get_or_404(ue_id) for ue_id in ues_cap_info.keys()]
ues.sort(key=lambda u: u.numero)
for ue in ues:
H.append(

View File

@ -884,7 +884,7 @@ def _ligne_evaluation(
f"""{gr_moyenne["gr_moy"]}&nbsp; (<a class="stdlink" href="{
url_for('notes.evaluation_listenotes',
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id,
tf_submitted=1, **{'group_ids:list': gr_moyenne["group_id"]})
tf_submitted=1, **{'group_ids': gr_moyenne["group_id"]})
}">{gr_moyenne["gr_nb_notes"]} notes</a>"""
)
if gr_moyenne["gr_nb_att"] > 0:
@ -899,7 +899,7 @@ def _ligne_evaluation(
H.append(
f"""<a class="redlink" href="{url_for('notes.form_saisie_notes',
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id,
**{'group_ids:list': gr_moyenne["group_id"]})
**{'group_ids': gr_moyenne["group_id"]})
}">incomplet&nbsp;: terminer saisie</a></font>]"""
)
else:

View File

@ -17,6 +17,8 @@ def can_suppress_annotation(annotation_id):
Seuls l'auteur de l'annotation et le chef de dept peuvent supprimer
une annotation.
"""
if current_user.passwd_must_be_changed:
return False
annotation = (
EtudAnnotation.query.filter_by(id=annotation_id)
.join(Identite)
@ -30,8 +32,10 @@ def can_suppress_annotation(annotation_id):
)
def can_edit_suivi():
def can_edit_suivi() -> bool:
"""Vrai si l'utilisateur peut modifier les informations de suivi sur la page etud" """
if current_user.passwd_must_be_changed:
return False
return current_user.has_permission(Permission.EtudChangeAdr)

View File

@ -369,7 +369,7 @@ def copy_portal_photo_to_fs(etudid: int):
"""Copy the photo from portal (distant website) to local fs.
Returns rel. path or None if copy failed, with a diagnostic message
"""
etud: Identite = Identite.query.get_or_404(etudid)
etud: Identite = Identite.get_or_404(etudid)
url = photo_portal_url(etud.code_nip)
if not url:
return None, f"""{etud.nomprenom}: pas de code NIP"""

View File

@ -476,28 +476,37 @@ def get_etapes_apogee_dept():
return etapes
def _portal_date_dmy2date(s):
"""date inscription renvoyée sous la forme dd/mm/yy
renvoie un objet date, ou None
def _portal_date_dmy2date(s: str) -> datetime.date | None:
"""s est la date inscription fournie par le portail
sous la forme dd/mm/yy
Renvoie un objet date, ou None
Raises ValueError si format invalide.
"""
s = s.strip()
if not s:
return None
else:
d, m, y = [int(x) for x in s.split("/")] # raises ValueError if bad format
if y < 100:
y += 2000 # 21ème siècle
return datetime.date(y, m, d)
d, m, y = [int(x) for x in s.split("/")] # raises ValueError if bad format
if y < 100:
y += 2000 # 21ème siècle
return datetime.date(y, m, d)
def _normalize_apo_fields(infolist):
"""
infolist: liste de dict renvoyés par le portail Apogee
recode les champs: paiementinscription (-> booleen), datefinalisationinscription (date)
ajoute le champ 'paiementinscription_str' : 'ok', 'Non' ou '?'
ajoute les champs 'etape' (= None) et 'prenom' ('') s'ils ne sont pas présents.
ajoute le champ 'civilite_etat_civil' (=''), et 'prenom_etat_civil' (='') si non présent.
Recode les champs:
- paiementinscription (-> booleen)
- datefinalisationinscription (date)
Ajoute le champ
- 'paiementinscription_str' : 'ok', 'Non' ou '?'
S'ils ne sont pas présents, ajoute les champs:
- 'etape' (None)
- 'prenom' ('')
- 'civilite_etat_civil' ('')
- 'prenom_etat_civil' ('')
"""
for infos in infolist:
if "paiementinscription" in infos:
@ -516,9 +525,11 @@ def _normalize_apo_fields(infolist):
infos["datefinalisationinscription"] = _portal_date_dmy2date(
infos["datefinalisationinscription"]
)
infos["datefinalisationinscription_str"] = infos[
"datefinalisationinscription"
].strftime(scu.DATE_FMT)
infos["datefinalisationinscription_str"] = (
infos["datefinalisationinscription"].strftime(scu.DATE_FMT)
if infos["datefinalisationinscription"]
else ""
)
else:
infos["datefinalisationinscription"] = None
infos["datefinalisationinscription_str"] = ""

View File

@ -60,7 +60,7 @@ def etud_get_poursuite_info(sem: dict, etud: dict) -> dict:
for s in etud["sems"]:
if s["semestre_id"] == sem_id:
etudid = etud["etudid"]
formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"])
formsemestre = FormSemestre.get_or_404(s["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
dec = nt.get_etud_decision_sem(etudid)
# Moyennes et rangs des UE

View File

@ -91,7 +91,7 @@ def dict_pvjury(
'decisions_dict' : { etudid : decision (comme ci-dessus) },
}
"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if etudids is None:
etudids = nt.get_etudids()
@ -231,7 +231,7 @@ def dict_pvjury(
"is_apc": nt.is_apc,
"has_prev": has_prev,
"semestre_non_terminal": semestre_non_terminal,
"formation": Formation.query.get_or_404(sem["formation_id"]).to_dict(),
"formation": Formation.get_or_404(sem["formation_id"]).to_dict(),
"decisions": decisions,
"decisions_dict": D,
}

View File

@ -577,7 +577,7 @@ def descrform_pvjury(formsemestre: FormSemestre):
def formsemestre_lettres_individuelles(formsemestre_id):
"Lettres avis jury en PDF"
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
if request.method == "POST":
group_ids = request.form.getlist("group_ids")
else:

View File

@ -33,8 +33,10 @@ import time
from xml.etree import ElementTree
from flask import abort, g, render_template, request, url_for
from flask_login import current_user
from app import log
from app.auth.models import Permission
from app.but import bulletin_but
from app.comp import res_sem
from app.comp.res_common import ResultatsSemestre
@ -507,6 +509,7 @@ def gen_formsemestre_recapcomplet_html_table(
def _gen_formsemestre_recapcomplet_table(
res: ResultatsSemestre,
include_email_addresses=False,
include_evaluations=False,
mode_jury=False,
convert_values: bool = True,
@ -518,6 +521,7 @@ def _gen_formsemestre_recapcomplet_table(
table = table_class(
res,
convert_values=convert_values,
include_email_addresses=include_email_addresses,
include_evaluations=include_evaluations,
mode_jury=mode_jury,
read_only=not res.formsemestre.can_edit_jury(),
@ -539,8 +543,10 @@ def gen_formsemestre_recapcomplet_excel(
Attention: le tableau exporté depuis la page html est celui généré en js par DataTables,
et non celui-ci.
"""
# En excel, ajoute les adresses mail, si on a le droit de les voir.
table = _gen_formsemestre_recapcomplet_table(
res,
include_email_addresses=current_user.has_permission(Permission.ViewEtudData),
include_evaluations=include_evaluations,
mode_jury=mode_jury,
convert_values=False,

View File

@ -533,7 +533,7 @@ def table_suivi_cohorte(
s["members"] = orig_set.intersection(inset)
nb_dipl = 0 # combien de diplomes dans ce semestre ?
if s["semestre_id"] == nt.parcours.NB_SEM:
s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"])
s_formsemestre = FormSemestre.get_or_404(s["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre)
for etudid in s["members"]:
dec = nt.get_etud_decision_sem(etudid)
@ -1114,7 +1114,7 @@ def get_code_cursus_etud(
if formsemestres is None:
formsemestres = [
FormSemestre.query.get_or_404(s["formsemestre_id"]) for s in (sems or [])
FormSemestre.get_or_404(s["formsemestre_id"]) for s in (sems or [])
]
# élimine les semestres spéciaux hors cursus (LP en 1 sem., ...)
@ -1448,7 +1448,7 @@ def graph_cursus(
nxt = {}
etudid = etud["etudid"]
for s in etud["sems"]: # du plus recent au plus ancien
s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"])
s_formsemestre = FormSemestre.get_or_404(s["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre)
dec = nt.get_etud_decision_sem(etudid)
if nxt:

View File

@ -66,7 +66,7 @@ INDICATEUR_NAMES = {
def formsemestre_but_indicateurs(formsemestre_id: int, fmt="html"):
"""Page avec tableau indicateurs enquête ADIUT BUT 2022"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
indicateurs_by_bac = but_indicateurs_by_bac(formsemestre)
# finalement on fait une table avec en ligne

View File

@ -326,7 +326,7 @@ def do_evaluation_set_missing(
def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
"suppress all notes in this eval"
evaluation = Evaluation.query.get_or_404(evaluation_id)
evaluation = Evaluation.get_or_404(evaluation_id)
if evaluation.moduleimpl.can_edit_notes(current_user, allow_ens=False):
# On a le droit de modifier toutes les notes

View File

@ -157,7 +157,7 @@ class SemSet(dict):
def add(self, formsemestre_id):
"Ajoute ce semestre à l'ensemble"
# check for valid formsemestre_id
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
# check
if formsemestre_id in self.formsemestre_ids:
return # already there
@ -265,7 +265,7 @@ class SemSet(dict):
self["jury_nb_missing"] = 0
is_apc = None
for sem in self.sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
formsemestre = FormSemestre.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if is_apc is not None and is_apc != nt.is_apc:
raise ScoValueError(

View File

@ -299,7 +299,9 @@ def formsemestre_synchro_etuds(
)
return render_template(
"sco_page.j2", title="Synchronisation des étudiants", content="\n".join(H)
"formsemestre/synchro_etuds.j2",
title="Synchronisation des étudiants",
content="\n".join(H),
)
@ -523,7 +525,7 @@ def list_synch(sem, annee_apogee=None):
"etuds": set_to_sorted_list(etuds_ok, is_inscrit=True),
"infos": {
"id": "etuds_ok",
"title": "Étudiants dans Apogée et déjà inscrits",
"title": "Étudiants dans Apogée et déjà dans ScoDoc",
"help": """Ces etudiants sont inscrits dans le semestre ScoDoc et sont présents dans Apogée:
tout est donc correct. Décocher les étudiants que vous souhaitez désinscrire.""",
"title_target": "",
@ -848,7 +850,7 @@ def formsemestre_import_etud_admission(
for i in ins:
etudid = i["etudid"]
etud: Identite = Identite.query.get_or_404(etudid)
etud: Identite = Identite.get_or_404(etudid)
code_nip = etud.code_nip
if not code_nip:
etuds_no_nip.append(etud)

View File

@ -46,9 +46,11 @@ Opérations:
"""
import datetime
from flask import g, request, url_for
from flask import g, render_template, request, url_for
from app.models import Evaluation, FormSemestre
from app import db
from app.auth.models import User
from app.models import Evaluation, FormSemestre, ModuleImpl, NotesNotes, NotesNotesLog
from app.scodoc.intervals import intervalmap
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
@ -178,46 +180,119 @@ def evaluation_list_operations(evaluation_id: int):
return tab.make_page()
def formsemestre_list_saisies_notes(formsemestre_id, fmt="html"):
def formsemestre_list_notes_intervenants(formsemestre: FormSemestre) -> list[User]:
"Liste des comptes ayant saisi au moins une note dans le semestre"
q1 = (
User.query.join(NotesNotes)
.join(Evaluation)
.join(ModuleImpl)
.filter_by(formsemestre_id=formsemestre.id)
.distinct()
)
q2 = (
User.query.join(NotesNotesLog)
.join(Evaluation, Evaluation.id == NotesNotesLog.evaluation_id)
.join(ModuleImpl)
.filter_by(formsemestre_id=formsemestre.id)
.distinct()
)
return sorted(q1.union(q2).all(), key=lambda x: x.sort_key())
def formsemestre_list_saisies_notes(
formsemestre_id, only_modifs=False, user_name: str | None = None, fmt="html"
):
"""Table listant toutes les opérations de saisies de notes, dans toutes
les évaluations du semestre.
"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
rows = ndb.SimpleDictFetch(
"""SELECT i.nom, i.prenom, code_nip, n.*, mod.titre, e.description, e.date_debut,
u.user_name, e.id as evaluation_id
FROM notes_notes n, notes_evaluation e, notes_moduleimpl mi,
notes_modules mod, identite i, "user" u
WHERE mi.id = e.moduleimpl_id
and mi.module_id = mod.id
and e.id = n.evaluation_id
and i.id = n.etudid
and u.id = n.uid
and mi.formsemestre_id = %(formsemestre_id)s
ORDER BY date desc
""",
{"formsemestre_id": formsemestre_id},
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
only_modifs = scu.to_bool(only_modifs)
model = NotesNotesLog if only_modifs else NotesNotes
notes_query = (
db.session.query(model)
.join(Evaluation, Evaluation.id == model.evaluation_id)
.join(ModuleImpl)
.filter_by(formsemestre_id=formsemestre.id)
.order_by(model.date.desc())
)
if user_name:
user = db.session.query(User).filter_by(user_name=user_name).first()
if user:
notes_query = notes_query.join(User).filter(model.uid == user.id)
# Formate les notes
keep_numeric = fmt in scu.FORMATS_NUMERIQUES
for row in rows:
row["value"] = scu.fmt_note(row["value"], keep_numeric=keep_numeric)
row["date_evaluation"] = (
row["date_debut"].strftime("%d/%m/%Y %H:%M") if row["date_debut"] else ""
)
row["_date_evaluation_order"] = (
row["date_debut"].isoformat() if row["date_debut"] else ""
)
rows = []
for note in notes_query:
ens = User.get_user(note.uid)
evaluation = note.evaluation
row = {
"date": note.date.strftime(scu.DATEATIME_FMT),
"_date_order": note.date.isoformat(),
"code_nip": note.etudiant.code_nip,
"nom": note.etudiant.nom_disp(),
"prenom": note.etudiant.prenom_str,
"date_evaluation": (
evaluation.date_debut.strftime(scu.DATEATIME_FMT)
if evaluation and note.evaluation.date_debut
else ""
),
"_date_evaluation_order": (
note.evaluation.date_debut.isoformat()
if evaluation and note.evaluation.date_debut
else ""
),
"value": scu.fmt_note(note.value, keep_numeric=keep_numeric),
"module": (
(
note.evaluation.moduleimpl.module.code
or note.evaluation.moduleimpl.module.titre
)
if evaluation
else ""
),
"evaluation": note.evaluation.description if evaluation else "",
"_evaluation_target": (
url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=note.evaluation_id,
)
if evaluation
else ""
),
"user_name": ens.user_name if ens else "",
}
if only_modifs:
# si c'est une modif de note, ajoute une colonne avec la nouvelle valeur
new = NotesNotes.query.filter_by(
evaluation_id=note.evaluation_id, etudid=note.etudid
).first()
if new:
row["new_value"] = scu.fmt_note(new.value, keep_numeric=keep_numeric)
row["old_date"] = row["date"]
row["_old_date_order"] = row["_date_order"]
row["date"] = new.date.strftime(scu.DATEATIME_FMT)
row["_date_order"] = new.date.isoformat()
rows.append(row)
columns_ids = (
"date",
"code_nip",
"nom",
"prenom",
"value",
)
if only_modifs:
columns_ids += (
"new_value",
"old_date",
)
columns_ids += (
"user_name",
"titre",
"evaluation_id",
"description",
"module",
"evaluation",
"date_evaluation",
"comment",
)
@ -225,16 +300,20 @@ def formsemestre_list_saisies_notes(formsemestre_id, fmt="html"):
"code_nip": "NIP",
"nom": "nom",
"prenom": "prenom",
"date": "Date",
"date": "Date modif." if only_modifs else "Date saisie",
"old_date": "Date saisie précédente",
"value": "Note",
"comment": "Remarque",
"user_name": "Enseignant",
"evaluation_id": "evaluation_id",
"titre": "Module",
"description": "Evaluation",
"module": "Module",
"evaluation": "Evaluation",
"date_evaluation": "Date éval.",
}
tab = GenTable(
if only_modifs:
titles["value"] = "Ancienne note"
titles["new_value"] = "Nouvelle note"
table = GenTable(
titles=titles,
columns_ids=columns_ids,
rows=rows,
@ -244,11 +323,31 @@ def formsemestre_list_saisies_notes(formsemestre_id, fmt="html"):
html_sortable=True,
caption=f"Saisies de notes dans {formsemestre.titre_annee()}",
preferences=sco_preferences.SemPreferences(formsemestre_id),
base_url="%s?formsemestre_id=%s" % (request.base_url, formsemestre_id),
base_url=f"""{request.base_url}?formsemestre_id={
formsemestre_id}&only_modifs={int(only_modifs)}"""
+ (f"&user_name={user_name}" if user_name else ""),
origin=f"Généré par {sco_version.SCONAME} le " + scu.timedate_human_repr() + "",
table_id="formsemestre_list_saisies_notes",
filename=(
(
f"modifs_notes-S{formsemestre.semestre_id}"
if only_modifs
else f"saisies_notes_S{formsemestre.semestre_id}"
)
+ ("-" + user_name if user_name else "")
),
)
return tab.make_page(fmt=fmt)
if fmt == "html":
return render_template(
"formsemestre/list_saisies_notes.j2",
table=table,
title="Opérations de saisies de notes",
only_modifs=only_modifs,
formsemestre_id=formsemestre.id,
intervenants=formsemestre_list_notes_intervenants(formsemestre),
user_name=user_name,
)
return table.make_page(fmt=fmt, page_title="Opérations de saisies de notes")
def get_note_history(evaluation_id, etudid, fmt=""):

View File

@ -184,9 +184,11 @@ def list_users(
if not current_user.is_administrator():
# si non super-admin, ne donne pas la date exacte de derniere connexion
d["last_seen"] = _approximate_date(u.last_seen)
d["passwd_must_be_changed"] = "OUI" if d["passwd_must_be_changed"] else ""
else:
d["date_modif_passwd"] = "(non visible)"
d["non_migre"] = ""
d["passwd_must_be_changed"] = ""
if detail_roles:
d["roles_set"] = {
f"{r.role.name or ''}_{r.dept or ''}" for r in u.user_roles
@ -209,6 +211,7 @@ def list_users(
"roles_string",
"date_expiration",
"date_modif_passwd",
"passwd_must_be_changed",
"non_migre",
"status_txt",
]
@ -240,6 +243,7 @@ def list_users(
"roles_string": "Rôles",
"date_expiration": "Expiration",
"date_modif_passwd": "Modif. mot de passe",
"passwd_must_be_changed": "À changer",
"last_seen": "Dernière cnx.",
"non_migre": "Non migré (!)",
"status_txt": "Etat",

View File

@ -152,6 +152,61 @@ def convert_fr_date(
raise ScoValueError("Date (j/m/a) invalide")
# Various implementations of heure_to_iso8601 with timing:
# def heure_to_iso8601_v0(t: str | datetime.time = None) -> str: # 682 nsec per loop
# """Convert time string or time object to ISO 8601 (allows 16:03, 16h03, 16),
# returns hh:mm:ss or empty string
# """
# if isinstance(t, datetime.time):
# return t.isoformat()
# if not t:
# return ""
# parts = t.strip().upper().replace("H", ":").split(":")
# # here we can have hh, hh:mm or hh:mm:ss
# h = int(parts[0])
# m = int(parts[1]) if len(parts) > 1 and parts[1].strip() else 0
# s = int(parts[2]) if len(parts) > 2 and parts[1].strip() else 0
# return datetime.time(h, m, s).isoformat()
# def heure_to_iso8601_v1(t: str | datetime.time = None) -> str: # 581 nsec per loop
# """Convert time string or time object to ISO 8601 (allows 16:03, 16h03, 16),
# returns hh:mm:ss or empty string
# """
# if isinstance(t, datetime.time):
# return t.isoformat()
# if not t:
# return ""
# parts = t.strip().upper().replace("H", ":").split(":")
# # here we can have hh, hh:mm or hh:mm:ss
# h = int(parts[0])
# m = int(parts[1]) if len(parts) > 1 and parts[1].strip() else 0
# s = int(parts[2]) if len(parts) > 2 and parts[1].strip() else 0
# return "%02d:%02d:%02d" % (h, m, s)
def heure_to_iso8601(t: str | datetime.time = None) -> str: # 500 nsec per loop
"""Convert time string or time object to ISO 8601 (allows 16:03, 16h03, 16),
returns hh:mm:ss or empty string
"""
if isinstance(t, datetime.time):
return t.isoformat()
if not t:
return ""
parts = t.strip().upper().replace("H", ":").split(":")
# here we can have hh, hh:mm or hh:mm:ss
h = int(parts[0])
try:
m = int(parts[1])
except:
m = 0
try:
s = int(parts[2])
except:
s = 0
return "%02d:%02d:%02d" % (h, m, s)
def print_progress_bar(
iteration,
total,
@ -377,7 +432,6 @@ def localize_datetime(date: datetime.datetime) -> datetime.datetime:
Tente de mettre l'offset de la timezone du serveur (ex : UTC+1)
Si erreur, mettra l'offset UTC
"""
new_date: datetime.datetime = date
if new_date.tzinfo is None:
try:
@ -387,12 +441,17 @@ def localize_datetime(date: datetime.datetime) -> datetime.datetime:
return new_date
def get_local_timezone_offset() -> str:
def get_local_timezone_offset(date_iso: str | None = None) -> str:
"""Récupère l'offset de la timezone du serveur, sous la forme
"+HH:MM"
"+HH:MM", pour le jour indiqué (date courante par défaut).
Par exemple get_local_timezone_offset("2024-10-30") == "+01:00"
"""
local_time = datetime.datetime.now().astimezone()
utc_offset = local_time.utcoffset()
the_time = (
datetime.datetime.now()
if date_iso is None
else datetime.datetime.fromisoformat(date_iso)
)
utc_offset = the_time.astimezone().utcoffset()
total_seconds = int(utc_offset.total_seconds())
offset_hours = total_seconds // 3600
offset_minutes = (abs(total_seconds) % 3600) // 60

View File

@ -67,7 +67,7 @@ span.ens-non-reconnu {
#cal_warning {
display: inline-block;
display: none;
color: red;
background-color: yellow;
font-size: 120%;

View File

@ -171,6 +171,9 @@
justify-content: flex-start;
align-items: center;
flex-direction: column;
box-sizing: border-box;
border-left: 1px solid rgb(144, 144, 144);
border-right: 1px solid rgb(144, 144, 144);
}
.mini-timeline-block {

View File

@ -3812,11 +3812,14 @@ div.link_defaillance {
}
div.pas_sembox {
max-width: var(--sco-content-max-width);
min-width: var(--sco-content-min-width);
margin-top: 10px;
border: 2px solid #a0522d;
padding: 5px;
margin-right: 10px;
font-family: arial, verdana, sans-serif;
background-color: #f7f7f7;
}
span.sp_etape {
@ -3846,8 +3849,48 @@ span.paspaye a {
color: #9400d3 !important;
}
table.etuds-box {
border-collapse: collapse;
}
table.etuds-box th,
table.etuds-box tbody tr td {
padding: 4px;
text-align: left;
}
table.etuds-box th {
background-color: #f2f2f2;
}
/* adapte taille et enlève fleche sur cols no-sort */
table.etuds-box thead>tr>th.no-sort {
padding-right: 4px;
padding-left: 4px;
}
table.etuds-box>thead>tr>th.no-sort span.dt-column-order::before {
content: "";
}
table.etuds-box td.etape {
font-family: monospace;
}
table.etuds-box td.paiement,
table.etuds-box td.finalise {
padding-left: 8px;
padding-right: 8px;
}
table.etuds-box td.paiement.paspaye {
color: #9400d3 !important;
}
table.etuds-box td.finalise,
span.finalisationinscription {
color: green;
font-weight: normal;
}
.pas_sembox_title a {

View File

@ -2,6 +2,14 @@
@media screen and (max-width: 768px) {
#app-content>.gtrcontent {
margin-left: 4px;
}
div.sco-app-content {
margin-right: 4px;
}
/* <== Module Assiduité ==> */
#ajout-assiduite-etud .description>textarea,
#ajout-justificatif-etud .raison>textarea {
@ -86,17 +94,19 @@
}
.etud_row .assiduites_bar {
/* grid-column: 4 !important;
grid-row: 1; */
/* Ne montre pas la minitimeline sur écrans étroits */
display: none !important;
}
.etud_row .btns_field.single {
grid-column: 4 !important;
grid-row: 1;
}
.etud_row .btns_field.single {
grid-column: 3 !important;
grid-row: 1;
}
.rbtn::before {
--size: 35px;
--size: 26px;
width: var(--size) !important;
height: var(--size) !important;
}

View File

@ -460,6 +460,18 @@ async function creerTousLesEtudiants(etuds) {
.forEach((etud, index) => {
etudsDiv.appendChild(creerLigneEtudiant(etud, index + 1));
});
// Récupère l'offset timezone serveur pour la date sélectionnée
const date_iso = getSelectedDateIso();
try {
const res = await fetch(`../../api/assiduite/date_time_offset/${date_iso}`);
if (!res.ok) {
throw new Error("Network response was not ok");
}
const text = await res.text();
SERVER_TIMEZONE_OFFSET = text;
} catch (error) {
console.error('Error:', error);
}
}
/**
@ -609,7 +621,7 @@ async function actionAssiduite(etud, etat, type, assiduite = null) {
const modimpl_id = $("#moduleimpl_select").val();
if (assiduite && assiduite.etat.toLowerCase() === etat) type = "suppression";
const { deb, fin } = getPeriodAsDate(true); // en tz server
const { deb, fin } = getPeriodAsISO(); // chaines sans timezone pour l'API
// génération d'un objet assiduité basique qui sera complété
let assiduiteObjet = assiduite ?? {
date_debut: deb,
@ -722,9 +734,12 @@ function mettreToutLeMonde(etat, el = null) {
const lignesEtuds = [...document.querySelectorAll("fieldset.btns_field")];
const { deb, fin } = getPeriodAsDate(true); // tz server
const period_iso = getPeriodAsISO(); // chaines sans timezone pour l'API
const deb_iso = period_iso.deb;
const fin_iso = period_iso.fin;
const assiduiteObjet = {
date_debut: deb,
date_fin: fin,
date_debut: deb_iso,
date_fin: fin_iso,
etat: etat,
moduleimpl_id: $("#moduleimpl_select").val(),
};
@ -741,14 +756,14 @@ function mettreToutLeMonde(etat, el = null) {
.filter((e) => e.getAttribute("type") == "edition")
.map((e) => Number(e.getAttribute("assiduite_id")));
// On récupère les assiduités conflictuelles mais qui sont comprisent
// Dans la plage de suppression
// On récupère les assiduités conflictuelles mais qui sont comprises
// dans la plage de suppression
const unDeleted = {};
lignesEtuds
.filter((e) => e.getAttribute("type") == "conflit")
.forEach((e) => {
const etud = etuds.get(Number(e.getAttribute("etudid")));
// On récupère les assiduités couvertent par la plage de suppression
// On récupère les assiduités couvertes par la plage de suppression
etud.assiduites.forEach((a) => {
const date_debut = new Date(a.date_debut);
const date_fin = new Date(a.date_fin);
@ -756,8 +771,8 @@ function mettreToutLeMonde(etat, el = null) {
// (qui intersectent la plage de suppression)
if (
Date.intersect(
{ deb: deb, fin: fin },
{ deb: date_debut, fin: date_fin }
{ deb: deb, fin: fin }, // la plage, en Date avec timezone serveur
{ deb: date_debut, fin: date_fin } // dates de l'assiduité avec leur timezone
)
) {
// Si l'assiduité est couverte par la plage de suppression

View File

@ -5,7 +5,7 @@
function get_etudid_from_elem(e) {
// renvoie l'etudid, obtenu a partir de l'id de l'element
// qui est soit de la forme xxxx-etudid, soit tout simplement etudid
var etudid = e.id.split("-")[1];
let etudid = e.id.split("-")[1];
if (etudid == undefined) {
return e.id;
} else {

View File

@ -5,14 +5,14 @@ Gestion des listes d'assiduités et justificatifs
from datetime import datetime
from flask import url_for, request
from flask import url_for
from flask_login import current_user
from flask_sqlalchemy.query import Query
from sqlalchemy import desc, literal, union, asc
from app import db, g
from app.auth.models import User
from app.models import Assiduite, Identite, Justificatif, Module
from app.models import Assiduite, Identite, Justificatif, Module, ScoDocModel
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import (
@ -715,7 +715,7 @@ class AssiFiltre:
if date_fin is not None:
self.filtres["date_fin"]: tuple[int, datetime] = date_fin
def filtrage(self, query: Query, obj_class: db.Model) -> Query:
def filtrage(self, query: Query, obj_class: ScoDocModel) -> Query:
"""
filtrage Filtre la query passée en paramètre et retourne l'objet filtré

View File

@ -51,6 +51,7 @@ class TableRecap(tb.Table):
self,
res: ResultatsSemestre,
convert_values=False,
include_email_addresses=False,
include_evaluations=False,
mode_jury=False,
row_class=None,
@ -61,6 +62,7 @@ class TableRecap(tb.Table):
self.rows: list["RowRecap"] = [] # juste pour que VSCode nous aide sur .rows
super().__init__(row_class=row_class or RowRecap, **kwargs)
self.res = res
self.include_email_addresses = include_email_addresses
self.include_evaluations = include_evaluations
self.mode_jury = mode_jury
self.read_only = read_only # utilisé seulement dans sous-classes
@ -603,7 +605,7 @@ class RowRecap(tb.Row):
target=url_bulletin,
target_attrs={"class": "etudinfo", "id": str(etud.id)},
)
self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
self.add_cell("prenom", "Prénom", etud.prenom_str, "identite_detail")
self.add_cell(
"nom_short",
"Nom",
@ -618,6 +620,13 @@ class RowRecap(tb.Row):
target=url_bulletin,
target_attrs={"class": "etudinfo", "id": str(etud.id)},
)
if self.table.include_email_addresses:
address = etud.adresses.first()
if address:
self.add_cell("email", "Email", address.email, "identite_detail")
self.add_cell(
"emailperso", "Email perso", address.emailperso, "identite_detail"
)
def add_abs(self):
"Ajoute les colonnes absences"

View File

@ -12,7 +12,7 @@
</div>
</div>
<script>
const SERVER_TIMEZONE_OFFSET = "{{ scu.get_local_timezone_offset() }}";
var SERVER_TIMEZONE_OFFSET = "{{ scu.get_local_timezone_offset() }}"; // modifié par creerTousLesEtudiants()
const timelineContainer = document.querySelector(".timeline-container");
const periodTimeLine = document.querySelector(".period");
const t_start = {{ t_start }};
@ -336,7 +336,14 @@
const [hours, minutes] = time.split(separator).map((el) => Number(el))
return hours + minutes / 60
}
// Renvoie les valeurs de la période sous forme de date
// La date ISO du datepicker
function getSelectedDateIso() {
return $("#date")
.datepicker("getDate")
.format("yyyy-mm-dd")
.substring(0, 10); // récupération que de la date, pas des heures
}
// Renvoie les valeurs de la période sous forme de Date
// Les heures sont récupérées depuis la timeline
// la date est récupérée depuis un champ "#date" (datepicker)
function getPeriodAsDate(add_server_tz = false) {
@ -344,17 +351,30 @@
deb = numberToTime(deb);
fin = numberToTime(fin);
const dateStr = getSelectedDateIso();
// Les heures deb et fin sont telles qu'affichées, c'est à dire
// en heure locale DU SERVEUR (des étudiants donc)
let offset = add_server_tz ? SERVER_TIMEZONE_OFFSET : "";
return {
deb: new Date(`${dateStr}T${deb}${offset}`),
fin: new Date(`${dateStr}T${fin}${offset}`)
}
}
// Renvoie les valeurs de la période sous forme de chaine ISO sans time zone.
function getPeriodAsISO() {
let [deb, fin] = getPeriodValues();
deb = numberToTime(deb);
fin = numberToTime(fin);
const dateStr = $("#date")
.datepicker("getDate")
.format("yyyy-mm-dd")
.substring(0, 10); // récupération que de la date, pas des heures
// Les heures deb et fin sont telles qu'affichées, c'est à dire
// en heure locale DU SERVEUR (des étudiants donc)
let offset = add_server_tz ? SERVER_TIMEZONE_OFFSET : "";
// retourne des chaines ISO sans timezone
return {
deb: new Date(`${dateStr}T${deb}${offset}`),
fin: new Date(`${dateStr}T${fin}${offset}`)
deb : `${dateStr}T${deb}`,
fin : `${dateStr}T${fin}`
}
}
// Sauvegarde les valeurs de la période dans le local storage

View File

@ -0,0 +1,38 @@
<style>
div.msg-change-passwd {
border: 3px solid white;
border-radius: 8px;
padding: 16px;
width: fit-content;
margin-left: auto;
margin-right: auto;
margin-top: 28px;
margin-bottom: 28px;
}
div.msg-change-passwd, div.msg-change-passwd a {
font-size: 36px;
font-weight: bold;
background-color: red;
color: white;
}
div.msg-change-passwd a, div.msg-change-passwd a:visited {
text-decoration: underline;
}
</style>
<div class="msg-change-passwd">
Vous devez
{% if current_user.dept %}
<a class="nav-link" href="{{
url_for(
'users.form_change_password',
scodoc_dept=current_user.dept,
user_name=current_user.user_name
)
}}">
{% endif %}
changer votre mot de passe
{% if current_user.dept %}
</a>
{% endif %}
!
</div>

View File

@ -18,6 +18,9 @@
<br>
<b>Nom :</b> {{user.nom or ""}}<br>
<b>Prénom :</b> {{user.prenom or ""}}<br>
{% if user.passwd_must_be_changed %}
<div style="color:white; background-color: red; padding:8px; margin-top: 4px; width: fit-content;">mot de passe à changer</div>
{% endif %}
<b>Mail :</b> {{user.email}}<br>
<b>Mail institutionnel:</b> {{user.email_institutionnel or ""}}<br>
<b>Identifiant EDT:</b> {{user.edt_id or ""}}<br>

View File

@ -25,6 +25,9 @@
{% block navbar %}
{%- endblock navbar %}
<div id="sco_msg" class="head_message"></div>
{% if current_user and current_user.passwd_must_be_changed %}
{% include "auth/msg_change_password.j2" %}
{% endif %}
{% block content -%}
{%- endblock content %}

View File

@ -46,7 +46,8 @@
{% if current_user.is_anonymous %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('auth.login') }}">connexion</a></li>
{% else %}
<li class="nav-item">{% if current_user.dept %}
<li class="nav-item">
{% if current_user.dept %}
<a class="nav-link" href="{{ url_for('users.user_info_page', scodoc_dept=current_user.dept, user_name=current_user.user_name )
}}">{{current_user.user_name}} ({{current_user.dept}})</a>
{% else %}
@ -89,7 +90,7 @@
<script>
const SCO_URL = "{% if g.scodoc_dept %}{{
url_for('scolar.index_html', scodoc_dept=g.scodoc_dept)}}{% endif %}";
document.querySelector('.navbar-toggler').addEventListener('click', function() {
document.querySelector('.navbar-collapse').classList.toggle('show');
});

View File

@ -0,0 +1,71 @@
{% extends "sco_page.j2" %}
{% block styles %}
{{super()}}
<style>
.h-spaced {
margin-left: 32px;
}
</style>
{% endblock %}
{% block app_content %}
<h2 class="formsemestre">{{title_h2}}</h2>
<div>{{table.get_nb_rows()}}
{% if only_modifs %}modifications{% else %}saisies{% endif %}
de notes dans ce semestre.
</div>
<form id="filter-form">
<label>
<input type="checkbox" id="only-modifs-checkbox" name="only_modifs" value="1"
{% if only_modifs %}checked{% endif %}>
Lister uniquement les modifications
</label>
<label class="h-spaced" for="user-select">Restreindre à un enseignant&nbsp;:</label>
<select id="user-select" name="user_name">
<option value="">Choisir...</option>
{% for user in intervenants %}
<option value="{{ user.user_name }}"
{% if user.user_name == user_name %}selected{% endif %}>
{{ user.get_nomplogin() }}
</option>
{% endfor %}
</select>
<span class="h-spaced">{{table.xls_export_button()|safe}} excel</span>
</form>
{{table.html()|safe}}
{% endblock %}
{% block scripts %}
{{super()}}
<script>
document.getElementById('only-modifs-checkbox').addEventListener('change', function() {
var form = document.getElementById('filter-form');
var onlyModifs = this.checked ? '1' : '0';
var url = new URL(window.location.href);
url.searchParams.set('formsemestre_id', {{formsemestre_id}});
url.searchParams.set('only_modifs', onlyModifs);
window.location.href = url.toString();
});
document.getElementById('user-select').addEventListener('change', function() {
var form = document.getElementById('filter-form');
var userName = this.value;
var url = new URL(window.location.href);
url.searchParams.set('formsemestre_id', {{formsemestre_id}});
url.searchParams.set('user_name', userName);
window.location.href = url.toString();
});
</script>
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends "sco_page.j2" %}
{% import 'wtf.j2' as wtf %}
{# Utilisé pour passage d'un semestre à l'autre et pour synchro Apogée #}
{% block scripts %}
{{ super() }}
<script>
function sem_select(formsemestre_id, state) {
let elems = document.getElementById(formsemestre_id).getElementsByTagName("input");
for (var i =0; i < elems.length; i++) {
elems[i].checked = state;
}
}
function sem_select_inscrits(formsemestre_id) {
var elems = document.getElementById(formsemestre_id).getElementsByTagName("input");
for (var i =0; i < elems.length; i++) {
if (elems[i].parentNode.className.indexOf('inscrit') >= 0) {
elems[i].checked=true;
} else {
elems[i].checked=false;
}
}
}
$(document).ready(function() {
$('.etuds-box').DataTable({
paging: false,
autoWidth: false,
searching: false,
info: false, // Disable the "Showing 1 to X of X entries"
columnDefs: [
{ targets: 'no-sort', orderable: false } // Disable sorting on columns with class 'no-sort'
]
});
});
</script>
{% endblock %}

View File

@ -1,8 +1,10 @@
{# Tableau de bord utilisateur #}
{% extends "base.j2" %}
{% block app_content %}
{% block styles %}
{{super()}}
<link type="text/css" rel="stylesheet" href="{{scu.STATIC_DIR}}/libjs/qtip/jquery.qtip-3.0.3.min.css" />
<link rel="stylesheet" type="text/css" href="{{scu.STATIC_DIR}}/DataTables/datatables.min.css" />
<style>
.ub-formsemestres {
display: flex;
@ -60,7 +62,14 @@
color: black;
text-decoration: none;
}
div.scobox.saisies-notes {
background-color: rgb(243, 255, 255);
}
</style>
{% endblock %}
{% block app_content %}
<div class="tab-content">
<h2>{{user.get_nomcomplet()}}</h2>
@ -105,6 +114,63 @@
{% endfor %}
</div>
{% endfor %}
<div class="scobox saisies-notes">
{% if current_user.is_administrator() or current_user.id == user.id %}
<div class="scobox-title">
Dernières saisies de notes par {{user.get_prenomnom()}}
</div>
<table id="saisies-notes" class="display" style="width:100%">
<thead>
<tr>
<th>Date</th>
<th>Évaluation</th>
<th>Étudiant</th>
<th>Note</th>
</tr>
</thead>
<tbody>
<!-- Data will be loaded dynamically via JavaScript -->
</tbody>
</table>
{% else %}
<div class="help">
Vous n'avez pas les droits pour voir les notes de cet utilisateur.
</div>
{% endif %}
</div>
</div>
{% endblock app_content %}
{% block scripts %}
{{ super() }}
<script src="{{scu.STATIC_DIR}}/js/etud_info.js"></script>
<script src="{{scu.STATIC_DIR}}/DataTables/datatables.min.js"></script>
<script>
$(document).ready(function() {
$('#saisies-notes').DataTable({
"processing": true,
"serverSide": true,
"ajax": {
"url": "{{ url_for('apiweb.operations_user_notes',
scodoc_dept=current_user.dept or g.scodoc_dept or fallback_dept.acronym,
uid=user.id) }}",
"type": "GET"
},
"columns": [
{ "data": "date_dmy", "orderable": false },
{ "data": "evaluation_link", "orderable": false },
{ "data": "etudiant_link", "orderable": false },
{ "data": "value", "orderable": false }
],
"language": {
search: "Chercher (date, titre, étudiant) :", // Change the "Search:" label
lengthMenu: "Show _MENU_ records per page"
}
});
});
</script>
{% endblock %}

View File

@ -37,7 +37,7 @@ def start_scodoc_request():
# current_app.logger.info(f"start_scodoc_request")
ndb.open_db_connection()
if current_user and current_user.is_authenticated:
current_user.last_seen = datetime.datetime.utcnow()
current_user.last_seen = datetime.datetime.now()
db.session.commit()
# caches locaux (durée de vie=la requête en cours)
g.stored_get_formsemestre = {}

Some files were not shown because too many files have changed in this diff Show More