Parcourir la source

trophies: automatically remove undeserved trophies

... and other minor edits from the trophies branch.
master
Lephe il y a 1 mois
Parent
révision
4cefe39c36

+ 38
- 11
app/data/trophies.yaml Voir le fichier

@@ -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

+ 1
- 1
app/forms/account.py Voir le fichier

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



+ 1
- 1
app/forms/trophies.py Voir le fichier

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

+ 10
- 3
app/models/trophies.py Voir le fichier

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

+ 116
- 32
app/models/users.py Voir le fichier

@@ -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

+ 7
- 9
app/routes/admin/account.py Voir le fichier

@@ -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'])

+ 22
- 13
app/static/css/form.css Voir le fichier

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

+ 1
- 23
app/static/css/global.css Voir le fichier

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

+ 5
- 1
app/templates/admin/edit_account.html Voir le fichier

@@ -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>


+ 5
- 3
app/templates/admin/trophies.html Voir le fichier

@@ -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>

+ 102
- 34
master.py Voir le fichier

@@ -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:])

Chargement…
Annuler
Enregistrer