This commit is contained in:
Emmanuel Viennet 2021-11-01 17:02:24 +01:00
commit 39a9f353d2
28 changed files with 833 additions and 182 deletions

View File

@ -6,3 +6,4 @@ from flask import Blueprint
bp = Blueprint("api", __name__)
from app.api import sco_api
from app.api import tokens

View File

@ -33,6 +33,7 @@ token_auth = HTTPTokenAuth()
@basic_auth.verify_password
def verify_password(username, password):
# breakpoint()
user = User.query.filter_by(user_name=username).first()
if user and user.check_password(password):
return user
@ -51,3 +52,17 @@ def verify_token(token):
@token_auth.error_handler
def token_auth_error(status):
return error_response(status)
def token_permission_required(permission):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
scodoc_dept = getattr(g, "scodoc_dept", None)
if not current_user.has_permission(permission, scodoc_dept):
abort(403)
return f(*args, **kwargs)
return login_required(decorated_function)
return decorator

View File

@ -48,9 +48,9 @@ from app.api.errors import bad_request
from app import models
@bp.route("/ScoDoc/api/list_depts", methods=["GET"])
@bp.route("list_depts", methods=["GET"])
@token_auth.login_required
def list_depts():
depts = models.Departement.query.filter_by(visible=True).all()
data = {"items": [d.to_dict() for d in depts]}
data = [d.to_dict() for d in depts]
return jsonify(data)

View File

@ -213,6 +213,9 @@ class User(UserMixin, db.Model):
@staticmethod
def check_token(token):
"""Retreive user for given token, chek token's validity
and returns the user object.
"""
user = User.query.filter_by(token=token).first()
if user is None or user.token_expiration < datetime.utcnow():
return None

View File

@ -50,9 +50,19 @@ def scodoc(func):
@wraps(func)
def scodoc_function(*args, **kwargs):
# print("@scodoc")
# interdit les POST si pas loggué
if request.method == "POST" and not current_user.is_authenticated:
current_app.logger.info("POST by non authenticated user")
if (
request.method == "POST"
and not current_user.is_authenticated
and not request.form.get(
"__ac_password"
) # exception pour compat API ScoDoc7
):
current_app.logger.info(
"POST by non authenticated user (request.form=%s)",
str(request.form)[:2048],
)
return redirect(
url_for(
"auth.login",
@ -68,6 +78,7 @@ def scodoc(func):
# current_app.logger.info("setting dept to None")
g.scodoc_dept = None
g.scodoc_dept_id = -1 # invalide
return func(*args, **kwargs)
return scodoc_function
@ -97,8 +108,8 @@ def permission_required_compat_scodoc7(permission):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# current_app.logger.warning("PERMISSION; kwargs=%s" % str(kwargs))
# cherche les paramètre d'auth:
# print("@permission_required_compat_scodoc7")
auth_ok = False
if request.method == "GET":
user_name = request.args.get("__ac_name")
@ -113,7 +124,6 @@ def permission_required_compat_scodoc7(permission):
if u and u.check_password(user_password):
auth_ok = True
flask_login.login_user(u)
# reprend le chemin classique:
scodoc_dept = getattr(g, "scodoc_dept", None)
@ -150,6 +160,7 @@ def scodoc7func(func):
2. or be called directly from Python.
"""
# print("@scodoc7func")
# Détermine si on est appelé via une route ("toplevel")
# ou par un appel de fonction python normal.
top_level = not hasattr(g, "scodoc7_decorated")

View File

@ -41,7 +41,7 @@ class Identite(db.Model):
code_nip = db.Column(db.Text())
code_ine = db.Column(db.Text())
# Ancien id ScoDoc7 pour les migrations de bases anciennes
# ne pas utiliser après migrate_scodoc7_dept_archive
# ne pas utiliser après migrate_scodoc7_dept_archives
scodoc7_id = db.Column(db.Text(), nullable=True)
#
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")

View File

@ -137,6 +137,12 @@ class NotesTag(db.Model):
# Association tag <-> module
notes_modules_tags = db.Table(
"notes_modules_tags",
db.Column("tag_id", db.Integer, db.ForeignKey("notes_tags.id")),
db.Column("module_id", db.Integer, db.ForeignKey("notes_modules.id")),
db.Column(
"tag_id",
db.Integer,
db.ForeignKey("notes_tags.id", ondelete="CASCADE"),
),
db.Column(
"module_id", db.Integer, db.ForeignKey("notes_modules.id", ondelete="CASCADE")
),
)

View File

@ -79,7 +79,7 @@ class FormSemestre(db.Model):
)
# Ancien id ScoDoc7 pour les migrations de bases anciennes
# ne pas utiliser après migrate_scodoc7_dept_archive
# ne pas utiliser après migrate_scodoc7_dept_archives
scodoc7_id = db.Column(db.Text(), nullable=True)
def __init__(self, **kwargs):

View File

@ -987,7 +987,7 @@ def mail_bulletin(formsemestre_id, I, pdfdata, filename, recipient_addr):
bcc = copy_addr.strip()
else:
bcc = ""
msg = Message(subject, sender=sender, recipients=recipients, bcc=bcc)
msg = Message(subject, sender=sender, recipients=recipients, bcc=[bcc])
msg.body = hea
# Attach pdf

View File

@ -1949,6 +1949,15 @@ class BasePreferences(object):
"name": name,
},
)
if len(pdb) > 1:
# suppress buggy duplicates (may come from corrupted database for ice ages)
log(
f"**oups** detected duplicated preference !\n({self.dept_id}, {formsemestre_id}, {name}, {value})"
)
for obj in pdb[1:]:
self._editor.delete(cnx, obj["id"])
pdb = [pdb[0]]
if not pdb:
# crée préférence
log("create pref sem=%s %s=%s" % (formsemestre_id, name, value))
@ -1962,10 +1971,8 @@ class BasePreferences(object):
},
)
modif = True
log("create pref sem=%s %s=%s" % (formsemestre_id, name, value))
else:
# edit existing value
existing_value = pdb[0]["value"] # old stored value
if (
(existing_value != value)

View File

@ -1,9 +1,13 @@
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% macro render_field(field) %}
{% macro render_field(field, auth_name=None) %}
<tr style="">
<td class="wtf-field">{{ field.label }}</td>
{% if auth_name %}
<td class="wtf-field"> {{ field.label }}<span style="font-weight:700;"> ({{ auth_name }}):</span></td>
{% else %}
<td class="wtf-field">{{ field.label }}</td>
{% endif %}
<td class="wtf-field">{{ field(**kwargs)|safe }}
{% if field.errors %}
<ul class=errors>
@ -20,16 +24,20 @@
<h1>Modification du compte ScoDoc <tt>{{form.user_name.data}}</tt></h1>
<div class="help">
<p>Identifiez-vous avez votre mot de passe actuel</p>
<p>Vous pouvez changer le mot de passe et/ou l'adresse email.</p>
<p>Les champs vides ne seront pas changés.</p>
</div>
<form method=post>
{{ form.user_name }}
{{ form.csrf_token }}
<table class="tf"><tbody>
{{ render_field(form.old_password, size=14,
{{ render_field(form.old_password, size=14, auth_name=auth_username,
style="padding:1px; margin-left: 1em; margin-top: 4px;") }}
{{ render_field(form.new_password, size=14,
<tr>
<td colspan=""2">
<p>Vous pouvez changer le mot de passe et/ou l'adresse email.</p>
<p>Les champs laissés vides ne seront pas modifiés.</p>
</td>
</tr>
{{ render_field(form.new_password, size=14,
style="padding:1px; margin-left: 1em; margin-top: 12px;") }}
{{ render_field(form.bis_password, size=14,
style="padding:1px; margin-left: 1em; margin-top: 4px;") }}

View File

@ -15,4 +15,6 @@
<p>{{ url_for('auth.reset_password', token=token, _external=True) }}</p>
{% endif %}
<p>A bientôt !</p>
<p>A bientôt !</p>
<p>Ce message a été généré automatiquement par le serveur ScoDoc.</p>

View File

@ -8,4 +8,6 @@ Votre identifiant de connexion est: {{ user.user_name }}
{{ url_for('auth.reset_password', token=token, _external=True) }}
{% endif %}
<p>A bientôt !</p>
A bientôt !
Ce message a été généré automatiquement par le serveur ScoDoc.

View File

@ -1058,7 +1058,8 @@ def AddBilletAbsence(
code_nip=None,
code_ine=None,
justified=True,
xml_reply=True,
format="json",
xml_reply=True, # deprecated
):
"""Mémorise un "billet"
begin et end sont au format ISO (eg "1999-01-08 04:05:06")
@ -1082,6 +1083,7 @@ def AddBilletAbsence(
raise ValueError("invalid dates")
#
justified = bool(justified)
xml_reply = bool(xml_reply)
#
cnx = ndb.GetDBConnexion()
billet_id = sco_abs.billet_absence_create(
@ -1095,17 +1097,17 @@ def AddBilletAbsence(
"justified": justified,
},
)
if xml_reply:
# Renvoie le nouveau billet en XML
billets = sco_abs.billet_absence_list(cnx, {"billet_id": billet_id})
tab = _tableBillets(billets, etud=etud)
log("AddBilletAbsence: new billet_id=%s (%gs)" % (billet_id, time.time() - t0))
return tab.make_page(format="xml")
else:
return billet_id
if xml_reply: # backward compat
format = "xml"
# Renvoie le nouveau billet au format demandé
billets = sco_abs.billet_absence_list(cnx, {"billet_id": billet_id})
tab = _tableBillets(billets, etud=etud)
log("AddBilletAbsence: new billet_id=%s (%gs)" % (billet_id, time.time() - t0))
return tab.make_page(format=format)
@bp.route("/AddBilletAbsenceForm")
@bp.route("/AddBilletAbsenceForm", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoAbsAddBillet)
@scodoc7func

View File

@ -264,7 +264,7 @@ sco_publish(
@bp.route(
"formsemestre_bulletinetud", methods=["GET", "POST"]
"/formsemestre_bulletinetud", methods=["GET", "POST"]
) # POST pour compat anciens clients PHP (deprecated)
@scodoc
@permission_required_compat_scodoc7(Permission.ScoView)

View File

@ -110,7 +110,7 @@ def get_etud_dept():
# il peut y avoir plusieurs réponses si l'étudiant est passé par plusieurs départements
etuds = Identite.query.filter_by(code_nip=request.args["code_nip"]).all()
elif "code_ine" in request.args:
etuds = Identite.query.filter_by(code_nip=request.args["code_ine"]).all()
etuds = Identite.query.filter_by(code_ine=request.args["code_ine"]).all()
else:
raise BadRequest(
"missing argument (expected one among: etudid, code_nip or code_ine)"

View File

@ -302,7 +302,34 @@ sco_publish(
methods=["GET", "POST"],
)
sco_publish("/groups_view", sco_groups_view.groups_view, Permission.ScoView)
@bp.route("/groups_view")
@scodoc
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def groups_view(
group_ids=(),
format="html",
# Options pour listes:
with_codes=0,
etat=None,
with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail)
with_archives=0, # ajoute colonne avec noms fichiers archivés
with_annotations=0,
formsemestre_id=None,
):
return sco_groups_view.groups_view(
group_ids=(),
format=format,
# Options pour listes:
with_codes=with_codes,
etat=etat,
with_paiement=with_paiement, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail)
with_archives=with_archives, # ajoute colonne avec noms fichiers archivés
with_annotations=with_annotations,
formsemestre_id=formsemestre_id,
)
sco_publish(
"/export_groups_as_moodle_csv",

View File

@ -35,6 +35,7 @@ Emmanuel Viennet, 2021
"""
import datetime
import re
from enum import auto, IntEnum
from xml.etree import ElementTree
import flask
@ -116,6 +117,12 @@ class ChangePasswordForm(FlaskForm):
raise ValidationError("Mot de passe actuel incorrect, ré-essayez")
class Mode(IntEnum):
WELCOME_AND_CHANGE_PASSWORD = auto()
WELCOME_ONLY = auto()
SILENT = auto()
@bp.route("/")
@bp.route("/index_html")
@scodoc
@ -145,6 +152,8 @@ def user_info(user_name, format="json"):
def create_user_form(user_name=None, edit=0, all_roles=1):
"form. création ou edition utilisateur"
auth_dept = current_user.dept
auth_username = current_user.user_name
from_mail = current_user.email
initvalues = {}
edit = int(edit)
all_roles = int(all_roles)
@ -529,19 +538,20 @@ def create_user_form(user_name=None, edit=0, all_roles=1):
)
return "\n".join(H) + msg + "\n" + tf[1] + F
# Traitement initial (mode) : 3 cas
# cf énumération Mode
# A: envoi de welcome + procedure de reset
# B: envoi de welcome seulement (mot de passe saisie dans le formulaire)
# C: Aucun envoi (mot de passe saisi dans le formulaire)
if vals["welcome:list"] == "1":
if vals["reset_password:list"] == "1":
mode = "A"
mode = Mode.WELCOME_AND_CHANGE_PASSWORD
else:
mode = "B"
mode = Mode.WELCOME_ONLY
else:
mode = "C"
mode = Mode.SILENT
# check passwords
if mode == "A":
if mode == Mode.WELCOME_AND_CHANGE_PASSWORD:
vals["password"] = generate_password()
else:
if vals["password"]:
@ -567,14 +577,14 @@ def create_user_form(user_name=None, edit=0, all_roles=1):
db.session.add(u)
db.session.commit()
# envoi éventuel d'un message
if mode == "A" or mode == "B":
if mode == "A":
if mode == Mode.WELCOME_AND_CHANGE_PASSWORD or mode == Mode.WELCOME_ONLY:
if mode == Mode.WELCOME_AND_CHANGE_PASSWORD:
token = u.get_reset_password_token()
else:
token = None
send_email(
"[ScoDoc] Création de votre compte",
sender=current_app.config["ADMINS"][0],
sender=from_mail, # current_app.config["ADMINS"][0],
recipients=[u.email],
text_body=render_template("email/welcome.txt", user=u, token=token),
html_body=render_template(
@ -787,7 +797,10 @@ def form_change_password(user_name=None):
return redirect(destination)
return render_template(
"auth/change_password.html", form=form, title="Modification compte ScoDoc"
"auth/change_password.html",
form=form,
title="Modification compte ScoDoc",
auth_username=current_user.user_name,
)

View File

@ -0,0 +1,34 @@
"""cascade tags modules
Revision ID: 75cf18659984
Revises: d74b4e16fb3c
Create Date: 2021-10-26 10:17:15.547905
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '75cf18659984'
down_revision = 'd74b4e16fb3c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('notes_modules_tags_tag_id_fkey', 'notes_modules_tags', type_='foreignkey')
op.drop_constraint('notes_modules_tags_module_id_fkey', 'notes_modules_tags', type_='foreignkey')
op.create_foreign_key(None, 'notes_modules_tags', 'notes_tags', ['tag_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key(None, 'notes_modules_tags', 'notes_modules', ['module_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'notes_modules_tags', type_='foreignkey')
op.drop_constraint(None, 'notes_modules_tags', type_='foreignkey')
op.create_foreign_key('notes_modules_tags_module_id_fkey', 'notes_modules_tags', 'notes_modules', ['module_id'], ['id'])
op.create_foreign_key('notes_modules_tags_tag_id_fkey', 'notes_modules_tags', 'notes_tags', ['tag_id'], ['id'])
# ### end Alembic commands ###

View File

@ -1,133 +0,0 @@
#!/usr/bin/env python3
# -*- mode: python -*-
# -*- coding: utf-8 -*-
"""Exemple connexion sur ScoDoc et utilisation de l'API
- Ouverture session
- Liste semestres
- Liste modules
- Creation d'une évaluation
- Saisie d'une note
Attention: cet exemple est en Python 3 (>= 3.6)
"""
import requests
import urllib3
import pdb
from pprint import pprint as pp
from flask import g, url_for
# A modifier pour votre serveur:
CHECK_CERTIFICATE = False # set to True in production
BASEURL = "https://scodoc.xxx.net/ScoDoc/RT/Scolarite"
USER = "XXX"
PASSWORD = "XXX"
# ---
if not CHECK_CERTIFICATE:
urllib3.disable_warnings()
class ScoError(Exception):
pass
def GET(s, path, errmsg=None):
"""Get and returns as JSON"""
r = s.get(BASEURL + "/" + path, verify=CHECK_CERTIFICATE)
if r.status_code != 200:
raise ScoError(errmsg or "erreur !")
return r.json() # decode la reponse JSON
def POST(s, path, data, errmsg=None):
"""Post"""
r = s.post(BASEURL + "/" + path, data=data, verify=CHECK_CERTIFICATE)
if r.status_code != 200:
raise ScoError(errmsg or "erreur !")
return r.text
# --- Ouverture session (login)
s = requests.Session()
s.post(
"https://deb11.viennet.net/api/auth/login",
data={"user_name": USER, "password": PASSWORD},
)
r = s.get(BASEURL, auth=(USER, PASSWORD), verify=CHECK_CERTIFICATE)
if r.status_code != 200:
raise ScoError("erreur de connection: vérifier adresse et identifiants")
# --- Recupere la liste de tous les semestres:
sems = GET(s, "Notes/formsemestre_list?format=json", "Aucun semestre !")
# sems est une liste de semestres (dictionnaires)
for sem in sems:
if sem["etat"]:
break
if sem["etat"] == "0":
raise ScoError("Aucun semestre non verrouillé !")
# Affiche le semestre trouvé:
pp(sem)
# ---- Récupère la description de ce semestre:
# semdescr = GET(s, f"Notes/formsemestre_description?formsemestre_id={sem['formsemestre_id']}&with_evals=0&format=json" )
# ---- Liste les modules et prend le premier
mods = GET(s, f"/Notes/moduleimpl_list?formsemestre_id={sem['formsemestre_id']}")
print(f"{len(mods)} modules dans le semestre {sem['titre']}")
mod = mods[0]
# ---- Etudiants inscrits dans ce module
inscrits = GET(
s, f"Notes/do_moduleimpl_inscription_list?moduleimpl_id={mod['moduleimpl_id']}"
)
print(f"{len(inscrits)} inscrits dans ce module")
# prend le premier inscrit, au hasard:
etudid = inscrits[0]["etudid"]
# ---- Création d'une evaluation le dernier jour du semestre
jour = sem["date_fin"]
evaluation_id = POST(
s,
"/Notes/do_evaluation_create",
data={
"moduleimpl_id": mod["moduleimpl_id"],
"coefficient": 1,
"jour": jour, # "5/9/2019",
"heure_debut": "9h00",
"heure_fin": "10h00",
"note_max": 20, # notes sur 20
"description": "essai",
},
errmsg="échec création évaluation",
)
print(
f"Evaluation créée dans le module {mod['moduleimpl_id']}, evaluation_id={evaluation_id}"
)
print(
"Pour vérifier, aller sur: ",
url_for(
"notes.moduleimpl_status",
scodoc_dept="DEPT",
moduleimpl_id=mod["moduleimpl_id"],
),
)
# ---- Saisie d'une note
junk = POST(
s,
"/Notes/save_note",
data={
"etudid": etudid,
"evaluation_id": evaluation_id,
"value": 16.66, # la note !
"comment": "test API",
},
)

View File

@ -319,9 +319,9 @@ def import_scodoc7_dept(dept: str, dept_db_name: str = ""): # import-scodoc7-de
@app.cli.command()
@click.argument("dept", default="")
@with_appcontext
def migrate_scodoc7_dept_archive(dept: str): # migrate-scodoc7-dept-archive
def migrate_scodoc7_dept_archives(dept: str): # migrate-scodoc7-dept-archives
"""Post-migration: renomme les archives en fonction des id de ScoDoc 9"""
tools.migrate_scodoc7_dept_archive(dept)
tools.migrate_scodoc7_dept_archives(dept)
@app.cli.command()

View File

@ -0,0 +1,149 @@
#!/usr/bin/env python3
# -*- mode: python -*-
# -*- coding: utf-8 -*-
"""Exemple utilisation API ScoDoc 9 avec jeton obtenu par basic athentication
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).
Travail en cours, un seul point d'API (list_depts).
"""
from dotenv import load_dotenv
import os
import pdb
import requests
import urllib3
from pprint import pprint as pp
# --- Lecture configuration (variables d'env ou .env)
BASEDIR = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(BASEDIR, ".env"))
CHECK_CERTIFICATE = bool(int(os.environ.get("CHECK_CERTIFICATE", False)))
SCODOC_URL = os.environ["SCODOC_URL"]
SCODOC_DEPT = os.environ["SCODOC_DEPT"]
DEPT_URL = SCODOC_URL + "/ScoDoc/" + SCODOC_DEPT + "/Scolarite/"
SCODOC_USER = os.environ["SCODOC_USER"]
SCODOC_PASSWORD = os.environ["SCODOC_PASSWD"]
print(f"SCODOC_URL={SCODOC_URL}")
# ---
if not CHECK_CERTIFICATE:
urllib3.disable_warnings()
class ScoError(Exception):
pass
def GET(path: str, headers={}, errmsg=None):
"""Get and returns as JSON"""
r = requests.get(
DEPT_URL + "/" + path, headers=headers or HEADERS, verify=CHECK_CERTIFICATE
)
if r.status_code != 200:
raise ScoError(errmsg or "erreur !")
return r.json() # decode la reponse JSON
def POST(s, path: str, data: dict, errmsg=None):
"""Post"""
r = s.post(DEPT_URL + "/" + path, data=data, verify=CHECK_CERTIFICATE)
if r.status_code != 200:
raise ScoError(errmsg or "erreur !")
return r.text
# --- Obtention du jeton (token)
r = requests.post(
SCODOC_URL + "/ScoDoc/api/tokens", auth=(SCODOC_USER, SCODOC_PASSWORD)
)
assert r.status_code == 200
token = r.json()["token"]
HEADERS = {"Authorization": f"Bearer {token}"}
r = requests.get(
SCODOC_URL + "/ScoDoc/api/list_depts", headers=HEADERS, verify=CHECK_CERTIFICATE
)
if r.status_code != 200:
raise ScoError("erreur de connexion: vérifier adresse et identifiants")
pp(r.json())
# # --- Recupere la liste de tous les semestres:
# sems = GET(s, "Notes/formsemestre_list?format=json", "Aucun semestre !")
# # sems est une liste de semestres (dictionnaires)
# for sem in sems:
# if sem["etat"]:
# break
# if sem["etat"] == "0":
# raise ScoError("Aucun semestre non verrouillé !")
# # Affiche le semestre trouvé:
# pp(sem)
# # ---- Récupère la description de ce semestre:
# # semdescr = GET(s, f"Notes/formsemestre_description?formsemestre_id={sem['formsemestre_id']}&with_evals=0&format=json" )
# # ---- Liste les modules et prend le premier
# mods = GET(s, f"/Notes/moduleimpl_list?formsemestre_id={sem['formsemestre_id']}")
# print(f"{len(mods)} modules dans le semestre {sem['titre']}")
# mod = mods[0]
# # ---- Etudiants inscrits dans ce module
# inscrits = GET(
# s, f"Notes/do_moduleimpl_inscription_list?moduleimpl_id={mod['moduleimpl_id']}"
# )
# print(f"{len(inscrits)} inscrits dans ce module")
# # prend le premier inscrit, au hasard:
# etudid = inscrits[0]["etudid"]
# # ---- Création d'une evaluation le dernier jour du semestre
# jour = sem["date_fin"]
# evaluation_id = POST(
# s,
# "/Notes/do_evaluation_create",
# data={
# "moduleimpl_id": mod["moduleimpl_id"],
# "coefficient": 1,
# "jour": jour, # "5/9/2019",
# "heure_debut": "9h00",
# "heure_fin": "10h00",
# "note_max": 20, # notes sur 20
# "description": "essai",
# },
# errmsg="échec création évaluation",
# )
# print(
# f"Evaluation créée dans le module {mod['moduleimpl_id']}, evaluation_id={evaluation_id}"
# )
# print(
# f"Pour vérifier, aller sur: {DEPT_URL}/Notes/moduleimpl_status?moduleimpl_id={mod['moduleimpl_id']}",
# )
# # ---- Saisie d'une note
# junk = POST(
# s,
# "/Notes/save_note",
# data={
# "etudid": etudid,
# "evaluation_id": evaluation_id,
# "value": 16.66, # la note !
# "comment": "test API",
# },
# )

View File

@ -0,0 +1,197 @@
#!/usr/bin/env python3
# -*- mode: python -*-
# -*- coding: utf-8 -*-
"""Exemple connexion sur ScoDoc 9 et utilisation de l'ancienne API ScoDoc 7
à la mode "PHP": les gens passaient directement __ac_name et __ac_password
dans chaque requête, en POST ou en GET.
Cela n'a jamais été documenté mais était implicitement supporté. C'est "deprecated"
et ne sera plus supporté à partir de juillet 2022.
Ce script va tester:
- Liste semestres
- Liste modules
- Creation d'une évaluation
- Saisie d'une note
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).
"""
from dotenv import load_dotenv
import json
import os
import pdb
import requests
import urllib3
from pprint import pprint as pp
# --- Lecture configuration (variables d'env ou .env)
BASEDIR = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(BASEDIR, ".env"))
CHECK_CERTIFICATE = bool(int(os.environ.get("CHECK_CERTIFICATE", False)))
SCODOC_URL = os.environ["SCODOC_URL"]
SCODOC_DEPT = os.environ["SCODOC_DEPT"]
DEPT_URL = SCODOC_URL + "/ScoDoc/" + SCODOC_DEPT + "/Scolarite"
SCODOC_USER = os.environ["SCODOC_USER"]
SCODOC_PASSWORD = os.environ["SCODOC_PASSWD"]
print(f"SCODOC_URL={SCODOC_URL}")
# ---
if not CHECK_CERTIFICATE:
urllib3.disable_warnings()
class ScoError(Exception):
pass
def GET(path: str, params=None, errmsg=None):
"""Get and returns as JSON"""
# ajoute auth
params["__ac_name"] = SCODOC_USER
params["__ac_password"] = SCODOC_PASSWORD
r = requests.get(DEPT_URL + "/" + path, params=params, verify=CHECK_CERTIFICATE)
if r.status_code != 200:
raise ScoError(errmsg or "erreur !")
return r.json() # decode la reponse JSON
def POST(path: str, data: dict, errmsg=None):
"""Post"""
data["__ac_name"] = data.get("__ac_name", SCODOC_USER)
data["__ac_password"] = data.get("__ac_password", SCODOC_PASSWORD)
r = requests.post(DEPT_URL + "/" + path, data=data, verify=CHECK_CERTIFICATE)
return r
# ---
# pas besoin d'ouvrir une session, on y va directement:
# --- Recupere la liste de tous les semestres:
sems = GET("Notes/formsemestre_list", params={"format": "json"})
# sems est une liste de semestres (dictionnaires)
for sem in sems:
if sem["etat"]:
break
if sem["etat"] == "0":
raise ScoError("Aucun semestre non verrouillé !")
# Affiche le semestre trouvé:
pp(sem)
# Liste des étudiants dans le 1er semestre non verrouillé:
group_list = GET(
"groups_view",
params={
"formsemestre_id": sem["formsemestre_id"],
"with_codes": 1,
"format": "json",
},
)
if not group_list:
# config inadaptée pour les tests...
raise ScoError("aucun étudiant inscrit dans le semestre")
etud = group_list[0] # le premier étudiant inscrit ici
# test un POST
r = POST(
"Absences/AddBilletAbsence",
{
"begin": "2021-10-25",
"end": "2021-10-26",
"description": "test API scodoc7",
"etudid": etud["etudid"],
},
)
assert r.status_code == 200
assert r.text.startswith('<?xml version="1.0" encoding="utf-8"?>')
assert "billet_id" in r.text
# Essai avec un compte invalide
r_invalid = POST(
"Absences/AddBilletAbsence",
{
"__ac_name": "xxx",
"begin": "2021-10-25",
"end": "2021-10-26",
"description": "test API scodoc7",
"etudid": etud["etudid"],
},
)
assert r_invalid.status_code == 403 # compte invalide => not authorized
# AddBilletAbsence en json
r = POST(
"Absences/AddBilletAbsence",
{
"begin": "2021-10-25",
"end": "2021-10-26",
"description": "test API scodoc7",
"etudid": etud["etudid"],
"xml_reply": 0,
},
)
assert r.status_code == 200
assert isinstance(json.loads(r.text)[0]["billet_id"], int)
# Les fonctions ci-dessous ne fonctionnent plus en ScoDoc 9
# Voir https://scodoc.org/git/viennet/ScoDoc/issues/149
# # ---- Liste les modules et prend le premier
# mods = GET("/Notes/moduleimpl_list", params={"formsemestre_id": sem["formsemestre_id"]})
# print(f"{len(mods)} modules dans le semestre {sem['titre']}")
# mod = mods[0]
# # ---- Etudiants inscrits dans ce module
# inscrits = GET(
# "Notes/do_moduleimpl_inscription_list",
# params={"moduleimpl_id": mod["moduleimpl_id"]},
# )
# print(f"{len(inscrits)} inscrits dans ce module")
# # prend le premier inscrit, au hasard:
# etudid = inscrits[0]["etudid"]
# # ---- Création d'une evaluation le dernier jour du semestre
# jour = sem["date_fin"]
# evaluation_id = POST(
# "/Notes/do_evaluation_create",
# data={
# "moduleimpl_id": mod["moduleimpl_id"],
# "coefficient": 1,
# "jour": jour, # "5/9/2019",
# "heure_debut": "9h00",
# "heure_fin": "10h00",
# "note_max": 20, # notes sur 20
# "description": "essai",
# },
# errmsg="échec création évaluation",
# )
# print(
# f"Evaluation créée dans le module {mod['moduleimpl_id']}, evaluation_id={evaluation_id}"
# )
# print(
# f"Pour vérifier, aller sur: {DEPT_URL}/Notes/moduleimpl_status?moduleimpl_id={mod['moduleimpl_id']}",
# )
# # ---- Saisie d'une note
# junk = POST(
# "/Notes/save_note",
# data={
# "etudid": etudid,
# "evaluation_id": evaluation_id,
# "value": 16.66, # la note !
# "comment": "test API",
# },
# )

View File

@ -0,0 +1,307 @@
"""Test calculs moyennes de modules
Vérif moyennes de modules des bulletins
et aussi moyennes modules et UE internes (via nt)
"""
from config import TestConfig
from tests.unit import sco_fake_gen
from flask import g
import app
from app.scodoc import sco_bulletins
from app.scodoc import sco_cache
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_utils as scu
DEPT = TestConfig.DEPT_TEST
def check_nt(
etudid,
formsemestre_id,
ue_id,
moduleimpl_id,
expected_moy_ue=False,
expected_mod_moy=False,
expected_sum_coefs_ue=False,
):
"""Vérification bas niveau: vérif resultat avec l'API internet "nt"
(peut changer dans le futur, ne pas utiliser hors ScoDoc !)
ne vérifie que les valeurs expected non False
"""
nt = sco_cache.NotesTableCache.get(formsemestre_id)
mod_moy = nt.get_etud_mod_moy(moduleimpl_id, etudid)
if expected_moy_ue is not False:
ue_status = nt.get_etud_ue_status(etudid, ue_id)
assert expected_moy_ue == ue_status["moy"]
if expected_mod_moy is not False:
assert expected_mod_moy == mod_moy
if expected_sum_coefs_ue is not False:
ue_status = nt.get_etud_ue_status(etudid, ue_id)
assert expected_sum_coefs_ue == ue_status["sum_coefs"]
def test_notes_modules(test_client):
"""Test calcul des moyennes de modules et d'UE
Création étudiant, formation, semestre, inscription etudiant,
création evaluation, saisie de notes.
Vérifie calcul moyenne avec absences (ABS), excuse (EXC), attente (ATT)
"""
app.set_sco_dept(DEPT)
G = sco_fake_gen.ScoFake(verbose=False)
etuds = [G.create_etud(code_nip=None) for i in range(2)] # 2 étudiants
f = G.create_formation(acronyme="")
ue = G.create_ue(formation_id=f["formation_id"], acronyme="TST1", titre="ue test")
ue_id = ue["ue_id"]
mat = G.create_matiere(ue_id=ue_id, titre="matière test")
coef_mod_1 = 1.5
mod = G.create_module(
matiere_id=mat["matiere_id"],
code="TSM1",
coefficient=coef_mod_1,
titre="module test",
ue_id=ue["ue_id"],
formation_id=f["formation_id"],
)
# --- Mise place d'un semestre
sem = G.create_formsemestre(
formation_id=f["formation_id"],
semestre_id=1,
date_debut="01/01/2020",
date_fin="30/06/2020",
)
formsemestre_id = sem["formsemestre_id"]
mi = G.create_moduleimpl(
module_id=mod["module_id"],
formsemestre_id=formsemestre_id,
)
moduleimpl_id = mi["moduleimpl_id"]
# --- Inscription des étudiants
for etud in etuds:
G.inscrit_etudiant(sem, etud)
etud = etuds[0]
etudid = etud["etudid"]
# --- Creation évaluations: e1, e2
coef_1 = 1.0
coef_2 = 2.0
e1 = G.create_evaluation(
moduleimpl_id=moduleimpl_id,
jour="01/01/2020",
description="evaluation 1",
coefficient=coef_1,
)
e2 = G.create_evaluation(
moduleimpl_id=moduleimpl_id,
jour="01/01/2020",
description="evaluation 2",
coefficient=coef_2,
)
# --- Notes ordinaires
note_1 = 12.0
note_2 = 13.0
_, _, _ = G.create_note(evaluation=e1, etud=etuds[0], note=note_1)
_, _, _ = G.create_note(evaluation=e2, etud=etuds[0], note=note_2)
_, _, _ = G.create_note(evaluation=e1, etud=etuds[1], note=note_1 / 2)
_, _, _ = G.create_note(evaluation=e2, etud=etuds[1], note=note_2 / 3)
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
# Vérifie structure du bulletin:
assert b["etudid"] == etud["etudid"]
assert len(b["ues"][0]["modules"][0]["evaluations"]) == 2
assert len(b["ues"][0]["modules"]) == 1
# Note moyenne:
note_th = (coef_1 * note_1 + coef_2 * note_2) / (coef_1 + coef_2)
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(note_th)
check_nt(
etudid,
formsemestre_id,
ue_id,
moduleimpl_id,
expected_mod_moy=note_th,
expected_moy_ue=note_th,
expected_sum_coefs_ue=coef_mod_1,
)
# Absence à une évaluation
_, _, _ = G.create_note(evaluation=e1, etud=etud, note=None) # abs
_, _, _ = G.create_note(evaluation=e2, etud=etud, note=note_2)
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
note_th = (coef_1 * 0.0 + coef_2 * note_2) / (coef_1 + coef_2)
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(note_th)
# Absences aux deux évaluations
_, _, _ = G.create_note(evaluation=e1, etud=etud, note=None) # abs
_, _, _ = G.create_note(evaluation=e2, etud=etud, note=None) # abs
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(0.0)
check_nt(
etudid,
formsemestre_id,
ue_id,
moduleimpl_id,
expected_mod_moy=0.0,
expected_moy_ue=0.0,
expected_sum_coefs_ue=0.0,
)
# Note excusée EXC <-> scu.NOTES_NEUTRALISE
_, _, _ = G.create_note(evaluation=e1, etud=etud, note=note_1)
_, _, _ = G.create_note(evaluation=e2, etud=etud, note=scu.NOTES_NEUTRALISE) # EXC
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(note_1)
check_nt(
etudid,
formsemestre_id,
ue_id,
moduleimpl_id,
expected_mod_moy=note_1,
expected_moy_ue=note_1,
expected_sum_coefs_ue=coef_mod_1,
)
# Note en attente ATT <-> scu.NOTES_ATTENTE
_, _, _ = G.create_note(evaluation=e1, etud=etud, note=note_1)
_, _, _ = G.create_note(evaluation=e2, etud=etud, note=scu.NOTES_ATTENTE) # ATT
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(note_1)
check_nt(
etudid,
formsemestre_id,
ue_id,
moduleimpl_id,
expected_mod_moy=note_1,
expected_moy_ue=note_1,
expected_sum_coefs_ue=coef_mod_1,
)
# Neutralisation (EXC) des 2 évals
_, _, _ = G.create_note(evaluation=e1, etud=etud, note=scu.NOTES_NEUTRALISE) # EXC
_, _, _ = G.create_note(evaluation=e2, etud=etud, note=scu.NOTES_NEUTRALISE) # EXC
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == "-"
check_nt(
etudid,
sem["formsemestre_id"],
ue["ue_id"],
mi["moduleimpl_id"],
expected_mod_moy="NA0",
expected_moy_ue=0.0,
expected_sum_coefs_ue=0.0,
)
# Attente (ATT) sur les 2 evals
_, _, _ = G.create_note(evaluation=e1, etud=etud, note=scu.NOTES_ATTENTE) # ATT
_, _, _ = G.create_note(evaluation=e2, etud=etud, note=scu.NOTES_ATTENTE) # ATT
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == "-"
check_nt(
etudid,
sem["formsemestre_id"],
ue["ue_id"],
mi["moduleimpl_id"],
expected_mod_moy="NA0",
expected_moy_ue=0.0,
expected_sum_coefs_ue=0.0,
)
# Non inscrit
# - désinscrit notre étudiant:
inscr = sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=mi["moduleimpl_id"], etudid=etud["etudid"]
)
assert len(inscr) == 1
oid = inscr[0]["moduleimpl_inscription_id"]
sco_moduleimpl.do_moduleimpl_inscription_delete(
oid, formsemestre_id=mi["formsemestre_id"]
)
# -
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
assert b["ues"] == [] # inscrit à aucune UE !
check_nt(
etudid,
formsemestre_id,
ue_id,
moduleimpl_id,
expected_mod_moy="NI",
expected_moy_ue=0.0,
expected_sum_coefs_ue=0.0,
)
# --- Maintenant avec 2 modules dans l'UE
mod2 = G.create_module(
matiere_id=mat["matiere_id"],
code="TSM2",
coefficient=coef_mod_2,
titre="module test 2",
ue_id=ue_id,
formation_id=f["formation_id"],
)
mi2 = G.create_moduleimpl(
module_id=mod2["module_id"],
formsemestre_id=formsemestre_id,
)
# Re-inscription au premier module de l'UE
sco_moduleimpl.do_moduleimpl_inscription_create(
{"etudid": etudid, "moduleimpl_id": mi["moduleimpl_id"]},
formsemestre_id=formsemestre_id,
)
_, _, _ = G.create_note(evaluation=e1, etud=etud, note=12.5)
nt = sco_cache.NotesTableCache.get(formsemestre_id)
ue_status = nt.get_etud_ue_status(etudid, ue_id)
assert ue_status["nb_missing"] == 1 # 1 même si etud non inscrit à l'autre module
assert ue_status["nb_notes"] == 1
assert not ue_status["was_capitalized"]
# Inscription au deuxième module de l'UE
sco_moduleimpl.do_moduleimpl_inscription_create(
{"etudid": etudid, "moduleimpl_id": mi2["moduleimpl_id"]},
formsemestre_id=formsemestre_id,
)
nt = sco_cache.NotesTableCache.get(formsemestre_id)
ue_status = nt.get_etud_ue_status(etudid, ue_id)
assert ue_status["nb_missing"] == 1 # mi2 n'a pas encore de note
assert ue_status["nb_notes"] == 1
# Note dans module 2:
e_m2 = G.create_evaluation(
moduleimpl_id=mi2["moduleimpl_id"],
jour="01/01/2020",
description="evaluation mod 2",
coefficient=1.0,
)
_, _, _ = G.create_note(evaluation=e_m2, etud=etud, note=19.5)
nt = sco_cache.NotesTableCache.get(formsemestre_id)
ue_status = nt.get_etud_ue_status(etudid, ue_id)
assert ue_status["nb_missing"] == 0
assert ue_status["nb_notes"] == 2
# Moyenne d'UE si l'un des modules est EXC ("NA0")
# 2 modules, notes EXC dans le premier, note valide n dans le second
# la moyenne de l'UE doit être n
_, _, _ = G.create_note(evaluation=e1, etud=etud, note=scu.NOTES_NEUTRALISE) # EXC
_, _, _ = G.create_note(evaluation=e2, etud=etud, note=scu.NOTES_NEUTRALISE) # EXC
_, _, _ = G.create_note(evaluation=e_m2, etud=etud, note=12.5)
_, _, _ = G.create_note(evaluation=e1, etud=etuds[1], note=11.0)
_, _, _ = G.create_note(evaluation=e2, etud=etuds[1], note=11.0)
_, _, _ = G.create_note(evaluation=e_m2, etud=etuds[1], note=11.0)
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
assert b["ues"][0]["ue_status"]["cur_moy_ue"] == 12.5
assert b["ues"][0]["ue_status"]["moy"] == 12.5
b2 = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etuds[1]["etudid"]
)
assert b2["ues"][0]["ue_status"]["cur_moy_ue"] == 11.0
assert b2["ues"][0]["ue_status"]["moy"] == 11

View File

@ -15,8 +15,8 @@ DEPT = TestConfig.DEPT_TEST
def test_notes_rattrapage(test_client):
"""Test quelques opérations élémentaires de ScoDoc
Création 10 étudiants, formation, semestre, inscription etudiant,
creation 1 evaluation, saisie 10 notes.
Création 1 étudiant, formation, semestre, inscription etudiant,
creation 1 evaluation, saisie notes.
"""
app.set_sco_dept(DEPT)

View File

@ -6,4 +6,4 @@
from tools.import_scodoc7_user_db import import_scodoc7_user_db
from tools.import_scodoc7_dept import import_scodoc7_dept
from tools.migrate_scodoc7_archives import migrate_scodoc7_dept_archive
from tools.migrate_scodoc7_archives import migrate_scodoc7_dept_archives

View File

@ -233,7 +233,7 @@ do
done
# ----- Post-Migration: renomme archives en fonction des nouveaux ids
su -c "(cd $SCODOC_DIR && source venv/bin/activate && flask migrate-scodoc7-dept-archive)" "$SCODOC_USER" || die "Erreur de la post-migration des archives"
su -c "(cd $SCODOC_DIR && source venv/bin/activate && flask migrate-scodoc7-dept-archives)" "$SCODOC_USER" || die "Erreur de la post-migration des archives"
# --- Si migration "en place", désactive ScoDoc 7

View File

@ -11,7 +11,7 @@ from app.models.formsemestre import FormSemestre
from app.models.etudiants import Identite
def migrate_scodoc7_dept_archive(dept_name=""):
def migrate_scodoc7_dept_archives(dept_name=""):
if dept_name:
depts = Departement.query.filter_by(acronym=dept_name)
else: