diff --git a/app/__init__.py b/app/__init__.py index 10e8cde..3543c48 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -14,4 +14,4 @@ login.login_view = 'login' login.login_message = "Veuillez vous authentifier avant de continuer." from app import models -from app.routes import index, login, search, account +from app.routes import index, login, search, account, admin diff --git a/app/models/privs.py b/app/models/privs.py index 80f056b..f3a6324 100644 --- a/app/models/privs.py +++ b/app/models/privs.py @@ -15,13 +15,17 @@ class SpecialPrivilege(db.Model): __tablename__ = 'special_privilege' id = db.Column(db.Integer, primary_key=True) - # User that is granted the privilege - uid = db.Column(db.Integer, db.ForeignKey('user.id'), index=True) + # Member that is granted the privilege + mid = db.Column(db.Integer, db.ForeignKey('member.id'), index=True) # Privilege name priv = db.Column(db.String(V5Config.PRIVS_MAXLEN)) + def __init__(self, member, priv): + self.mid = member.id + self.priv = priv + def __repr__(self): - return f'' + return f'' # Group: User group, corresponds to a community role and a set of privileges class Group(db.Model): @@ -34,10 +38,17 @@ class Group(db.Model): # The CSS code should not assume any specific layout and typically applies # to a text node. Use attributes like color, font-style, font-weight, etc. css = db.Column(db.UnicodeText) + # Textual description + description = db.Column(db.UnicodeText) # List of members (lambda delays evaluation) members = db.relationship('Member', secondary=lambda:GroupMember, back_populates='groups') + def __init__(self, name, css): + self.name = name + self.css = css + self.members = [] + def __repr__(self): return f'' @@ -46,12 +57,14 @@ GroupMember = db.Table('group_member', db.Model.metadata, db.Column('gid', db.Integer, db.ForeignKey('group.id')), db.Column('uid', db.Integer, db.ForeignKey('member.id'))) -# GroupPrivilege: Privilege granted to all users in a group +# Meny-to-many relationship for privileges granted to groups class GroupPrivilege(db.Model): __tablename__ = 'group_privilege' id = db.Column(db.Integer, primary_key=True) - # Group that is granted the privilege - gid = db.Column(db.Integer, db.ForeignKey('group.id'), index=True) - # Privilege name + gid = db.Column(db.Integer, db.ForeignKey('group.id')) priv = db.Column(db.String(V5Config.PRIVS_MAXLEN)) + + def __init__(self, group, priv): + self.gid = group.id + self.priv = priv diff --git a/app/models/users.py b/app/models/users.py index d800bb5..20597e0 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -2,7 +2,9 @@ from datetime import date, datetime from app import db from flask_login import UserMixin from app.models.contents import Content -from app.models.privs import Group, GroupMember +from app.models.privs import SpecialPrivilege, Group, GroupMember, \ + GroupPrivilege +from config import V5Config import werkzeug.security import app @@ -29,6 +31,32 @@ class User(UserMixin, db.Model): def __repr__(self): return f'' + @staticmethod + def valid_name(name): + """ + Checks whether a string is a valid user name. The criteria are: + 1. No whitespace-class character + 2. At least one letter + 3. At least 3 characters and no longer than 32 characters + + Possibily other intresting criteria: + 4. Unicode restriction + """ + + if type(name) != str or len(name) < 3 or len(name) > 32: + return False + if name in V5Config.FORBIDDEN_USERNAMES: + return False + # Reject all Unicode whitespaces. This is important to avoid the most + # common Unicode tricks! + if re.search(r'\s', name) is not None: + return False + # There must be at least one letter (avoid complete garbage) + if re.search(r'\w', name) is None: + return False + + return True + # Guest: Unregistered user with minimal privileges class Guest(User, db.Model): __tablename__ = 'guest' @@ -77,33 +105,9 @@ class Member(User, db.Model): # trophies = db.relationship('Trophy', back_populates='member') # tests = db.relationship('Test', back_populates='author') - @staticmethod - def valid_name(name): - """ - Checks whether a string is a valid member name. The criteria are: - 1. No whitespace-class character - 2. At least one letter - 3. No longer than 32 characters - - Possibily other intresting criteria: - 4. Unicode restriction - """ - - if type(name) != str or len(name) > 32: - return False - # Reject all Unicode whitespaces. This is important to avoid the most - # common Unicode tricks! - if re.search(r'\s', name) is not None: - return False - # There must be at least one letter (avoid complete garbage) - if re.search(r'\w', name) is None: - return False - - return True - def __init__(self, name, email, password): """Register a new user.""" - if not Member.valid_name(name): + if not User.valid_name(name): raise Exception(f'{name} is not a valid user name') self.name = name @@ -116,7 +120,16 @@ class Member(User, db.Model): self.signature = "" self.birthday = None - def update(self, data): + def priv(self, priv): + """Check whether the member has the specified privilege.""" + if SpecialPrivilege.filter(uif=self.id, priv=priv): + return True + return False +# return db.session.query(User, Group, GroupPrivilege).filter( +# Group.id.in_(User.groups), GroupPrivilege.gid==Group.id, +# GroupPrivilege.priv==priv).first() is not None + + def update(self, **data): """ Update all or part of the user's metadata. The [data] dictionary accepts the following keys: @@ -138,7 +151,7 @@ class Member(User, db.Model): data = { key: data[key] for key in data if data[key] is not None } if "name" in data: - if not Member.valid_name(data["name"]): + if not User.valid_name(data["name"]): raise Exception(f'{data["name"]} is not a valid user name') self.name = data["name"] diff --git a/app/routes/account.py b/app/routes/account.py index 27a5f1c..cfd624a 100644 --- a/app/routes/account.py +++ b/app/routes/account.py @@ -11,14 +11,14 @@ def account(): form = UpdateAccountForm() if request.method == "POST": if form.validate_on_submit(): - current_user.update({ - "email": form.email.data, - "password": form.password.data, - "signature": form.signature.data, - "bio": form.biography.data, - "birthday": form.birthday.data, - "newsletter": form.newsletter.data - }) + current_user.update( + email= form.email.data, + password= form.password.data, + signature= form.signature.data, + bio= form.biography.data, + birthday= form.birthday.data, + newsletter= form.newsletter.data + ) db.session.add(current_user) db.session.commit() flash('Modifications effectuées', 'ok') @@ -43,4 +43,4 @@ def register(): def validation(): if current_user.is_authenticated : return redirect(url_for('index')) - return render('validation.html') \ No newline at end of file + return render('validation.html') diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..bea040e --- /dev/null +++ b/app/routes/admin.py @@ -0,0 +1,58 @@ +from flask_login import login_required +from flask_wtf import FlaskForm +from wtforms import SubmitField +from app.models.users import Member, Group, GroupPrivilege +from app.models.privs import SpecialPrivilege +from app.utils.render import render +from app import app, db + +@app.route('/admin', methods=['GET', 'POST']) +@login_required +def admin(): + + class AdminForm(FlaskForm): + submit = SubmitField('Régénérer les groupes, les privilèges, et les ' + + 'membres de test "PlanèteCasio" et "GLaDOS" (mdp "v5-forever")') + + form = AdminForm() + if form.validate_on_submit(): + # Clean up groups + for g in Group.query.all(): + db.session.delete(g) + db.session.commit( ) + + # Create base groups + g_admins = Group('Administrateur', 'color: red') + g_modos = Group('Modérateur', 'color: green') + g_redacs = Group('Rédacteur', 'color: blue') + g_community = Group('Compte communautaire', 'background: #c8c8c8;' + + 'border-radius: 4px; color: #303030; padding: 1px 2px') + db.session.add(g_admins) + db.session.add(g_modos) + db.session.add(g_redacs) + db.session.add(g_community) + + # Clean up test members + for name in "PlanèteCasio GLaDOS".split(): + m = Member.query.filter_by(name=name).first() + if m is not None: + db.session.delete(m) + db.session.commit() + + # Create template members + m = Member('PlanèteCasio','contact@planet-casio.com','v5-forever') + m.groups.append(g_community) + db.session.add(m) + + m = Member('GLaDOS', 'glados@aperture.science', 'v5-forever') + m.groups.append(g_modos) + m.groups.append(g_redacs) + db.session.add(m) + db.session.add(SpecialPrivilege(m, "edit-posts")) + db.session.add(SpecialPrivilege(m, "shoutbox-ban")) + + db.session.commit() + + users = Member.query.all() + groups = Group.query.all() + return render('admin.html', users=users, groups=groups, form=form) diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..76ea1ca --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,42 @@ +{% extends "base/container.html" %} + +{% block content %} + +
+ {{ form.hidden_tag() }} + {{ form.submit }} +
+ +

List of members

+ + + + + + {% for user in users %} + + + + + {% endfor %} +
NameEmailRegisterXPInn.Newsletter
{{ user.name }}{{ user.email }}{{ user.register_date }}{{ user.xp }}{{ user.innovation }}{{ "Yes" if user.newsletter else "No" }}
+ +

List of groups

+ + + + + {% for group in groups %} + + {% endfor %} +
GroupMembersPrivileges
{{ group.name }} + {% for user in group.members %} + {{ user.name }} + {% endfor %} + + {% for priv in group.privs %} + {{ priv }} + {% endfor %} +
+ +{% endblock %} diff --git a/app/utils/decorators.py b/app/utils/decorators.py new file mode 100644 index 0000000..d36344e --- /dev/null +++ b/app/utils/decorators.py @@ -0,0 +1,17 @@ +from flask import redirect, url_for, flash +from flask import current_user +import functools + +# Use only with @login_required. +def privilege_required(priv): + def privilege_decorator(f): + @functools.wraps(f) + def wrapper(): + if not current_user.priv(priv): + flash(f'Cette page est protégée par le privilège {priv}'+ + '', 'error') + return redirect(url_for('index')) + else: + f() + return wrapper + return privilege_decorator diff --git a/app/utils/validators.py b/app/utils/validators.py index 81a34e9..5d8818b 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -4,7 +4,7 @@ def name(form, name): member = Member.query.filter_by(name=name.data).first() if member is not None: raise ValidationError('Pseudo indisponible.') - if not Member.valid_name(name.data): + if not User.valid_name(name.data): raise ValidationError("Nom d'utilisateur invalide.") def email(form, email): @@ -19,4 +19,4 @@ def password(form, password): def authentication(form, old_password): if not current_user.check_password(old_password.data): - raise ValidationError('Mot de passe erroné') \ No newline at end of file + raise ValidationError('Mot de passe erroné') diff --git a/assets/privs.txt b/assets/privs.txt index 2257b80..6a1d5ac 100644 --- a/assets/privs.txt +++ b/assets/privs.txt @@ -33,5 +33,6 @@ Shoutbox: Miscellaenous: unlimited-pms Removes the limit on the number of private messages footer-statistics View performance statistics in the page footer + community-login Automatically login as a community account Administration panel... diff --git a/config.py b/config.py index ee548e6..be42965 100644 --- a/config.py +++ b/config.py @@ -9,3 +9,5 @@ class Config(object): class V5Config(object): # Length allocated to privilege names (slugs) PRIVS_MAXLEN = 64 + # Forbidden user names + FORBIDDEN_USERNAMES = [ "admin", "root", "webmaster", "contact" ] diff --git a/migrations/versions/53f5ab9da859_.py b/migrations/versions/53f5ab9da859_.py new file mode 100644 index 0000000..50ab4b8 --- /dev/null +++ b/migrations/versions/53f5ab9da859_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 53f5ab9da859 +Revises: 8b2cd63804b3 +Create Date: 2019-02-04 15:09:58.315410 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '53f5ab9da859' +down_revision = '8b2cd63804b3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('group', sa.Column('description', sa.UnicodeText(), nullable=True)) + op.drop_index('ix_group_privilege_gid', table_name='group_privilege') + op.add_column('special_privilege', sa.Column('mid', sa.Integer(), nullable=True)) + op.create_index(op.f('ix_special_privilege_mid'), 'special_privilege', ['mid'], unique=False) + op.drop_index('ix_special_privilege_uid', table_name='special_privilege') + op.drop_constraint('special_privilege_uid_fkey', 'special_privilege', type_='foreignkey') + op.create_foreign_key(None, 'special_privilege', 'member', ['mid'], ['id']) + op.drop_column('special_privilege', 'uid') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('special_privilege', sa.Column('uid', sa.INTEGER(), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'special_privilege', type_='foreignkey') + op.create_foreign_key('special_privilege_uid_fkey', 'special_privilege', 'user', ['uid'], ['id']) + op.create_index('ix_special_privilege_uid', 'special_privilege', ['uid'], unique=False) + op.drop_index(op.f('ix_special_privilege_mid'), table_name='special_privilege') + op.drop_column('special_privilege', 'mid') + op.create_index('ix_group_privilege_gid', 'group_privilege', ['gid'], unique=False) + op.drop_column('group', 'description') + # ### end Alembic commands ###