From 4cefe39c36240ab0a85e83cf65a39fbc739dcbac Mon Sep 17 00:00:00 2001 From: Lephe Date: Mon, 19 Aug 2019 16:15:32 +0200 Subject: [PATCH] trophies: automatically remove undeserved trophies ... and other minor edits from the trophies branch. --- app/data/trophies.yaml | 53 ++++++--- app/forms/account.py | 2 +- app/forms/trophies.py | 2 +- app/models/trophies.py | 13 ++- app/models/users.py | 148 ++++++++++++++++++++------ app/routes/admin/account.py | 16 ++- app/static/css/form.css | 35 +++--- app/static/css/global.css | 24 +---- app/templates/admin/edit_account.html | 6 +- app/templates/admin/trophies.html | 8 +- master.py | 136 +++++++++++++++++------ 11 files changed, 310 insertions(+), 133 deletions(-) diff --git a/app/data/trophies.yaml b/app/data/trophies.yaml index b6a11fc..6097e21 100644 --- a/app/data/trophies.yaml +++ b/app/data/trophies.yaml @@ -1,3 +1,8 @@ +# This is a list of trophies. For each trophies, the following keys may be set: +# name Trophy name as displayed on the site. +# is_title If True, the trophy can be worn as a title next to the avatar. + +# Manually awarded - name: Membre de CreativeCalc is_title: True @@ -11,28 +16,37 @@ name: Gourou is_title: True - - name: Grand Maitre des traits d'esprit + name: Grand Maître des traits d'esprit is_title: True + +# Number of posts of any kind +- + name: Premiers mots + is_title: False - name: Beau parleur is_title: False - - name: Jeune écrivain + name: Plume infaillible is_title: False - name: Romancier émérite is_title: True + +# Number of posted tutorials - - name: Apprenti instructeur - is_title: False -- - name: Pédagogue averti + name: Pédagogue is_title: False - name: Encyclopédie vivante - is_title: True + is_title: False - - name: Nouveau + name: Guerrier du savoir + is_title: True + +# Account age (awarded on login only) +- + name: Initié is_title: False - name: Aficionado @@ -43,6 +57,11 @@ - name: Papy Casio is_title: True +- + name: Vétéran mythique + is_title: True + +# Number of "good" programs - name: Programmeur du dimanche is_title: False @@ -52,33 +71,41 @@ - name: Je code donc je suis is_title: True + +# Number of posted tests - name: Testeur is_title: False - - name: Examinateur + name: Grand joueur is_title: False - name: Hard tester is_title: True + +# Number of event participations - - name: Participant avéré + name: Participant is_title: False - name: Concourant encore is_title: False - - name: Concurrent de l’extrême + name: Concurrent de l'extrême is_title: True + +# Number of posted art - - name: Designer en herbe + name: Dessinateur en herbe is_title: False - - name: Graphiste expérimenté + name: Open pixel is_title: False - name: Roi du pixel is_title: True + +# Miscellaneous automatically awarded - name: Actif is_title: False diff --git a/app/forms/account.py b/app/forms/account.py index ee522cc..563076b 100644 --- a/app/forms/account.py +++ b/app/forms/account.py @@ -51,7 +51,7 @@ class AdminUpdateAccountForm(FlaskForm): class AdminAccountEditTrophyForm(FlaskForm): - # Boolean inputs are generated on-the-fly from trophies list + # Boolean inputs are generated on-the-fly from trophy list submit = SubmitField('Modifier') diff --git a/app/forms/trophies.py b/app/forms/trophies.py index 1634ea3..6395b44 100644 --- a/app/forms/trophies.py +++ b/app/forms/trophies.py @@ -6,7 +6,7 @@ from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty class TrophyForm(FlaskForm): name = StringField('Nom', validators=[DataRequired()]) - icon = FileField('Icone') + icon = FileField('Icône') title = BooleanField('Titre', description='Un titre peut être affiché en dessous du pseudo.', validators=[Optional()]) css = StringField('CSS', description='CSS appliqué au titre, le cas échéant.') submit = SubmitField('Envoyer') diff --git a/app/models/trophies.py b/app/models/trophies.py index ae81ffa..a884e6a 100644 --- a/app/models/trophies.py +++ b/app/models/trophies.py @@ -4,15 +4,16 @@ from app import db class Trophy(db.Model): __tablename__ = 'trophy' + # Trophy ID and type (polymorphic discriminator) id = db.Column(db.Integer, primary_key=True) - # Trophy type (polymorphic discriminator) type = db.Column(db.String(20)) __mapper_args__ = { 'polymorphic_identity': __tablename__, 'polymorphic_on': type } - # Standalone properties + + # Trophy name (in French) name = db.Column(db.Unicode(64), index=True) owners = db.relationship('Member', secondary=lambda: TrophyMember, @@ -21,6 +22,9 @@ class Trophy(db.Model): def __init__(self, name): self.name = name + def __repr__(self): + return f'' + # Title: Rare trophies that can be displayed along one's name class Title(Trophy): __tablename__ = 'title' @@ -29,10 +33,13 @@ class Title(Trophy): id = db.Column(db.Integer, db.ForeignKey('trophy.id'), primary_key=True) css = db.Column(db.UnicodeText) - def __init__(self, name, css): + def __init__(self, name, css=""): self.name = name self.css = css + def __repr__(self): + return f'' + # Many-to-many relation for users earning trophies TrophyMember = db.Table('trophy_member', db.Model.metadata, diff --git a/app/models/users.py b/app/models/users.py index 50d056d..98853ff 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -33,7 +33,7 @@ class User(UserMixin, db.Model): } def __repr__(self): - return f'' + return f'' # Guest: Unregistered user with minimal privileges class Guest(User, db.Model): @@ -215,8 +215,6 @@ class Member(User, db.Model): t = Trophy.query.filter_by(name=t).first() if t not in self.trophies: self.trophies.append(t) - db.session.merge(self) - db.session.commit() # TODO: implement the notification system # self.notify(f"Vous venez de débloquer le trophée '{t.name}'") @@ -231,8 +229,6 @@ class Member(User, db.Model): t = Trophy.query.filter_by(name=name).first() if t in self.trophies: self.trophies.remove(t) - db.session.merge(self) - db.session.commit() def update_trophies(self, context=None): """ @@ -243,43 +239,131 @@ class Member(User, db.Model): - new-tutorial - new-test - new-event-participation - - new-picture - - on-program-reward + - new-art + - on-program-tested + - on-program-rewarded - on-login - on-profile-update """ - if context == "new-post" or context is None: - pass - if context == "new-program" or context is None: - pass - if context == "new-tutorial" or context is None: - pass - if context == "new-test" or context is None: - pass - if context == "new-event-participation" or context is None: - pass - if context == "new-picture" or context is None: - pass - if context == "on-program-reward" or context is None: - pass - if context == "on-login" or context is None: + def progress(trophies, value): + """Award or delete all trophies from a progressive category.""" + for level in trophies: + if value >= level: + self.add_trophy(trophies[level]) + else: + self.del_trophy(trophies[level]) + + if context in ["new-post", "new-program", "new-tutorial", "new-test", + None]: + # TODO: Amount of posts by the user + post_count = 0 + + levels = { + 20: "Premiers mots", + 500: "Beau parleur", + 1500: "Plume infaillible", + 5000: "Romancier émérite", + } + progress(levels, post_count) + + if context in ["new-program", None]: + # TODO: Amount of programs by the user + program_count = 0 + + levels = { + 5: "Programmeur du dimanche", + 10: "Codeur invétéré", + 20: "Je code donc je suis", + } + progress(levels, program_count) + + if context in ["new-tutorial", None]: + # TODO: Number of tutorials by user + tutorial_count = 0 + + levels = { + 5: "Pédagogue", + 10: "Encyclopédie vivante", + 25: "Guerrier du savoir", + } + progress(levels, tutorial_count) + + if context in ["new-test", None]: + # TODO: Number of tests by user + test_count = 0 + + levels = { + 5: "Testeur", + 25: "Grand joueur", + 100: "Hard tester", + } + progress(levels, test_count) + + if context in ["new-event-participation", None]: + # TODO: Number of event participations by user + event_participations = 0 + + levels = { + 1: "Participant", + 5: "Concourant encore", + 15: "Concurrent de l'extrême", + } + progress(levels, event_participations) + + if context in ["new-art", None]: + # TODO: Number of art posts by user + art_count = 0 + + levels = { + 5: "Dessinateur en herbe", + 30: "Open pixel", + 100: "Roi du pixel", + } + progress(levels, art_count) + + if context in ["on-program-tested", None]: + # TODO: Number of "coups de coeur" of user + heart_count = 0 + + levels = { + 5: "Bourreau des cœurs", + } + progress(levels, heart_count) + + if context in ["on-program-rewarded", None]: + # TODO: Number of programs with labels + label_count = 0 + + levels = { + 5: "Maître du code", + } + progress(levels, label_count) + + if context in ["on-login", None]: # Seniority-based trophies age = date.today() - self.register_date - if age.days > 30: - self.add_trophy("Nouveau") - if age.days > 365.25: - self.add_trophy("Aficionado") - if age.days > 365.25 * 2: - self.add_trophy("Veni, vidi, casii") - if age.days > 365.25 * 5: - self.add_trophy("Papy Casio") - if context == "on-profile-update" or context is None: + + levels = { + 30: "Initié", + 365.25: "Aficionado", + 365.25 * 2: "Veni, vidi, casii", + 365.25 * 5: "Papy Casio", + 365.25 * 10: "Vétéran mythique", + } + progress(levels, age.days) + + # TODO: Trophy "actif" + + if context in ["on-profile-update", None]: # TODO: add a better condition (this is for test) self.add_trophy("Artiste") + db.session.merge(self) + db.session.commit() + def __repr__(self): - return f'' + return f'' @app.login.user_loader diff --git a/app/routes/admin/account.py b/app/routes/admin/account.py index f1b0dda..d75faac 100644 --- a/app/routes/admin/account.py +++ b/app/routes/admin/account.py @@ -62,20 +62,18 @@ def adm_edit_account(user_id): else: print(f"Del trophy {id[1:]}") user.del_trophy(int(id[1:])) + + db.session.merge(user) + db.session.commit() else: flash("Erreur lors de l'ajout du trophée", 'error') - # if deltrophy_form.submit.data: - # if deltrophy_form.validate_on_submit(): - # trophy = Trophy.query.get(deltrophy_form.trophy.data) - # if trophy is not None: - # user.del_trophy(trophy) - # flash('Trophée retiré', 'ok') - # else: - # flash("Erreur lors du retrait du trophée", 'error') + user_owned = set() + for t in user.trophies: + user_owned.add(f"t{t.id}") return render('admin/edit_account.html', user=user, - form=form, trophy_form=trophy_form) + form=form, trophy_form=trophy_form, user_owned=user_owned) @app.route('/admin/account//delete', methods=['GET', 'POST']) diff --git a/app/static/css/form.css b/app/static/css/form.css index 1ea1957..9747677 100644 --- a/app/static/css/form.css +++ b/app/static/css/form.css @@ -11,7 +11,8 @@ margin-bottom: 16px; } -.form form label { +.form form label, +.trophies-panel p { display: inline-block; margin-bottom: 4px; } @@ -19,16 +20,13 @@ margin: 0 0 4px 0; } -.form input { - cursor: pointer; /* don't know why it is not a cursor by default */ -} - .form input[type='text'], .form input[type='email'], .form input[type='date'], .form input[type='password'], .form input[type='search'], -.form textarea { +.form textarea, +.trophies-panel > div { display: block; width: 100%; padding: 6px 8px; border: 1px solid #c8c8c8; @@ -51,6 +49,12 @@ resize: vertical; } +.form input[type="checkbox"], +.form input[type="radio"] { + display: inline; + vertical-align: middle; +} + .form input[type="submit"] { /*width: 20%;*/ } @@ -66,14 +70,19 @@ color: gray; } -.trophies-panel { - display: flex; flex-wrap: wrap; -} -.trophies-panel > div { - margin: 3px 5px; padding: 3px; - border: 1px solid #969696; - border-radius: 3px; +.form hr { + color: white; + height: 3px; + border: 0 solid #b0b0b0; + border-width: 1px 0; + margin: 24px 0; } .trophies-panel label { margin-right: 5px; } +.trophies-panel p:first-child { + margin-top: 0; +} +.trophies-panel p label { + margin: 0; +} diff --git a/app/static/css/global.css b/app/static/css/global.css index 5aba4db..69ec2e0 100644 --- a/app/static/css/global.css +++ b/app/static/css/global.css @@ -40,29 +40,6 @@ section ul { line-height: 24px; } -/* Forms */ - -input, -textarea { - display: block; - background: #FFFFFF; color: #000000; - border: none; -} -input:focus:not(type="button"), -textarea:focus { - box-shadow: 0 0 0 0.2rem rgba(0,123,255,0.25); -} - -textarea { - width: 100%; - border: 1px solid #eeeeee; -} - -input[type="checkbox"] { - display: inline; - vertical-align: middle; -} - /* Buttons */ .button, @@ -71,6 +48,7 @@ input[type="submit"] { padding: 6px 10px; border-radius: 2px; cursor: pointer; font-family: 'DejaVu Sans', sans-serif; font-weight: 400; + border: 0; } input[type="button"]:hover, input[type="submit"]:hover, diff --git a/app/templates/admin/edit_account.html b/app/templates/admin/edit_account.html index 808067d..4527567 100644 --- a/app/templates/admin/edit_account.html +++ b/app/templates/admin/edit_account.html @@ -91,6 +91,8 @@
{{ form.submit(class_="bg-green") }}
+
+
{{ trophy_form.hidden_tag() }}

Trophées

@@ -99,7 +101,7 @@ {% if id[0] == "t" %}
{# TODO: add trophies icons #} - {{ input(checked=id in trophy_form.user_trophies) }} + {{ input(checked=id in user_owned) }} {{ input.label }}
{% endif %} @@ -108,6 +110,8 @@
{{ trophy_form.submit(class_="bg-green") }}
+
+

Supprimer le compte

Supprimer le compte diff --git a/app/templates/admin/trophies.html b/app/templates/admin/trophies.html index d434e95..4755157 100644 --- a/app/templates/admin/trophies.html +++ b/app/templates/admin/trophies.html @@ -6,7 +6,9 @@ {% block content %}
-

Cette page présente une vue d'ensemble des titres et trophées.

+

Cette page présente une vue d'ensemble des titres et trophées. Les + conditions d'obtention exactes des trophées sont définies dans le code et + non dans la base de données.

Titres et trophées

@@ -20,8 +22,8 @@ {{ trophy.name }} {{ trophy | is_title }} {{ trophy.css }} - Modifier - Supprimer + Modifier + Supprimer {% endfor %} diff --git a/master.py b/master.py index dc40c40..20a5490 100755 --- a/master.py +++ b/master.py @@ -3,17 +3,26 @@ from app import app, db from app.models.users import Member, Group, GroupPrivilege from app.models.privs import SpecialPrivilege -from app.models.trophies import Trophy, Title +from app.models.trophies import Trophy, Title, TrophyMember +from app.utils import unicode_names import os import sys import yaml +import readline help_msg = """ This is the Planète Casio master shell. Type 'exit' or C-D to leave. -Type 'members' to see a list of members and 'groups' to see a list of groups. +Type a category name to see a list of elements. Available categories are: -Type 'reset-groups-and-privs' to reset all groups and privileges to the + 'members' Registered community members + 'groups' Privilege groups + 'trophies' Trophies + 'trophy-members' Trophies owned by members + +Type a category name followed by 'clear' to remove all entries in the category. + +Type 'create-groups-and-privs' to recreate all groups and privileges to the default. This function generates a minimal set of groups and members to prepare the database. 1. Deletes all groups @@ -26,43 +35,88 @@ the database. Type 'add-group #' to add a new member to a group. -Type 'reset-trophies' to reset trophies and titles. +Type 'create-trophies' to reset trophies and titles. """ -def members(): +# +# Category viewers +# + +def members(*args): + if args == ("clear",): + for m in Member.query.all(): + m.delete() + db.session.commit() + print("Removed all members.") + return + for m in Member.query.all(): - print(m.name) + print(m) + +def groups(*args): + if args == ("clear",): + for g in Group.query.all(): + g.delete() + db.session.commit() + print("Removed all groups.") + return -def groups(): for g in Group.query.all(): - print(f"#{g.id} {g.name}") + print(f"#{g.id} {g.name}") -def reset_groups_and_privs(): +def trophies(*args): + if args == ("clear",): + for t in Trophy.query.all(): + db.session.delete(t) + db.session.commit() + print("Removed all trophies.") + return + + for t in Trophy.query.all(): + print(t) + +def trophy_members(*args): + for t in Trophy.query.all(): + if t.owners == []: + continue + + print(t) + for m in t.owners: + print(f" {m}") + +# +# Creation and edition +# + +def create_groups_and_privs(): # Clean up groups - for g in Group.query.all(): - g.delete() + groups("clear") # Create base groups - groups = [] + gr = [] with open(os.path.join(app.root_path, "data", "groups.yaml")) as fp: - groups = yaml.load(fp.read()) + gr = yaml.safe_load(fp.read()) - for g in groups: + for g in gr: g["obj"] = Group(g["name"], g["css"], g["descr"]) db.session.add(g["obj"]) db.session.commit() - for g in groups: + for g in gr: for priv in g.get("privs", "").split(): db.session.add(GroupPrivilege(g["obj"], priv)) db.session.commit() + print(f"Created {len(gr)} groups.") + # Clean up test members for name in "PlanèteCasio GLaDOS".split(): m = Member.query.filter_by(name=name).first() if m is not None: m.delete() + print("Removed test members.") + # Create template members def addgroup(member, group): @@ -70,11 +124,11 @@ def reset_groups_and_privs(): if g is not None: member.groups.append(g) - m = Member('PlanèteCasio', 'contact@planet-casio.com', 'v5-forever') + m = Member("PlanèteCasio", "contact@planet-casio.com", "v5-forever") addgroup(m, "Compte communautaire") db.session.add(m) - m = Member('GLaDOS', 'glados@aperture.science', 'v5-forever') + m = Member("GLaDOS", "glados@aperture.science", "v5-forever") m.xp = 1338 addgroup(m, "Robot") db.session.add(m) @@ -85,39 +139,50 @@ def reset_groups_and_privs(): db.session.commit() + print(f"Created 2 test members with some privileges.") -def reset_trophies(): + +def create_trophies(): # Clean up trophies - for t in Trophy.query.all(): - db.session.delete(t) + trophies("clear") # Create base trophies - trophies = [] + tr = [] with open(os.path.join(app.root_path, "data", "trophies.yaml")) as fp: - trophies = yaml.load(fp.read()) + tr = yaml.safe_load(fp.read()) - for t in trophies: + for t in tr: if t["is_title"]: - t["obj"] = Title(t["name"], t.get("css", "")) + trophy = Title(t["name"], t.get("css", "")) else: - t["obj"] = Trophy(t["name"]) - db.session.add(t["obj"]) + trophy = Trophy(t["name"]) + db.session.add(trophy) db.session.commit() + print(f"Created {len(tr)} trophies.") def add_group(member, group): - if group[0] != '#': - print("error: group id should start with '#'.") + if group[0] != "#": + print(f"error: group id {group} should start with '#'") return gid = int(group[1:]) + norm = unicode_names.normalize(member) + g = Group.query.filter_by(id=gid).first() - m = Member.query.filter_by(name=member).first() + m = Member.query.filter_by(norm=norm).first() + + if m is None: + print(f"error: no member has a normalized name of '{norm}'") + return m.groups.append(g) db.session.add(m) db.session.commit() +# +# Main program +# print(help_msg) @@ -125,20 +190,23 @@ commands = { "exit": lambda: sys.exit(0), "members": members, "groups": groups, - "reset-groups-and-privs": reset_groups_and_privs, - "reset-trophies": reset_trophies, + "trophies": trophies, + "trophy-members": trophy_members, + "create-groups-and-privs": create_groups_and_privs, + "create-trophies": create_trophies, "add-group": add_group, } while True: try: - print('> ', end='') + print("@> ", end="") cmd = input().split() except EOFError: sys.exit(0) - if not cmd: continue + if not cmd: + continue if cmd[0] not in commands: - print("error: unknown command.") + print(f"error: unknown command '{cmd[0]}'") else: commands[cmd[0]](*cmd[1:])