diff --git a/app/__init__.py b/app/__init__.py index 47625cc..b101104 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -16,5 +16,5 @@ login.login_message = "Veuillez vous authentifier avant de continuer." from app import models # IDK why this is here, but it works from app.routes import index, search, users # To load routes at initialization from app.routes.account import login, account -from app.routes.admin import index, groups, account +from app.routes.admin import index, groups, account, trophies from app.utils import pluralize # To use pluralize into the templates diff --git a/app/forms/account.py b/app/forms/account.py index fcfb0df..aff6f92 100644 --- a/app/forms/account.py +++ b/app/forms/account.py @@ -36,20 +36,11 @@ class DeleteAccountForm(FlaskForm): class AdminUpdateAccountForm(FlaskForm): - username = StringField('Pseudonyme', - validators=[Optional(), vd.name_valid]) - avatar = FileField('Avatar', - validators=[Optional(), vd.avatar]) - email = StringField('Adresse email', - validators=[Optional(), Email(), vd.email]) - email_validate = BooleanField("""Envoyer un email de validation à la - nouvelle adresse""", - description="""Si décoché, l'utilisateur devra demander explicitement - un email de validation, ou faire valider son adresse email par un - administrateur.""") - password = PasswordField('Mot de passe', - description="L'ancien mot de passe ne pourra pas être récupéré !", - validators=[Optional(), vd.password]) + username = StringField('Pseudonyme', validators=[Optional(), vd.name_valid]) + avatar = FileField('Avatar', validators=[Optional(), vd.avatar]) + email = StringField('Adresse email', validators=[Optional(), Email(), vd.email]) + email_validate = BooleanField("Envoyer un email de validation à la nouvelle adresse", description="Si décoché, l'utilisateur devra demander explicitement un email de validation, ou faire valider son adresse email par un administrateur.") + password = PasswordField('Mot de passe', description="L'ancien mot de passe ne pourra pas être récupéré !", validators=[Optional(), vd.password]) xp = DecimalField('XP', validators=[Optional()]) birthday = DateField('Anniversaire', validators=[Optional()]) signature = TextAreaField('Signature', validators=[Optional()]) diff --git a/app/forms/trophies.py b/app/forms/trophies.py new file mode 100644 index 0000000..49535ba --- /dev/null +++ b/app/forms/trophies.py @@ -0,0 +1,16 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, BooleanField +from wtforms.validators import DataRequired, Optional +from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty + + +class TrophyForm(FlaskForm): + name = StringField('Nom', validators=[DataRequired()]) + icon = FileField('Icone') + title = StringField('Titre', description='Titre affiché dans le cas échéant. Laisser vide pour un simple trophée.', validators=[Optional()]) + css = StringField('CSS', description='CSS appliqué au titre, le cas échéant.') + submit = SubmitField('Envoyer') + +class DeleteTrophyForm(FlaskForm): + delete = BooleanField('Confirmer la suppression', validators=[DataRequired()], description='Attention, cette opération est irréversible !') + submit = SubmitField('Supprimer le trophée') diff --git a/app/models/trophies.py b/app/models/trophies.py new file mode 100644 index 0000000..6a56d25 --- /dev/null +++ b/app/models/trophies.py @@ -0,0 +1,39 @@ +from app import db + + +class Trophy(db.Model): + __tablename__ = 'trophy' + 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 + name = db.Column(db.Unicode(64), index=True) + + owners = db.relationship('Member', secondary=lambda: TrophyMember, + back_populates='trophies') + + def __init__(self, name): + self.name = name + +class Title(Trophy): + __tablename__ = 'title' + __mapper_args__ = {'polymorphic_identity': __tablename__} + + id = db.Column(db.Integer, db.ForeignKey('trophy.id'), primary_key=True) + title = db.Column(db.Unicode(64)) + css = db.Column(db.Text(convert_unicode=True)) + + def __init__(self, name, title, css): + self.name = name + self.title = title + self.css = css + + +# Many-to-many relation for users earning trophies +TrophyMember = db.Table('trophy_member', db.Model.metadata, + db.Column('tid', db.Integer, db.ForeignKey('trophy.id')), + db.Column('uid', db.Integer, db.ForeignKey('member.id'))) diff --git a/app/models/users.py b/app/models/users.py index 68c063d..fd62e6f 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -4,6 +4,7 @@ from flask_login import UserMixin from app.models.contents import Content from app.models.privs import SpecialPrivilege, Group, GroupMember, \ GroupPrivilege +from app.models.trophies import Trophy, TrophyMember import app.utils.unicode_names as unicode_names from config import V5Config @@ -90,7 +91,8 @@ class Member(User, db.Model): newsletter = db.Column(db.Boolean, default=False) # Relations - # trophies = db.relationship('Trophy', back_populates='member') + trophies = db.relationship('Trophy', secondary=TrophyMember, + back_populates='owners') # tests = db.relationship('Test', back_populates='author') def __init__(self, name, email, password): diff --git a/app/routes/admin/account.py b/app/routes/admin/account.py index 4311686..55a400b 100644 --- a/app/routes/admin/account.py +++ b/app/routes/admin/account.py @@ -6,8 +6,8 @@ from app.utils.render import render from app import app, db -@app.route('/admin/edit-account/', methods=['GET', 'POST']) -@priv_required('edit-account') +@app.route('/admin/account//edit', methods=['GET', 'POST']) +@priv_required('access-admin-panel', 'edit-account') def adm_edit_account(user_id): user = Member.query.filter_by(id=user_id).first_or_404() @@ -42,8 +42,8 @@ def adm_edit_account(user_id): return render('admin/edit_account.html', user=user, form=form) -@app.route('/admin/edit-account//delete', methods=['GET', 'POST']) -@priv_required('delete-account') +@app.route('/admin/account//delete', methods=['GET', 'POST']) +@priv_required('access-admin-panel', 'delete-account') def adm_delete_account(user_id): user = Member.query.filter_by(id=user_id).first_or_404() diff --git a/app/routes/admin/index.py b/app/routes/admin/index.py index 5ef458e..bd0c7cc 100644 --- a/app/routes/admin/index.py +++ b/app/routes/admin/index.py @@ -3,7 +3,7 @@ from app.utils.render import render from app import app -@app.route('/admin', methods=['GET', 'POST']) +@app.route('/admin', methods=['GET']) @priv_required('access-admin-panel') def adm(): return render('admin/index.html') diff --git a/app/routes/admin/trophies.py b/app/routes/admin/trophies.py new file mode 100644 index 0000000..dda263c --- /dev/null +++ b/app/routes/admin/trophies.py @@ -0,0 +1,71 @@ +from flask import request, flash, redirect, url_for +from app.utils.priv_required import priv_required +from app.models.trophies import Trophy, Title +from app.forms.trophies import TrophyForm, DeleteTrophyForm +from app.utils.render import render +from app import app, db + + +@app.route('/admin/trophies', methods=['GET', 'POST']) +@priv_required('access-admin-panel', 'edit-trophies') +def adm_trophies(): + form = TrophyForm() + if request.method == "POST": + if form.validate_on_submit(): + is_title = form.title.data != "" + if is_title: + trophy = Title(form.name.data, form.title.data, form.css.data) + else: + trophy = Trophy(form.name.data) + db.session.add(trophy) + db.session.commit() + flash(f'Nouveau {["trophée", "titre"][is_title]} ajouté', 'ok') + else: + flash('Erreur lors de la création du trophée', 'error') + + trophies = Trophy.query.all() + return render('admin/trophies.html', trophies=trophies, + form=form) + + +@app.route('/admin/trophies//edit', methods=['GET', 'POST']) +@priv_required('access-admin-panel', 'edit-trophies') +def adm_edit_trophy(trophy_id): + trophy = Trophy.query.filter_by(id=trophy_id).first_or_404() + + form = TrophyForm() + if request.method == "POST": + if form.validate_on_submit(): + is_title = form.title.data != "" + if is_title: + trophy.name = form.name.data + trophy.title = form.title.data + trophy.css = form.css.data + else: + trophy.name = form.name.data + db.session.merge(trophy) + db.session.commit() + flash(f'{["Trophée", "Titre"][is_title]} modifié', 'ok') + return redirect(url_for('adm_trophies')) + else: + flash('Erreur lors de la création du trophée', 'error') + return render('admin/edit_trophy.html', trophy=trophy, form=form) + + +@app.route('/admin/trophies//delete', methods=['GET', 'POST']) +@priv_required('access-admin-panel', 'edit-trophies') +def adm_delete_trophy(trophy_id): + trophy = Trophy.query.filter_by(id=trophy_id).first_or_404() + + # TODO: Add an overview of what will be deleted. + del_form = DeleteTrophyForm() + if request.method == "POST": + if del_form.validate_on_submit(): + db.session.delete(trophy) + db.session.commit() + flash('Trophée supprimé', 'ok') + return redirect(url_for('adm_trophies')) + else: + flash('Erreur lors de la suppression du trophée', 'error') + del_form.delete.data = False # Force to tick to delete the account + return render('admin/delete_trophy.html', trophy=trophy, del_form=del_form) diff --git a/app/templates/admin/delete_trophy.html b/app/templates/admin/delete_trophy.html new file mode 100644 index 0000000..1ae2813 --- /dev/null +++ b/app/templates/admin/delete_trophy.html @@ -0,0 +1,28 @@ +{% extends "base/base.html" %} + +{% block title %} +Panneau d'administration » Titres et trophées »

Suppression du trophée '{{ trophy.name }}'

+{% endblock %} + +{% block content %} +
+

Confirmer la suppression du trophée

+

Le trophée '{{ trophy.name }}' que vous allez supprimer est lié à :

+
    +
  • {{ trophy.owners | length }} membre{{ trophy.owners|length|pluralize }}
  • +
+ +
+ {{ del_form.hidden_tag() }} +
+ {{ del_form.delete.label }} + {{ del_form.delete(checked=False) }} +
{{ del_form.delete.description }}
+ {% for error in del_form.delete.errors %} + {{ error }} + {% endfor %} +
+
{{ del_form.submit(class_="bg-red") }}
+
+
+{% endblock %} diff --git a/app/templates/admin/edit_trophy.html b/app/templates/admin/edit_trophy.html new file mode 100644 index 0000000..83aacd9 --- /dev/null +++ b/app/templates/admin/edit_trophy.html @@ -0,0 +1,38 @@ +{% extends "base/base.html" %} + +{% block title %} +Panneau d'administration » Titres et trophées »

Édition du trophée '{{ trophy.name }}'

+{% endblock %} + +{% block content %} +
+
+ {{ form.hidden_tag() }} +

Éditer le trophée

+ +
+ {{ form.name.label }} + {{ form.name(value=trophy.name) }} + {% for error in form.name.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.title.label }} + {{ form.title(value=trophy.title) }} +
{{ form.title.description }}
+ {% for error in form.title.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.css.label }} + {{ form.css(value=trophy.css) }} +
{{ form.css.description }}
+ {% for error in form.css.errors %} + {{ error }} + {% endfor %} +
+
{{ form.submit(class_="bg-green") }}
+
+{% endblock %} diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html index 7ea09d0..f4f37ee 100644 --- a/app/templates/admin/index.html +++ b/app/templates/admin/index.html @@ -9,6 +9,7 @@

Pages générales du panneau d'administration :

{% endblock %} diff --git a/app/templates/admin/trophies.html b/app/templates/admin/trophies.html new file mode 100644 index 0000000..80e628f --- /dev/null +++ b/app/templates/admin/trophies.html @@ -0,0 +1,59 @@ +{% extends "base/base.html" %} + +{% block title %} +Panneau d'administration »

Titres et trophées

+{% endblock %} + +{% block content %} +
+

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

+ +

Titres et trophées

+ + + + + + {% for trophy in trophies %} + + + + + + + + + {% endfor %} +
idIcôneNomTitreStyleModifierSupprimer
{{ trophy.id }}{{ trophy.name }}{{ trophy.name }}{{ trophy.title }}{{ trophy.css }}ModifierSupprimer
+
+ +
+ + {{ form.hidden_tag() }} +

Nouveau trophée

+
+ {{ form.name.label }} + {{ form.name }} + {% for error in form.name.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.title.label }} + {{ form.title }} +
{{ form.title.description }}
+ {% for error in form.title.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.css.label }} + {{ form.css }} +
{{ form.css.description }}
+ {% for error in form.css.errors %} + {{ error }} + {% endfor %} +
+
{{ form.submit(class_="bg-green") }}
+
+{% endblock %} diff --git a/app/utils/validators.py b/app/utils/validators.py index a922f38..5705f1c 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -1,6 +1,6 @@ from flask_login import current_user from wtforms.validators import ValidationError -from app.models.users import User, Member +from app.models.users import Member from app.utils.valid_name import valid_name from app.utils.unicode_names import normalize from config import V5Config @@ -65,3 +65,21 @@ def old_password(form, field): raise ValidationError('Votre ancien mot de passe est requis pour cette modification.') if not current_user.check_password(form.old_password.data): raise ValidationError('Mot de passe actuel erroné.') + + +def id_exists(object): + """Check if an id exists in a table""" + def _id_exists(form, id): + try: + id = int(id.data) + except ValueError: + raise ValidationError('L\'id n\'est pas un entier valide') + r = object.query.filter_by(id=id) + if not r: + raise ValidationError('L\'id n\'existe pas dans la BDD') + return _id_exists + + +def css(form, css): + """Check if input is valid and sane CSS""" + pass diff --git a/config.py b/config.py index bf71675..85eb3ba 100644 --- a/config.py +++ b/config.py @@ -7,6 +7,7 @@ class Config(object): 'postgresql+psycopg2://' + os.environ.get('USER') + ':@/pcv5' SQLALCHEMY_TRACK_MODIFICATIONS = False UPLOAD_FOLDER = './app/static/avatars' + LOGIN_DISABLED = True class V5Config(object): diff --git a/migrations/versions/6ae59d74cf54_update_des_titres_et_trophées.py b/migrations/versions/6ae59d74cf54_update_des_titres_et_trophées.py new file mode 100644 index 0000000..93ceafc --- /dev/null +++ b/migrations/versions/6ae59d74cf54_update_des_titres_et_trophées.py @@ -0,0 +1,30 @@ +"""Update des titres et trophées + +Revision ID: 6ae59d74cf54 +Revises: c961d7b7a7ea +Create Date: 2019-06-06 23:34:53.521239 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6ae59d74cf54' +down_revision = 'c961d7b7a7ea' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('title', sa.Column('css', sa.Text(_expect_unicode=True), nullable=True)) + op.create_index(op.f('ix_trophy_name'), 'trophy', ['name'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_trophy_name'), table_name='trophy') + op.drop_column('title', 'css') + # ### end Alembic commands ### diff --git a/migrations/versions/c961d7b7a7ea_ajout_des_titres_et_trophées.py b/migrations/versions/c961d7b7a7ea_ajout_des_titres_et_trophées.py new file mode 100644 index 0000000..964c42e --- /dev/null +++ b/migrations/versions/c961d7b7a7ea_ajout_des_titres_et_trophées.py @@ -0,0 +1,47 @@ +"""Ajout des titres et trophées + +Revision ID: c961d7b7a7ea +Revises: a6e89f3510d9 +Create Date: 2019-06-06 15:18:09.893001 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c961d7b7a7ea' +down_revision = 'a6e89f3510d9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('trophy', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=20), nullable=True), + sa.Column('name', sa.Text(_expect_unicode=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('title', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.Text(_expect_unicode=True), nullable=True), + sa.ForeignKeyConstraint(['id'], ['trophy.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('trophy_member', + sa.Column('tid', sa.Integer(), nullable=True), + sa.Column('uid', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['tid'], ['trophy.id'], ), + sa.ForeignKeyConstraint(['uid'], ['member.id'], ) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('trophy_member') + op.drop_table('title') + op.drop_table('trophy') + # ### end Alembic commands ###