Browse Source

users: review code and expand a little

Nothing ground-breaking here, but lays the ground for
later expansions.
master
lephe 10 months ago
parent
commit
2311c7f3d8
10 changed files with 275 additions and 104 deletions
  1. +5
    -3
      app/forms.py
  2. +165
    -79
      app/models/users.py
  3. +9
    -10
      app/routes.py
  4. +2
    -2
      app/templates/account.html
  5. +2
    -5
      app/templates/base/footer.html
  6. +1
    -1
      app/templates/base/navbar/account.html
  7. +5
    -0
      assets/users.txt
  8. +0
    -4
      config.py
  9. +36
    -0
      migrations/versions/7697154d0a4b_.py
  10. +50
    -0
      migrations/versions/d2c96bebc596_.py

+ 5
- 3
app/forms.py View File

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

+ 165
- 79
app/models/users.py View File

@@ -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'<User #{self.id}>'

# 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'<Guest: {self.username} ({self.ip})>'

# 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)
# 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)

# Personal information, all optional
bio = db.Column(db.UnicodeText)
signature = db.Column(db.UnicodeText)
birthday = db.Column(db.Date)

def __init__(self, username, email, password):
self.username = username
# Settings
newsletter = db.Column(db.Boolean, default=False)

# Relations
# 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):
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

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 add_xp(self, n):
self.xp_points += n
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):
"""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, 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'<Member {self.username}>'
return f'<Member: {self.name}>'

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


def __repr__(self):
return f'<Guest {self.username} ({self.ip})>'
class Group(db.Model):
__tablename__ = 'group'

# 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)

+ 9
- 10
app/routes.py View File

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

+ 2
- 2
app/templates/account.html View File

@@ -6,7 +6,7 @@

<form action="" method="post">
{{ form.hidden_tag() }}
<div>
{{ form.avatar.label }}
<div>
@@ -51,7 +51,7 @@
</div>
<div>
{{ form.biography.label }}
<textarea id="{{ form.biography.name }}" name="{{ form.biography.name }}">{{ current_user.biography }}</textarea>
<textarea id="{{ form.biography.name }}" name="{{ form.biography.name }}">{{ current_user.bio }}</textarea>
{% for error in form.biography.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}

+ 2
- 5
app/templates/base/footer.html View File

@@ -1,8 +1,5 @@
<footer>
<p>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.</p>
<p>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.</p>
<p>Planète Casio est un site communautaire non affilié à Casio | Toute reproduction de Planète Casio, même partielle, est interdite.</p>
<p>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.</p>
<p>CASIO est une marque déposée par CASIO Computer Co., Ltd.</p>
</footer>

+ 1
- 1
app/templates/base/navbar/account.html View File

@@ -2,7 +2,7 @@
<div>
<h2>
<img src="{{ url_for('static', filename= 'images/3864.png') }}">
{{ current_user.username }}
{{ current_user.name }}
</h2>
<a href="#">
<svg viewBox="0 0 24 24">

+ 5
- 0
assets/users.txt View File

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

+ 0
- 4
config.py View File

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

+ 36
- 0
migrations/versions/7697154d0a4b_.py View File

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

+ 50
- 0
migrations/versions/d2c96bebc596_.py View File

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

Loading…
Cancel
Save