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
import app
from app import db
from app.api import api_bp as bp, api_web_bp
from app.api import tools
from app.but import bulletin_but_court
@ -28,10 +29,12 @@ from app.models import (
FormSemestreInscription,
FormSemestre,
Identite,
ScolarNews,
)
from app.scodoc import sco_bulletins
from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc import sco_etud
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error, suppress_accents
@ -475,3 +478,48 @@ def etudiant_groups(formsemestre_id: int, etudid: int = None):
data = sco_groups.get_etud_groups(etud.id, formsemestre.id)
return data
@bp.route("/etudiant/create", methods=["POST"], defaults={"force": False})
@bp.route("/etudiant/create/force", methods=["POST"], defaults={"force": True})
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def etudiant_create(force=False):
"""Création d'un nouvel étudiant
Si force, crée même si homonymie détectée.
L'étudiant créé n'est pas inscrit à un semestre.
Champs requis: nom, prenom (sauf si config sans prénom), dept (string:acronyme)
"""
args = request.get_json(force=True) # may raise 400 Bad Request
dept = args.get("dept", None)
if not dept:
return scu.json_error(400, "dept requis")
dept_o = Departement.query.filter_by(acronym=dept).first()
if not dept_o:
return scu.json_error(400, "dept invalide")
app.set_sco_dept(dept)
args["dept_id"] = dept_o.id
# vérifie que le département de création est bien autorisé
if not current_user.has_permission(Permission.EtudInscrit, dept):
return json_error(403, "departement non autorisé")
nom = args.get("nom", None)
prenom = args.get("prenom", None)
ok, homonyms = sco_etud.check_nom_prenom_homonyms(nom=nom, prenom=prenom)
if not ok:
return scu.json_error(400, "nom ou prénom invalide")
if len(homonyms) > 0 and not force:
return scu.json_error(
400, f"{len(homonyms)} homonymes détectés. Vous pouvez utiliser /force."
)
etud = Identite.create_etud(**args)
# 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")
if not partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is None:
return json_error(API_CLIENT_ERROR, "missing group name or invalid data format")
if not GroupDescr.check_name(partition, group_name):
return json_error(API_CLIENT_ERROR, "invalid group_name")
group_name = group_name.strip()
group = GroupDescr(group_name=group_name, partition_id=partition_id)
args = request.get_json(force=True) # may raise 400 Bad Request
group_name = args.get("group_name")
if not isinstance(group_name, str):
return json_error(API_CLIENT_ERROR, "missing group name or invalid data format")
args["group_name"] = args["group_name"].strip()
if not GroupDescr.check_name(partition, args["group_name"]):
return json_error(API_CLIENT_ERROR, "invalid group_name")
args["partition_id"] = partition_id
try:
group = GroupDescr(**args)
except TypeError:
return json_error(API_CLIENT_ERROR, "invalid arguments")
db.session.add(group)
db.session.commit()
log(f"created group {group}")
@ -369,16 +373,22 @@ def group_edit(group_id: int):
return json_error(403, "partition non editable")
if not group.partition.formsemestre.can_change_groups():
return json_error(401, "opération non autorisée")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is not None:
group_name = group_name.strip()
if not GroupDescr.check_name(group.partition, group_name, existing=True):
args = request.get_json(force=True) # may raise 400 Bad Request
if "group_name" in args:
if not isinstance(args["group_name"], str):
return json_error(API_CLIENT_ERROR, "invalid data format for group_name")
args["group_name"] = args["group_name"].strip() if args["group_name"] else ""
if not GroupDescr.check_name(
group.partition, args["group_name"], existing=True
):
return json_error(API_CLIENT_ERROR, "invalid group_name")
group.group_name = group_name
db.session.add(group)
db.session.commit()
log(f"modified {group}")
group.from_dict(args)
db.session.add(group)
db.session.commit()
log(f"modified {group}")
app.set_sco_dept(group.partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return group.to_dict(with_partition=True)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,6 +64,7 @@ from config import Config
from app import log, ScoDocJSONEncoder
from app.scodoc.codes_cursus import NOTES_TOLERANCE, CODES_EXPL
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_xml
import sco_version
@ -1139,6 +1140,61 @@ def abbrev_prenom(prenom):
return abrv
def format_civilite(civilite):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
personne ne souhaitant pas d'affichage).
Raises ScoValueError if conversion fails.
"""
try:
return {
"M": "M.",
"F": "Mme",
"X": "",
}[civilite]
except KeyError as exc:
raise ScoValueError(f"valeur invalide pour la civilité: {civilite}") from exc
def format_nomprenom(etud, reverse=False):
"""Formatte civilité/nom/prenom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
DEPRECATED: utiliser Identite.nomprenom
"""
nom = etud.get("nom_disp", "") or etud.get("nom_usuel", "") or etud["nom"]
prenom = format_prenom(etud["prenom"])
civilite = format_civilite(etud["civilite"])
if reverse:
fs = [nom, prenom]
else:
fs = [civilite, prenom, nom]
return " ".join([x for x in fs if x])
def format_nom(s, uppercase=True):
"Formatte le nom"
if not s:
return ""
if uppercase:
return s.upper()
else:
return format_prenom(s)
def format_prenom(s):
"""Formatte prenom etudiant pour affichage
DEPRECATED: utiliser Identite.prenom_str
"""
if not s:
return ""
frags = s.split()
r = []
for frag in frags:
fs = frag.split("-")
r.append("-".join([x.lower().capitalize() for x in fs]))
return " ".join(r)
#
def timedate_human_repr():
"representation du temps courant pour utilisateur"
@ -1476,3 +1532,15 @@ def is_assiduites_module_forced(
except (TypeError, ValueError):
retour = sco_preferences.get_preference("forcer_module", dept_id=dept_id)
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 {
--color-present: #6bdb83;
--color-absent: #e62a11;
--color-absent-clair: #F25D4A;
--color-retard: #f0c865;
--color-justi: #7059FF;
--color-justi-clair: #6885E3;
--color-justi-invalide: #a84476;
--color-nonwork: #badfff;
--color-absent-justi: #e65ab7;
--color-retard-justi: #ffef7a;
--color-error: #FF0000;
--color-error: #e62a11;
--color-warning: #eec660;
--color-information: #658ef0;
@ -21,7 +23,7 @@
--color-defaut: #FFF;
--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);

View File

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

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">
<h3>Justifier des absences ou retards</h3>
{% 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">
@ -60,6 +57,10 @@
</fieldset>
</section>
<section class="liste">
<a class="icon filter" onclick="filterJusti()"></a>
{% include "assiduites/widgets/tableau_justi.j2" %}
</section>
<div class="legende">
@ -224,12 +225,12 @@
if (document.getElementById('justi_journee').checked) {
date_deb.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 {
date_deb.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 journee = document.querySelector('.page #justi_journee').checked
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 {
"deb": deb,
"fin": fin,

View File

@ -343,6 +343,9 @@
</style>
<script>
const datePivot = "{{scu.get_assiduites_time_config("pivot")}}".split(":").map((el) => Number(el))
function getDaysBetweenDates(start, end) {
let now = new Date(start);
end = new Date(end);
@ -476,7 +479,7 @@
const matin = [new Date(date), new Date(date)]
color = "sans_etat"
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");
const aprem = [new Date(date), new Date(date)]
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)

View File

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

View File

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

View File

@ -456,6 +456,7 @@
td {
border: 1px solid #dddddd;
padding: 8px;
color: var(--color-default-text);
}
th {
@ -498,17 +499,25 @@
.l-absent,
.l-invalid {
background-color: var(--color-absent);
background-color: var(--color-absent-clair);
}
.l-valid {
background-color: var(--color-primary);
background-color: var(--color-justi-clair);
}
.l-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 */
.pagination-container {
display: flex;

View File

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

View File

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

View File

@ -293,7 +293,8 @@ def signal_assiduites_etud():
"js/date_utils.js",
"js/etud_info.js",
],
cssstyles=[
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
)
@ -318,27 +319,43 @@ def signal_assiduites_etud():
header,
_mini_timeline(),
render_template(
"assiduites/pages/signal_assiduites_etud.j2",
"assiduites/pages/ajout_assiduites.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,
assi_limit_annee=sco_preferences.get_preference(
"assi_limit_annee",
dept_id=g.scodoc_dept_id,
),
assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"),
assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"),
saisie_eval=saisie_eval,
date_deb=date_deb,
date_fin=date_fin,
redirect_url=redirect_url,
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()

View File

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

View File

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

View File

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

View File

@ -28,10 +28,11 @@ from tests.api.setup_test_api import (
API_URL,
API_USER_ADMIN,
CHECK_CERTIFICATE,
DEPT_ACRONYM,
POST_JSON,
api_headers,
get_auth_headers,
)
from tests.api.setup_test_api import api_headers # pylint: disable=unused-import
from tests.api.tools_test_api import (
BULLETIN_ETUDIANT_FIELDS,
BULLETIN_FIELDS,
@ -923,3 +924,20 @@ def test_etudiant_groups(api_headers):
group = groups[0]
fields_ok = verify_fields(group, fields)
assert fields_ok is True
def test_etudiant_create(api_headers):
"""/etudiant/create"""
admin_header = get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN)
args = {
"prenom": "Carl Philipp Emanuel",
"nom": "Bach",
"dept": DEPT_ACRONYM,
"civilite": "M",
}
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 group_r["group_name"] == group_d["group_name"]
assert group_r["edt_id"] is None
# --- Liste groupes de la partition
partition = GET(f"/partition/{partition_r['id']}", headers=headers)
assert isinstance(partition, dict)
@ -99,6 +100,26 @@ def test_formsemestre_partition(api_headers):
group = partition["groups"][str(group_r["id"])] # nb: str car clés json en string
assert group["group_name"] == group_d["group_name"]
# --- Ajout d'un groupe avec edt_id
group_d = {"group_name": "extra", "edt_id": "GEDT"}
group_r = POST_JSON(
f"/partition/{partition_r['id']}/group/create",
group_d,
headers=headers,
)
assert group_r["edt_id"] == "GEDT"
# Edit edt_id
group_r = POST_JSON(
f"/group/{group_r['id']}/edit",
{"edt_id": "GEDT2"},
headers=headers,
)
assert group_r["edt_id"] == "GEDT2"
partition = GET(f"/partition/{partition_r['id']}", headers=headers)
group = partition["groups"][str(group_r["id"])] # nb: str car clés json en string
assert group["group_name"] == group_d["group_name"]
assert group["edt_id"] == "GEDT2"
# Place un étudiant dans le groupe
etud = GET(f"/formsemestre/{formsemestre_id}/etudiants", headers=headers)[0]
repl = POST_JSON(f"/group/{group['id']}/set_etudiant/{etud['id']}", headers=headers)

View File

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

View File

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