diff --git a/app/forms.py b/app/forms.py index 2e1aaff..c9815bf 100644 --- a/app/forms.py +++ b/app/forms.py @@ -9,10 +9,12 @@ from wtforms.meta import DefaultMeta # TODO: Put those validators into a specific file -def validate_username(self, username): - member = Member.query.filter_by(username=username.data).first() +def validate_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): + raise ValidationError("Nom d'utilisateur invalide.") def validate_email(form, email): member = Member.query.filter_by(email=email.data).first() @@ -39,7 +41,7 @@ class LoginForm(FlaskForm): class RegistrationForm(FlaskForm): - username = StringField('Pseudonyme :', validators=[DataRequired(), validate_username]) + username = StringField('Pseudonyme :', validators=[DataRequired(), validate_name]) email = StringField('Adresse Email :', validators=[DataRequired(), Email(), validate_email]) password = PasswordField('Mot de passe', validators=[DataRequired(), validate_password]) password2 = PasswordField('Répéter le mot de passe', validators=[DataRequired(), EqualTo('password')]) diff --git a/app/models/users.py b/app/models/users.py index 4731acb..312880f 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -1,122 +1,208 @@ from datetime import date, datetime -from app import db, login +from app import db from flask_login import UserMixin -from werkzeug.security import generate_password_hash, check_password_hash from app.models.contents import Content +import werkzeug.security +import app +import re +# User: Website user that performs actions on the content class User(UserMixin, db.Model): __tablename__ = 'user' + + # User ID, should be used to refer to any user. Thea actual user can either + # be a guest (with IP as key) or a member (with this ID as key). id = db.Column(db.Integer, primary_key=True) - type = db.Column(db.String(20)) + # User type (polymorphic discriminator) + type = db.Column(db.String(30)) + + # TODO: add good relation + contents = db.relationship('Content', back_populates="author") + __mapper_args__ = { 'polymorphic_identity': __tablename__, 'polymorphic_on': type } - # TODO: add good relation - contents = db.relationship('Content', back_populates="author") def __repr__(self): return f'' +# Guest: Unregistered user with minimal privileges +class Guest(User, db.Model): + __tablename__ = 'guest' + __mapper_args__ = { 'polymorphic_identity': __tablename__ } + + # ID of the [User] entry + id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + # Reusable username, can be the name of a member (will be distinguished at + # rendering time) + username = db.Column(db.Unicode(64), index=True) + # IP address, 47 characters is the max for an IPv6 address + ip = db.Column(db.String(47)) + + def __repr__(self): + return f'' + +# Member: Registered user with full access to the website's services class Member(User, db.Model): __tablename__ = 'member' __mapper_args__ = { 'polymorphic_identity': __tablename__ } + # Id of the [User] entry id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) - # Standalone properties - username = db.Column(db.Unicode(64), index=True, unique=True) - email = db.Column(db.String(120), index=True, unique=True) + + # Primary attributes (needed for the system to work) + name = db.Column(db.Unicode(32), index=True, unique=True) + email = db.Column(db.Unicode(120), index=True, unique=True) password_hash = db.Column(db.String(255)) - xp_points = db.Column(db.Integer) - innovation_points = db.Column(db.Integer) - biography = db.Column(db.Text(convert_unicode=True)) - signature = db.Column(db.Text(convert_unicode=True)) - birthday = db.Column(db.Date) + xp = db.Column(db.Integer) + innovation = db.Column(db.Integer) register_date = db.Column(db.Date, default=date.today) + + # Personal information, all optional + bio = db.Column(db.UnicodeText) + signature = db.Column(db.UnicodeText) + birthday = db.Column(db.Date) + + # Settings + newsletter = db.Column(db.Boolean, default=False) + # Relations - #trophies = db.relationship('Trophy') # TODO: add good relation - #tests = db.relationship('Test') # TODO: add good relation - # Privacy assets - receive_newsletter = db.Column(db.Boolean, default=False) + # 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 - def __init__(self, username, email, password): - self.username = username + 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): + raise Exception(f'{name} is not a valid user name') + + self.name = name self.email = email self.set_password(password) + self.xp = 0 + self.innovation = 0 + + self.bio = "" + self.signature = "" + self.birthday = None + + def update(self, data): + """ + Update all or part of the user's metadata. The [data] dictionary + accepts the following keys: + "name" str User name + "email" str User mail ddress + "password" str Raw password + "bio" str Biograpy + "signature" str Post signature + "birthday" date Birthday date + "newsletter" bool Newsletter setting + For future compatibility, other attributes are silently ignored. None + values can be specified and are ignored. + + It is the caller's responsibility to check that the request sender has + the right to change user names, password... otherwise this method will + turn out dangerous! + """ + + 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"]): + raise Exception(f'{data["name"]} is not a valid user name') + self.name = data["name"] - def update(self, username=None, email=None, password=None, - biography=None, signature=None, birthday=None, - receive_newsletter=None): # TODO: verify good type of those args, think about the password mgt - if username: - self.username = username - if email: - self.email = email - if password: - self.set_password(password) - if biography: - self.biography = biography - if signature: - self.signature = signature - if birthday: - self.birthday = birthday - if receive_newsletter: - self.receive_newsletter = receive_newsletter + if "email" in data: + self.email = data["email"] + if "password" in data: + self.set_password(data["password"]) + if "bio" in data: + self.biography = data["bio"] + if "signature" in data: + self.signature = data["signature"] + if "birthday" in data: + self.birthday = data["birthday"] + if "newsletter" in data: + self.newsletter = data["newsletter"] - def get_public_data(self, username=False, xp_points=False, - innovation_points=False, biography=None, signature=None, - birthday=None, register_date=False): - out = {} - if username: - out['username'] = username - if xp_points: - out['xp_points'] = xp_points - if innovation_points: - out['innovation_points'] = innovation_points - if biography: - out['biography'] = biography - if signature: - out['signature'] = signature - if birthday: - out['birthday'] = birthday - if register_date: - out['register_date'] = register_date + def get_public_data(self): + """Returns the public information of the member.""" + return { + "name": self.name, + "xp": self.xp, + "innovation": self.innovation, + "register_date": self.register_date, + "bio": self.bio, + "signature": self.signature, + "birthday": self.birthday, + } - def add_xp(self, n): - self.xp_points += n + def add_xp(self, amount): + """ + Reward xp to a member. If [amount] is negative, the xp total of the + member will decrease, down to zero. + """ + self.xp_points = max(self.xp_points + amount, 0) def add_innovation(self, n): - self.innovation_points += n + """ + Reward innovation points to a member. If [amount] is negative, the + innovation points total will decrease, down to zero. + """ + self.innovation = max(self.innovation + amount, 0) def set_password(self, password): - self.password_hash = generate_password_hash(password, + """ + Set the user's password. Check whether the request sender has the right + to do this! + """ + self.password_hash = werkzeug.security.generate_password_hash(password, method='pbkdf2:sha512', salt_length=10) def check_password(self, password): - return check_password_hash(self.password_hash, password) + """Compares password against member hash.""" + return werkzeug.security.check_password_hash(self.password_hash, + password) def __repr__(self): - return f'' + return f'' -@login.user_loader +@app.login.user_loader def load_user(id): return User.query.get(int(id)) -class Guest(User, db.Model): - __tablename__ = 'guest' - id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) - __mapper_args__ = { 'polymorphic_identity': __tablename__ } - # Standalone propeties - username = db.Column(db.Unicode(64), index=True) - ip = db.Column(db.String(47)) # Max IPv6 adress length - last_post = db.Column(db.DateTime, default=datetime.now) +class Group(db.Model): + __tablename__ = 'group' - - def __repr__(self): - return f'' - -# Can be implemented if needed -# class Organization(User, db.Model): -# pass + id = db.Column(db.Integer, primary_key=True) + # Full name, such as "Administrateur" or "Membre d'honneur". + name = db.Column(db.Unicode(50), unique=True) + # 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) diff --git a/app/routes.py b/app/routes.py index a1cd895..a8f89a9 100644 --- a/app/routes.py +++ b/app/routes.py @@ -27,7 +27,7 @@ def login(): return redirect(url_for('index')) login_form = LoginForm() if login_form.validate_on_submit(): - member = Member.query.filter_by(username=login_form.username.data).first() + member = Member.query.filter_by(name=login_form.username.data).first() if member is None or not member.check_password(login_form.password.data): flash('Pseudo ou mot de passe invalide', 'error') return redirect(request.referrer) @@ -50,13 +50,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, - biography = form.biography.data, - birthday = form.birthday.data, - receive_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') @@ -71,8 +72,6 @@ def register(): form = RegistrationForm() if form.validate_on_submit(): member = Member(form.username.data, form.email.data, form.password.data) - member.biography = "" - member.signature = "" db.session.add(member) db.session.commit() flash('Inscription réussie', 'ok') diff --git a/app/templates/account.html b/app/templates/account.html index 7cf5133..c580876 100644 --- a/app/templates/account.html +++ b/app/templates/account.html @@ -6,7 +6,7 @@
{{ form.hidden_tag() }} - +
{{ form.avatar.label }}
@@ -51,7 +51,7 @@
{{ form.biography.label }} - + {% for error in form.biography.errors %} {{ error }} {% endfor %} diff --git a/app/templates/base/footer.html b/app/templates/base/footer.html index c4f1f15..4880fb9 100644 --- a/app/templates/base/footer.html +++ b/app/templates/base/footer.html @@ -1,8 +1,5 @@
-

Planète Casio est un site communautaire indépendant, géré bénévolement et n'est donc pas - affilié à Casio | Toute reproduction de Planète Casio, même partielle, est interdite.

-

Les fichiers, programmes et autres publications présents sur Planète Casio restent la - propriété de leurs auteurs respectifs et peuvent être soumis à des licences ou des - copyrights.

+

Planète Casio est un site communautaire non affilié à Casio | Toute reproduction de Planète Casio, même partielle, est interdite.

+

Les programmes et autres publications présentes sur Planète Casio restent la propriété de leurs auteurs et peuvent être soumis à des licences ou des copyrights.

CASIO est une marque déposée par CASIO Computer Co., Ltd.

diff --git a/app/templates/base/navbar/account.html b/app/templates/base/navbar/account.html index 2c7e352..f776200 100644 --- a/app/templates/base/navbar/account.html +++ b/app/templates/base/navbar/account.html @@ -2,7 +2,7 @@

- {{ current_user.username }} + {{ current_user.name }}

diff --git a/assets/users.txt b/assets/users.txt index 26e176c..ba432c1 100644 --- a/assets/users.txt +++ b/assets/users.txt @@ -10,6 +10,11 @@ User information: Points Participation measure (mainly number of posts) Innovation points A different kind of participation measure + Biography Description of the user + Signature Short signature added at the end of every post + Birthday Birthday date + + Newsletter Subscription to newsletter Settings... Relations: diff --git a/config.py b/config.py index 6f9b910..82ec0d4 100644 --- a/config.py +++ b/config.py @@ -1,11 +1,7 @@ import os -basedir = os.path.abspath(os.path.dirname(__file__)) - class Config(object): SECRET_KEY = os.environ.get('SECRET_KEY') or 'a-random-secret-key' -# SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ -# 'sqlite:///' + os.path.join(basediru, 'app.db') SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 'postgresql+psycopg2://' + os.environ.get('USER') + ':@/pcv5' SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/migrations/versions/7697154d0a4b_.py b/migrations/versions/7697154d0a4b_.py new file mode 100644 index 0000000..e4a11dc --- /dev/null +++ b/migrations/versions/7697154d0a4b_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: 7697154d0a4b +Revises: 7dfd5e3aa1fb +Create Date: 2019-02-02 20:54:59.683353 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '7697154d0a4b' +down_revision = '7dfd5e3aa1fb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('group', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.Unicode(length=50), nullable=True), + sa.Column('css', sa.UnicodeText(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.drop_column('guest', 'last_post') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('guest', sa.Column('last_post', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) + op.drop_table('group') + # ### end Alembic commands ### diff --git a/migrations/versions/d2c96bebc596_.py b/migrations/versions/d2c96bebc596_.py new file mode 100644 index 0000000..5730aad --- /dev/null +++ b/migrations/versions/d2c96bebc596_.py @@ -0,0 +1,50 @@ +"""empty message + +Revision ID: d2c96bebc596 +Revises: 7697154d0a4b +Create Date: 2019-02-02 21:37:53.802259 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd2c96bebc596' +down_revision = '7697154d0a4b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('member', sa.Column('bio', sa.UnicodeText(), nullable=True)) + op.add_column('member', sa.Column('innovation', sa.Integer(), nullable=True)) + op.add_column('member', sa.Column('name', sa.Unicode(length=32), nullable=True)) + op.add_column('member', sa.Column('newsletter', sa.Boolean(), nullable=True)) + op.add_column('member', sa.Column('xp', sa.Integer(), nullable=True)) + op.create_index(op.f('ix_member_name'), 'member', ['name'], unique=True) + op.drop_index('ix_member_username', table_name='member') + op.drop_column('member', 'xp_points') + op.drop_column('member', 'receive_newsletter') + op.drop_column('member', 'biography') + op.drop_column('member', 'username') + op.drop_column('member', 'innovation_points') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('member', sa.Column('innovation_points', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('member', sa.Column('username', sa.VARCHAR(length=64), autoincrement=False, nullable=True)) + op.add_column('member', sa.Column('biography', sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column('member', sa.Column('receive_newsletter', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.add_column('member', sa.Column('xp_points', sa.INTEGER(), autoincrement=False, nullable=True)) + op.create_index('ix_member_username', 'member', ['username'], unique=True) + op.drop_index(op.f('ix_member_name'), table_name='member') + op.drop_column('member', 'xp') + op.drop_column('member', 'newsletter') + op.drop_column('member', 'name') + op.drop_column('member', 'innovation') + op.drop_column('member', 'bio') + # ### end Alembic commands ###