# -*- 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 '' % 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()