467 lines
17 KiB
Python
467 lines
17 KiB
Python
# -*- mode: python -*-
|
|
# -*- coding: utf-8 -*-
|
|
|
|
##############################################################################
|
|
#
|
|
# Gestion scolarite IUT
|
|
#
|
|
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 2 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
|
#
|
|
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
|
#
|
|
##############################################################################
|
|
|
|
"""Ajout/Modification/Supression formations
|
|
(portage from DTML)
|
|
"""
|
|
import flask
|
|
from flask import flash, g, url_for, request
|
|
import sqlalchemy
|
|
|
|
from app import db
|
|
from app.models import SHORT_STR_LEN
|
|
from app.models.formations import Formation
|
|
from app.models.modules import Module
|
|
from app.models.ues import UniteEns
|
|
from app.models import ScolarNews
|
|
|
|
import app.scodoc.sco_utils as scu
|
|
from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
|
|
from app.scodoc.sco_exceptions import ScoValueError, ScoNonEmptyFormationObject
|
|
|
|
from app.scodoc import html_sco_header
|
|
from app.scodoc import sco_cache
|
|
from app.scodoc import codes_cursus
|
|
from app.scodoc import sco_edit_ue
|
|
from app.scodoc import sco_formsemestre
|
|
|
|
|
|
def formation_delete(formation_id=None, dialog_confirmed=False):
|
|
"""Delete a formation"""
|
|
formation: Formation = Formation.query.get_or_404(formation_id)
|
|
|
|
H = [
|
|
html_sco_header.sco_header(page_title="Suppression d'une formation"),
|
|
f"""<h2>Suppression de la formation {formation.titre} ({formation.acronyme})</h2>""",
|
|
]
|
|
|
|
sems = sco_formsemestre.do_formsemestre_list({"formation_id": formation_id})
|
|
if sems:
|
|
H.append(
|
|
"""<p class="warning">Impossible de supprimer cette formation,
|
|
car les sessions suivantes l'utilisent:</p>
|
|
<ul>"""
|
|
)
|
|
for sem in sems:
|
|
H.append(
|
|
'<li><a class="stdlink" href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titremois)s</a></li>'
|
|
% sem
|
|
)
|
|
H.append(
|
|
'</ul><p><a class="stdlink" href="%s">Revenir</a></p>' % scu.NotesURL()
|
|
)
|
|
else:
|
|
if not dialog_confirmed:
|
|
return scu.confirm_dialog(
|
|
f"""<h2>Confirmer la suppression de la formation
|
|
{formation.titre} ({formation.acronyme}) ?
|
|
</h2>
|
|
<p><b>Attention:</b> la suppression d'une formation est <b>irréversible</b>
|
|
et implique la supression de toutes les UE, matières et modules de la formation !
|
|
</p>
|
|
""",
|
|
OK="Supprimer cette formation",
|
|
cancel_url=scu.NotesURL(),
|
|
parameters={"formation_id": formation_id},
|
|
)
|
|
else:
|
|
do_formation_delete(formation_id)
|
|
H.append(
|
|
f"""<p>OK, formation supprimée.</p>
|
|
<p><a class="stdlink" href="{scu.NotesURL()}">continuer</a></p>"""
|
|
)
|
|
|
|
H.append(html_sco_header.sco_footer())
|
|
return "\n".join(H)
|
|
|
|
|
|
def do_formation_delete(formation_id):
|
|
"""delete a formation (and all its UE, matieres, modules)
|
|
Warning: delete all ues, will ask if there are validations !
|
|
"""
|
|
formation: Formation = db.session.get(Formation, formation_id)
|
|
if formation is None:
|
|
return
|
|
acronyme = formation.acronyme
|
|
if formation.formsemestres.count():
|
|
raise ScoNonEmptyFormationObject(
|
|
type_objet="formation",
|
|
msg=formation.titre,
|
|
dest_url=url_for(
|
|
"notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id
|
|
),
|
|
)
|
|
|
|
with sco_cache.DeferredSemCacheManager():
|
|
# Suppression des modules
|
|
for module in formation.modules:
|
|
db.session.delete(module)
|
|
db.session.flush()
|
|
# Suppression des UEs
|
|
for ue in formation.ues:
|
|
sco_edit_ue.do_ue_delete(ue, force=True)
|
|
|
|
db.session.delete(formation)
|
|
|
|
# news
|
|
ScolarNews.add(
|
|
typ=ScolarNews.NEWS_FORM,
|
|
obj=formation_id,
|
|
text=f"Suppression de la formation {acronyme}",
|
|
max_frequency=0,
|
|
)
|
|
|
|
|
|
def formation_create():
|
|
"""Creation d'une formation"""
|
|
return formation_edit(create=True)
|
|
|
|
|
|
def formation_edit(formation_id=None, create=False):
|
|
"""Edit or create a formation"""
|
|
if create:
|
|
H = [
|
|
html_sco_header.sco_header(page_title="Création d'une formation"),
|
|
"""<h2>Création d'une formation</h2>
|
|
|
|
<p class="help">Une "formation" décrit une filière, comme un DUT ou une Licence. La formation se subdivise en unités pédagogiques (UE, matières, modules). Elle peut se diviser en plusieurs semestres (ou sessions), qui seront mis en place séparément.
|
|
</p>
|
|
|
|
<p>Le <tt>titre</tt> est le nom complet, parfois adapté pour mieux distinguer les modalités ou versions de programme pédagogique. Le <tt>titre_officiel</tt> est le nom complet du diplôme, qui apparaitra sur certains PV de jury de délivrance du diplôme.
|
|
</p>
|
|
""",
|
|
]
|
|
submitlabel = "Créer cette formation"
|
|
initvalues = {"type_parcours": codes_cursus.DEFAULT_TYPE_CURSUS}
|
|
is_locked = False
|
|
else:
|
|
# edit an existing formation
|
|
formation: Formation = Formation.query.get_or_404(formation_id)
|
|
form_dict = formation.to_dict()
|
|
form_dict["commentaire"] = form_dict["commentaire"] or ""
|
|
initvalues = form_dict
|
|
is_locked = formation.has_locked_sems(formation_id)
|
|
submitlabel = "Modifier les valeurs"
|
|
H = [
|
|
html_sco_header.sco_header(page_title="Modification d'une formation"),
|
|
f"""<h2>Modification de la formation {formation.acronyme}
|
|
version {formation.version}
|
|
</h2>""",
|
|
]
|
|
if is_locked:
|
|
H.append(
|
|
'<p class="warning">Attention: Formation verrouillée, le type de parcours ne peut être modifié.</p>'
|
|
)
|
|
|
|
tf = TrivialFormulator(
|
|
request.base_url,
|
|
scu.get_request_args(),
|
|
(
|
|
("formation_id", {"default": formation_id, "input_type": "hidden"}),
|
|
(
|
|
"acronyme",
|
|
{
|
|
"size": 12,
|
|
"explanation": "identifiant de la formation (par ex. DUT R&T)",
|
|
"allow_null": False,
|
|
},
|
|
),
|
|
(
|
|
"titre",
|
|
{
|
|
"size": 80,
|
|
"explanation": "nom complet de la formation (ex: DUT Réseaux et Télécommunications",
|
|
"allow_null": False,
|
|
},
|
|
),
|
|
(
|
|
"titre_officiel",
|
|
{
|
|
"size": 80,
|
|
"explanation": "nom officiel (pour les PV de jury)",
|
|
"allow_null": False,
|
|
},
|
|
),
|
|
(
|
|
"type_parcours",
|
|
{
|
|
"input_type": "menu",
|
|
"title": "Type de parcours",
|
|
"type": "int",
|
|
"allowed_values": codes_cursus.FORMATION_CURSUS_TYPES,
|
|
"labels": codes_cursus.FORMATION_CURSUS_DESCRS,
|
|
"explanation": "détermine notamment le nombre de semestres et les règles de validation d'UE et de semestres (barres)",
|
|
"readonly": is_locked,
|
|
},
|
|
),
|
|
(
|
|
"formation_code",
|
|
{
|
|
"size": 12,
|
|
"title": "Code formation",
|
|
"explanation": "code interne. Toutes les formations partageant le même code sont compatibles (compensation de semestres, capitalisation d'UE). Laisser vide si vous ne savez pas, ou entrer le code d'une formation existante.",
|
|
"validator": lambda val, _: len(val) < SHORT_STR_LEN,
|
|
},
|
|
),
|
|
(
|
|
"code_specialite",
|
|
{
|
|
"size": 12,
|
|
"title": "Code spécialité",
|
|
"explanation": "optionel: code utilisé pour échanger avec d'autres logiciels et identifiant la filière ou spécialité (exemple: ASUR). N'est utilisé que s'il n'y a pas de numéro de semestre.",
|
|
},
|
|
),
|
|
(
|
|
"commentaire",
|
|
{
|
|
"input_type": "textarea",
|
|
"rows": 3,
|
|
"cols": 77,
|
|
"title": "Commentaire",
|
|
"explanation": "commentaire libre.",
|
|
},
|
|
),
|
|
),
|
|
initvalues=initvalues,
|
|
submitlabel=submitlabel,
|
|
)
|
|
if tf[0] == 0:
|
|
return "\n".join(H) + tf[1] + html_sco_header.sco_footer()
|
|
elif tf[0] == -1:
|
|
return flask.redirect(scu.NotesURL())
|
|
else:
|
|
# check unicity : constraint UNIQUE(acronyme,titre,version)
|
|
if create:
|
|
version = 1
|
|
else:
|
|
version = initvalues["version"]
|
|
args = {
|
|
"acronyme": tf[2]["acronyme"],
|
|
"titre": tf[2]["titre"],
|
|
"version": version,
|
|
"dept_id": g.scodoc_dept_id,
|
|
}
|
|
other_formations: list[Formation] = Formation.query.filter_by(**args).all()
|
|
if other_formations and (
|
|
(len(other_formations) > 1) or other_formations[0].id != formation_id
|
|
):
|
|
return (
|
|
"\n".join(H)
|
|
+ tf_error_message(
|
|
f"""Valeurs incorrectes: il existe déjà <a href="{
|
|
url_for('notes.ue_table',
|
|
scodoc_dept=g.scodoc_dept, formation_id=other_formations[0].id)
|
|
}">une formation</a> avec même titre,
|
|
acronyme et version.
|
|
"""
|
|
)
|
|
+ tf[1]
|
|
+ html_sco_header.sco_footer()
|
|
)
|
|
#
|
|
if create:
|
|
formation = do_formation_create(tf[2])
|
|
else:
|
|
if do_formation_edit(tf[2]):
|
|
flash(
|
|
f"""Modification de la formation {
|
|
formation.titre} ({formation.acronyme}) version {formation.version}"""
|
|
)
|
|
return flask.redirect(
|
|
url_for(
|
|
"notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id
|
|
)
|
|
)
|
|
|
|
|
|
def do_formation_create(args: dict) -> Formation:
|
|
"create a formation"
|
|
formation = Formation(
|
|
dept_id=g.scodoc_dept_id,
|
|
acronyme=args["acronyme"].strip(),
|
|
titre=args["titre"].strip(),
|
|
titre_officiel=args["titre_officiel"].strip(),
|
|
version=args.get("version"),
|
|
commentaire=scu.strip_str(args.get("commentaire", "")) or None,
|
|
formation_code=args.get("formation_code", "").strip() or None,
|
|
type_parcours=args.get("type_parcours"),
|
|
code_specialite=args.get("code_specialite", "").strip() or None,
|
|
referentiel_competence_id=args.get("referentiel_competence_id"),
|
|
)
|
|
db.session.add(formation)
|
|
|
|
try:
|
|
db.session.commit()
|
|
except sqlalchemy.exc.IntegrityError as exc:
|
|
db.session.rollback()
|
|
raise ScoValueError(
|
|
"On ne peut pas créer deux formations avec mêmes acronymes, titres et versions !",
|
|
dest_url=url_for(
|
|
"notes.formation_edit",
|
|
scodoc_dept=g.scodoc_dept,
|
|
formation_id=formation.id,
|
|
),
|
|
) from exc
|
|
|
|
ScolarNews.add(
|
|
typ=ScolarNews.NEWS_FORM,
|
|
text=f"""Création de la formation {
|
|
formation.titre} ({formation.acronyme}) version {formation.version}""",
|
|
max_frequency=0,
|
|
)
|
|
return formation
|
|
|
|
|
|
def do_formation_edit(args) -> bool:
|
|
"edit a formation, returns True if modified"
|
|
|
|
# On ne peut jamais supprimer le code formation:
|
|
if "formation_code" in args and not args["formation_code"]:
|
|
del args["formation_code"]
|
|
|
|
formation: Formation = Formation.query.get_or_404(args["formation_id"])
|
|
# On autorise la modif de la formation meme si elle est verrouillee
|
|
# car cela ne change que du cosmetique, (sauf eventuellement le code formation ?)
|
|
# mais si verrouillée on ne peut changer le type de parcours
|
|
if formation.has_locked_sems():
|
|
if "type_parcours" in args:
|
|
del args["type_parcours"]
|
|
|
|
modified = False
|
|
for field in formation.__dict__:
|
|
if field in args:
|
|
value = args[field].strip() if isinstance(args[field], str) else args[field]
|
|
if field and field[0] != "_" and getattr(formation, field, None) != value:
|
|
setattr(formation, field, value)
|
|
modified = True
|
|
|
|
if not modified:
|
|
return False
|
|
|
|
db.session.add(formation)
|
|
try:
|
|
db.session.commit()
|
|
except sqlalchemy.exc.IntegrityError as exc:
|
|
db.session.rollback()
|
|
raise ScoValueError(
|
|
"On ne peut pas créer deux formations avec mêmes acronymes, titres et versions !",
|
|
dest_url=url_for(
|
|
"notes.formation_edit",
|
|
scodoc_dept=g.scodoc_dept,
|
|
formation_id=formation.id,
|
|
),
|
|
) from exc
|
|
formation.invalidate_cached_sems()
|
|
return True
|
|
|
|
|
|
def module_move(module_id, after=0, redirect=True):
|
|
"""Move before/after previous one (decrement/increment numero)"""
|
|
redirect = bool(redirect)
|
|
module = Module.query.get_or_404(module_id)
|
|
after = int(after) # 0: deplace avant, 1 deplace apres
|
|
if after not in (0, 1):
|
|
raise ValueError(f'invalid value for "after" ({after})')
|
|
if module.formation.is_apc():
|
|
# pas de matières, mais on prend tous les modules de même type de la formation
|
|
query = Module.query.filter_by(
|
|
semestre_id=module.semestre_id,
|
|
formation=module.formation,
|
|
module_type=module.module_type,
|
|
)
|
|
else:
|
|
query = Module.query.filter_by(matiere=module.matiere)
|
|
modules = query.order_by(Module.numero, Module.code).all()
|
|
if len({o.numero for o in modules}) != len(modules):
|
|
# il y a des numeros identiques !
|
|
scu.objects_renumber(db, modules)
|
|
if len(modules) > 1:
|
|
idx = [m.id for m in modules].index(module.id)
|
|
neigh = None # object to swap with
|
|
if after == 0 and idx > 0:
|
|
neigh = modules[idx - 1]
|
|
elif after == 1 and idx < len(modules) - 1:
|
|
neigh = modules[idx + 1]
|
|
if neigh: # échange les numéros
|
|
module.numero, neigh.numero = neigh.numero, module.numero
|
|
db.session.add(module)
|
|
db.session.add(neigh)
|
|
db.session.commit()
|
|
module.formation.invalidate_cached_sems()
|
|
# redirect to ue_list page:
|
|
if redirect:
|
|
return flask.redirect(
|
|
url_for(
|
|
"notes.ue_table",
|
|
scodoc_dept=g.scodoc_dept,
|
|
formation_id=module.formation.id,
|
|
semestre_idx=module.ue.semestre_idx,
|
|
)
|
|
)
|
|
|
|
|
|
def ue_move(ue_id, after=0, redirect=1):
|
|
"""Move UE before/after previous one (decrement/increment numero)"""
|
|
ue = UniteEns.query.get_or_404(ue_id)
|
|
redirect = int(redirect)
|
|
after = int(after) # 0: deplace avant, 1 deplace apres
|
|
if after not in (0, 1):
|
|
raise ValueError('invalid value for "after"')
|
|
others_q = ue.formation.ues.order_by(UniteEns.numero)
|
|
if ue.formation.is_apc():
|
|
others_q = others_q.filter_by(semestre_idx=ue.semestre_idx)
|
|
others = others_q.all()
|
|
if len({o.numero for o in others}) != len(others):
|
|
# il y a des numeros identiques !
|
|
scu.objects_renumber(db, others)
|
|
ue.formation.invalidate_cached_sems()
|
|
if len(others) > 1:
|
|
idx = [u.id for u in others].index(ue.id)
|
|
neigh = None # object to swap with
|
|
if after == 0 and idx > 0:
|
|
neigh = others[idx - 1]
|
|
elif after == 1 and idx < len(others) - 1:
|
|
neigh = others[idx + 1]
|
|
if neigh: #
|
|
# swap numero between partition and its neighbor
|
|
ue.numero, neigh.numero = neigh.numero, ue.numero
|
|
db.session.add(ue)
|
|
db.session.add(neigh)
|
|
db.session.commit()
|
|
ue.formation.invalidate_cached_sems()
|
|
|
|
# redirect to ue_list page
|
|
if redirect:
|
|
return flask.redirect(
|
|
url_for(
|
|
"notes.ue_table",
|
|
scodoc_dept=g.scodoc_dept,
|
|
formation_id=ue.formation_id,
|
|
semestre_idx=ue.semestre_idx,
|
|
)
|
|
)
|