浏览代码

trophies: automatically remove undeserved trophies

... and other minor edits from the trophies branch.
pull/32/head
Lephe 2 年前
父节点
当前提交
4cefe39c36
  1. 49
      app/data/trophies.yaml
  2. 2
      app/forms/account.py
  3. 2
      app/forms/trophies.py
  4. 13
      app/models/trophies.py
  5. 148
      app/models/users.py
  6. 16
      app/routes/admin/account.py
  7. 35
      app/static/css/form.css
  8. 24
      app/static/css/global.css
  9. 6
      app/templates/admin/edit_account.html
  10. 8
      app/templates/admin/trophies.html
  11. 136
      master.py

49
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
name: Pédagogue
is_title: False
-
name: Pédagogue averti
name: Encyclopédie vivante
is_title: False
-
name: Encyclopédie vivante
name: Guerrier du savoir
is_title: True
# Account age (awarded on login only)
-
name: Nouveau
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 lextrê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

2
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')

2
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')

13
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'<Trophy: {self.name}>'
# 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'<Title: {self.name}>'
# Many-to-many relation for users earning trophies
TrophyMember = db.Table('trophy_member', db.Model.metadata,

148
app/models/users.py

@ -33,7 +33,7 @@ class User(UserMixin, db.Model):
}
def __repr__(self):
return f'<User #{self.id}>'
return f'<User: #{self.id}>'
# 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'<Member: {self.name}>'
return f'<Member: {self.name} ({self.norm})>'
@app.login.user_loader

16
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/<user_id>/delete', methods=['GET', 'POST'])

35
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;
}

24
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,

6
app/templates/admin/edit_account.html

@ -91,6 +91,8 @@
<div>{{ form.submit(class_="bg-green") }}</div>
</form>
<hr>
<form action="{{ url_for('adm_edit_account', user_id=user.id) }}" method="post">
{{ trophy_form.hidden_tag() }}
<h2>Trophées</h2>
@ -99,7 +101,7 @@
{% if id[0] == "t" %}
<div>
{# TODO: add trophies icons #}
{{ input(checked=id in trophy_form.user_trophies) }}
{{ input(checked=id in user_owned) }}
{{ input.label }}
</div>
{% endif %}
@ -108,6 +110,8 @@
<div>{{ trophy_form.submit(class_="bg-green") }}</div>
</form>
<hr>
<h2 style="margin-top:30px;">Supprimer le compte</h2>
<a href="{{ url_for('adm_delete_account', user_id=user.id) }}" class="button bg-red">Supprimer le compte</a>

8
app/templates/admin/trophies.html

@ -6,7 +6,9 @@
{% block content %}
<section>
<p>Cette page présente une vue d'ensemble des titres et trophées.</p>
<p>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.</p>
<h2>Titres et trophées</h2>
@ -20,8 +22,8 @@
<td style="{{ trophy.css }}">{{ trophy.name }}</td>
<td>{{ trophy | is_title }}</td>
<td><code>{{ trophy.css }}</code></td>
<td><a href="{{ url_for('adm_edit_trophy', trophy_id=trophy.id) }}">Modifier</a></td>
<td><a href="{{ url_for('adm_delete_trophy', trophy_id=trophy.id) }}">Supprimer</a></td>
<td style="text-align: center"><a href="{{ url_for('adm_edit_trophy', trophy_id=trophy.id) }}">Modifier</a></td>
<td style="text-align: center"><a href="{{ url_for('adm_delete_trophy', trophy_id=trophy.id) }}">Supprimer</a></td>
</tr>
{% endfor %}
</table>

136
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 <member> #<group-id>' 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 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 reset_groups_and_privs():
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:])
正在加载...
取消
保存