diff --git a/app/auth/models.py b/app/auth/models.py index 073f687e9a..3115119135 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -353,8 +353,8 @@ class User(UserMixin, db.Model): return mails # Permissions management: - def has_permission(self, perm: int, dept=False): - """Check if user has permission `perm` in given `dept`. + def has_permission(self, perm: int, dept: str = False): + """Check if user has permission `perm` in given `dept` (acronym). Similar to Zope ScoDoc7 `has_permission`` Args: diff --git a/app/models/departements.py b/app/models/departements.py index 6f3f775989..d4005d24d8 100644 --- a/app/models/departements.py +++ b/app/models/departements.py @@ -80,8 +80,6 @@ class Departement(db.Model): def create_dept(acronym: str, visible=True) -> Departement: "Create new departement" - from app.models import ScoPreference - if Departement.invalid_dept_acronym(acronym): raise ScoValueError("acronyme departement invalide") existing = Departement.query.filter_by(acronym=acronym).count() diff --git a/app/models/etudiants.py b/app/models/etudiants.py index a451526f7d..a033bbce6c 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -86,6 +86,31 @@ class Identite(db.Model): f"" ) + 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" + ) + 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: "lien vers la fiche" return f""" "FormSemestre": - """ "FormSemestre ou 404, cherche uniquement dans le département courant""" + def get_formsemestre( + 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: + 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( - id=formsemestre_id, dept_id=g.scodoc_dept_id + id=formsemestre_id, dept_id=dept_id ).first_or_404() return cls.query.filter_by(id=formsemestre_id).first_or_404() diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index 282b51957b..7837c299f2 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -275,14 +275,16 @@ def do_formsemestre_inscription_with_modules( etat=scu.INSCRIT, etape=None, method="inscription_with_modules", + dept_id: int = None, ): """Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS (donc sauf le sport) + Si dept_id est spécifié, utilise ce département au lieu du courant. """ group_ids = group_ids or [] if isinstance(group_ids, int): group_ids = [group_ids] - formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id) # inscription au semestre args = {"formsemestre_id": formsemestre_id, "etudid": etudid} if etat is not None: diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 554ce78046..4b62d38176 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -642,6 +642,12 @@ def menus_etud(etudid): "args": {"etudid": etud["etudid"]}, "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...", "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): diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index baf8105039..500b41094c 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -10,12 +10,12 @@ html, body { - margin: 0; - padding: 0; - width: 100%; background-color: var(--sco-color-background); font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 12pt; + margin: 0; + padding: 0; + width: 100%; } @media print { @@ -24,6 +24,10 @@ body { } } +div.container { + margin-bottom: 24px; +} + h1, h2, h3 { @@ -1672,6 +1676,10 @@ formsemestre_page_title .lock img { font-family: Arial, Helvetica, sans-serif; } +.menu-etudiant>li { + width: 200px !important; +} + span.inscr_addremove_menu { width: 150px; } diff --git a/app/templates/scolar/etud_copy_in_other_dept.j2 b/app/templates/scolar/etud_copy_in_other_dept.j2 new file mode 100644 index 0000000000..55b4f77ffd --- /dev/null +++ b/app/templates/scolar/etud_copy_in_other_dept.j2 @@ -0,0 +1,99 @@ +{# -*- mode: jinja-html -*- #} +{% extends 'base.j2' %} + +{% block styles %} +{{super()}} + +{% endblock %} + +{% block app_content %} + +

Création d'une copie de {{ etud.html_link_fiche() | safe }}

+ +
+ +

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. +

+ +

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". +

+ +

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. +

+ +

Dans chaque département autorisés, seuls les semestres non verrouillés sont +montrés. Choisir le semestre destination et valider le formulaire. +

+ +

Ensuite, ne pas oublier d'inscrire l'étudiant à ses groupes, notamment son +parcours si besoin. +

+ +
+ +
+ {% for dept in departements.values() %} +
+
Département {{ dept.acronym }}
+ {% for sem in formsemestres_by_dept[dept.id]%} +
+ +
+ {% endfor %} +
+ {% endfor %} + + +
+ + + +{% endblock %} \ No newline at end of file diff --git a/app/views/scolar.py b/app/views/scolar.py index 7b13e2dd4f..7d1cf2e258 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -31,11 +31,12 @@ issu de ScoDoc7 / ZScolar.py Emmanuel Viennet, 2021 """ import datetime -import requests import time +import requests + 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_json import as_json from flask_login import current_user @@ -43,6 +44,7 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed from wtforms import SubmitField +import app from app import db from app import log from app.decorators import ( @@ -52,6 +54,7 @@ from app.decorators import ( permission_required_compat_scodoc7, ) from app.models import ( + Departement, FormSemestre, Identite, Partition, @@ -69,6 +72,7 @@ from app.scodoc.scolog import logdb from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( AccessDenied, + ScoPermissionDenied, ScoValueError, ) @@ -1770,6 +1774,77 @@ def _etudident_create_or_edit_form(edit): ) +@bp.route("/etud_copy_in_other_dept/", 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"]) @scodoc @permission_required(Permission.ScoEtudInscrit)