Compare commits

...

11 Commits

44 changed files with 884 additions and 318 deletions

View File

@ -18,6 +18,7 @@ from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import VARCHAR from sqlalchemy.dialects.postgresql import VARCHAR
import app import app
from app import db
from app.api import api_bp as bp, api_web_bp from app.api import api_bp as bp, api_web_bp
from app.api import tools from app.api import tools
from app.but import bulletin_but_court from app.but import bulletin_but_court
@ -28,10 +29,12 @@ from app.models import (
FormSemestreInscription, FormSemestreInscription,
FormSemestre, FormSemestre,
Identite, Identite,
ScolarNews,
) )
from app.scodoc import sco_bulletins from app.scodoc import sco_bulletins
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc import sco_etud
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error, suppress_accents from app.scodoc.sco_utils import json_error, suppress_accents
@ -475,3 +478,48 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
data = sco_groups.get_etud_groups(etud.id, formsemestre.id) data = sco_groups.get_etud_groups(etud.id, formsemestre.id)
return data return data
@bp.route("/etudiant/create", methods=["POST"], defaults={"force": False})
@bp.route("/etudiant/create/force", methods=["POST"], defaults={"force": True})
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def etudiant_create(force=False):
"""Création d'un nouvel étudiant
Si force, crée même si homonymie détectée.
L'étudiant créé n'est pas inscrit à un semestre.
Champs requis: nom, prenom (sauf si config sans prénom), dept (string:acronyme)
"""
args = request.get_json(force=True) # may raise 400 Bad Request
dept = args.get("dept", None)
if not dept:
return scu.json_error(400, "dept requis")
dept_o = Departement.query.filter_by(acronym=dept).first()
if not dept_o:
return scu.json_error(400, "dept invalide")
app.set_sco_dept(dept)
args["dept_id"] = dept_o.id
# vérifie que le département de création est bien autorisé
if not current_user.has_permission(Permission.EtudInscrit, dept):
return json_error(403, "departement non autorisé")
nom = args.get("nom", None)
prenom = args.get("prenom", None)
ok, homonyms = sco_etud.check_nom_prenom_homonyms(nom=nom, prenom=prenom)
if not ok:
return scu.json_error(400, "nom ou prénom invalide")
if len(homonyms) > 0 and not force:
return scu.json_error(
400, f"{len(homonyms)} homonymes détectés. Vous pouvez utiliser /force."
)
etud = Identite.create_etud(**args)
# Poste une nouvelle dans le département concerné:
ScolarNews.add(
typ=ScolarNews.NEWS_INSCR,
text=f"Nouvel étudiant {etud.html_link_fiche()}",
url=etud.url_fiche(),
max_frequency=0,
dept_id=dept_o.id,
)
db.session.commit()
return etud.to_dict_short()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ from sqlalchemy import desc, text
from app import db, log from app import db, log
from app import models from app import models
from app.models.scolar_event import ScolarEvent
from app.scodoc import notesdb as ndb from app.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat from app.scodoc.sco_bac import Baccalaureat
from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
@ -170,9 +170,13 @@ class Identite(db.Model, models.ScoDocModel):
def html_link_fiche(self) -> str: def html_link_fiche(self) -> str:
"lien vers la fiche" "lien vers la fiche"
return f"""<a class="stdlink" href="{ return f"""<a class="stdlink" href="{self.url_fiche()}">{self.nomprenom}</a>"""
url_for("scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id)
}">{self.nomprenom}</a>""" def url_fiche(self) -> str:
"url de la fiche étudiant"
return url_for(
"scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id
)
@classmethod @classmethod
def from_request(cls, etudid=None, code_nip=None) -> "Identite": def from_request(cls, etudid=None, code_nip=None) -> "Identite":
@ -211,6 +215,10 @@ class Identite(db.Model, models.ScoDocModel):
etud.admission = Admission() etud.admission = Admission()
etud.adresses.append(Adresse(typeadresse="domicile")) etud.adresses.append(Adresse(typeadresse="domicile"))
db.session.flush() db.session.flush()
event = ScolarEvent(etud=etud, event_type="CREATION")
db.session.add(event)
log(f"Identite.create {etud}")
return etud return etud
@property @property

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@ from app.comp.res_compat import NotesTableCompat
from app.models import Formation, FormSemestre, FormSemestreInscription, Scolog from app.models import Formation, FormSemestre, FormSemestreInscription, Scolog
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.groups import Partition, GroupDescr from app.models.groups import Partition, GroupDescr
from app.models.validations import ScolarEvent from app.models.scolar_event import ScolarEvent
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import log from app import log
from app.scodoc.scolog import logdb from app.scodoc.scolog import logdb

View File

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

View File

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

View File

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

View File

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

View File

@ -1244,7 +1244,7 @@ def _form_saisie_notes(
'<span class="%s">' % classdem '<span class="%s">' % classdem
+ e["civilite_str"] + e["civilite_str"]
+ " " + " "
+ sco_etud.format_nomprenom(e, reverse=True) + scu.format_nomprenom(e, reverse=True)
+ "</span>" + "</span>"
) )

View File

@ -164,9 +164,9 @@ def trombino_html(groups_infos):
H.append("</span>") H.append("</span>")
H.append( H.append(
'<span class="trombi_legend"><span class="trombi_prenom">' '<span class="trombi_legend"><span class="trombi_prenom">'
+ sco_etud.format_prenom(t["prenom"]) + scu.format_prenom(t["prenom"])
+ '</span><span class="trombi_nom">' + '</span><span class="trombi_nom">'
+ sco_etud.format_nom(t["nom"]) + scu.format_nom(t["nom"])
+ (" <i>(dem.)</i>" if t["etat"] == "D" else "") + (" <i>(dem.)</i>" if t["etat"] == "D" else "")
) )
H.append("</span></span></span>") H.append("</span></span></span>")
@ -349,7 +349,7 @@ def _trombino_pdf(groups_infos):
[img], [img],
[ [
Paragraph( Paragraph(
SU(sco_etud.format_nomprenom(t)), SU(scu.format_nomprenom(t)),
style_sheet["Normal"], style_sheet["Normal"],
) )
], ],
@ -428,7 +428,7 @@ def _listeappel_photos_pdf(groups_infos):
t = groups_infos.members[i] t = groups_infos.members[i]
img = _get_etud_platypus_image(t, image_width=PHOTO_WIDTH) img = _get_etud_platypus_image(t, image_width=PHOTO_WIDTH)
txt = Paragraph( txt = Paragraph(
SU(sco_etud.format_nomprenom(t)), SU(scu.format_nomprenom(t)),
style_sheet["Normal"], style_sheet["Normal"],
) )
if currow: if currow:

View File

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

View File

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

View File

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

View File

@ -64,6 +64,7 @@ from config import Config
from app import log, ScoDocJSONEncoder from app import log, ScoDocJSONEncoder
from app.scodoc.codes_cursus import NOTES_TOLERANCE, CODES_EXPL from app.scodoc.codes_cursus import NOTES_TOLERANCE, CODES_EXPL
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_xml from app.scodoc import sco_xml
import sco_version import sco_version
@ -1139,6 +1140,61 @@ def abbrev_prenom(prenom):
return abrv return abrv
def format_civilite(civilite):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
personne ne souhaitant pas d'affichage).
Raises ScoValueError if conversion fails.
"""
try:
return {
"M": "M.",
"F": "Mme",
"X": "",
}[civilite]
except KeyError as exc:
raise ScoValueError(f"valeur invalide pour la civilité: {civilite}") from exc
def format_nomprenom(etud, reverse=False):
"""Formatte civilité/nom/prenom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
DEPRECATED: utiliser Identite.nomprenom
"""
nom = etud.get("nom_disp", "") or etud.get("nom_usuel", "") or etud["nom"]
prenom = format_prenom(etud["prenom"])
civilite = format_civilite(etud["civilite"])
if reverse:
fs = [nom, prenom]
else:
fs = [civilite, prenom, nom]
return " ".join([x for x in fs if x])
def format_nom(s, uppercase=True):
"Formatte le nom"
if not s:
return ""
if uppercase:
return s.upper()
else:
return format_prenom(s)
def format_prenom(s):
"""Formatte prenom etudiant pour affichage
DEPRECATED: utiliser Identite.prenom_str
"""
if not s:
return ""
frags = s.split()
r = []
for frag in frags:
fs = frag.split("-")
r.append("-".join([x.lower().capitalize() for x in fs]))
return " ".join(r)
# #
def timedate_human_repr(): def timedate_human_repr():
"representation du temps courant pour utilisateur" "representation du temps courant pour utilisateur"
@ -1476,3 +1532,15 @@ def is_assiduites_module_forced(
except (TypeError, ValueError): except (TypeError, ValueError):
retour = sco_preferences.get_preference("forcer_module", dept_id=dept_id) retour = sco_preferences.get_preference("forcer_module", dept_id=dept_id)
return retour return retour
def get_assiduites_time_config(config_type: str) -> str:
from app.models import ScoDocSiteConfig
match config_type:
case "matin":
return ScoDocSiteConfig.get("assi_morning_time", "08:00:00")
case "aprem":
return ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00")
case "pivot":
return ScoDocSiteConfig.get("assi_lunch_time", "13:00:00")

View File

@ -1,15 +1,17 @@
:root { :root {
--color-present: #6bdb83; --color-present: #6bdb83;
--color-absent: #e62a11; --color-absent: #e62a11;
--color-absent-clair: #F25D4A;
--color-retard: #f0c865; --color-retard: #f0c865;
--color-justi: #7059FF; --color-justi: #7059FF;
--color-justi-clair: #6885E3;
--color-justi-invalide: #a84476; --color-justi-invalide: #a84476;
--color-nonwork: #badfff; --color-nonwork: #badfff;
--color-absent-justi: #e65ab7; --color-absent-justi: #e65ab7;
--color-retard-justi: #ffef7a; --color-retard-justi: #ffef7a;
--color-error: #FF0000; --color-error: #e62a11;
--color-warning: #eec660; --color-warning: #eec660;
--color-information: #658ef0; --color-information: #658ef0;
@ -21,7 +23,7 @@
--color-defaut: #FFF; --color-defaut: #FFF;
--color-defaut-dark: #444; --color-defaut-dark: #444;
--color-default-text: #1F1F1F;
--motif-justi: repeating-linear-gradient(135deg, transparent, transparent 4px, var(--color-justi) 4px, var(--color-justi) 8px); --motif-justi: repeating-linear-gradient(135deg, transparent, transparent 4px, var(--color-justi) 4px, var(--color-justi) 8px);

View File

@ -4347,6 +4347,10 @@ table.dataTable td.group {
background: #fff; background: #fff;
} }
#zonePartitions .edt_id {
color: rgb(85, 255, 24);
}
/* ------------- Nouveau tableau recap ------------ */ /* ------------- Nouveau tableau recap ------------ */
div.table_recap { div.table_recap {
margin-top: 6px; margin-top: 6px;
@ -4856,7 +4860,3 @@ div.cas_etat_certif_ssl {
font-style: italic; font-style: italic;
color: rgb(231, 0, 0); color: rgb(231, 0, 0);
} }
.edt_id {
color: rgb(85, 255, 24);
}

View File

@ -953,6 +953,89 @@ function createAssiduite(etat, etudid) {
); );
return !with_errors; return !with_errors;
} }
/**
* Création d'une assiduité pour un étudiant
* @param {String} etat l'état de l'étudiant
* @param {Number | String} etudid l'identifiant de l'étudiant
*
* TODO : Rendre asynchrone
*/
function createAssiduiteComplete(assiduite, etudid) {
if (!hasModuleImpl(assiduite) && window.forceModule) {
const html = `
<h3>Aucun module n'a été spécifié</h3>
`;
const div = document.createElement("div");
div.innerHTML = html;
openAlertModal("Erreur Module", div);
return false;
}
const path = getUrl() + `/api/assiduite/${etudid}/create`;
let with_errors = false;
sync_post(
path,
[assiduite],
(data, status) => {
//success
if (data.success.length > 0) {
let obj = data.success["0"].message.assiduite_id;
}
if (data.errors.length > 0) {
console.error(data.errors["0"].message);
if (data.errors["0"].message == "Module non renseigné") {
const HTML = `
<p>Attention, le module doit obligatoirement être renseigné.</p>
<p>Cela vient de la configuration du semestre ou plus largement du département.</p>
<p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
`;
const content = document.createElement("div");
content.innerHTML = HTML;
openAlertModal("Sélection du module", content);
}
if (
data.errors["0"].message == "L'étudiant n'est pas inscrit au module"
) {
const HTML = `
<p>Attention, l'étudiant n'est pas inscrit à ce module.</p>
<p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
`;
const content = document.createElement("div");
content.innerHTML = HTML;
openAlertModal("Sélection du module", content);
}
if (
data.errors["0"].message ==
"Duplication: la période rentre en conflit avec une plage enregistrée"
) {
const HTML = `
<p>L'assiduité n'a pas pu être enregistrée car une autre assiduité existe sur la période sélectionnée</p>
<p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
`;
const content = document.createElement("div");
content.innerHTML = HTML;
openAlertModal("Période conflictuelle", content);
}
with_errors = true;
}
},
(data, status) => {
//error
console.error(data, status);
errorAlert();
with_errors = true;
}
);
return !with_errors;
}
/** /**
* Suppression d'une assiduité * Suppression d'une assiduité

View File

@ -0,0 +1,234 @@
{% include "assiduites/widgets/toast.j2" %}
{% block pageContent %}
<div class="pageContent">
<h3>Ajouter une assiduité</h3>
{% include "assiduites/widgets/tableau_base.j2" %}
{% if saisie_eval %}
<div id="saisie_eval">
<br>
<h3>
La saisie de l'assiduité a été préconfigurée en fonction de l'évaluation. <br>
Une fois la saisie finie, cliquez sur le lien si dessous pour revenir sur la gestion de l'évaluation
</h3>
<a href="{{redirect_url}}">retourner sur la page de l'évaluation</a>
</div>
{% endif %}
<section class="assi-form page">
<fieldset>
<div class="assi-row">
<div class="assi-label">
<legend for="assi_date_debut" required>Date de début</legend>
<scodoc-datetime name="assi_date_debut" id="assi_date_debut"> </scodoc-datetime>
<span>Journée entière</span> <input type="checkbox" name="assi_journee" id="assi_journee">
</div>
<div class="assi-label" id="date_fin">
<legend for="assi_date_fin" required>Date de fin</legend>
<scodoc-datetime name="assi_date_fin" id="assi_date_fin"></scodoc-datetime>
</div>
</div>
<div class="assi-row">
<div class="assi-label">
<legend for="assi_etat" required>Etat de l'assiduité</legend>
<select name="assi_etat" id="assi_etat">
<option value="absent" selected>Absent</option>
<option value="retard">Retard</option>
<option value="present">Présent</option>
</select>
</div>
</div>
<div class="assi-row">
<div class="assi-label">
<legend for="assi_module" required>Module</legend>
{% with moduleid="ajout_assiduite_module_impl",label=false %}
{% include "assiduites/widgets/moduleimpl_dynamic_selector.j2" %}
{% endwith %}
</div>
</div>
<div class="assi-row">
<div class="assi-label">
<legend for="assi_raison">Raison</legend>
<textarea name="assi_raison" id="assi_raison" cols="50" rows="10" maxlength="500"></textarea>
</div>
</div>
<div class="assi-row">
<button onclick="validerFormulaire(this)">Créer l'assiduité</button>
<button onclick="effacerFormulaire()">Remettre à zero</button>
</div>
</fieldset>
</section>
<section class="liste">
<a class="icon filter" onclick="filterAssi()"></a>
{% include "assiduites/widgets/tableau_assi.j2" %}
</section>
</div>
<style>
.assi-row {
margin: 5px 0;
}
.assi-form fieldset {
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.pageContent {
max-width: var(--sco-content-max-width);
margin-top: 15px;
}
.assi-label {
margin: 0 10px;
}
[required]::after {
content: "*";
color: var(--color-error);
}
</style>
<script>
function validateFields() {
const field = document.querySelector('.assi-form')
const { deb, fin } = getDates()
const date_debut = new Date(deb);
const date_fin = new Date(fin);
if (deb == "" || fin == "" || !date_debut.isValid() || !date_fin.isValid()) {
openAlertModal("Erreur détéctée", document.createTextNode("Il faut indiquer une date de début et une date de fin valide."), "", color = "crimson");
return false;
}
if (date_fin.isBefore(date_debut)) {
openAlertModal("Erreur détéctée", document.createTextNode("La date de fin doit se trouver après la date de début."), "", color = "crimson");
return false;
}
return true
}
function fieldsToAssiduite() {
const field = document.querySelector('.assi-form.page')
const { deb, fin } = getDates()
const etat = field.querySelector('#assi_etat').value;
const raison = field.querySelector('#assi_raison').value;
const module = field.querySelector("#ajout_assiduite_module_impl").value;
return {
date_debut: new Date(deb).toFakeIso(),
date_fin: new Date(fin).toFakeIso(),
etat: etat,
description: raison,
moduleimpl_id: module,
}
}
function validerFormulaire(btn) {
if (!validateFields()) return
const assiduite = fieldsToAssiduite();
let assiduite_id = null;
createAssiduiteComplete(assiduite, etudid);
loadAll();
btn.disabled = true;
setTimeout(() => {
btn.disabled = false;
}, 1000)
}
function effacerFormulaire() {
const field = document.querySelector('.assi-form')
field.querySelector('#assi_date_debut').value = "";
field.querySelector('#assi_date_fin').value = "";
field.querySelector('#assi_etat').value = "attente";
field.querySelector('#assi_raison').value = "";
}
function dayOnly() {
const date_deb = document.getElementById("assi_date_debut");
const date_fin = document.getElementById("assi_date_fin");
if (document.getElementById('assi_journee').checked) {
date_deb.setAttribute("show", "date")
date_fin.setAttribute("show", "date")
document.querySelector(`legend[for="assi_date_fin"]`).removeAttribute("required")
} else {
date_deb.removeAttribute("show")
date_fin.removeAttribute("show")
document.querySelector(`legend[for="assi_date_fin"]`).setAttribute("required", "")
}
}
function getDates() {
const date_deb = document.querySelector(".page #assi_date_debut")
const date_fin = document.querySelector(".page #assi_date_fin")
const journee = document.querySelector('.page #assi_journee').checked
const deb = date_deb.valueAsObject.date + "T" + (journee ? assi_morning : date_deb.valueAsObject.time)
let fin = "T" + (journee ? assi_evening : date_fin.valueAsObject.time)
if (journee) {
fin = (date_fin.valueAsObject.date || date_deb.valueAsObject.date) + fin
} else {
fin = date_fin.valueAsObject.date + fin
}
return {
"deb": deb,
"fin": fin,
}
}
const etudid = {{ sco.etud.id }};
const assi_limit_annee = "{{ assi_limit_annee }}" == "True" ? true : false;
const assi_morning = '{{assi_morning}}';
const assi_evening = '{{assi_evening}}';
{% if saisie_eval %}
const saisie_eval = true;
const date_deb = "{{date_deb}}";
const date_fin = "{{date_fin}}";
const moduleimpl = {{ moduleimpl_id }};
{% else %}
const saisie_eval = false;
{% endif %}
window.addEventListener("load", () => {
loadAll();
document.getElementById('assi_journee').addEventListener('click', () => { dayOnly() });
dayOnly()
if (saisie_eval) {
document.getElementById("assi_date_debut").value = Date.removeUTC(date_deb);
document.getElementById("assi_date_fin").value = Date.removeUTC(date_fin);
} else {
const today = (new Date()).format("YYYY-MM-DD");
document.getElementById("assi_date_debut").valueAsObject = { date: today, time: assi_morning }
document.getElementById("assi_date_fin").valueAsObject = { time: assi_evening }
}
document.getElementById("assi_date_debut").addEventListener("blur", (event) => {
updateSelect(null, "#ajout_assiduite_module_impl", event.target.valueAsObject.date)
})
updateSelect(saisie_eval ? moduleimpl : "", "#ajout_assiduite_module_impl", document.getElementById("assi_date_debut").valueAsObject.date);
});
</script>
{% endblock pageContent %}

View File

@ -3,10 +3,7 @@
<div class="pageContent"> <div class="pageContent">
<h3>Justifier des absences ou retards</h3> <h3>Justifier des absences ou retards</h3>
{% include "assiduites/widgets/tableau_base.j2" %} {% include "assiduites/widgets/tableau_base.j2" %}
<section class="liste">
<a class="icon filter" onclick="filterJusti()"></a>
{% include "assiduites/widgets/tableau_justi.j2" %}
</section>
<section class="justi-form page"> <section class="justi-form page">
@ -60,6 +57,10 @@
</fieldset> </fieldset>
</section> </section>
<section class="liste">
<a class="icon filter" onclick="filterJusti()"></a>
{% include "assiduites/widgets/tableau_justi.j2" %}
</section>
<div class="legende"> <div class="legende">
@ -224,12 +225,12 @@
if (document.getElementById('justi_journee').checked) { if (document.getElementById('justi_journee').checked) {
date_deb.setAttribute("show", "date") date_deb.setAttribute("show", "date")
date_fin.setAttribute("show", "date") date_fin.setAttribute("show", "date")
document.getElementById("date_fin").classList.add("hidden"); document.querySelector(`legend[for="justi_date_fin"]`).removeAttribute("required")
} else { } else {
date_deb.removeAttribute("show") date_deb.removeAttribute("show")
date_fin.removeAttribute("show") date_fin.removeAttribute("show")
document.getElementById("date_fin").classList.remove("hidden"); document.querySelector(`legend[for="justi_date_fin"]`).setAttribute("required", "")
} }
} }
@ -238,8 +239,12 @@
const date_fin = document.querySelector(".page #justi_date_fin") const date_fin = document.querySelector(".page #justi_date_fin")
const journee = document.querySelector('.page #justi_journee').checked const journee = document.querySelector('.page #justi_journee').checked
const deb = date_deb.valueAsObject.date + "T" + (journee ? assi_morning : date_deb.valueAsObject.time) const deb = date_deb.valueAsObject.date + "T" + (journee ? assi_morning : date_deb.valueAsObject.time)
const fin = (journee ? date_deb.valueAsObject.date : date_fin.valueAsObject.date) + "T" + (journee ? assi_evening : date_fin.valueAsObject.time) let fin = "T" + (journee ? assi_evening : date_fin.valueAsObject.time)
if (journee) {
fin = (date_fin.valueAsObject.date || date_deb.valueAsObject.date) + fin
} else {
fin = date_fin.valueAsObject.date + fin
}
return { return {
"deb": deb, "deb": deb,
"fin": fin, "fin": fin,

View File

@ -343,6 +343,9 @@
</style> </style>
<script> <script>
const datePivot = "{{scu.get_assiduites_time_config("pivot")}}".split(":").map((el) => Number(el))
function getDaysBetweenDates(start, end) { function getDaysBetweenDates(start, end) {
let now = new Date(start); let now = new Date(start);
end = new Date(end); end = new Date(end);
@ -476,7 +479,7 @@
const matin = [new Date(date), new Date(date)] const matin = [new Date(date), new Date(date)]
color = "sans_etat" color = "sans_etat"
matin[0].setHours(0, 0, 0, 0) matin[0].setHours(0, 0, 0, 0)
matin[1].setHours(12, 59, 59) matin[1].setHours(...datePivot)
@ -515,7 +518,8 @@
span_aprem.classList.add("color"); span_aprem.classList.add("color");
const aprem = [new Date(date), new Date(date)] const aprem = [new Date(date), new Date(date)]
color = "sans_etat" color = "sans_etat"
aprem[0].setHours(13, 0, 0, 0) aprem[0].setHours(...datePivot)
aprem[0].add(1, "seconds")
aprem[1].setHours(23, 59, 59) aprem[1].setHours(23, 59, 59)

View File

@ -1,13 +1,24 @@
<label for="moduleimpl_select"> <div>
{% if label != false%}
<label for="moduleimpl_select">
Module Module
</label>
{% else %}
{% endif %}
{% if moduleid %}
<select id="{{moduleid}}" class="dynaSelect">
{% include "assiduites/widgets/simplemoduleimpl_select.j2" %}
</select>
{% else %}
<select id="moduleimpl_select" class="dynaSelect"> <select id="moduleimpl_select" class="dynaSelect">
{% include "assiduites/widgets/simplemoduleimpl_select.j2" %} {% include "assiduites/widgets/simplemoduleimpl_select.j2" %}
</select> </select>
{% endif %}
<div id="saved" style="display: none;"> <div id="saved" style="display: none;">
{% include "assiduites/widgets/simplemoduleimpl_select.j2" %} {% include "assiduites/widgets/simplemoduleimpl_select.j2" %}
</div> </div>
</label> </div>
<script> <script>

View File

@ -88,6 +88,7 @@
td.textContent = getModuleImpl(assiduite); td.textContent = getModuleImpl(assiduite);
} else if (k.indexOf('est_just') != -1) { } else if (k.indexOf('est_just') != -1) {
td.textContent = assiduite[k] ? "Oui" : "Non" td.textContent = assiduite[k] ? "Oui" : "Non"
if (assiduite[k]) row.classList.add("est_just")
} else if (k.indexOf('etudid') != -1) { } else if (k.indexOf('etudid') != -1) {
const e = getEtudiant(assiduite.etudid); const e = getEtudiant(assiduite.etudid);

View File

@ -456,6 +456,7 @@
td { td {
border: 1px solid #dddddd; border: 1px solid #dddddd;
padding: 8px; padding: 8px;
color: var(--color-default-text);
} }
th { th {
@ -498,17 +499,25 @@
.l-absent, .l-absent,
.l-invalid { .l-invalid {
background-color: var(--color-absent); background-color: var(--color-absent-clair);
} }
.l-valid { .l-valid {
background-color: var(--color-primary); background-color: var(--color-justi-clair);
} }
.l-retard { .l-retard {
background-color: var(--color-retard); background-color: var(--color-retard);
} }
.l-absent.est_just {
background-color: var(--color-absent-justi);
}
.l-retard.est_just {
background-color: var(--color-retard-justi);
}
/* Ajoutez des styles pour le conteneur de pagination et les boutons */ /* Ajoutez des styles pour le conteneur de pagination et les boutons */
.pagination-container { .pagination-container {
display: flex; display: flex;

View File

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

View File

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

View File

@ -293,7 +293,8 @@ def signal_assiduites_etud():
"js/date_utils.js", "js/date_utils.js",
"js/etud_info.js", "js/etud_info.js",
], ],
cssstyles=[ cssstyles=CSSSTYLES
+ [
"css/assiduites.css", "css/assiduites.css",
], ],
) )
@ -318,27 +319,43 @@ def signal_assiduites_etud():
header, header,
_mini_timeline(), _mini_timeline(),
render_template( render_template(
"assiduites/pages/signal_assiduites_etud.j2", "assiduites/pages/ajout_assiduites.j2",
sco=ScoData(etud), sco=ScoData(etud),
date=_dateiso_to_datefr(date), assi_limit_annee=sco_preferences.get_preference(
morning=morning, "assi_limit_annee",
lunch=lunch, dept_id=g.scodoc_dept_id,
timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])),
afternoon=afternoon,
nonworkdays=_non_work_days(),
forcer_module=sco_preferences.get_preference(
"forcer_module", dept_id=g.scodoc_dept_id
),
diff=_differee(
etudiants=[sco_etud.get_etud_info(etudid=etud.etudid, filled=True)[0]],
moduleimpl_select=select,
), ),
assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"),
assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"),
saisie_eval=saisie_eval, saisie_eval=saisie_eval,
date_deb=date_deb, date_deb=date_deb,
date_fin=date_fin, date_fin=date_fin,
redirect_url=redirect_url, redirect_url=redirect_url,
moduleimpl_id=moduleimpl_id, moduleimpl_id=moduleimpl_id,
), ),
# render_template(
# "assiduites/pages/signal_assiduites_etud.j2",
# sco=ScoData(etud),
# date=_dateiso_to_datefr(date),
# morning=morning,
# lunch=lunch,
# timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])),
# afternoon=afternoon,
# nonworkdays=_non_work_days(),
# forcer_module=sco_preferences.get_preference(
# "forcer_module", dept_id=g.scodoc_dept_id
# ),
# diff=_differee(
# etudiants=[sco_etud.get_etud_info(etudid=etud.etudid, filled=True)[0]],
# moduleimpl_select=select,
# ),
# saisie_eval=saisie_eval,
# date_deb=date_deb,
# date_fin=date_fin,
# redirect_url=redirect_url,
# moduleimpl_id=moduleimpl_id,
# ),
).build() ).build()

View File

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

View File

@ -1,4 +1,5 @@
[pytest] [pytest]
norecursedirs = .git app/static
markers = markers =
slow: marks tests as slow (deselect with '-m "not slow"') slow: marks tests as slow (deselect with '-m "not slow"')
apo apo

View File

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

View File

@ -28,10 +28,11 @@ from tests.api.setup_test_api import (
API_URL, API_URL,
API_USER_ADMIN, API_USER_ADMIN,
CHECK_CERTIFICATE, CHECK_CERTIFICATE,
DEPT_ACRONYM,
POST_JSON, POST_JSON,
api_headers,
get_auth_headers, get_auth_headers,
) )
from tests.api.setup_test_api import api_headers # pylint: disable=unused-import
from tests.api.tools_test_api import ( from tests.api.tools_test_api import (
BULLETIN_ETUDIANT_FIELDS, BULLETIN_ETUDIANT_FIELDS,
BULLETIN_FIELDS, BULLETIN_FIELDS,
@ -923,3 +924,20 @@ def test_etudiant_groups(api_headers):
group = groups[0] group = groups[0]
fields_ok = verify_fields(group, fields) fields_ok = verify_fields(group, fields)
assert fields_ok is True assert fields_ok is True
def test_etudiant_create(api_headers):
"""/etudiant/create"""
admin_header = get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN)
args = {
"prenom": "Carl Philipp Emanuel",
"nom": "Bach",
"dept": DEPT_ACRONYM,
"civilite": "M",
}
etud = POST_JSON(
"/etudiant/create",
args,
headers=admin_header,
)
assert etud["nom"] == args["nom"].upper()

View File

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

View File

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

View File

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