ScoDoc/app/scodoc/sco_tag_module.py

333 lines
11 KiB
Python

# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 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
#
##############################################################################
"""Gestion des tags sur les modules
Implementation expérimentale (Jul. 2016) pour grouper les modules sur
les avis de poursuites d'études.
Pour l'UI, voir https://goodies.pixabay.com/jquery/tag-editor/demo.html
"""
import http
import re
from flask import g
from app import db, log
from app.models import Formation, NotesTag
from app.scodoc import sco_edit_module
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc.sco_exceptions import ScoValueError
# Opérations implementées:
# + liste des modules des formations de code donné (formation_code) avec ce tag
# + liste de tous les noms de tag
# + tag pour un nom
# + creer un tag (nom)
# + lier un tag à un module
# + enlever un tag d'un module (si c'est le dernier, supprimer le tag lui même)
#
# API publiée:
# module_tag_list_all -> tous les noms d etags (pour l'autocomplete)
# module_tag_list( module_id ) -> les noms de tags associés à ce module
# module_tag_set( module_id, taglist ) -> modifie les tags
# NOTA: ancien code, n'utile pas de modèles SQLAlchemy
class ScoTag(object):
"""Generic tags for ScoDoc"""
# must be overloaded:
tag_table = None # table (tag_id, title)
assoc_table = None # table (tag_id, object_id)
obj_colname = None # column name for object_id in assoc_table
def __init__(self, title, object_id=""):
"""Load tag, or create if does not exist"""
self.title = title.strip()
if not self.title:
raise ScoValueError("invalid empty tag")
r = ndb.SimpleDictFetch(
"SELECT id as tag_id, * FROM "
+ self.tag_table
+ " WHERE dept_id=%(dept_id)s AND title = %(title)s",
{"dept_id": g.scodoc_dept_id, "title": self.title},
)
if r:
self.tag_id = r[0]["tag_id"]
else:
# Create new tag:
# log("creating new tag: %s" % self.title)
cnx = ndb.GetDBConnexion()
self.tag_id = ndb.DBInsertDict(
cnx,
self.tag_table,
{"title": self.title, "dept_id": g.scodoc_dept_id},
commit=True,
)
if object_id:
self.tag_object(object_id)
def __repr__(self): # debug
return '<tag "%s">' % self.title
@classmethod
def check_tag_title(cls, title: str) -> bool:
""" "true si le tag est acceptable: longueur max (32), syntaxe
un_tag ou un_tag:78
"""
if not title or len(title) > 32:
return False
if re.match(r"^[\w0-9\-_$!?+=,&\.]*(:[0-9]*)?$", title):
return True
return False
def delete(self):
"""Delete this tag.
This object should not be used after this call !
"""
args = {"tag_id": self.tag_id}
ndb.SimpleQuery(
"DELETE FROM " + self.tag_table + " t WHERE t.id = %(tag_id)s",
args,
)
def tag_object(self, object_id):
"""Associate tag to given object"""
args = {self.obj_colname: object_id, "tag_id": self.tag_id}
r = ndb.SimpleDictFetch(
"SELECT * FROM "
+ self.assoc_table
+ " a WHERE a."
+ self.obj_colname
+ " = %("
+ self.obj_colname
+ ")s AND a.tag_id = %(tag_id)s",
args,
)
if not r:
# log("tag %s with %s" % (object_id, self.title))
cnx = ndb.GetDBConnexion()
query = f"""INSERT INTO {self.assoc_table}
(tag_id, {self.obj_colname})
VALUES"""
ndb.SimpleQuery(
query + " (%(tag_id)s, %(object_id)s)",
{"tag_id": self.tag_id, "object_id": object_id},
)
cnx.commit()
def remove_tag_from_object(self, object_id):
"""Remove tag from module.
If no more modules tagged with this tag, delete it.
Return True if Tag still exists.
"""
# log("removing tag %s from %s" % (self.title, object_id))
args = {"object_id": object_id, "tag_id": self.tag_id}
ndb.SimpleQuery(
"DELETE FROM "
+ self.assoc_table
+ " a WHERE a."
+ self.obj_colname
+ " = %(object_id)s AND a.tag_id = %(tag_id)s",
args,
)
r = ndb.SimpleDictFetch(
"""SELECT * FROM notes_modules_tags mt WHERE tag_id = %(tag_id)s
""",
args,
)
if not r:
# tag no more used, delete
ndb.SimpleQuery(
"""DELETE FROM notes_tags t WHERE t.id = %(tag_id)s""",
args,
)
class ModuleTag(ScoTag):
"""Tags sur les modules dans les programmes pédagogiques"""
tag_table = "notes_tags" # table (tag_id, title)
assoc_table = "notes_modules_tags" # table (tag_id, object_id)
obj_colname = "module_id" # column name for object_id in assoc_table
def list_modules(self, formation_code=""):
"""Liste des modules des formations de code donné (formation_code) avec ce tag"""
args = {"tag_id": self.tag_id}
if not formation_code:
# tous les modules de toutes les formations !
r = ndb.SimpleDictFetch(
"SELECT id AS"
+ self.obj_colname
+ " FROM "
+ self.assoc_table
+ " WHERE tag_id = %(tag_id)s",
args,
)
else:
args["formation_code"] = formation_code
r = ndb.SimpleDictFetch(
"""SELECT mt.module_id
FROM notes_modules_tags mt, notes_modules m, notes_formations f
WHERE mt.tag_id = %(tag_id)s
AND m.id = mt.module_id
AND m.formation_id = f.id
AND f.formation_code = %(formation_code)s
""",
args,
)
return [x["module_id"] for x in r]
# API
# TODO placer dans la vraie API et ne plus utiliser sco_publish
def module_tag_search(term: str | int):
"""List all used tag names (for auto-completion)"""
term = "" if term is None else str(term)
# restrict charset to avoid injections
if not scu.ALPHANUM_EXP.match(term):
data = []
else:
r = ndb.SimpleDictFetch(
"SELECT title FROM notes_tags WHERE title LIKE %(term)s AND dept_id=%(dept_id)s",
{
"term": term + "%",
"dept_id": g.scodoc_dept_id,
},
)
data = [x["title"] for x in r]
return scu.sendJSON(data)
def module_tag_list(module_id="") -> list[str]:
"""les noms de tags associés à ce module"""
r = ndb.SimpleDictFetch(
"""SELECT t.title
FROM notes_modules_tags mt, notes_tags t
WHERE mt.tag_id = t.id
AND mt.module_id = %(module_id)s
""",
{"module_id": module_id},
)
return [x["title"] for x in r]
def module_tag_set(module_id="", taglist=None):
"""taglist may either be:
a string with tag names separated by commas ("un,deux")
or a list of strings (["un", "deux"])
Remplace les tags existants
"""
if not taglist:
taglist = []
elif isinstance(taglist, str):
taglist = taglist.split(",")
taglist = [t.strip() for t in taglist]
log(f"module_tag_set: module_id={module_id} taglist={taglist}")
# Check tags syntax
for tag in taglist:
if not ScoTag.check_tag_title(tag):
log(f"module_tag_set({module_id}): invalid tag title")
return scu.json_error(404, "invalid tag")
# TODO code à moderniser (+ revoir classe ScoTag, utiliser modèle)
# Sanity check:
mod_dict = sco_edit_module.module_list(args={"module_id": module_id})
if not mod_dict:
raise ScoValueError("invalid module !")
newtags = set(taglist)
oldtags = set(module_tag_list(module_id))
to_del = oldtags - newtags
to_add = newtags - oldtags
# should be atomic, but it's not.
for tagname in to_add:
t = ModuleTag(tagname, object_id=module_id)
for tagname in to_del:
t = ModuleTag(tagname)
t.remove_tag_from_object(module_id)
return "", http.HTTPStatus.NO_CONTENT
def split_tagname_coeff(tag: str, separateur=":") -> tuple[str, float]:
"""Découpage d'un tag, tel que saisi par un utilisateur dans le programme,
pour en extraire :
* son _nom de tag_ (tagname) (chaine de caractère correspondant au tag)
* un éventuel coefficient de pondération, avec le séparateur fourni (par défaut ":").
Args:
tag: La saisie utilisateur du tag dans le programme
separateur: Le séparateur des informations dans la saisie utilisateur
Return:
Tuple (tagname, coeff_de_ponderation) extrait de la saisie utilisateur
(avec coeff_de_ponderation=1.0 si non mentionné)
Author:
Cléo Baras
"""
if separateur in tag:
temp = tag.split(":")
try:
pond = float(temp[1])
return (temp[0], pond)
except (IndexError, ValueError, TypeError):
# Renvoie tout le tag si le découpage a échoué
return (tag, 1.0)
else:
# initialise le coeff de pondération à 1 lorsqu'aucun coeff de pondération
# n'ait indiqué dans le tag
return (tag, 1.0)
def formation_tag_modules_by_type(formation: Formation):
"""Taggue tous les modules de la formation en fonction de leur type : 'res', 'sae', 'malus'
Ne taggue pas les modules standards.
"""
tag_titles = {
m.type_abbrv() for m in formation.modules
} # usually {'res', 'mod', 'sae'}
tag_by_type = {
tag_title: NotesTag.get_or_create(title=tag_title, dept_id=formation.dept_id)
for tag_title in tag_titles
}
for module in formation.modules:
if module.module_type != scu.ModuleType.STANDARD:
module.add_tag(tag_by_type[module.type_abbrv()])
db.session.commit()