Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into edit_roles

This commit is contained in:
Emmanuel Viennet 2023-09-28 22:55:55 +02:00
commit db062d5b4a
27 changed files with 384 additions and 106 deletions

View File

@ -353,8 +353,8 @@ class User(UserMixin, db.Model):
return mails return mails
# Permissions management: # Permissions management:
def has_permission(self, perm: int, dept=False): def has_permission(self, perm: int, dept: str = False):
"""Check if user has permission `perm` in given `dept`. """Check if user has permission `perm` in given `dept` (acronym).
Similar to Zope ScoDoc7 `has_permission`` Similar to Zope ScoDoc7 `has_permission``
Args: Args:

View File

@ -70,16 +70,11 @@ class Assiduite(db.Model):
def to_dict(self, format_api=True) -> dict: def to_dict(self, format_api=True) -> dict:
"""Retourne la représentation json de l'assiduité""" """Retourne la représentation json de l'assiduité"""
etat = self.etat etat = self.etat
username = self.user_id user: User = None
if format_api: if format_api:
etat = EtatAssiduite.inverse().get(self.etat).name etat = EtatAssiduite.inverse().get(self.etat).name
if self.user_id is not None: if self.user_id is not None:
user: User = db.session.get(User, self.user_id) user = db.session.get(User, self.user_id)
if user is None:
username = "Non renseigné"
else:
username = user.get_prenomnom()
data = { data = {
"assiduite_id": self.id, "assiduite_id": self.id,
"etudid": self.etudid, "etudid": self.etudid,
@ -90,7 +85,8 @@ class Assiduite(db.Model):
"etat": etat, "etat": etat,
"desc": self.description, "desc": self.description,
"entry_date": self.entry_date, "entry_date": self.entry_date,
"user_id": username, "user_id": None if user is None else user.id, # l'uid
"user_name": None if user is None else user.user_name, # le login
"est_just": self.est_just, "est_just": self.est_just,
"external_data": self.external_data, "external_data": self.external_data,
} }

View File

@ -80,8 +80,6 @@ class Departement(db.Model):
def create_dept(acronym: str, visible=True) -> Departement: def create_dept(acronym: str, visible=True) -> Departement:
"Create new departement" "Create new departement"
from app.models import ScoPreference
if Departement.invalid_dept_acronym(acronym): if Departement.invalid_dept_acronym(acronym):
raise ScoValueError("acronyme departement invalide") raise ScoValueError("acronyme departement invalide")
existing = Departement.query.filter_by(acronym=acronym).count() existing = Departement.query.filter_by(acronym=acronym).count()

View File

@ -86,6 +86,50 @@ class Identite(db.Model):
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>" f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
) )
def clone(self, not_copying=(), new_dept_id: int = None):
"""Clone, not copying the given attrs
Clone aussi les adresses.
Si new_dept_id est None, le nouvel étudiant n'a pas de département.
Attention: la copie n'a pas d'id avant le prochain flush ou commit.
"""
if new_dept_id == self.dept_id:
raise ScoValueError(
"clonage étudiant: le département destination est identique à celui de départ"
)
# Vérifie les contraintes d'unicité
# ("dept_id", "code_nip") et ("dept_id", "code_ine")
if (
self.code_nip is not None
and Identite.query.filter_by(
dept_id=new_dept_id, code_nip=self.code_nip
).count()
> 0
) or (
self.code_ine is not None
and Identite.query.filter_by(
dept_id=new_dept_id, code_ine=self.code_ine
).count()
> 0
):
raise ScoValueError(
"""clonage étudiant: un étudiant de même code existe déjà
dans le département destination"""
)
d = dict(self.__dict__)
d.pop("id", None) # get rid of id
d.pop("_sa_instance_state", None) # get rid of SQLAlchemy special attr
d.pop("departement", None) # relationship
d["dept_id"] = new_dept_id
for k in not_copying:
d.pop(k, None)
copy = self.__class__(**d)
copy.adresses = [adr.clone() for adr in self.adresses]
db.session.add(copy)
log(
f"cloning etud <{self.id} {self.nom!r} {self.prenom!r}> in dept_id={new_dept_id}"
)
return copy
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="{
@ -660,6 +704,19 @@ class Adresse(db.Model):
) )
description = db.Column(db.Text) description = db.Column(db.Text)
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain flush ou commit.
"""
d = dict(self.__dict__)
d.pop("id", None) # get rid of id
d.pop("_sa_instance_state", None) # get rid of SQLAlchemy special attr
for k in not_copying:
d.pop(k, None)
copy = self.__class__(**d)
db.session.add(copy)
return copy
def to_dict(self, convert_nulls_to_str=False): def to_dict(self, convert_nulls_to_str=False):
"""Représentation dictionnaire,""" """Représentation dictionnaire,"""
e = dict(self.__dict__) e = dict(self.__dict__)

View File

@ -177,11 +177,15 @@ class FormSemestre(db.Model):
""" """
@classmethod @classmethod
def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre": def get_formsemestre(
""" "FormSemestre ou 404, cherche uniquement dans le département courant""" cls, formsemestre_id: int, dept_id: int = None
) -> "FormSemestre":
""" "FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant"""
if g.scodoc_dept: if g.scodoc_dept:
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
if dept_id is not None:
return cls.query.filter_by( return cls.query.filter_by(
id=formsemestre_id, dept_id=g.scodoc_dept_id id=formsemestre_id, dept_id=dept_id
).first_or_404() ).first_or_404()
return cls.query.filter_by(id=formsemestre_id).first_or_404() return cls.query.filter_by(id=formsemestre_id).first_or_404()

View File

@ -237,7 +237,7 @@ class TF(object):
def setdefaultvalues(self): def setdefaultvalues(self):
"set default values and convert numbers to strings" "set default values and convert numbers to strings"
for (field, descr) in self.formdescription: for field, descr in self.formdescription:
# special case for boolcheckbox # special case for boolcheckbox
if descr.get("input_type", None) == "boolcheckbox" and self.submitted(): if descr.get("input_type", None) == "boolcheckbox" and self.submitted():
if field not in self.values: if field not in self.values:
@ -278,7 +278,7 @@ class TF(object):
"check values. Store .result and returns msg" "check values. Store .result and returns msg"
ok = 1 ok = 1
msg = [] msg = []
for (field, descr) in self.formdescription: for field, descr in self.formdescription:
val = self.values[field] val = self.values[field]
# do not check "unckecked" items # do not check "unckecked" items
if descr.get("withcheckbox", False): if descr.get("withcheckbox", False):
@ -287,7 +287,7 @@ class TF(object):
# null values # null values
allow_null = descr.get("allow_null", True) allow_null = descr.get("allow_null", True)
if not allow_null: if not allow_null:
if val == "" or val == None: if val is None or (isinstance(val, str) and not val.strip()):
msg.append( msg.append(
"Le champ '%s' doit être renseigné" % descr.get("title", field) "Le champ '%s' doit être renseigné" % descr.get("title", field)
) )
@ -871,7 +871,7 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
def _ReadOnlyVersion(self, formdescription): def _ReadOnlyVersion(self, formdescription):
"Generate HTML for read-only view of the form" "Generate HTML for read-only view of the form"
R = ['<table class="tf-ro">'] R = ['<table class="tf-ro">']
for (field, descr) in formdescription: for field, descr in formdescription:
R.append(self._ReadOnlyElement(field, descr)) R.append(self._ReadOnlyElement(field, descr))
R.append("</table>") R.append("</table>")
return R return R

View File

@ -19,26 +19,35 @@ class Trace:
"""gestionnaire de la trace des fichiers justificatifs""" """gestionnaire de la trace des fichiers justificatifs"""
def __init__(self, path: str) -> None: def __init__(self, path: str) -> None:
log(f"init Trace {path}")
self.path: str = path + "/_trace.csv" self.path: str = path + "/_trace.csv"
self.content: dict[str, list[datetime, datetime, str]] = {} self.content: dict[str, list[datetime, datetime, str]] = {}
self.import_from_file() self.import_from_file()
def import_from_file(self): def import_from_file(self):
"""import trace from file""" """import trace from file"""
if os.path.isfile(self.path):
with open(self.path, "r", encoding="utf-8") as file: def import_from_csv(path):
with open(path, "r", encoding="utf-8") as file:
for line in file.readlines(): for line in file.readlines():
csv = line.split(",") csv = line.split(",")
if len(csv) < 4: if len(csv) < 4:
continue continue
fname: str = csv[0] fname: str = csv[0]
if fname not in os.listdir(self.path.replace("/_trace.csv", "")):
continue
entry_date: datetime = is_iso_formated(csv[1], True) entry_date: datetime = is_iso_formated(csv[1], True)
delete_date: datetime = is_iso_formated(csv[2], True) delete_date: datetime = is_iso_formated(csv[2], True)
user_id = csv[3] user_id = csv[3]
self.content[fname] = [entry_date, delete_date, user_id] self.content[fname] = [entry_date, delete_date, user_id]
if os.path.isfile(self.path):
import_from_csv(self.path)
else:
parent_dir: str = self.path[: self.path.rfind("/", 0, self.path.rfind("/"))]
if os.path.isfile(parent_dir + "/_trace.csv"):
import_from_csv(parent_dir + "/_trace.csv")
self.save_trace()
def set_trace(self, *fnames: str, mode: str = "entry", current_user: str = None): def set_trace(self, *fnames: str, mode: str = "entry", current_user: str = None):
"""Ajoute une trace du fichier donné """Ajoute une trace du fichier donné
mode : entry / delete mode : entry / delete
@ -57,9 +66,11 @@ class Trace:
) )
self.save_trace() self.save_trace()
def save_trace(self): def save_trace(self, new_path: str = None):
"""Enregistre la trace dans le fichier _trace.csv""" """Enregistre la trace dans le fichier _trace.csv"""
lines: list[str] = [] lines: list[str] = []
if new_path is not None:
self.path = new_path
for fname, traced in self.content.items(): for fname, traced in self.content.items():
date_fin: datetime or None = traced[1].isoformat() if traced[1] else "None" date_fin: datetime or None = traced[1].isoformat() if traced[1] else "None"
if traced[0] is not None: if traced[0] is not None:
@ -126,7 +137,6 @@ class JustificatifArchiver(BaseArchiver):
) )
fname: str = self.store(archive_id, filename, data, dept_id=etud.dept_id) fname: str = self.store(archive_id, filename, data, dept_id=etud.dept_id)
log(f"obj_dir {self.get_obj_dir(etud.id, dept_id=etud.dept_id)} | {archive_id}")
trace = Trace(archive_id) trace = Trace(archive_id)
trace.set_trace(fname, mode="entry") trace.set_trace(fname, mode="entry")
if user_id is not None: if user_id is not None:

View File

@ -136,7 +136,7 @@ class WrapDict(object):
try: try:
value = self.dict[key] value = self.dict[key]
except KeyError: except KeyError:
raise return f"XXX {key} invalide XXX"
if value is None: if value is None:
return self.none_value return self.none_value
return value return value

View File

@ -31,6 +31,7 @@
import flask import flask
from flask import url_for, g from flask import url_for, g
from flask_login import current_user from flask_login import current_user
import sqlalchemy as sa
from app import db, log from app import db, log
@ -72,7 +73,7 @@ _evaluationEditor = ndb.EditableTable(
) )
def get_evaluation_dict(args: dict) -> list[dict]: def get_evaluations_dict(args: dict) -> list[dict]:
"""Liste evaluations, triées numero (or most recent date first). """Liste evaluations, triées numero (or most recent date first).
Fonction de transition pour ancien code ScoDoc7. Fonction de transition pour ancien code ScoDoc7.
@ -83,7 +84,12 @@ def get_evaluation_dict(args: dict) -> list[dict]:
'descrheure' : ' de 15h00 à 16h30' 'descrheure' : ' de 15h00 à 16h30'
""" """
# calcule duree (chaine de car.) de chaque evaluation et ajoute jour_iso, matin, apresmidi # calcule duree (chaine de car.) de chaque evaluation et ajoute jour_iso, matin, apresmidi
return [e.to_dict() for e in Evaluation.query.filter_by(**args)] return [
e.to_dict()
for e in Evaluation.query.filter_by(**args).order_by(
sa.desc(Evaluation.numero), sa.desc(Evaluation.date_debut)
)
]
def do_evaluation_list_in_formsemestre(formsemestre_id): def do_evaluation_list_in_formsemestre(formsemestre_id):
@ -91,7 +97,7 @@ def do_evaluation_list_in_formsemestre(formsemestre_id):
mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id)
evals = [] evals = []
for modimpl in mods: for modimpl in mods:
evals += get_evaluation_dict(args={"moduleimpl_id": modimpl["moduleimpl_id"]}) evals += get_evaluations_dict(args={"moduleimpl_id": modimpl["moduleimpl_id"]})
return evals return evals
@ -161,7 +167,6 @@ def moduleimpl_evaluation_move(evaluation_id: int, after=0, redirect=1):
(published) (published)
""" """
evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id) evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
moduleimpl_id = evaluation.moduleimpl_id
redirect = int(redirect) redirect = int(redirect)
# access: can change eval ? # access: can change eval ?
if not evaluation.moduleimpl.can_edit_evaluation(current_user): if not evaluation.moduleimpl.can_edit_evaluation(current_user):
@ -171,12 +176,12 @@ def moduleimpl_evaluation_move(evaluation_id: int, after=0, redirect=1):
Evaluation.moduleimpl_evaluation_renumber( Evaluation.moduleimpl_evaluation_renumber(
evaluation.moduleimpl, only_if_unumbered=True evaluation.moduleimpl, only_if_unumbered=True
) )
e = get_evaluation_dict(args={"evaluation_id": evaluation_id})[0] e = get_evaluations_dict(args={"evaluation_id": evaluation_id})[0]
after = int(after) # 0: deplace avant, 1 deplace apres after = int(after) # 0: deplace avant, 1 deplace apres
if after not in (0, 1): if after not in (0, 1):
raise ValueError('invalid value for "after"') raise ValueError('invalid value for "after"')
mod_evals = get_evaluation_dict({"moduleimpl_id": e["moduleimpl_id"]}) mod_evals = get_evaluations_dict({"moduleimpl_id": e["moduleimpl_id"]})
if len(mod_evals) > 1: if len(mod_evals) > 1:
idx = [p["evaluation_id"] for p in mod_evals].index(evaluation_id) idx = [p["evaluation_id"] for p in mod_evals].index(evaluation_id)
neigh = None # object to swap with neigh = None # object to swap with

View File

@ -133,7 +133,7 @@ def do_evaluation_etat(
) # { etudid : note } ) # { etudid : note }
# ---- Liste des groupes complets et incomplets # ---- Liste des groupes complets et incomplets
E = sco_evaluation_db.get_evaluation_dict(args={"evaluation_id": evaluation_id})[0] E = sco_evaluation_db.get_evaluations_dict(args={"evaluation_id": evaluation_id})[0]
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
is_malus = Mod["module_type"] == ModuleType.MALUS # True si module de malus is_malus = Mod["module_type"] == ModuleType.MALUS # True si module de malus

View File

@ -1445,7 +1445,7 @@ def do_formsemestre_delete(formsemestre_id):
mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id)
for mod in mods: for mod in mods:
# evaluations # evaluations
evals = sco_evaluation_db.get_evaluation_dict( evals = sco_evaluation_db.get_evaluations_dict(
args={"moduleimpl_id": mod["moduleimpl_id"]} args={"moduleimpl_id": mod["moduleimpl_id"]}
) )
for e in evals: for e in evals:

View File

@ -275,14 +275,16 @@ def do_formsemestre_inscription_with_modules(
etat=scu.INSCRIT, etat=scu.INSCRIT,
etape=None, etape=None,
method="inscription_with_modules", method="inscription_with_modules",
dept_id: int = None,
): ):
"""Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS """Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS
(donc sauf le sport) (donc sauf le sport)
Si dept_id est spécifié, utilise ce département au lieu du courant.
""" """
group_ids = group_ids or [] group_ids = group_ids or []
if isinstance(group_ids, int): if isinstance(group_ids, int):
group_ids = [group_ids] group_ids = [group_ids]
formsemestre = FormSemestre.get_formsemestre(formsemestre_id) formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id)
# inscription au semestre # inscription au semestre
args = {"formsemestre_id": formsemestre_id, "etudid": etudid} args = {"formsemestre_id": formsemestre_id, "etudid": etudid}
if etat is not None: if etat is not None:

View File

@ -490,7 +490,7 @@ def retreive_formsemestre_from_request() -> int:
modimpl = modimpl[0] modimpl = modimpl[0]
formsemestre_id = modimpl["formsemestre_id"] formsemestre_id = modimpl["formsemestre_id"]
elif "evaluation_id" in args: elif "evaluation_id" in args:
E = sco_evaluation_db.get_evaluation_dict( E = sco_evaluation_db.get_evaluations_dict(
{"evaluation_id": args["evaluation_id"]} {"evaluation_id": args["evaluation_id"]}
) )
if not E: if not E:
@ -884,7 +884,7 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
jour = datetime.date.today().isoformat(), jour = datetime.date.today().isoformat(),
group_ids=group.id, group_ids=group.id,
)}"> )}">
<button>Visualiser l'assiduité</button></a> <button>Visualiser</button></a>
</div> </div>
<div> <div>
<a class="btn" href="{ <a class="btn" href="{

View File

@ -69,10 +69,10 @@ def do_evaluation_listenotes(
mode = None mode = None
if moduleimpl_id: if moduleimpl_id:
mode = "module" mode = "module"
evals = sco_evaluation_db.get_evaluation_dict({"moduleimpl_id": moduleimpl_id}) evals = sco_evaluation_db.get_evaluations_dict({"moduleimpl_id": moduleimpl_id})
elif evaluation_id: elif evaluation_id:
mode = "eval" mode = "eval"
evals = sco_evaluation_db.get_evaluation_dict({"evaluation_id": evaluation_id}) evals = sco_evaluation_db.get_evaluations_dict({"evaluation_id": evaluation_id})
else: else:
raise ValueError("missing argument: evaluation or module") raise ValueError("missing argument: evaluation or module")
if not evals: if not evals:

View File

@ -642,6 +642,12 @@ def menus_etud(etudid):
"args": {"etudid": etud["etudid"]}, "args": {"etudid": etud["etudid"]},
"enabled": authuser.has_permission(Permission.ScoEtudInscrit), "enabled": authuser.has_permission(Permission.ScoEtudInscrit),
}, },
{
"title": "Copier dans un autre département...",
"endpoint": "scolar.etud_copy_in_other_dept",
"args": {"etudid": etud["etudid"]},
"enabled": authuser.has_permission(Permission.ScoEtudInscrit),
},
{ {
"title": "Supprimer cet étudiant...", "title": "Supprimer cet étudiant...",
"endpoint": "scolar.etudident_delete", "endpoint": "scolar.etudident_delete",
@ -656,7 +662,9 @@ def menus_etud(etudid):
}, },
] ]
return htmlutils.make_menu("Étudiant", menuEtud, alone=True) return htmlutils.make_menu(
"Étudiant", menuEtud, alone=True, css_class="menu-etudiant"
)
def etud_info_html(etudid, with_photo="1", debug=False): def etud_info_html(etudid, with_photo="1", debug=False):

View File

@ -138,7 +138,7 @@ class PlacementForm(FlaskForm):
def set_evaluation_infos(self, evaluation_id): def set_evaluation_infos(self, evaluation_id):
"""Initialise les données du formulaire avec les données de l'évaluation.""" """Initialise les données du formulaire avec les données de l'évaluation."""
eval_data = sco_evaluation_db.get_evaluation_dict( eval_data = sco_evaluation_db.get_evaluations_dict(
{"evaluation_id": evaluation_id} {"evaluation_id": evaluation_id}
) )
if not eval_data: if not eval_data:
@ -239,7 +239,7 @@ class PlacementRunner:
self.groups_ids = [ self.groups_ids = [
gid if gid != TOUS else form.tous_id for gid in form["groups"].data gid if gid != TOUS else form.tous_id for gid in form["groups"].data
] ]
self.eval_data = sco_evaluation_db.get_evaluation_dict( self.eval_data = sco_evaluation_db.get_evaluations_dict(
{"evaluation_id": self.evaluation_id} {"evaluation_id": self.evaluation_id}
)[0] )[0]
self.groups = sco_groups.listgroups(self.groups_ids) self.groups = sco_groups.listgroups(self.groups_ids)

View File

@ -524,11 +524,11 @@ def table_suivi_cohorte(
# 3-- Regroupe les semestres par date de debut # 3-- Regroupe les semestres par date de debut
P = [] # liste de periodsem P = [] # liste de periodsem
class periodsem(object): class PeriodSem:
pass pass
# semestre de depart: # semestre de depart:
porigin = periodsem() porigin = PeriodSem()
d, m, y = [int(x) for x in sem["date_debut"].split("/")] d, m, y = [int(x) for x in sem["date_debut"].split("/")]
porigin.datedebut = datetime.datetime(y, m, d) porigin.datedebut = datetime.datetime(y, m, d)
porigin.sems = [sem] porigin.sems = [sem]
@ -543,7 +543,7 @@ def table_suivi_cohorte(
merged = True merged = True
break break
if not merged: if not merged:
p = periodsem() p = PeriodSem()
p.datedebut = s["date_debut_dt"] p.datedebut = s["date_debut_dt"]
p.sems = [s] p.sems = [s]
P.append(p) P.append(p)
@ -743,7 +743,7 @@ def formsemestre_suivi_cohorte(
civilite=None, civilite=None,
statut="", statut="",
only_primo=False, only_primo=False,
): ) -> str:
"""Affiche suivi cohortes par numero de semestre""" """Affiche suivi cohortes par numero de semestre"""
annee_bac = str(annee_bac or "") annee_bac = str(annee_bac or "")
annee_admission = str(annee_admission or "") annee_admission = str(annee_admission or "")
@ -794,14 +794,6 @@ def formsemestre_suivi_cohorte(
'<p><a href="%s&percent=1">Afficher les résultats en pourcentages</a></p>' '<p><a href="%s&percent=1">Afficher les résultats en pourcentages</a></p>'
% burl % burl
) )
help = (
pplink
+ """
<p class="help">Nombre d'étudiants dans chaque semestre. Les dates indiquées sont les dates approximatives de <b>début</b> des semestres (les semestres commençant à des dates proches sont groupés). Le nombre de diplômés est celui à la <b>fin</b> du semestre correspondant. Lorsqu'il y a moins de %s étudiants dans une case, vous pouvez afficher leurs noms en passant le curseur sur le chiffre.</p>
<p class="help">Les menus permettent de n'étudier que certaines catégories d'étudiants (titulaires d'un type de bac, garçons ou filles). La case "restreindre aux primo-entrants" permet de ne considérer que les étudiants qui n'ont jamais été inscrits dans ScoDoc avant le semestre considéré.</p>
"""
% (MAX_ETUD_IN_DESCR,)
)
H = [ H = [
html_sco_header.sco_header(page_title=tab.page_title), html_sco_header.sco_header(page_title=tab.page_title),
@ -824,7 +816,20 @@ def formsemestre_suivi_cohorte(
percent=percent, percent=percent,
), ),
t, t,
help, f"""{pplink}
<p class="help">Nombre d'étudiants dans chaque semestre.
Les dates indiquées sont les dates approximatives de <b>début</b> des semestres
(les semestres commençant à des dates proches sont groupés). Le nombre de diplômés
est celui à la <b>fin</b> du semestre correspondant.
Lorsqu'il y a moins de {MAX_ETUD_IN_DESCR} étudiants dans une case, vous pouvez
afficher leurs noms en passant le curseur sur le chiffre.
</p>
<p class="help">Les menus permettent de n'étudier que certaines catégories
d'étudiants (titulaires d'un type de bac, garçons ou filles).
La case "restreindre aux primo-entrants" permet de ne considérer que les étudiants
qui n'ont jamais été inscrits dans ScoDoc avant le semestre considéré.
</p>
""",
expl, expl,
html_sco_header.sco_footer(), html_sco_header.sco_footer(),
] ]
@ -870,35 +875,33 @@ def _gen_form_selectetuds(
else: else:
selected = 'selected="selected"' selected = 'selected="selected"'
F = [ F = [
"""<form id="f" method="get" action="%s"> f"""<form id="f" method="get" action="{request.base_url}">
<p>Bac: <select name="bac" onchange="javascript: submit(this);"> <p>Bac: <select name="bac" onchange="javascript: submit(this);">
<option value="" %s>tous</option> <option value="" {selected}>tous</option>
""" """
% (request.base_url, selected)
] ]
for b in bacs: for b in bacs:
if bac == b: if bac == b:
selected = 'selected="selected"' selected = 'selected="selected"'
else: else:
selected = "" selected = ""
F.append('<option value="%s" %s>%s</option>' % (b, selected, b)) F.append(f'<option value="{b}" {selected}>{b}</option>')
F.append("</select>") F.append("</select>")
if bacspecialite: if bacspecialite:
selected = "" selected = ""
else: else:
selected = 'selected="selected"' selected = 'selected="selected"'
F.append( F.append(
"""&nbsp; Bac/Specialité: <select name="bacspecialite" onchange="javascript: submit(this);"> f"""&nbsp; Bac/Specialité: <select name="bacspecialite" onchange="javascript: submit(this);">
<option value="" %s>tous</option> <option value="" {selected}>tous</option>
""" """
% selected
) )
for b in bacspecialites: for b in bacspecialites:
if bacspecialite == b: if bacspecialite == b:
selected = 'selected="selected"' selected = 'selected="selected"'
else: else:
selected = "" selected = ""
F.append('<option value="%s" %s>%s</option>' % (b, selected, b)) F.append(f'<option value="{b}" {selected}>{b}</option>')
F.append("</select>") F.append("</select>")
# #
F.append( F.append(
@ -910,46 +913,44 @@ def _gen_form_selectetuds(
) )
# #
F.append( F.append(
"""&nbsp; Genre: <select name="civilite" onchange="javascript: submit(this);"> f"""&nbsp; Genre: <select name="civilite" onchange="javascript: submit(this);">
<option value="" %s>tous</option> <option value="" {selected}>tous</option>
""" """
% selected
) )
for b in civilites: for b in civilites:
if civilite == b: if civilite == b:
selected = 'selected="selected"' selected = 'selected="selected"'
else: else:
selected = "" selected = ""
F.append('<option value="%s" %s>%s</option>' % (b, selected, b)) F.append(f'<option value="{b}" {selected}>{b}</option>')
F.append("</select>") F.append("</select>")
F.append( F.append(
"""&nbsp; Statut: <select name="statut" onchange="javascript: submit(this);"> f"""&nbsp; Statut: <select name="statut" onchange="javascript: submit(this);">
<option value="" %s>tous</option> <option value="" {selected}>tous</option>
""" """
% selected
) )
for b in statuts: for b in statuts:
if statut == b: if statut == b:
selected = 'selected="selected"' selected = 'selected="selected"'
else: else:
selected = "" selected = ""
F.append('<option value="%s" %s>%s</option>' % (b, selected, b)) F.append(f'<option value="{b}" {selected}>{b}</option>')
F.append("</select>") F.append("</select>")
if only_primo:
checked = 'checked="1"'
else:
checked = ""
F.append( F.append(
'<br><input type="checkbox" name="only_primo" onchange="javascript: submit(this);" %s/>Restreindre aux primo-entrants' f"""<br>
% checked <input type="checkbox" name="only_primo"
onchange="javascript: submit(this);"
{'checked="1"' if only_primo else ""}/>Restreindre aux primo-entrants
<input type="hidden" name="formsemestre_id" value="{formsemestre_id}"/>
<input type="hidden" name="percent" value="{percent}"/>
</p>
</form>
"""
) )
F.append(
'<input type="hidden" name="formsemestre_id" value="%s"/>' % formsemestre_id
)
F.append('<input type="hidden" name="percent" value="%s"/>' % percent)
F.append("</p></form>")
return "\n".join(F) return "\n".join(F)
@ -964,7 +965,7 @@ def _gen_select_annee(field, values, value) -> str:
return menu_html + "</select>" return menu_html + "</select>"
def _descr_etud_set(etudids): def _descr_etud_set(etudids) -> str:
"textual html description of a set of etudids" "textual html description of a set of etudids"
etuds = [] etuds = []
for etudid in etudids: for etudid in etudids:
@ -980,12 +981,19 @@ def _count_dem_reo(formsemestre_id, etudids):
"count nb of demissions and reorientation in this etud set" "count nb of demissions and reorientation in this etud set"
formsemestre = FormSemestre.get_formsemestre(formsemestre_id) formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
validations_annuelles = nt.get_validations_annee() if nt.is_apc else {}
dems = set() dems = set()
reos = set() reos = set()
for etudid in etudids: for etudid in etudids:
if nt.get_etud_etat(etudid) == "D": if nt.get_etud_etat(etudid) == "D":
dems.add(etudid) dems.add(etudid)
if nt.is_apc:
# BUT: utilise les validations annuelles
validation = validations_annuelles.get(etudid)
if validation and validation.code in codes_cursus.CODES_SEM_REO:
reos.add(etudid)
else:
# Autres formations: validations de semestres
dec = nt.get_etud_decision_sem(etudid) dec = nt.get_etud_decision_sem(etudid)
if dec and dec["code"] in codes_cursus.CODES_SEM_REO: if dec and dec["code"] in codes_cursus.CODES_SEM_REO:
reos.add(etudid) reos.add(etudid)

View File

@ -149,7 +149,7 @@ def list_operations(evaluation_id):
def evaluation_list_operations(evaluation_id): def evaluation_list_operations(evaluation_id):
"""Page listing operations on evaluation""" """Page listing operations on evaluation"""
E = sco_evaluation_db.get_evaluation_dict({"evaluation_id": evaluation_id})[0] E = sco_evaluation_db.get_evaluations_dict({"evaluation_id": evaluation_id})[0]
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
Ops = list_operations(evaluation_id) Ops = list_operations(evaluation_id)

View File

@ -10,12 +10,12 @@
html, html,
body { body {
margin: 0;
padding: 0;
width: 100%;
background-color: var(--sco-color-background); background-color: var(--sco-color-background);
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 12pt; font-size: 12pt;
margin: 0;
padding: 0;
width: 100%;
} }
@media print { @media print {
@ -24,6 +24,10 @@ body {
} }
} }
div.container {
margin-bottom: 24px;
}
h1, h1,
h2, h2,
h3 { h3 {
@ -1672,6 +1676,10 @@ formsemestre_page_title .lock img {
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
.menu-etudiant>li {
width: 200px !important;
}
span.inscr_addremove_menu { span.inscr_addremove_menu {
width: 150px; width: 150px;
} }

View File

@ -219,20 +219,27 @@
} }
function dayOnly() { function dayOnly() {
const { deb, fin } = getDates();
if (document.getElementById('justi_journee').checked) { if (document.getElementById('justi_journee').checked) {
document.getElementById("justi_date_debut").type = "date" document.getElementById("justi_date_debut").type = "date"
document.getElementById("justi_date_debut").value = deb.slice(0, deb.indexOf('T'))
document.getElementById("justi_date_fin").type = "date" document.getElementById("justi_date_fin").type = "date"
document.getElementById("justi_date_fin").value = fin.slice(0, fin.indexOf('T'))
} else { } else {
document.getElementById("justi_date_debut").type = "datetime-local" document.getElementById("justi_date_debut").type = "datetime-local"
document.getElementById("justi_date_debut").value = `${deb}T${assi_morning}`
document.getElementById("justi_date_fin").type = "datetime-local" document.getElementById("justi_date_fin").type = "datetime-local"
document.getElementById("justi_date_fin").value = `${fin}T${assi_evening}`
} }
} }
function getDates() { function getDates() {
if (document.querySelector('.page #justi_journee').checked) { if (document.querySelector('.page #justi_journee').checked) {
const date_str_deb = document.querySelector(".page #justi_date_debut").value const date_str_deb = document.querySelector(".page #justi_date_debut").value
const date_str_fin = document.querySelector(".page #justi_date_debut").value const date_str_fin = document.querySelector(".page #justi_date_fin").value

View File

@ -0,0 +1,99 @@
{# -*- mode: jinja-html -*- #}
{% extends 'base.j2' %}
{% block styles %}
{{super()}}
<style>
.dept-name {
font-size: 120%;
font-weight: bold;
}
.dept {
background-color: bisque;
border-radius: 12px;
padding: 8px;
margin-bottom: 12px;
}
.dept label {
font-weight: normal;
}
button[name="action"] {
margin-right: 32px;
}
#submit-button:disabled {
background-color: #CCCCCC;
color: #888888;
cursor: not-allowed;
border: 1px solid #AAAAAA;
}
</style>
{% endblock %}
{% block app_content %}
<h2>Création d'une copie de {{ etud.html_link_fiche() | safe }}</h2>
<div class="help">
<p>Utiliser cette page lorsqu'un étudinat change de département. ScoDoc gère
séparéement les étudiants des départements. Il faut donc dans ce cas
exceptionnel créer une copie de l'étudiant et l'inscrire dans un semestre de son
nouveau département. Seules les donénes sur l'identité de l'étudiant (état
civil, adresse, ...) sont dupliquées. Dans le noveau département, les résultats
obtenus dans le département d'origine ne seront pas visibles.
</p>
<p>Si des UEs ou compétences de l'ancien département doivent être validées dans
le nouveau, il faudra utiliser ensuite une "validation d'UE antérieure".
</p>
<p>Attention: seuls les départements dans lesquels vous avez la permission
d'inscrire des étudiants sont présentés ici. Il faudra peut-être solliciter
l'administrateur de ce ScoDoc.
</p>
<p>Dans chaque département autorisés, seuls les semestres non verrouillés sont
montrés. Choisir le semestre destination et valider le formulaire.
</p>
<p>Ensuite, ne pas oublier d'inscrire l'étudiant à ses groupes, notamment son
parcours si besoin.
</p>
</div>
<form method="POST">
{% for dept in departements.values() %}
<div class="dept">
<div class="dept-name">Département {{ dept.acronym }}</div>
{% for sem in formsemestres_by_dept[dept.id]%}
<div>
<label>
<input type="radio" class="formsemestre" name="formsemestre_id" value="{{ sem.id }}">
{{ sem.html_link_status() | safe }}
</label>
</div>
{% endfor %}
</div>
{% endfor %}
<button type="submit" name="action" value="submit" disabled id="submit-button">Créer une copie de l'étudiant et l'inscrire au semestre choisi</button>
<button type="submit" name="action" value="cancel">Annuler</button>
</form>
<script>
const radioButtons = document.querySelectorAll('input.formsemestre');
const submitButton = document.getElementById('submit-button');
radioButtons.forEach(radioButton => {
radioButton.addEventListener('change', () => {
const isAnyRadioButtonChecked = [...radioButtons].some(radioButton => radioButton.checked);
if (isAnyRadioButtonChecked) {
submitButton.removeAttribute('disabled');
} else {
submitButton.setAttribute('disabled', 'disabled');
}
});
});
</script>
{% endblock %}

View File

@ -517,7 +517,7 @@ def ajout_justificatif_etud():
dept_id=g.scodoc_dept_id, dept_id=g.scodoc_dept_id,
), ),
assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"), assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"),
assi_evening=ScoDocSiteConfig.get("assi_evening_time", "18:00"), assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"),
), ),
).build() ).build()
@ -1129,7 +1129,7 @@ def signal_assiduites_diff():
defdem=_get_etuds_dem_def(formsemestre), defdem=_get_etuds_dem_def(formsemestre),
timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"), timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"),
timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"), timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"),
timeEvening=ScoDocSiteConfig.get("assi_evening_time", "18:00:00"), timeEvening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00"),
defaultDates=_get_days_between_dates(date_deb, date_fin), defaultDates=_get_days_between_dates(date_deb, date_fin),
nonworkdays=_non_work_days(), nonworkdays=_non_work_days(),
), ),

View File

@ -407,7 +407,6 @@ def moduleimpl_evaluation_renumber(moduleimpl_id):
) )
Evaluation.moduleimpl_evaluation_renumber(modimpl) Evaluation.moduleimpl_evaluation_renumber(modimpl)
# redirect to moduleimpl page: # redirect to moduleimpl page:
if redirect:
return flask.redirect( return flask.redirect(
url_for( url_for(
"notes.moduleimpl_status", "notes.moduleimpl_status",

View File

@ -31,11 +31,12 @@ issu de ScoDoc7 / ZScolar.py
Emmanuel Viennet, 2021 Emmanuel Viennet, 2021
""" """
import datetime import datetime
import requests
import time import time
import requests
import flask import flask
from flask import url_for, flash, render_template, make_response from flask import abort, flash, make_response, render_template, url_for
from flask import g, request from flask import g, request
from flask_json import as_json from flask_json import as_json
from flask_login import current_user from flask_login import current_user
@ -43,6 +44,7 @@ from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed from flask_wtf.file import FileField, FileAllowed
from wtforms import SubmitField from wtforms import SubmitField
import app
from app import db from app import db
from app import log from app import log
from app.decorators import ( from app.decorators import (
@ -52,6 +54,7 @@ from app.decorators import (
permission_required_compat_scodoc7, permission_required_compat_scodoc7,
) )
from app.models import ( from app.models import (
Departement,
FormSemestre, FormSemestre,
Identite, Identite,
Partition, Partition,
@ -69,6 +72,7 @@ from app.scodoc.scolog import logdb
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
AccessDenied, AccessDenied,
ScoPermissionDenied,
ScoValueError, ScoValueError,
) )
@ -1770,6 +1774,77 @@ def _etudident_create_or_edit_form(edit):
) )
@bp.route("/etud_copy_in_other_dept/<int:etudid>", methods=["GET", "POST"])
@scodoc
@permission_required(
Permission.ScoView
) # il faut aussi ScoEtudInscrit dans le nouveau dept
def etud_copy_in_other_dept(etudid: int):
"""Crée une copie de l'étudiant (avec ses adresses et codes) dans un autre département
et l'inscrit à un formsemestre
"""
etud = Identite.get_etud(etudid)
if request.method == "POST":
action = request.form.get("action")
if action == "cancel":
return flask.redirect(
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
)
try:
formsemestre_id = int(request.form.get("formsemestre_id"))
except ValueError:
log("etud_copy_in_other_dept: invalid formsemestre_id")
abort(404, description="formsemestre_id invalide")
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not current_user.has_permission(
Permission.ScoEtudInscrit, formsemestre.departement.acronym
):
raise ScoPermissionDenied("non autorisé")
new_etud = etud.clone(new_dept_id=formsemestre.dept_id)
db.session.commit()
# Attention: change le département pour opérer dans le nouveau
# avec les anciennes fonctions ScoDoc7
orig_dept = g.scodoc_dept
try:
app.set_sco_dept(formsemestre.departement.acronym, open_cnx=False)
sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules(
formsemestre.id,
new_etud.id,
method="etud_copy_in_other_dept",
dept_id=formsemestre.dept_id,
)
finally:
app.set_sco_dept(orig_dept, open_cnx=False)
flash(f"Etudiant dupliqué et inscrit en {formsemestre.departement.acronym}")
# Attention, ce redirect change de département !
return flask.redirect(
url_for(
"scolar.ficheEtud",
scodoc_dept=formsemestre.departement.acronym,
etudid=new_etud.id,
)
)
departements = {
dept.id: dept
for dept in Departement.query.order_by(Departement.acronym)
if current_user.has_permission(Permission.ScoEtudInscrit, dept.acronym)
and dept.id != etud.dept_id
}
formsemestres_by_dept = {
dept.id: dept.formsemestres.filter_by(etat=True)
.filter(FormSemestre.modalite != "EXT")
.order_by(FormSemestre.date_debut, FormSemestre.semestre_id)
.all()
for dept in departements.values()
}
return render_template(
"scolar/etud_copy_in_other_dept.j2",
departements=departements,
etud=etud,
formsemestres_by_dept=formsemestres_by_dept,
)
@bp.route("/etudident_delete", methods=["GET", "POST"]) @bp.route("/etudident_delete", methods=["GET", "POST"])
@scodoc @scodoc
@permission_required(Permission.ScoEtudInscrit) @permission_required(Permission.ScoEtudInscrit)

View File

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

View File

@ -129,7 +129,7 @@ def check_fields(data: dict, fields: dict = None):
""" """
assert set(data.keys()) == set(fields.keys()) assert set(data.keys()) == set(fields.keys())
for key in data: for key in data:
if key in ("moduleimpl_id", "desc", "user_id", "external_data"): if key in ("moduleimpl_id", "desc", "external_data"):
assert ( assert (
isinstance(data[key], fields[key]) or data[key] is None isinstance(data[key], fields[key]) or data[key] is None
), f"error [{key}:{type(data[key])}, {data[key]}, {fields[key]}]" ), f"error [{key}:{type(data[key])}, {data[key]}, {fields[key]}]"

View File

@ -6,6 +6,7 @@ Ecrit par HARTMANN Matthias
""" """
from random import randint from random import randint
from types import NoneType
from tests.api.setup_test_api import ( from tests.api.setup_test_api import (
GET, GET,
@ -34,7 +35,8 @@ ASSIDUITES_FIELDS = {
"etat": str, "etat": str,
"desc": str, "desc": str,
"entry_date": str, "entry_date": str,
"user_id": str, "user_id": (int, NoneType),
"user_name": (str, NoneType),
"est_just": bool, "est_just": bool,
"external_data": dict, "external_data": dict,
} }