EDT: ajout des edt_id dans formsemestre, groupes, modules, users

This commit is contained in:
Emmanuel Viennet 2023-11-06 22:05:38 +01:00
parent 5824b7fb59
commit e71e4b27ec
15 changed files with 259 additions and 148 deletions

View File

@ -12,6 +12,7 @@ from typing import Optional
import cracklib # pylint: disable=import-error
import flask
from flask import current_app, g
from flask_login import UserMixin, AnonymousUserMixin
@ -88,7 +89,8 @@ class User(UserMixin, db.Model):
"""
cas_last_login = db.Column(db.DateTime, nullable=True)
"""date du dernier login via CAS"""
edt_id = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
password_hash = db.Column(db.String(128))
password_scodoc7 = db.Column(db.String(42))
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
@ -172,7 +174,8 @@ class User(UserMixin, db.Model):
return False
# if CAS activated and forced, allow only super-user and users with cas_allow_scodoc_login
if ScoDocSiteConfig.is_cas_enabled() and ScoDocSiteConfig.get("cas_force"):
cas_enabled = ScoDocSiteConfig.is_cas_enabled()
if cas_enabled and ScoDocSiteConfig.get("cas_force"):
if (not self.is_administrator()) and not self.cas_allow_scodoc_login:
return False
@ -182,7 +185,18 @@ class User(UserMixin, db.Model):
return self._migrate_scodoc7_password(password)
return False
return check_password_hash(self.password_hash, password)
password_ok = check_password_hash(self.password_hash, password)
if password_ok and cas_enabled and flask.session.get("CAS_EDT_ID"):
# essaie de récupérer l'edt_id s'il est présent
# cet ID peut être renvoyé par le CAS et extrait par ScoDoc
# via l'expression `cas_edt_id_from_xml_regexp`
# voir flask_cas.routing
edt_id = flask.session.get("CAS_EDT_ID")
log(f"Storing edt_id for {self.user_name}: '{edt_id}'")
self.edt_id = edt_id
db.session.add(self)
db.session.commit()
return password_ok
def _migrate_scodoc7_password(self, password) -> bool:
"""After migration, rehash password."""

View File

@ -4,8 +4,8 @@
"""
import json
import urllib.parse
import re
import urllib.parse
from flask import flash
from app import current_app, db, log
@ -13,8 +13,6 @@ from app.comp import bonus_spo
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu
from datetime import time
from app.scodoc.codes_cursus import (
ABAN,
ABL,
@ -105,6 +103,7 @@ class ScoDocSiteConfig(db.Model):
"cas_validate_route": str,
"cas_attribute_id": str,
"cas_uid_from_mail_regexp": str,
"cas_edt_id_from_xml_regexp": str,
# Assiduité
"morning_time": str,
"lunch_time": str,

View File

@ -64,6 +64,8 @@ class FormSemestre(db.Model):
titre = db.Column(db.Text(), nullable=False)
date_debut = db.Column(db.Date(), nullable=False)
date_fin = db.Column(db.Date(), nullable=False)
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
"False si verrouillé"
modalite = db.Column(

View File

@ -213,10 +213,12 @@ class GroupDescr(db.Model):
id = db.Column(db.Integer, primary_key=True)
group_id = db.synonym("id")
partition_id = db.Column(db.Integer, db.ForeignKey("partition.id"))
# "A", "C2", ... (NULL for 'all'):
group_name = db.Column(db.String(GROUPNAME_STR_LEN))
# Numero = ordre de presentation
"""nom du groupe: "A", "C2", ... (NULL for 'all')"""
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
numero = db.Column(db.Integer, nullable=False, default=0)
"Numero = ordre de presentation"
etuds = db.relationship(
"Identite",
@ -272,6 +274,40 @@ class GroupDescr(db.Model):
return False
return True
def set_name(
self, group_name: str, edt_id: str | bool = False, dest_url: str = None
):
"""Set group name, and optionally edt_id.
Check permission and invalidate caches. Commit session.
dest_url is used for error messages.
"""
if not self.partition.formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
if self.group_name is None:
raise ValueError("can't set a name to default group")
if group_name:
group_name = group_name.strip()
if not group_name:
raise ScoValueError("nom de groupe vide !", dest_url=dest_url)
if group_name != self.group_name and not GroupDescr.check_name(
self.partition, group_name
):
raise ScoValueError(
"Le nom de groupe existe déjà dans la partition", dest_url=dest_url
)
self.group_name = group_name
if edt_id is not False:
if isinstance(edt_id, str):
edt_id = edt_id.strip() or None
self.edt_id = edt_id
db.session.add(self)
db.session.commit()
sco_cache.invalidate_formsemestre(
formsemestre_id=self.partition.formsemestre_id
)
def remove_etud(self, etud: "Identite"):
"Enlève l'étudiant de ce groupe s'il en fait partie (ne fait rien sinon)"
if etud in self.etuds:

View File

@ -34,8 +34,10 @@ class Module(db.Model):
# note: en APC, le semestre qui fait autorité est celui de l'UE
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
# id de l'element pedagogique Apogee correspondant:
code_apogee = db.Column(db.String(APO_CODE_STR_LEN))
"id de l'element pedagogique Apogee correspondant"
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
# Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)
module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0")
# Relations:

View File

@ -34,16 +34,13 @@ XXX incompatible avec les ics HyperPlanning Paris 13 (était pour GPU).
"""
import icalendar
import pprint
import traceback
import urllib
import app.scodoc.sco_utils as scu
from app import log
from app.scodoc import html_sco_header
from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_preferences

View File

@ -42,7 +42,7 @@ from app import cache, db, log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, Identite, Scolog
from app.models import GROUPNAME_STR_LEN, SHORT_STR_LEN
from app.models import SHORT_STR_LEN
from app.models.groups import GroupDescr, Partition
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
@ -1299,85 +1299,6 @@ def partition_set_name(partition_id, partition_name, redirect=1):
)
def group_set_name(group: GroupDescr, group_name: str, redirect=True):
"""Set group name"""
if not group.partition.formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
if group.group_name is None:
raise ValueError("can't set a name to default group")
destination = url_for(
"scolar.affect_groups",
scodoc_dept=g.scodoc_dept,
partition_id=group.partition_id,
)
if group_name:
group_name = group_name.strip()
if not group_name:
raise ScoValueError("nom de groupe vide !", dest_url=destination)
if not GroupDescr.check_name(group.partition, group_name):
raise ScoValueError(
"Le nom de groupe existe déjà dans la partition", dest_url=destination
)
redirect = int(redirect)
group.group_name = group_name
db.session.add(group)
db.session.commit()
sco_cache.invalidate_formsemestre(formsemestre_id=group.partition.formsemestre_id)
# redirect to partition edit page:
if redirect:
return flask.redirect(destination)
def group_rename(group_id):
"""Form to rename a group"""
group = GroupDescr.query.get_or_404(group_id)
formsemestre_id = group.partition.formsemestre_id
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if not formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
H = [f"<h2>Renommer un groupe de {group.partition.partition_name or '-'}</h2>"]
tf = TrivialFormulator(
request.base_url,
scu.get_request_args(),
(
("group_id", {"default": group_id, "input_type": "hidden"}),
(
"group_name",
{
"title": "Nouveau nom",
"default": group.group_name,
"size": 12,
"allow_null": False,
"validator": lambda val, _: len(val) < GROUPNAME_STR_LEN,
},
),
),
submitlabel="Renommer",
cancelbutton="Annuler",
)
if tf[0] == 0:
return (
html_sco_header.sco_header()
+ "\n".join(H)
+ "\n"
+ tf[1]
+ html_sco_header.sco_footer()
)
elif tf[0] == -1:
return flask.redirect(
url_for(
"scolar.affect_groups",
scodoc_dept=g.scodoc_dept,
partition_id=group.partition_id,
)
)
else:
# form submission
return group_set_name(group, tf[2]["group_name"])
def groups_auto_repartition(partition: Partition):
"""Réparti les etudiants dans des groupes dans une partition, en respectant le niveau
et la mixité.

View File

@ -27,11 +27,15 @@
"""Formulaires gestion des groupes
"""
from flask import render_template
import flask
from flask import flash, g, render_template, request, url_for
from app.models import Partition
from app.models import FormSemestre, GroupDescr, Partition
from app.models import GROUPNAME_STR_LEN
from app.scodoc import html_sco_header
from app.scodoc.sco_exceptions import AccessDenied
import app.scodoc.sco_utils as scu
from app.scodoc.TrivialFormulator import TrivialFormulator
def affect_groups(partition_id):
@ -59,3 +63,64 @@ def affect_groups(partition_id):
),
formsemestre_id=formsemestre.id,
)
def group_rename(group_id):
"""Form to rename a group"""
group: GroupDescr = GroupDescr.query.get_or_404(group_id)
formsemestre_id = group.partition.formsemestre_id
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if not formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
H = [f"<h2>Renommer un groupe de {group.partition.partition_name or '-'}</h2>"]
tf = TrivialFormulator(
request.base_url,
scu.get_request_args(),
(
("group_id", {"default": group_id, "input_type": "hidden"}),
(
"group_name",
{
"title": "Nouveau nom",
"default": group.group_name,
"size": 12,
"allow_null": False,
"validator": lambda val, _: len(val) < GROUPNAME_STR_LEN,
"explanation": "doit être unique dans cette partition",
},
),
(
"edt_id",
{
"title": "Id EDT",
"default": group.edt_id or "",
"size": 12,
"allow_null": True,
"explanation": "optionnel : identifiant du groupe dans le logiciel d'emploi du temps",
},
),
),
submitlabel="Renommer",
cancelbutton="Annuler",
)
dest_url = url_for(
"scolar.partition_editor",
scodoc_dept=g.scodoc_dept,
formsemestre_id=group.partition.formsemestre_id,
edit_partition=1,
)
if tf[0] == 0:
return (
html_sco_header.sco_header()
+ "\n".join(H)
+ "\n"
+ tf[1]
+ html_sco_header.sco_footer()
)
elif tf[0] == -1:
return flask.redirect(dest_url)
else:
# form submission
group.set_name(tf[2]["group_name"], edt_id=tf[2]["edt_id"], dest_url=dest_url)
flash("groupe modifié")
return flask.redirect(dest_url)

View File

@ -31,11 +31,8 @@
# Re-ecriture en 2014 (re-organisation de l'interface, modernisation du code)
import collections
import datetime
import urllib
from urllib.parse import parse_qs
import time
from flask import url_for, g, request
@ -45,7 +42,6 @@ from app import db
from app.models import FormSemestre
import app.scodoc.sco_utils as scu
from app.scodoc import html_sco_header
from app.scodoc import sco_cal
from app.scodoc import sco_excel
from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups

View File

@ -302,6 +302,10 @@ body.editionActivated .filtres>div>div>div>div {
display: none;
}
#zonePartitions span.editing a {
text-decoration: none;
}
.editionActivated #zonePartitions .filtres .config {
display: block;
}

View File

@ -4834,3 +4834,7 @@ div.cas_etat_certif_ssl {
font-style: italic;
color: rgb(231, 0, 0);
}
.edt_id {
color: rgb(85, 255, 24);
}

View File

@ -8,7 +8,8 @@
<h2>Filtres</h2>
<div>
<label class="edition">
<input type="checkbox" autocomplete="off" id="inputModif">
<input type="checkbox" autocomplete="off" id="inputModif"
{% if edit_partition %}checked{% endif %}>
Modifier les partitions et groupes
</label>
<div class="filtres"></div>
@ -246,15 +247,15 @@
let div = document.createElement("button");
div.classList.add("dt-button");
div.dataset.idgroupe = groupe.id;
let edt_id_str = groupe.edt_id ? `<tt class="edt_id" title="id edt">[${groupe.edt_id}]</tt>` : "";
div.innerHTML = `
<span class="editing move">||</span>
<span>${groupe.group_name}</span>
<span class="editing modif">✏️</span>
<span>${groupe.group_name} ${edt_id_str}</span>
<span class="editing"><a href="/ScoDoc/{{formsemestre.departement.acronym}}/Scolarite/group_rename?group_id=${groupe.id}">✏️</a></span>
<span class="editing suppr">❌</span>`;
div.addEventListener("click", filtre);
div.querySelector(".move").addEventListener("mousedown", moveStart);
div.querySelector(".modif").addEventListener("click", editText);
div.querySelector(".suppr").addEventListener("click", suppr);
return div;

View File

@ -842,7 +842,7 @@ sco_publish("/setGroups", sco_groups.setGroups, Permission.ScoView, methods=["PO
sco_publish(
"/group_rename",
sco_groups.group_rename,
sco_groups_edit.group_rename,
Permission.ScoView,
methods=["GET", "POST"],
)

View File

@ -1,19 +1,21 @@
"""
Routes for CAS authentication
Modified for ScoDoc
"""
import re
import ssl
from urllib.error import URLError
from urllib.request import urlopen
import flask
from xmltodict import parse
from flask import current_app
from xmltodict import parse
from .cas_urls import create_cas_login_url
from .cas_urls import create_cas_logout_url
from .cas_urls import create_cas_validate_url
try:
from urllib import urlopen # python 2
except ImportError:
from urllib.request import urlopen # python 3
from urllib.error import URLError
blueprint = flask.Blueprint("cas", __name__)
@ -53,7 +55,6 @@ def login():
flask.session[cas_token_session_key] = flask.request.args["ticket"]
if cas_token_session_key in flask.session:
if validate(flask.session[cas_token_session_key]):
if "CAS_AFTER_LOGIN_SESSION_URL" in flask.session:
redirect_url = flask.session.pop("CAS_AFTER_LOGIN_SESSION_URL")
@ -64,7 +65,7 @@ def login():
else:
flask.session.pop(cas_token_session_key, None)
current_app.logger.debug("Redirecting to: {redirect_url}")
current_app.logger.debug(f"cas.login: redirecting to {redirect_url}")
return flask.redirect(redirect_url)
@ -84,6 +85,7 @@ def logout():
flask.session.pop(cas_username_session_key, None)
flask.session.pop(cas_attributes_session_key, None)
flask.session.pop(cas_token_session_key, None) # added by EV
flask.session.pop("CAS_EDT_ID", None) # added by EV
cas_after_logout = current_app.config["CAS_AFTER_LOGOUT"]
if cas_after_logout is not None:
@ -102,7 +104,7 @@ def logout():
else:
redirect_url = create_cas_logout_url(current_app.config["CAS_SERVER"], None)
current_app.logger.debug(f"Redirecting to: {redirect_url}")
current_app.logger.debug(f"cas.logout: redirecting to {redirect_url}")
return flask.redirect(redirect_url)
@ -114,11 +116,12 @@ def validate(ticket):
key `CAS_USERNAME_SESSION_KEY` while the validated attributes dictionary
is saved under the key 'CAS_ATTRIBUTES_SESSION_KEY'.
"""
from app.models.config import ScoDocSiteConfig
cas_username_session_key = current_app.config["CAS_USERNAME_SESSION_KEY"]
cas_attributes_session_key = current_app.config["CAS_ATTRIBUTES_SESSION_KEY"]
cas_error_callback = current_app.config.get("CAS_ERROR_CALLBACK")
current_app.logger.debug("validating token {0}".format(ticket))
current_app.logger.debug(f"validating token {ticket}")
cas_validate_url = create_cas_validate_url(
current_app.config["CAS_SERVER"],
@ -182,7 +185,7 @@ def validate(ticket):
attributes = xml_from_dict.get("cas:attributes", {})
if attributes and "cas:memberOf" in attributes:
if isinstance(attributes["cas:memberOf"], basestring):
if isinstance(attributes["cas:memberOf"], str):
attributes["cas:memberOf"] = (
attributes["cas:memberOf"].lstrip("[").rstrip("]").split(",")
)
@ -190,6 +193,15 @@ def validate(ticket):
attributes["cas:memberOf"][group_number] = (
attributes["cas:memberOf"][group_number].lstrip(" ").rstrip(" ")
)
# Extract auxiliary informations (utilisé pour edt_id)
exp = ScoDocSiteConfig.get("cas_edt_id_from_xml_regexp")
if exp:
m = re.search(exp, xmldump)
if m and len(m.groups()) > 0:
cas_edt_id = m.group(1)
if cas_edt_id:
flask.session["CAS_EDT_ID"] = cas_edt_id
flask.session[cas_username_session_key] = username
flask.session[cas_attributes_session_key] = attributes
else:

View File

@ -0,0 +1,58 @@
"""edt_id
Revision ID: 6fb956addd69
Revises: fd805feb7ba8
Create Date: 2023-11-06 12:14:42.808476
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "6fb956addd69"
down_revision = "fd805feb7ba8"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("group_descr", schema=None) as batch_op:
batch_op.add_column(sa.Column("edt_id", sa.Text(), nullable=True))
batch_op.create_index(
batch_op.f("ix_group_descr_edt_id"), ["edt_id"], unique=False
)
with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op:
batch_op.add_column(sa.Column("edt_id", sa.Text(), nullable=True))
batch_op.create_index(
batch_op.f("ix_notes_formsemestre_edt_id"), ["edt_id"], unique=False
)
with op.batch_alter_table("notes_modules", schema=None) as batch_op:
batch_op.add_column(sa.Column("edt_id", sa.Text(), nullable=True))
batch_op.create_index(
batch_op.f("ix_notes_modules_edt_id"), ["edt_id"], unique=False
)
with op.batch_alter_table("user", schema=None) as batch_op:
batch_op.add_column(sa.Column("edt_id", sa.Text(), nullable=True))
batch_op.create_index(batch_op.f("ix_user_edt_id"), ["edt_id"], unique=False)
def downgrade():
with op.batch_alter_table("user", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_user_edt_id"))
batch_op.drop_column("edt_id")
with op.batch_alter_table("notes_modules", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_notes_modules_edt_id"))
batch_op.drop_column("edt_id")
with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_notes_formsemestre_edt_id"))
batch_op.drop_column("edt_id")
with op.batch_alter_table("group_descr", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_group_descr_edt_id"))
batch_op.drop_column("edt_id")