diff --git a/.gitignore b/.gitignore index 0e32a60..88cb029 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ __pycache__/ app/__pycache__/ venv/ -migrations/ + +uwsgi.ini +run.sh diff --git a/app.db b/app.db deleted file mode 100644 index 358ace2..0000000 Binary files a/app.db and /dev/null differ diff --git a/app/__init__.py b/app/__init__.py index 7366fef..cfe5c17 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -8,6 +8,7 @@ app = Flask(__name__) app.config.from_object(Config) db = SQLAlchemy(app) migrate = Migrate(app, db) + login = LoginManager(app) login.login_view = 'login' diff --git a/app/models.py b/app/models.py deleted file mode 100644 index d290199..0000000 --- a/app/models.py +++ /dev/null @@ -1,36 +0,0 @@ -from datetime import datetime -from app import db, login -from flask_login import UserMixin -from werkzeug.security import generate_password_hash, check_password_hash - - -class User(UserMixin, db.Model): - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(64), index=True, unique=True) - email = db.Column(db.String(120), index=True, unique=True) - password_hash = db.Column(db.String(128)) - posts = db.relationship('Post', backref='author', lazy='dynamic') - - def __repr__(self): - return ''.format(self.username) - - def set_password(self, password): - self.password_hash = generate_password_hash(password) - - def check_password(self, password): - return check_password_hash(self.password_hash, password) - - -@login.user_loader -def load_user(id): - return User.query.get(int(id)) - - -class Post(db.Model): - id = db.Column(db.Integer, primary_key=True) - body = db.Column(db.String(140)) - timestamp = db.Column(db.DateTime, index=True, default=datetime.now) - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - - def __repr__(self): - return ''.format(self.body) diff --git a/app/models/users.py b/app/models/users.py index 6430209..4731acb 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -13,15 +13,17 @@ class User(UserMixin, db.Model): 'polymorphic_identity': __tablename__, 'polymorphic_on': type } - contents = db.relationship('Content', back_populates="author") # TODO: add good relation + # TODO: add good relation + contents = db.relationship('Content', back_populates="author") def __repr__(self): return f'' class Member(User, db.Model): __tablename__ = 'member' - id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) __mapper_args__ = { 'polymorphic_identity': __tablename__ } + + 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) @@ -108,7 +110,7 @@ class Guest(User, db.Model): __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 + ip = db.Column(db.String(47)) # Max IPv6 adress length last_post = db.Column(db.DateTime, default=datetime.now) diff --git a/assets/users.txt b/assets/users.txt new file mode 100644 index 0000000..26e176c --- /dev/null +++ b/assets/users.txt @@ -0,0 +1,68 @@ +# User management + +User information: + + Name Unique, no space, at least one letter + Avatar Stored in a server folder. Size limit? + Password Hashed, of course + Email Mail address, used to send newsletters + + Points Participation measure (mainly number of posts) + Innovation points A different kind of participation measure + + Settings... + +Relations: + + Notifications 1 to many + Groups 1 to many + Sent PMs 1 to many + Received PMs 1 to many + Trophies/Titles 1 to many + + All posts many to many (tutorials can have several authors) + Privileges many to many + +Rest API for users: + Requests where "Search" is set to "Yes" accept search patterns. The syntax + needs to be chosen, it could be something like "/users[name~=/*storm*/i]". + + Method URL Search Description + ----------------------------------------------------------------------------- + GET /users Yes Query users + POST /users - Create new user + ----------------------------------------------------------------------------- + GET /users/ - Get user information/settings + PATCH /users/ - Update user information/settings + DELETE /users/ - Delete user account + ----------------------------------------------------------------------------- + GET /users//trophies - Get unlocked trophies + ----------------------------------------------------------------------------- + GET /users//messages Yes Query private messages + POST /users//messages - Send PM ( is sender) + DELETE /users//messages Required Delete PMs matching pattern + ----------------------------------------------------------------------------- + GET /users//groups - Get user groups + PATCH /users//groups - Add or remove group memberships (*) + ----------------------------------------------------------------------------- + GET /users//privs - Get user privileges + PATCH /users//privs - Grant/revoke special privileges (*) + ----------------------------------------------------------------------------- + + Updating the participation scores is not a request, it's tied to posting + contents, so it has nothing to do in the API. + + (*) Not sure if this is relevant, since these are administrator duties. + +Rest API for groups: + + Method URL Search Description + ----------------------------------------------------------------------------- + GET /groups - Get list of groups + POST /groups - Create group + ----------------------------------------------------------------------------- + GET /groups/ - Get list of users in groups + ----------------------------------------------------------------------------- + + There are no methods to change the privileges associated to each groups + because this task is clearly for administrators, not API users. diff --git a/config.py b/config.py index 7b3ee64..6f9b910 100644 --- a/config.py +++ b/config.py @@ -4,6 +4,8 @@ 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 \ - 'sqlite:///' + os.path.join(basedir, 'app.db') + 'postgresql+psycopg2://' + os.environ.get('USER') + ':@/pcv5' SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..2d31c5a --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,90 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig +import logging + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from flask import current_app +config.set_main_option('sqlalchemy.url', + current_app.config.get('SQLALCHEMY_DATABASE_URI')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + engine = engine_from_config(config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + connection = engine.connect() + context.configure(connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args) + + try: + with context.begin_transaction(): + context.run_migrations() + except Exception as exception: + logger.error(exception) + raise exception + finally: + connection.close() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/27c0fff58193_.py b/migrations/versions/27c0fff58193_.py new file mode 100644 index 0000000..350a217 --- /dev/null +++ b/migrations/versions/27c0fff58193_.py @@ -0,0 +1,73 @@ +"""empty message + +Revision ID: 27c0fff58193 +Revises: +Create Date: 2019-02-02 16:06:49.372395 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '27c0fff58193' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('content', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=20), nullable=True), + sa.Column('data', sa.Text(convert_unicode=True), nullable=True), + sa.Column('date_created', sa.DateTime(), nullable=True), + sa.Column('date_modified', sa.DateTime(), nullable=True), + sa.Column('author_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['author_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('guest', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.Unicode(length=64), nullable=True), + sa.Column('last_post', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_guest_username'), 'guest', ['username'], unique=False) + op.create_table('member', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.Unicode(length=64), nullable=True), + sa.Column('email', sa.String(length=120), nullable=True), + sa.Column('password_hash', sa.String(length=255), nullable=True), + sa.Column('xp_points', sa.Integer(), nullable=True), + sa.Column('innovation_points', sa.Integer(), nullable=True), + sa.Column('biography', sa.Text(convert_unicode=True), nullable=True), + sa.Column('signature', sa.Text(convert_unicode=True), nullable=True), + sa.Column('birthday', sa.Date(), nullable=True), + sa.Column('register_date', sa.Date(), nullable=True), + sa.Column('receive_newsletter', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_member_email'), 'member', ['email'], unique=True) + op.create_index(op.f('ix_member_username'), 'member', ['username'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_member_username'), table_name='member') + op.drop_index(op.f('ix_member_email'), table_name='member') + op.drop_table('member') + op.drop_index(op.f('ix_guest_username'), table_name='guest') + op.drop_table('guest') + op.drop_table('content') + op.drop_table('user') + # ### end Alembic commands ### diff --git a/migrations/versions/7dfd5e3aa1fb_fix_a_typo_in_a_field_name.py b/migrations/versions/7dfd5e3aa1fb_fix_a_typo_in_a_field_name.py new file mode 100644 index 0000000..b9efe89 --- /dev/null +++ b/migrations/versions/7dfd5e3aa1fb_fix_a_typo_in_a_field_name.py @@ -0,0 +1,28 @@ +"""Fix a typo in a field name + +Revision ID: 7dfd5e3aa1fb +Revises: 27c0fff58193 +Create Date: 2019-02-02 16:13:13.229250 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7dfd5e3aa1fb' +down_revision = '27c0fff58193' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('guest', sa.Column('ip', sa.String(length=47), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('guest', 'ip') + # ### end Alembic commands ###