From c74abf3fcca4b4a739e94ebd21425bd83c8dad09 Mon Sep 17 00:00:00 2001 From: Lephe Date: Sun, 12 Jun 2022 18:11:46 +0100 Subject: [PATCH] post: add a tagging system, with a common base set of tags Adds the tagging system, with 3 types of tags: * Calculator models grouped by compatibility classes * Programming languages * Game, tools, and course categories [MIGRATION] This commit contains a new version of the schema. [BREAKS] This commit breaks existing tag assignments. [MASTER] Run the 'update-tags' command of master.py. --- app/data/tags.yaml | 102 ++++++++++++++++++ app/models/program.py | 6 +- app/models/tag.py | 34 +++++- master.py | 44 +++++++- .../189bbc0e1543_add_tag_information.py | 42 ++++++++ 5 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 app/data/tags.yaml create mode 100644 migrations/versions/189bbc0e1543_add_tag_information.py diff --git a/app/data/tags.yaml b/app/data/tags.yaml new file mode 100644 index 0000000..8e09d88 --- /dev/null +++ b/app/data/tags.yaml @@ -0,0 +1,102 @@ +# This is a list of all tags, sorted by category. The category names are used +# to name CSS rules and shouldn't be changed directly. + +# The following category groups calculators by common compatibility properties. +# Each comment indicates why the group should exist on its own rather than +# being merged with another one. +calc: + # Middle-school level, only basic algorithms; a unique property in this list. + fx92: + pretty: fx-92 Scientifique Collège+ + # Some of the most limited Graph models, no add-ins. + g25: + pretty: Graph 25/25+E/25+EII + # The whole series with more Basic constructs than g25, but SH3 for add-ins + # We don't separate based on whether an OS update is required (deemed safe) + gsh3: + pretty: Graph 35+/75/85/95 (SH3) + # Same as gsh3, but with SH4 for add-ins; support CasioPython + gsh4: + pretty: Graph 35+/35+E/75+/75+E (SH4) + # Like gsh3, but has Python; also; issues with the display and MonochromLib + g35+e2: + pretty: Graph 35+E II + # Color display, nothing like the previous models + cg20: + pretty: fx-CG 10/20/Prizm + # Like cg20, but has Python, and some incompatibilities on add-in + g90+e: + pretty: Graph 90+E + # Different series entirely; has an SDK for add-ins + cp300: + pretty: Classpad 300/330 + # Like cp300, but does not have an SDK + cp330+: + pretty: Classpad 330+ + # Color display, entirely new model; no SDK + cp400: + pretty: Classpad 400/400+E + +lang: + basic: + pretty: Basic CASIO + cbasic: + pretty: C.Basic + python: + pretty: Python + c: + pretty: C/C++ (add-in) + lua: + pretty: LuaFX + other: + pretty: "Langage: autre" + +games: + adventure: + pretty: Aventure + fighting: + pretty: Combat + other: + pretty: "Jeu: autre" + platform: + pretty: Plateforme + puzzle: + pretty: Puzzle + rpg: + pretty: RPG + rythm: + pretty: Rythme + shooting: + pretty: Tir/FPS + simulation: + pretty: Simulation + sport: + pretty: Sport + strategy: + pretty: Stratégie + survival: + pretty: Survie + +tools: + conversion: + pretty: Outil de conversion + graphics: + pretty: Outil graphique + science: + pretty: Outil scientifique + programming: + pretty: Outil pour programmer + other: + pretty: "Outil: autre" + +courses: + math: + pretty: Maths + physics: + pretty: Physique + engineering: + pretty: SI/Électronique + economics: + pretty: Économie + other: + pretty: "Cours: autre" diff --git a/app/models/program.py b/app/models/program.py index 92c43db..23bf006 100644 --- a/app/models/program.py +++ b/app/models/program.py @@ -13,12 +13,16 @@ class Program(Post): # TODO: Category (games/utilities/lessons) # TODO: Compatible calculator models + # TODO: Number of views, statistics, etc + # Thread with the program description (top comment) and comments thread_id = db.Column(db.Integer,db.ForeignKey('thread.id'),nullable=False) thread = db.relationship('Thread', foreign_keys=thread_id, back_populates='owner_program') - # TODO: Number of views, statistics, attached files, etc + # Implicit attributes: + # * tags (inherited from Post) + # * attachements (available at thread.top_comment.attachments) def __init__(self, author, title, thread): """ diff --git a/app/models/tag.py b/app/models/tag.py index 5d96089..a7ad50d 100644 --- a/app/models/tag.py +++ b/app/models/tag.py @@ -1,15 +1,43 @@ from app import db +class TagInformation(db.Model): + """Detailed information about tags, by dot-string tag identifier.""" + + __tablename__ = 'tag_information' + # The ID is the dot-string of the tag (eg. "calc.g35+e2") + id = db.Column(db.String(64), primary_key=True) + + # List of uses. Note how we load tag information along individual tags, but + # we don't load uses unless the field is accessed. + uses = db.relationship('Tag', back_populates='tag', lazy='dynamic') + + # Pretty name + pretty = db.Column(db.String(64)) + + # ... any other static information about tags + + def __init__(self, id): + self.id = id + class Tag(db.Model): + """Association between a Post and a dot-string tag identifier.""" + __tablename__ = 'tag' id = db.Column(db.Integer, primary_key=True) # Tagged post post_id = db.Column(db.Integer, db.ForeignKey('post.id'), index=True) post = db.relationship('Post', back_populates='tags', foreign_keys=post_id) - # Tag name - name = db.Column(db.String(64), index=True) + # Tag name. Note how we always load the information along the tag, but not + # the other way around. + tag_id = db.Column(db.String(64), db.ForeignKey('tag_information.id'), + index=True) + tag = db.relationship('TagInformation', back_populates='uses', + foreign_keys=tag_id, lazy='joined') def __init__(self, post, tag): self.post = post - self.name = tag + + if isinstance(tag, str): + tag = TagInformation.query.filter_by(id=tag).one() + self.tag = tag diff --git a/master.py b/master.py index d587555..9cf9252 100755 --- a/master.py +++ b/master.py @@ -5,6 +5,7 @@ from app.models.user import Member, Group, GroupPrivilege from app.models.priv import SpecialPrivilege from app.models.trophy import Trophy, Title from app.models.forum import Forum +from app.models.tag import TagInformation from app.utils import unicode_names import os import sys @@ -23,11 +24,13 @@ Listing commands: groups Show privilege groups forums Show forum tree trophies Show trophies + tags Show tags Install and update commands: update-groups Create or update groups from app/data/ update-forums Create or update the forum tree from app/data/ update-trophies Create or update trophies + update-tags Create or update tag information generate-trophy-icons Regenerate all trophy icons create-common-accounts Remove and recreate 'Planète Casio' and 'GLaDOS' add-group # Add to group # (presumably admins) @@ -56,6 +59,11 @@ def trophies(*args): for t in Trophy.query.all(): print(t) +def tags(*args): + tags = TagInformation.query.all() + for t in sorted(tags, key=lambda t: t.id): + print(f"{t.id}: {t.pretty}") + # # Install and update commands # @@ -160,7 +168,6 @@ def update_trophies(): existing = Trophy.query.all() # Get the list of what we want to obtain - tr = [] with open(os.path.join(app.root_path, "data", "trophies.yaml")) as fp: tr = yaml.safe_load(fp.read()) tr = { t["name"]: t for t in tr } @@ -207,6 +214,39 @@ def update_trophies(): db.session.commit() +def update_tags(): + existing = TagInformation.query.all() + + with open(os.path.join(app.root_path, "data", "tags.yaml")) as fp: + data = yaml.safe_load(fp.read()) + tags = { ctgy + "." + name: data[ctgy][name] + for ctgy in data for name in data[ctgy] } + + # Remove bad tags + for t in existing: + if t.id not in tags: + print(f"[tags] Deleted '{t.id}'") + db.session.delete(t) + db.session.commit() + + for name, info in tags.items(): + pretty = info.get("pretty", name) + t = TagInformation.query.filter_by(id=name).first() + + if t is not None: + changes = (t.pretty != pretty) + t.pretty = pretty + if changes: + print(f"[tags] Updated '{name}'") + else: + t = TagInformation(name) + t.pretty = pretty + print(f"[tags] Created '{name}'") + + db.session.add(t) + + db.session.commit() + def generate_trophy_icons(): tr = [] with open(os.path.join(app.root_path, "data", "trophies.yaml")) as fp: @@ -308,9 +348,11 @@ commands = { "groups": groups, "forums": forums, "trophies": trophies, + "tags": tags, "update-groups": update_groups, "update-forums": update_forums, "update-trophies": update_trophies, + "update-tags": update_tags, "generate-trophy-icons": generate_trophy_icons, "create-common-accounts": create_common_accounts, "add-group": add_group, diff --git a/migrations/versions/189bbc0e1543_add_tag_information.py b/migrations/versions/189bbc0e1543_add_tag_information.py new file mode 100644 index 0000000..17f99bb --- /dev/null +++ b/migrations/versions/189bbc0e1543_add_tag_information.py @@ -0,0 +1,42 @@ +"""add tag information + +Revision ID: 189bbc0e1543 +Revises: fa34c9f43c24 +Create Date: 2022-06-09 22:26:58.562710 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '189bbc0e1543' +down_revision = 'fa34c9f43c24' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tag_information', + sa.Column('id', sa.String(length=64), nullable=False), + sa.Column('pretty', sa.String(length=64), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('tag', sa.Column('tag_id', sa.String(length=64), nullable=True)) + op.drop_index('ix_tag_name', table_name='tag') + op.create_index(op.f('ix_tag_tag_id'), 'tag', ['tag_id'], unique=False) + op.create_foreign_key(None, 'tag', 'tag_information', ['tag_id'], ['id']) + op.drop_column('tag', 'name') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tag', sa.Column('name', sa.VARCHAR(length=64), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'tag', type_='foreignkey') + op.drop_index(op.f('ix_tag_tag_id'), table_name='tag') + op.create_index('ix_tag_name', 'tag', ['name'], unique=False) + op.drop_column('tag', 'tag_id') + op.drop_table('tag_information') + # ### end Alembic commands ###