Update opolka/ScoDoc from ScoDoc/ScoDoc #2

Merged
opolka merged 1272 commits from ScoDoc/ScoDoc:master into master 2024-05-27 09:11:04 +02:00
9 changed files with 246 additions and 14 deletions
Showing only changes of commit 548a881d59 - Show all commits

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

@ -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,31 @@ 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"
)
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 +685,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

@ -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

@ -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

@ -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

@ -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

@ -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)