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

This commit is contained in:
ScoDoc service 2023-05-17 22:12:26 +02:00
commit 580293207d
24 changed files with 569 additions and 124 deletions

View File

@ -9,11 +9,12 @@
""" """
from operator import attrgetter, itemgetter from operator import attrgetter, itemgetter
from flask import g, request from flask import g, make_response, request
from flask_json import as_json from flask_json import as_json
from flask_login import login_required from flask_login import login_required
import app import app
from app import db
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.decorators import scodoc, permission_required from app.decorators import scodoc, permission_required
from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import json_error
@ -30,6 +31,7 @@ from app.models import (
ModuleImpl, ModuleImpl,
NotesNotes, NotesNotes,
) )
from app.models.formsemestre import GROUPS_AUTO_ASSIGNMENT_DATA_MAX
from app.scodoc.sco_bulletins import get_formsemestre_bulletin_etud_json from app.scodoc.sco_bulletins import get_formsemestre_bulletin_etud_json
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
@ -496,3 +498,44 @@ def formsemestre_resultat(formsemestre_id: int):
row["partitions"] = etud_groups.get(row["etudid"], {}) row["partitions"] = etud_groups.get(row["etudid"], {})
return rows return rows
@bp.route("/formsemestre/<int:formsemestre_id>/get_groups_auto_assignment")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/get_groups_auto_assignment")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def get_groups_auto_assignment(formsemestre_id: int):
"""rend les données"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
response = make_response(formsemestre.groups_auto_assignment_data or b"")
response.headers["Content-Type"] = scu.JSON_MIMETYPE
return response
@bp.route(
"/formsemestre/<int:formsemestre_id>/save_groups_auto_assignment", methods=["POST"]
)
@api_web_bp.route(
"/formsemestre/<int:formsemestre_id>/save_groups_auto_assignment", methods=["POST"]
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def save_groups_auto_assignment(formsemestre_id: int):
"""enregistre les données"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
if len(request.data) > GROUPS_AUTO_ASSIGNMENT_DATA_MAX:
return json_error(413, "data too large")
formsemestre.groups_auto_assignment_data = request.data
db.session.add(formsemestre)
db.session.commit()

View File

@ -38,13 +38,22 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
# Choix des parcours # Choix des parcours
ue_pids = [p.id for p in ue.parcours] ue_pids = [p.id for p in ue.parcours]
H.append("""<form id="choix_parcours">""") H.append("""<form id="choix_parcours">""")
ects_differents = {
ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours
} != {None}
for parcour in ref_comp.parcours: for parcour in ref_comp.parcours:
ects_parcour = ue.get_ects(parcour)
ects_parcour_txt = (
f" ({ue.get_ects(parcour):.3g} ects)" if ects_differents else ""
)
H.append( H.append(
f"""<label><input type="checkbox" name="{parcour.id}" value="{parcour.id}" f"""<label><input type="checkbox" name="{parcour.id}" value="{parcour.id}"
{'checked' if parcour.id in ue_pids else ""} {'checked' if parcour.id in ue_pids else ""}
onclick="set_ue_parcour(this);" onclick="set_ue_parcour(this);"
data-setter="{url_for("apiweb.set_ue_parcours", scodoc_dept=g.scodoc_dept, ue_id=ue.id)}" data-setter="{url_for("apiweb.set_ue_parcours",
>{parcour.code}</label>""" scodoc_dept=g.scodoc_dept, ue_id=ue.id)}"
>{parcour.code}{ects_parcour_txt}</label>"""
) )
H.append("""</form>""") H.append("""</form>""")
# #

View File

@ -421,7 +421,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
+ '</div><div class="warning">'.join(messages) + '</div><div class="warning">'.join(messages)
+ "</div>" + "</div>"
) )
#
# WIP TODO XXX def get_moyenne_annuelle(self)
def infos(self) -> str: def infos(self) -> str:
"""informations, for debugging purpose.""" """informations, for debugging purpose."""

View File

@ -106,6 +106,8 @@ class BonusSport:
if formsemestre.formation.is_apc(): if formsemestre.formation.is_apc():
# BUT # BUT
nb_ues_no_bonus = sem_modimpl_moys.shape[2] nb_ues_no_bonus = sem_modimpl_moys.shape[2]
if nb_ues_no_bonus == 0: # aucune UE...
return # no bonus at all
# Duplique les inscriptions sur les UEs non bonus: # Duplique les inscriptions sur les UEs non bonus:
modimpl_inscr_spo_stacked = np.stack( modimpl_inscr_spo_stacked = np.stack(
[modimpl_inscr_spo] * nb_ues_no_bonus, axis=2 [modimpl_inscr_spo] * nb_ues_no_bonus, axis=2

View File

@ -230,7 +230,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
} }
self.etuds_parcour_id = etuds_parcour_id self.etuds_parcour_id = etuds_parcour_id
ue_ids = [ue.id for ue in self.ues if ue.type != UE_SPORT] ue_ids = [ue.id for ue in self.ues if ue.type != UE_SPORT]
ue_ids_set = set(ue_ids)
if self.formsemestre.formation.referentiel_competence is None: if self.formsemestre.formation.referentiel_competence is None:
return pd.DataFrame( return pd.DataFrame(
1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float 1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
@ -240,7 +240,10 @@ class ResultatsSemestreBUT(NotesTableCompat):
np.nan, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float np.nan, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
) )
# Construit pour chaque parcours du référentiel l'ensemble de ses UE # Construit pour chaque parcours du référentiel l'ensemble de ses UE
# (considère aussi le cas des semestres sans parcours: None) # - considère aussi le cas des semestres sans parcours (clé parcour None)
# - retire les UEs qui ont un parcours mais qui ne sont pas dans l'un des
# parcours du semestre
ue_by_parcours = {} # parcours_id : {ue_id:0|1} ue_by_parcours = {} # parcours_id : {ue_id:0|1}
for ( for (
parcour parcour
@ -250,6 +253,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
for ue in self.formsemestre.formation.query_ues_parcour(parcour).filter( for ue in self.formsemestre.formation.query_ues_parcour(parcour).filter(
UniteEns.semestre_idx == self.formsemestre.semestre_id UniteEns.semestre_idx == self.formsemestre.semestre_id
) )
if ue.id in ue_ids_set
} }
# #
for etudid in etuds_parcour_id: for etudid in etuds_parcour_id:

View File

@ -318,7 +318,7 @@ class OffreCreationForm(FlaskForm):
duree = _build_string_field("Durée (*)") duree = _build_string_field("Durée (*)")
depts = MultiCheckboxField("Départements (*)", validators=[Optional()], coerce=int) depts = MultiCheckboxField("Départements (*)", validators=[Optional()], coerce=int)
expiration_date = DateField("Date expiration", validators=[Optional()]) expiration_date = DateField("Date expiration", validators=[Optional()])
correspondant = SelectField("Correspondant à contacté", validators=[Optional()]) correspondant = SelectField("Correspondant à contacter", validators=[Optional()])
fichier = FileField( fichier = FileField(
"Fichier", "Fichier",
validators=[ validators=[
@ -373,7 +373,7 @@ class OffreModificationForm(FlaskForm):
duree = _build_string_field("Durée (*)") duree = _build_string_field("Durée (*)")
depts = MultiCheckboxField("Départements (*)", validators=[Optional()], coerce=int) depts = MultiCheckboxField("Départements (*)", validators=[Optional()], coerce=int)
expiration_date = DateField("Date expiration", validators=[Optional()]) expiration_date = DateField("Date expiration", validators=[Optional()])
correspondant = SelectField("Correspondant à contacté", validators=[Optional()]) correspondant = SelectField("Correspondant à contacter", validators=[Optional()])
submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE) submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE)
cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE)

View File

@ -18,7 +18,7 @@ from app import models
from app.scodoc import notesdb as ndb from app.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat from app.scodoc.sco_bac import Baccalaureat
from app.scodoc.sco_exceptions import ScoInvalidParamError from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -104,7 +104,7 @@ class Identite(db.Model):
def create_etud(cls, **args): def create_etud(cls, **args):
"Crée un étudiant, avec admission et adresse vides." "Crée un étudiant, avec admission et adresse vides."
etud: Identite = cls(**args) etud: Identite = cls(**args)
etud.adresses.append(Adresse()) etud.adresses.append(Adresse(typeadresse="domicile"))
etud.admission.append(Admission()) etud.admission.append(Admission())
return etud return etud
@ -205,6 +205,50 @@ class Identite(db.Model):
reverse=True, reverse=True,
) )
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"Convert fields in the given dict. No other side effect"
fs_uppercase = {"nom", "prenom", "prenom_etat_civil"}
fs_empty_stored_as_nulls = {
"nom",
"prenom",
"nom_usuel",
"date_naissance",
"lieu_naissance",
"dept_naissance",
"nationalite",
"statut",
"photo_filename",
"code_nip",
"code_ine",
}
args_dict = {}
for key, value in args.items():
if hasattr(cls, key):
# compat scodoc7 (mauvaise idée de l'époque)
if key in fs_empty_stored_as_nulls and value == "":
value = None
if key in fs_uppercase and value:
value = value.upper()
if key == "civilite" or key == "civilite_etat_civil":
value = input_civilite(value)
elif key == "boursier":
value = bool(value)
elif key == "date_naissance":
value = ndb.DateDMYtoISO(value)
args_dict[key] = value
return args_dict
def from_dict(self, args: dict):
"update fields given in dict. Add to session but don't commit."
args_dict = Identite.convert_dict_fields(args)
args_dict.pop("id", None)
args_dict.pop("etudid", None)
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.add(self)
def to_dict_short(self) -> dict: def to_dict_short(self) -> dict:
"""Les champs essentiels""" """Les champs essentiels"""
return { return {
@ -547,6 +591,37 @@ def make_etud_args(
return args return args
def input_civilite(s):
"""Converts external representation of civilite to internal:
'M', 'F', or 'X' (and nothing else).
Raises ScoValueError if conversion fails.
"""
s = s.upper().strip()
if s in ("M", "M.", "MR", "H"):
return "M"
elif s in ("F", "MLLE", "MLLE.", "MELLE", "MME"):
return "F"
elif s == "X" or not s:
return "X"
raise ScoValueError(f"valeur invalide pour la civilité: {s}")
PIVOT_YEAR = 70
def pivot_year(y) -> int:
"converti et calcule l'année si saisie à deux chiffres"
if y == "" or y is None:
return None
y = int(round(float(y)))
if y >= 0 and y < 100:
if y < PIVOT_YEAR:
y = y + 2000
else:
y = y + 1900
return y
class Adresse(db.Model): class Adresse(db.Model):
"""Adresse d'un étudiant """Adresse d'un étudiant
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule) (le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule)
@ -640,19 +715,51 @@ class Admission(db.Model):
d = dict(self.__dict__) d = dict(self.__dict__)
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
if no_nulls: if no_nulls:
for k in d.keys(): for key, value in d.items():
if d[k] is None: if value is None:
col_type = getattr( col_type = getattr(
sqlalchemy.inspect(models.Admission).columns, "apb_groupe" sqlalchemy.inspect(models.Admission).columns, key
).expression.type ).expression.type
if isinstance(col_type, sqlalchemy.Text): if isinstance(col_type, sqlalchemy.Text):
d[k] = "" d[key] = ""
elif isinstance(col_type, sqlalchemy.Integer): elif isinstance(col_type, sqlalchemy.Integer):
d[k] = 0 d[key] = 0
elif isinstance(col_type, sqlalchemy.Boolean): elif isinstance(col_type, sqlalchemy.Boolean):
d[k] = False d[key] = False
return d return d
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"Convert fields in the given dict. No other side effect"
fs_uppercase = {"bac", "specialite"}
args_dict = {}
for key, value in args.items():
if hasattr(cls, key):
if (
value == ""
): # les chaines vides donne des NULLS (scodoc7 convention)
value = None
if key in fs_uppercase and value:
value = value.upper()
if key == "civilite" or key == "civilite_etat_civil":
value = input_civilite(value)
elif key == "annee" or key == "annee_bac":
value = pivot_year(value)
elif key == "classement" or key == "apb_classement_gr":
value = ndb.int_null_is_null(value)
args_dict[key] = value
return args_dict
def from_dict(self, args: dict): # TODO à refactoriser dans une super-classe
"update fields given in dict. Add to session but don't commit."
args_dict = Admission.convert_dict_fields(args)
args_dict.pop("adm_id", None)
args_dict.pop("id", None)
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.add(self)
# Suivi scolarité / débouchés # Suivi scolarité / débouchés
class ItemSuivi(db.Model): class ItemSuivi(db.Model):

View File

@ -217,7 +217,7 @@ class Formation(db.Model):
def query_ues_parcour( def query_ues_parcour(
self, parcour: ApcParcours, with_sport: bool = False self, parcour: ApcParcours, with_sport: bool = False
) -> Query: ) -> Query:
"""Les UEs (non bonus) d'un parcours de la formation """Les UEs (sans bonus, sauf si with_sport) d'un parcours de la formation
(déclarée comme faisant partie du parcours ou du tronc commun, sans aucun parcours) (déclarée comme faisant partie du parcours ou du tronc commun, sans aucun parcours)
Si parcour est None, les UE sans parcours. Si parcour est None, les UE sans parcours.
Exemple: pour avoir les UE du semestre 3, faire Exemple: pour avoir les UE du semestre 3, faire

View File

@ -15,10 +15,8 @@ from functools import cached_property
from operator import attrgetter from operator import attrgetter
from flask_login import current_user from flask_login import current_user
from flask_sqlalchemy.query import Query
from flask import flash, g from flask import flash, g
from sqlalchemy import and_, or_
from sqlalchemy.sql import text from sqlalchemy.sql import text
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -26,10 +24,7 @@ from app import db, log
from app.auth.models import User from app.auth.models import User
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models.but_refcomp import ( from app.models.but_refcomp import (
ApcAnneeParcours,
ApcNiveau,
ApcParcours, ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences, ApcReferentielCompetences,
parcours_formsemestre, parcours_formsemestre,
) )
@ -47,6 +42,8 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV from app.scodoc.sco_utils import MONTH_NAMES_ABBREV
from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_vdi import ApoEtapeVDI
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
class FormSemestre(db.Model): class FormSemestre(db.Model):
"""Mise en oeuvre d'un semestre de formation""" """Mise en oeuvre d'un semestre de formation"""
@ -113,6 +110,10 @@ class FormSemestre(db.Model):
elt_annee_apo = db.Column(db.Text()) elt_annee_apo = db.Column(db.Text())
"code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'" "code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
# Data pour groups_auto_assignment
# (ce champ est utilisé uniquement via l'API par le front js)
groups_auto_assignment_data = db.Column(db.LargeBinary(), nullable=True)
# Relations: # Relations:
etapes = db.relationship( etapes = db.relationship(
"FormSemestreEtape", cascade="all,delete", backref="formsemestre" "FormSemestreEtape", cascade="all,delete", backref="formsemestre"
@ -298,12 +299,14 @@ class FormSemestre(db.Model):
""" """
formation: Formation = self.formation formation: Formation = self.formation
if formation.is_apc(): if formation.is_apc():
# UEs de tronc commun (sans parcours indiqué)
sem_ues = { sem_ues = {
ue.id: ue ue.id: ue
for ue in formation.query_ues_parcour( for ue in formation.query_ues_parcour(
None, with_sport=with_sport None, with_sport=with_sport
).filter(UniteEns.semestre_idx == self.semestre_id) ).filter(UniteEns.semestre_idx == self.semestre_id)
} }
# Ajoute les UE de parcours
for parcour in self.parcours: for parcour in self.parcours:
sem_ues.update( sem_ues.update(
{ {

View File

@ -186,7 +186,7 @@ def DBSelectArgs(
cond = "" cond = ""
i = 1 i = 1
cl = [] cl = []
for (_, aux_id) in aux_tables: for _, aux_id in aux_tables:
cl.append("T0.%s = T%d.%s" % (id_name, i, aux_id)) cl.append("T0.%s = T%d.%s" % (id_name, i, aux_id))
i = i + 1 i = i + 1
cond += " and ".join(cl) cond += " and ".join(cl)
@ -403,7 +403,7 @@ class EditableTable(object):
def format_output(self, r, disable_formatting=False): def format_output(self, r, disable_formatting=False):
"Format dict using provided output_formators" "Format dict using provided output_formators"
for (k, v) in r.items(): for k, v in r.items():
if v is None and self.convert_null_outputs_to_empty: if v is None and self.convert_null_outputs_to_empty:
v = "" v = ""
# format value # format value

View File

@ -465,7 +465,7 @@ class ApoEtud(dict):
return VOID_APO_RES return VOID_APO_RES
return dict( return dict(
N="", N="", # n'exporte pas de moyenne indicative annuelle, car pas de définition officielle
B=20, B=20,
J="", J="",
R=ScoDocSiteConfig.get_code_apo(self.validation_annee_but.code), R=ScoDocSiteConfig.get_code_apo(self.validation_annee_but.code),

View File

@ -35,10 +35,10 @@ from operator import itemgetter
from flask import url_for, g from flask import url_for, g
from app import email from app import db, email
from app import log from app import log
from app.models import Admission from app.models import Admission, Identite
from app.models.etudiants import make_etud_args from app.models.etudiants import input_civilite, make_etud_args, pivot_year
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app.scodoc.sco_exceptions import ScoGenError, ScoValueError from app.scodoc.sco_exceptions import ScoGenError, ScoValueError
@ -72,6 +72,7 @@ def format_etud_ident(etud):
etud["nom_disp"] = etud["nom"] etud["nom_disp"] = etud["nom"]
etud["nomprenom"] = format_nomprenom(etud) # M. Pierre DUPONT etud["nomprenom"] = format_nomprenom(etud) # M. Pierre DUPONT
etud["etat_civil"] = format_etat_civil(etud)
if etud["civilite"] == "M": if etud["civilite"] == "M":
etud["ne"] = "" etud["ne"] = ""
elif etud["civilite"] == "F": elif etud["civilite"] == "F":
@ -127,21 +128,6 @@ def format_nom(s, uppercase=True):
return format_prenom(s) return format_prenom(s)
def input_civilite(s):
"""Converts external representation of civilite to internal:
'M', 'F', or 'X' (and nothing else).
Raises ScoValueError if conversion fails.
"""
s = s.upper().strip()
if s in ("M", "M.", "MR", "H"):
return "M"
elif s in ("F", "MLLE", "MLLE.", "MELLE", "MME"):
return "F"
elif s == "X" or not s:
return "X"
raise ScoValueError("valeur invalide pour la civilité: %s" % s)
def format_civilite(civilite): def format_civilite(civilite):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre, """returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
personne ne souhaitant pas d'affichage). personne ne souhaitant pas d'affichage).
@ -157,6 +143,14 @@ def format_civilite(civilite):
raise ScoValueError("valeur invalide pour la civilité: %s" % civilite) raise ScoValueError("valeur invalide pour la civilité: %s" % civilite)
def format_etat_civil(etud: dict):
if etud["prenom_etat_civil"]:
civ = {"M": "M.", "F": "Mme", "X": ""}[etud["civilite_etat_civil"]]
return f'{civ} {etud["prenom_etat_civil"]} {etud["nom"]}'
else:
return etud["nomprenom"]
def format_lycee(nomlycee): def format_lycee(nomlycee):
nomlycee = nomlycee.strip() nomlycee = nomlycee.strip()
s = nomlycee.lower() s = nomlycee.lower()
@ -195,21 +189,6 @@ def format_pays(s):
return "" return ""
PIVOT_YEAR = 70
def pivot_year(y):
if y == "" or y is None:
return None
y = int(round(float(y)))
if y >= 0 and y < 100:
if y < PIVOT_YEAR:
y = y + 2000
else:
y = y + 1900
return y
def etud_sort_key(etud: dict) -> tuple: def etud_sort_key(etud: dict) -> tuple:
"""Clé de tri pour les étudiants représentés par des dict (anciens codes). """Clé de tri pour les étudiants représentés par des dict (anciens codes).
Equivalent moderne: identite.sort_key Equivalent moderne: identite.sort_key
@ -281,7 +260,9 @@ def identite_list(cnx, *a, **kw):
def identite_edit_nocheck(cnx, args): def identite_edit_nocheck(cnx, args):
"""Modifie les champs mentionnes dans args, sans verification ni notification.""" """Modifie les champs mentionnes dans args, sans verification ni notification."""
_identiteEditor.edit(cnx, args) etud = Identite.query.get(args["etudid"])
etud.from_dict(args)
db.session.commit()
def check_nom_prenom(cnx, nom="", prenom="", etudid=None): def check_nom_prenom(cnx, nom="", prenom="", etudid=None):
@ -572,6 +553,7 @@ admission_delete = _admissionEditor.delete
admission_list = _admissionEditor.list admission_list = _admissionEditor.list
admission_edit = _admissionEditor.edit admission_edit = _admissionEditor.edit
# Edition simultanee de identite et admission # Edition simultanee de identite et admission
class EtudIdentEditor(object): class EtudIdentEditor(object):
def create(self, cnx, args): def create(self, cnx, args):
@ -615,7 +597,6 @@ class EtudIdentEditor(object):
_etudidentEditor = EtudIdentEditor() _etudidentEditor = EtudIdentEditor()
etudident_list = _etudidentEditor.list etudident_list = _etudidentEditor.list
etudident_edit = _etudidentEditor.edit etudident_edit = _etudidentEditor.edit
etudident_create = _etudidentEditor.create
def log_unknown_etud(): def log_unknown_etud():
@ -641,21 +622,8 @@ def get_etud_info(etudid=False, code_nip=False, filled=False) -> list[dict]:
return etud return etud
# Optim par cache local, utilité non prouvée mais def create_etud(cnx, args: dict = None):
# on s'oriente vers un cahce persistent dans Redis ou bien l'utilisation de NT """Création d'un étudiant. Génère aussi évenement et "news".
# def get_etud_info_filled_by_etudid(etudid, cnx=None) -> dict:
# """Infos sur un étudiant, avec cache local à la requête"""
# if etudid in g.stored_etud_info:
# return g.stored_etud_info[etudid]
# cnx = cnx or ndb.GetDBConnexion()
# etud = etudident_list(cnx, args={"etudid": etudid})
# fill_etuds_info(etud)
# g.stored_etud_info[etudid] = etud[0]
# return etud[0]
def create_etud(cnx, args={}):
"""Creation d'un étudiant. génère aussi évenement et "news".
Args: Args:
args: dict avec les attributs de l'étudiant args: dict avec les attributs de l'étudiant
@ -666,16 +634,16 @@ def create_etud(cnx, args={}):
from app.models import ScolarNews from app.models import ScolarNews
# creation d'un etudiant # creation d'un etudiant
etudid = etudident_create(cnx, args) args_dict = Identite.convert_dict_fields(args)
# crée une adresse vide (chaque etudiant doit etre dans la table "adresse" !) args_dict["dept_id"] = g.scodoc_dept_id
_ = adresse_create( etud = Identite.create_etud(**args_dict)
cnx, db.session.add(etud)
{ db.session.commit()
"etudid": etudid, admission = etud.admission.first()
"typeadresse": "domicile", admission.from_dict(args)
"description": "(creation individuelle)", db.session.add(admission)
}, db.session.commit()
) etudid = etud.id
# event # event
scolar_events_create( scolar_events_create(

View File

@ -40,7 +40,7 @@ import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app import log from app import log
from app.models import ScolarNews, GroupDescr from app.models import ScolarNews, GroupDescr
from app.models.etudiants import input_civilite
from app.scodoc.sco_excel import COLORS from app.scodoc.sco_excel import COLORS
from app.scodoc.sco_formsemestre_inscriptions import ( from app.scodoc.sco_formsemestre_inscriptions import (
do_formsemestre_inscription_with_modules, do_formsemestre_inscription_with_modules,
@ -370,7 +370,7 @@ def scolars_import_excel_file(
# xxx Ad-hoc checks (should be in format description) # xxx Ad-hoc checks (should be in format description)
if titleslist[i].lower() == "sexe": if titleslist[i].lower() == "sexe":
try: try:
val = sco_etud.input_civilite(val) val = input_civilite(val)
except: except:
raise ScoValueError( raise ScoValueError(
"valeur invalide pour 'SEXE' (doit etre 'M', 'F', ou 'MME', 'H', 'X' ou vide, mais pas '%s') ligne %d, colonne %s" "valeur invalide pour 'SEXE' (doit etre 'M', 'F', ou 'MME', 'H', 'X' ou vide, mais pas '%s') ligne %d, colonne %s"

View File

@ -2058,6 +2058,7 @@ span.eval_coef_ue_titre {}
div.list_but_ue_inscriptions { div.list_but_ue_inscriptions {
margin-top: 16px; margin-top: 16px;
margin-bottom: 16px; margin-bottom: 16px;
margin-right: 8px;
padding-left: 8px; padding-left: 8px;
padding-bottom: 8px; padding-bottom: 8px;
border-radius: 16px; border-radius: 16px;

View File

@ -693,7 +693,13 @@ def formation_import_xml_form():
{ html_sco_header.sco_header(page_title="Import d'une formation") } { html_sco_header.sco_header(page_title="Import d'une formation") }
<h2>Import d'une formation</h2> <h2>Import d'une formation</h2>
<p>Création d'une formation (avec UE, matières, modules) <p>Création d'une formation (avec UE, matières, modules)
à partir un fichier XML (réservé aux utilisateurs avertis) à partir un fichier XML (réservé aux utilisateurs avertis).
</p>
<p>S'il s'agit d'une formation par compétence (BUT), assurez-vous d'avoir
chargé le référentiel de compétences AVANT d'importer le fichier formation
(voir <a class="stdlink" href="{
url_for("notes.refcomp_table", scodoc_dept=g.scodoc_dept)
}">page des référentiels</a>).
</p> </p>
{ tf[1] } { tf[1] }
{ html_sco_header.sco_footer() } { html_sco_header.sco_footer() }

View File

@ -1742,8 +1742,15 @@ def _etudident_create_or_edit_form(edit):
etudid = etud["etudid"] etudid = etud["etudid"]
else: else:
# modif d'un etudiant # modif d'un etudiant
sco_etud.etudident_edit(cnx, tf[2]) etud_o = Identite.query.get(tf[2]["etudid"])
etud = sco_etud.etudident_list(cnx, {"etudid": etudid})[0] etud_o.from_dict(tf[2])
db.session.add(etud_o)
admission = etud_o.admission.first()
admission.from_dict(tf[2])
db.session.add(admission)
db.session.commit()
etud = sco_etud.etudident_list(cnx, {"etudid": etud_o.id})[0]
sco_etud.fill_etuds_info([etud]) sco_etud.fill_etuds_info([etud])
# Inval semesters with this student: # Inval semesters with this student:
to_inval = [s["formsemestre_id"] for s in etud["sems"]] to_inval = [s["formsemestre_id"] for s in etud["sems"]]

View File

@ -0,0 +1,34 @@
"""Add data for groups_auto_assignment
Revision ID: b8df1b913c79
Revises: 054dd6133b9c
Create Date: 2023-05-15 23:12:58.257709
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b8df1b913c79"
down_revision = "054dd6133b9c"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op:
batch_op.add_column(
sa.Column("groups_auto_assignment_data", sa.LargeBinary(), nullable=True)
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op:
batch_op.drop_column("groups_auto_assignment_data")
# ### end Alembic commands ###

View File

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

View File

@ -0,0 +1,71 @@
"""Test formsemestre
Utilisation :
créer les variables d'environnement: (indiquer les valeurs
pour le serveur ScoDoc que vous voulez interroger)
export SCODOC_URL="https://scodoc.xxx.net/"
export SCODOC_USER="xxx"
export SCODOC_PASSWD="xxx"
export CHECK_CERTIFICATE=0 # ou 1 si serveur de production avec certif SSL valide
(on peut aussi placer ces valeurs dans un fichier .env du répertoire tests/api).
Lancer :
pytest tests/api/test_api_formsemestre.py
"""
import requests
from app.scodoc import sco_utils as scu
from tests.api.setup_test_api import (
API_URL,
CHECK_CERTIFICATE,
api_headers,
)
def test_save_groups_auto_assignment(api_headers):
"""
Routes:
/formsemestre/<id>/save_groups_auto_assignment
/formsemestre/<id>/get_groups_auto_assignment
"""
formsemestre_id = 1
r = requests.get(
f"{API_URL}/formsemestre/{formsemestre_id}",
headers=api_headers,
verify=CHECK_CERTIFICATE,
timeout=scu.SCO_TEST_API_TIMEOUT,
)
assert r.status_code == 200
# On stocke une chaine quelconque
data_orig = (
"""{ "attribute" : "Un paquet de json", "valide": pas nécessairement +}--"""
)
r = requests.post(
f"{API_URL}/formsemestre/{formsemestre_id}/save_groups_auto_assignment",
data=data_orig.encode("utf-8"),
headers=api_headers,
verify=CHECK_CERTIFICATE,
timeout=scu.SCO_TEST_API_TIMEOUT,
)
assert r.status_code == 200
# GET
r = requests.get(
f"{API_URL}/formsemestre/{formsemestre_id}/get_groups_auto_assignment",
headers=api_headers,
verify=CHECK_CERTIFICATE,
timeout=scu.SCO_TEST_API_TIMEOUT,
)
assert r.status_code == 200
assert r.text == data_orig
# Tente d'envoyer trop de données
r = requests.post(
f"{API_URL}/formsemestre/{formsemestre_id}/save_groups_auto_assignment",
data="F*CK" * 1000000, # environ 4MB
headers=api_headers,
verify=CHECK_CERTIFICATE,
timeout=scu.SCO_TEST_API_TIMEOUT,
)
assert r.status_code == 413

View File

@ -3,41 +3,221 @@
# mais à ces niveaux sont associés des UEs dont les coefficients des ressources # mais à ces niveaux sont associés des UEs dont les coefficients des ressources
# varient selon le parcours. # varient selon le parcours.
# #
# Mise en place cursus avec parcours A et B
ReferentielCompetences: ReferentielCompetences:
filename: but-INFO-05012022-081701.xml filename: but-INFO-05012022-081701.xml
specialite: INFO specialite: INFO
Formation: Formation:
filename: scodoc_formation_BUT_INFO_v1.xml filename: scodoc_formation_BUT_INFO_v0514.xml
# nota: les associations UE/Niveaux sont déjà données dans ce fichier XML.
ues: ues:
# S1
'UE11':
annee: BUT1
'UE12':
annee: BUT1
'UE13':
annee: BUT1
'UE14':
annee: BUT1
'UE15':
annee: BUT1
'UE16':
annee: BUT1
# S2
'UE21':
annee: BUT1
'UE22':
annee: BUT1
'UE23':
annee: BUT1
'UE24':
annee: BUT1
'UE25':
annee: BUT1
'UE26':
annee: BUT1
# S3
'UE31':
annee: BUT2
'UE32':
annee: BUT2
'UE33':
annee: BUT2
'UE34':
annee: BUT2
'UE35':
annee: BUT2
'UE36':
annee: BUT2
# S4 # S4
'UE41-A': # UE pour le parcours A 'UE41-A': # UE pour le parcours A
annee: BUT2 annee: BUT2
competence: Réaliser 'UE41-B': # UE pour le parcours B (même contenu, coefs différents)
'UE41-C': # UE pour le parcours C (même contenu, coefs différents)
annee: BUT2 annee: BUT2
competence: Réaliser
'UE42': 'UE42':
annee: BUT2 annee: BUT2
competence: Optimiser
'UE43': 'UE43':
annee: BUT2 annee: BUT2
competence: Administrer
'UE44': 'UE44':
annee: BUT2 annee: BUT2
competence: Gérer
'UE45': 'UE45':
annee: BUT2 annee: BUT2
competence: Conduire
'UE46': 'UE46':
annee: BUT2 annee: BUT2
competence: Collaborer
FormSemestres: FormSemestres:
# S4 avec parcours A et C # Semestres avec parcours A et B
S4: S1:
idx: 1 idx: 1
date_debut: 2023-01-01 date_debut: 2021-09-01
date_fin: 2022-01-15
codes_parcours: ['A', 'B']
S2:
idx: 2
date_debut: 2022-01-16
date_fin: 2022-06-30
codes_parcours: ['A', 'B']
S3:
idx: 3
date_debut: 2022-09-01
date_fin: 2023-01-15
codes_parcours: ['A', 'B']
S4:
idx: 4
date_debut: 2023-01-16
date_fin: 2023-06-30 date_fin: 2023-06-30
codes_parcours: ['A', 'C'] codes_parcours: ['A', 'B']
S5:
idx: 5
date_debut: 2023-09-01
date_fin: 2024-01-15
codes_parcours: ['A', 'B']
S6:
idx: 6
date_debut: 2024-01-16
date_fin: 2024-06-30
codes_parcours: ['A', 'B']
Etudiants:
ex_a1: # cursus S1 -> S6, valide tout
prenom: Jean
civilite: M
formsemestres:
# on ne note que le portfolio, qui affecte toutes les UEs
S1:
parcours: A
notes_modules:
"P1": 11
S2:
parcours: A
notes_modules:
"P2": 12
S3:
parcours: A
notes_modules:
"P3": 13
S4:
parcours: A
notes_modules:
"P4-A": 14
S5:
parcours: A
notes_modules:
"P5-A": 15
S6:
parcours: A
notes_modules:
"P6-A": 16
ex_a2: # cursus S1 -> S6, valide tout sauf S5
prenom: Lucie
civilite: F
formsemestres:
# on ne note que le portfolio, qui affecte toutes les UEs
S1:
parcours: A
notes_modules:
"P1": 11
S2:
parcours: A
notes_modules:
"P2": 12
S3:
parcours: A
notes_modules:
"P3": 13
S4:
parcours: A
notes_modules:
"P4-A": 14
S5:
parcours: A
notes_modules:
"P5-A": 7
S6:
parcours: A
notes_modules:
"P6-A": 16
ex_b1: # cursus S1 -> S6, valide tout
prenom: Hélène
civilite: F
formsemestres:
# on ne note que le portfolio, qui affecte toutes les UEs
S1:
parcours: B
notes_modules:
"P1": 11
S2:
parcours: B
notes_modules:
"P2": 12
S3:
parcours: B
notes_modules:
"P3": 13
S4:
parcours: B
notes_modules:
"P4-B": 14
S5:
parcours: B
notes_modules:
"P5-B": 15
S6:
parcours: B
notes_modules:
"P6-B": 16
ex_b2: # cursus S1 -> S6, valide tout sauf S6
prenom: Rose
civilite: F
formsemestres:
# on ne note que le portfolio, qui affecte toutes les UEs
S1:
parcours: B
notes_modules:
"P1": 11
S2:
parcours: B
notes_modules:
"P2": 12
S3:
parcours: B
notes_modules:
"P3": 13
S4:
parcours: B
notes_modules:
"P4-B": 14
S5:
parcours: B
notes_modules:
"P5-B": 15
S6:
parcours: B
notes_modules:
"P6-B": 9

View File

@ -5,7 +5,7 @@
Ce test suppose une base département existante. Ce test suppose une base département existante.
Usage: pytest tests/unit/test_cursus_but.py Usage: pytest tests/unit/test_but_cursus.py
""" """
@ -63,6 +63,7 @@ def test_cursus_but_jury_gb(test_client):
# @pytest.mark.skip # XXX WIP # @pytest.mark.skip # XXX WIP
@pytest.mark.slow
def test_refcomp_niveaux_info(test_client): def test_refcomp_niveaux_info(test_client):
"""Test niveaux / parcours / UE pour un BUT INFO """Test niveaux / parcours / UE pour un BUT INFO
avec parcours A et C, même compétences mais coefs différents avec parcours A et C, même compétences mais coefs différents

View File

@ -120,7 +120,9 @@ def create_formsemestre(
modules = [ modules = [
m m
for m in formsemestre.formation.modules.filter_by(semestre_id=semestre_id) for m in formsemestre.formation.modules.filter_by(semestre_id=semestre_id)
if (not m.parcours) or ({p.id for p in m.parcours} & sem_parcours_ids) if (not m.parcours) # module de tronc commun
or (not sem_parcours_ids) # semestre sans parcours => tous
or ({p.id for p in m.parcours} & sem_parcours_ids)
] ]
for module in modules: for module in modules:
modimpl = ModuleImpl(module=module, responsable_id=a_user.id) modimpl = ModuleImpl(module=module, responsable_id=a_user.id)
@ -298,10 +300,11 @@ def setup_from_yaml(filename: str) -> dict:
with open(filename, encoding="utf-8") as f: with open(filename, encoding="utf-8") as f:
doc = yaml.safe_load(f.read()) doc = yaml.safe_load(f.read())
# Charge de ref. comp. avant la formation, de façon à pouvoir
# re-créer les associations UE/Niveaux
yaml_setup_but.setup_formation_referentiel(doc.get("ReferentielCompetences", {}))
formation = setup_formation(doc["Formation"]) formation = setup_formation(doc["Formation"])
yaml_setup_but.setup_formation_referentiel(
formation, doc.get("ReferentielCompetences", {})
)
yaml_setup_but.associe_ues_et_parcours(formation, doc["Formation"]) yaml_setup_but.associe_ues_et_parcours(formation, doc["Formation"])
setup_formsemestres(formation, doc) setup_formsemestres(formation, doc)
etudiants = doc.get("Etudiants") etudiants = doc.get("Etudiants")

View File

@ -32,13 +32,15 @@ from app.scodoc import sco_utils as scu
from app.scodoc import sco_pv_dict from app.scodoc import sco_pv_dict
def setup_formation_referentiel(formation: Formation, refcomp_infos: dict): def setup_formation_referentiel(
refcomp_infos: dict, formation: Formation = None
) -> ApcReferentielCompetences:
"""Si il y a un référentiel de compétences, indiqué dans le YAML, """Si il y a un référentiel de compétences, indiqué dans le YAML,
le charge au besoin et l'associe à la formation. le charge au besoin et l'associe à la formation.
""" """
if not refcomp_infos: if not refcomp_infos:
return return None
assert formation.is_apc() # si ref; comp., doit être APC assert formation is None or formation.is_apc() # si ref. comp., doit être APC
refcomp_filename = refcomp_infos["filename"] refcomp_filename = refcomp_infos["filename"]
refcomp_specialite = refcomp_infos["specialite"] refcomp_specialite = refcomp_infos["specialite"]
# --- Chargement Référentiel # --- Chargement Référentiel
@ -66,8 +68,10 @@ def setup_formation_referentiel(formation: Formation, refcomp_infos: dict):
specialite=refcomp_specialite specialite=refcomp_specialite
).first() # le recherche à nouveau (test) ).first() # le recherche à nouveau (test)
assert referentiel_competence assert referentiel_competence
formation.referentiel_competence_id = referentiel_competence.id if formation:
db.session.add(formation) formation.referentiel_competence_id = referentiel_competence.id
db.session.add(formation)
return referentiel_competence
def associe_ues_et_parcours(formation: Formation, formation_infos: dict): def associe_ues_et_parcours(formation: Formation, formation_infos: dict):
@ -100,15 +104,16 @@ def associe_ues_et_parcours(formation: Formation, formation_infos: dict):
ue.set_parcours(parcours) ue.set_parcours(parcours)
# Niveaux compétences: # Niveaux compétences:
competence = referentiel_competence.competences.filter_by( if ue_infos.get("competence"):
titre=ue_infos["competence"] competence = referentiel_competence.competences.filter_by(
).first() titre=ue_infos["competence"]
assert competence is not None # La compétence de titre indiqué doit exister ).first()
niveau: ApcNiveau = competence.niveaux.filter_by( assert competence is not None # La compétence de titre indiqué doit exister
annee=ue_infos["annee"] niveau: ApcNiveau = competence.niveaux.filter_by(
).first() annee=ue_infos["annee"]
assert niveau is not None # le niveau de l'année indiquée doit exister ).first()
ue.set_niveau_competence(niveau) assert niveau is not None # le niveau de l'année indiquée doit exister
ue.set_niveau_competence(niveau)
db.session.commit() db.session.commit()
associe_modules_et_parcours(formation, formation_infos) associe_modules_et_parcours(formation, formation_infos)

View File

@ -4,4 +4,4 @@ Architecture: amd64
Maintainer: Emmanuel Viennet <emmanuel@viennet.net> Maintainer: Emmanuel Viennet <emmanuel@viennet.net>
Description: ScoDoc 9 Description: ScoDoc 9
Un logiciel pour le suivi de la scolarité universitaire. Un logiciel pour le suivi de la scolarité universitaire.
Depends: adduser, curl, gcc, graphviz, libpq-dev, postfix|exim4, cracklib-runtime, libcrack2-dev, python3-dev, python3-venv, python3-pip, python3-wheel, nginx, postgresql, libpq-dev, redis, ufw Depends: adduser, curl, gcc, graphviz, graphviz-dev, libpq-dev, postfix|exim4, cracklib-runtime, libcrack2-dev, python3-dev, python3-venv, python3-pip, python3-wheel, nginx, postgresql, libpq-dev, redis, ufw