Compare commits
6 Commits
849b63628f
...
da2008b750
| Author | SHA1 | Date |
|---|---|---|
|
|
da2008b750 | 4 years ago |
|
|
f017ca20b2 | 4 years ago |
|
|
6c30a53376 | 4 years ago |
|
|
d681995de0 | 4 years ago |
|
|
052e9de8e9 | 4 years ago |
|
|
dca7509eb4 | 4 years ago |
44 changed files with 1254 additions and 32 deletions
@ -0,0 +1,4 @@ |
|||||
|
**/__pycache__/ |
||||
|
build/ |
||||
|
dist/ |
||||
|
**/*.egg-info |
||||
@ -0,0 +1,7 @@ |
|||||
|
FLASK_APP=monsun_backend |
||||
|
FLASK_ENV=development |
||||
|
SECRET=\\\x10#\xd9\x89,2|hBN\xe4\xdf\xe0\xf7W |
||||
|
POSTGRES_PASSWORD=pass |
||||
|
POSTGRES_USER=usr |
||||
|
POSTGRES_HOST=localhost |
||||
|
POSTGRES_DB_NAME=monsun_backend |
||||
@ -0,0 +1,38 @@ |
|||||
|
version: "3" |
||||
|
|
||||
|
services: |
||||
|
monsun_postgres: |
||||
|
image: postgres |
||||
|
container_name: monsun_postgres |
||||
|
restart: always |
||||
|
logging: |
||||
|
driver: json-file |
||||
|
options: |
||||
|
max-size: "200k" |
||||
|
max-file: "10" |
||||
|
volumes: |
||||
|
- ./db-data:/var/lib/postgresql/data |
||||
|
env_file: prod.env |
||||
|
|
||||
|
monsun_backend: |
||||
|
image: registry.berthoud.dev/monsun_backend |
||||
|
container_name: monsun_backend |
||||
|
restart: always |
||||
|
logging: |
||||
|
driver: json-file |
||||
|
options: |
||||
|
max-size: "200k" |
||||
|
max-file: "10" |
||||
|
ports: |
||||
|
- 30010:80 |
||||
|
volumes: |
||||
|
- ./config:/var/config/ |
||||
|
depends_on: |
||||
|
- monsun_postgres |
||||
|
environment: |
||||
|
POSTGRES_HOST: monsun_postgres |
||||
|
env_file: prod.env |
||||
|
devices: |
||||
|
- "/dev/ttyACM0:/dev/tty.client" |
||||
|
|
||||
|
command: ["bash", "./wait-for-it.sh", "monsun_postgres:5432", "-t", "60", "--", "./docker-entrypoint.sh"] |
||||
@ -0,0 +1,25 @@ |
|||||
|
version: "2" |
||||
|
|
||||
|
services: |
||||
|
monsun_postgres: |
||||
|
image: postgres |
||||
|
container_name: monsun_postgres |
||||
|
restart: always |
||||
|
volumes: |
||||
|
- ./db-data:/var/lib/postgresql/data |
||||
|
env_file: dev.env |
||||
|
|
||||
|
monsun_backend: |
||||
|
build: . |
||||
|
container_name: monsun_backd |
||||
|
restart: always |
||||
|
ports: |
||||
|
- 80:80 |
||||
|
volumes: |
||||
|
- ./config:/var/config/ |
||||
|
depends_on: |
||||
|
- monsun_postgres |
||||
|
environment: |
||||
|
POSTGRES_HOST: monsun_postgres |
||||
|
env_file: dev.env |
||||
|
command: ["bash", "./wait-for-it.sh", "monsun_postgres:5432", "-t", "60", "--", "./docker-entrypoint.sh"] |
||||
@ -0,0 +1,6 @@ |
|||||
|
#!/usr/bin/env bash |
||||
|
source /opt/venv/bin/activate |
||||
|
flask db upgrade |
||||
|
|
||||
|
service nginx start |
||||
|
uwsgi --ini uwsgi.ini |
||||
@ -0,0 +1,3 @@ |
|||||
|
docker build -t monsun_backend . |
||||
|
docker tag monsun_backend:latest registry.berthoud.dev/monsun_backend |
||||
|
docker push registry.berthoud.dev/monsun_backend:latest |
||||
@ -0,0 +1,45 @@ |
|||||
|
# stage 1 |
||||
|
FROM python:3.8-slim-buster as backend-build |
||||
|
|
||||
|
VOLUME /app |
||||
|
WORKDIR /app |
||||
|
|
||||
|
RUN apt-get update \ |
||||
|
&& apt-get -y install python3-dev \ |
||||
|
&& apt-get -y install build-essential |
||||
|
|
||||
|
RUN python3 -m venv /opt/venv |
||||
|
|
||||
|
COPY . . |
||||
|
|
||||
|
RUN . /opt/venv/bin/activate \ |
||||
|
&& pip install --upgrade setuptools wheel \ |
||||
|
&& pip install -r requirements.txt \ |
||||
|
&& python setup.py sdist bdist_wheel \ |
||||
|
&& pip install monsun_backend --no-index --find-links file:///app/dist |
||||
|
|
||||
|
# stage 2 |
||||
|
FROM python:3.8-slim-buster |
||||
|
|
||||
|
RUN apt-get update \ |
||||
|
&& apt-get -y install nginx \ |
||||
|
&& apt-get -y install python3-dev \ |
||||
|
&& apt-get -y install build-essential \ |
||||
|
&& apt-get -qy install netcat |
||||
|
|
||||
|
RUN mkdir /var/config |
||||
|
VOLUME /var/config |
||||
|
|
||||
|
VOLUME /app |
||||
|
WORKDIR /app |
||||
|
|
||||
|
COPY docker-entrypoint.sh /app/docker-entrypoint.sh |
||||
|
COPY wait-for-it.sh /app/wait-for-it.sh |
||||
|
COPY wsgi.py /app/wsgi.py |
||||
|
COPY nginx.conf /etc/nginx |
||||
|
COPY uwsgi.ini /app/uwsgi.ini |
||||
|
COPY migrations /app/migrations |
||||
|
|
||||
|
COPY --from=backend-build /opt/venv /opt/venv |
||||
|
|
||||
|
CMD ["bash", "./docker-entrypoint.sh"] |
||||
@ -0,0 +1 @@ |
|||||
|
Single-database configuration for Flask. |
||||
@ -0,0 +1,50 @@ |
|||||
|
# 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,flask_migrate |
||||
|
|
||||
|
[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 |
||||
|
|
||||
|
[logger_flask_migrate] |
||||
|
level = INFO |
||||
|
handlers = |
||||
|
qualname = flask_migrate |
||||
|
|
||||
|
[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 |
||||
@ -0,0 +1,88 @@ |
|||||
|
from __future__ import with_statement |
||||
|
|
||||
|
import logging |
||||
|
from logging.config import fileConfig |
||||
|
|
||||
|
from alembic import context |
||||
|
from flask import current_app |
||||
|
|
||||
|
# 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 |
||||
|
config.set_main_option( |
||||
|
"sqlalchemy.url", |
||||
|
str(current_app.extensions["migrate"].db.get_engine().url).replace("%", "%%"), |
||||
|
) |
||||
|
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, target_metadata=target_metadata, literal_binds=True) |
||||
|
|
||||
|
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.") |
||||
|
|
||||
|
connectable = current_app.extensions["migrate"].db.get_engine() |
||||
|
|
||||
|
with connectable.connect() as connection: |
||||
|
context.configure( |
||||
|
connection=connection, |
||||
|
target_metadata=target_metadata, |
||||
|
process_revision_directives=process_revision_directives, |
||||
|
**current_app.extensions["migrate"].configure_args |
||||
|
) |
||||
|
|
||||
|
with context.begin_transaction(): |
||||
|
context.run_migrations() |
||||
|
|
||||
|
|
||||
|
if context.is_offline_mode(): |
||||
|
run_migrations_offline() |
||||
|
else: |
||||
|
run_migrations_online() |
||||
@ -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"} |
||||
@ -0,0 +1,58 @@ |
|||||
|
"""empty message |
||||
|
|
||||
|
Revision ID: 312674fa965 |
||||
|
Revises: |
||||
|
Create Date: 2021-08-03 18:17:22.918120 |
||||
|
|
||||
|
""" |
||||
|
import sqlalchemy as sa |
||||
|
from alembic import op |
||||
|
|
||||
|
# revision identifiers, used by Alembic. |
||||
|
revision = "312674fa9656" |
||||
|
down_revision = None |
||||
|
branch_labels = None |
||||
|
depends_on = None |
||||
|
|
||||
|
|
||||
|
def upgrade(): |
||||
|
# ### commands auto generated by Alembic - please adjust! ### |
||||
|
op.create_table( |
||||
|
"role", |
||||
|
sa.Column("id", sa.Integer(), nullable=False), |
||||
|
sa.Column("name", sa.String(length=80), nullable=True), |
||||
|
sa.Column("description", sa.String(length=255), nullable=True), |
||||
|
sa.PrimaryKeyConstraint("id"), |
||||
|
sa.UniqueConstraint("name"), |
||||
|
) |
||||
|
op.create_table( |
||||
|
"user", |
||||
|
sa.Column("id", sa.Integer(), nullable=False), |
||||
|
sa.Column("email", sa.String(length=255), nullable=True), |
||||
|
sa.Column("password", sa.String(length=512), nullable=True), |
||||
|
sa.Column("active", sa.Boolean(), nullable=True), |
||||
|
sa.PrimaryKeyConstraint("id"), |
||||
|
sa.UniqueConstraint("email"), |
||||
|
) |
||||
|
op.create_table( |
||||
|
"roles_users", |
||||
|
sa.Column("user_id", sa.Integer(), nullable=True), |
||||
|
sa.Column("role_id", sa.Integer(), nullable=True), |
||||
|
sa.ForeignKeyConstraint( |
||||
|
["role_id"], |
||||
|
["role.id"], |
||||
|
), |
||||
|
sa.ForeignKeyConstraint( |
||||
|
["user_id"], |
||||
|
["user.id"], |
||||
|
), |
||||
|
) |
||||
|
# ### end Alembic commands ### |
||||
|
|
||||
|
|
||||
|
def downgrade(): |
||||
|
# ### commands auto generated by Alembic - please adjust! ### |
||||
|
op.drop_table("roles_users") |
||||
|
op.drop_table("user") |
||||
|
op.drop_table("role") |
||||
|
# ### end Alembic commands ### |
||||
@ -0,0 +1,3 @@ |
|||||
|
from .admin import make_admin_user # noqa |
||||
|
from .init import init_app # noqa |
||||
|
from .permissions import admin_permission # noqa |
||||
@ -0,0 +1,25 @@ |
|||||
|
from flask import Flask |
||||
|
from monsun_backend import models |
||||
|
|
||||
|
from ..container import get_initialize_container |
||||
|
|
||||
|
|
||||
|
def init_app(app: Flask): |
||||
|
app.before_first_request(make_admin_user) |
||||
|
|
||||
|
|
||||
|
def make_admin_user() -> models.User: |
||||
|
"""Makes the admin user if he does not exist |
||||
|
|
||||
|
The admin is always the first user (id==1) |
||||
|
""" |
||||
|
container = get_initialize_container() |
||||
|
admin_user: models.User = models.user_datastore.get_user(identifier=1) |
||||
|
if admin_user is None: |
||||
|
admin_user = models.user_datastore.create_user( |
||||
|
email=container.config.admin_user_email(), |
||||
|
password=container.config.admin_user_password(), |
||||
|
) |
||||
|
admin_user.save() |
||||
|
|
||||
|
return admin_user |
||||
@ -0,0 +1,10 @@ |
|||||
|
"""Cannot be part ot __init__.py because it must be executed AFTER imports""" |
||||
|
from flask import Flask |
||||
|
|
||||
|
from . import admin |
||||
|
from . import permissions |
||||
|
|
||||
|
|
||||
|
def init_app(app: Flask): |
||||
|
admin.init_app(app=app) |
||||
|
permissions.init_app(app=app) |
||||
@ -0,0 +1,24 @@ |
|||||
|
from flask import Flask |
||||
|
from flask_login import current_user |
||||
|
from flask_principal import AnonymousIdentity |
||||
|
from flask_principal import Identity |
||||
|
from flask_principal import Permission |
||||
|
from flask_principal import Principal |
||||
|
from flask_principal import RoleNeed |
||||
|
from monsun_backend import models |
||||
|
|
||||
|
principals = Principal() |
||||
|
|
||||
|
|
||||
|
@principals.identity_loader |
||||
|
def read_identity_from_flask_login(): |
||||
|
if current_user.is_authenticated: |
||||
|
return Identity(current_user.id) |
||||
|
return AnonymousIdentity() |
||||
|
|
||||
|
|
||||
|
def init_app(app: Flask): |
||||
|
principals.init_app(app=app) |
||||
|
|
||||
|
|
||||
|
admin_permission = Permission(RoleNeed(models.RoleType.admin.name)) |
||||
@ -0,0 +1,40 @@ |
|||||
|
from flask_sqlalchemy import SQLAlchemy |
||||
|
from sqlalchemy import Column |
||||
|
from sqlalchemy import Integer |
||||
|
from sqlalchemy.ext.declarative import as_declarative |
||||
|
from sqlalchemy.ext.declarative import declared_attr |
||||
|
|
||||
|
|
||||
|
@as_declarative() |
||||
|
class BaseModel: |
||||
|
"""This class provides helper methods that can be used by its subclasses""" |
||||
|
|
||||
|
id = Column(Integer, primary_key=True) |
||||
|
|
||||
|
@declared_attr |
||||
|
def __tablename__(cls): |
||||
|
return cls.__name__.lower() |
||||
|
|
||||
|
def save(self): |
||||
|
db.session.add(self) |
||||
|
db.session.commit() |
||||
|
return self |
||||
|
|
||||
|
def delete(self): |
||||
|
db.session.delete(self) |
||||
|
db.session.commit() |
||||
|
|
||||
|
@classmethod |
||||
|
def get_all(cls): |
||||
|
return cls.query.all() |
||||
|
|
||||
|
def __repr__(self): |
||||
|
return f"<{self.__class__.__name__} {self.name!r}>" |
||||
|
|
||||
|
|
||||
|
def session_commit(): |
||||
|
"""Shortcut for ``db.session.commit()``""" |
||||
|
db.session.commit() |
||||
|
|
||||
|
|
||||
|
db = SQLAlchemy(model_class=BaseModel) |
||||
@ -0,0 +1,15 @@ |
|||||
|
from flask import blueprints |
||||
|
from flask_api import status |
||||
|
|
||||
|
from .. import access |
||||
|
|
||||
|
bp = blueprints.Blueprint("admin", __name__) |
||||
|
|
||||
|
|
||||
|
@bp.route( |
||||
|
rule="/admin", |
||||
|
methods=["GET"], |
||||
|
) |
||||
|
@access.admin_permission.require(http_exception=status.HTTP_403_FORBIDDEN) |
||||
|
def admin(): |
||||
|
return "success", 200 |
||||
@ -0,0 +1,38 @@ |
|||||
|
from typing import Optional |
||||
|
|
||||
|
from flask import blueprints |
||||
|
from flask import make_response |
||||
|
from flask import request |
||||
|
from flask_api import status |
||||
|
from flask_login import login_user |
||||
|
from monsun_backend import error |
||||
|
from monsun_backend import marshmallow_schemas |
||||
|
from monsun_backend import models |
||||
|
|
||||
|
bp = blueprints.Blueprint("login", __name__) |
||||
|
|
||||
|
|
||||
|
@bp.route( |
||||
|
rule="/login", |
||||
|
methods=["POST"], |
||||
|
) |
||||
|
def login(): |
||||
|
email_: Optional[str] = request.form.get("email") |
||||
|
if email_ is None: |
||||
|
raise error.BadRequest("email is missing") |
||||
|
|
||||
|
user_: models.User = models.User.query.filter_by(email=email_).first() |
||||
|
if not user_: |
||||
|
raise error.BadRequest("User does not exist") |
||||
|
|
||||
|
if user_.check_password(password=request.form.get("password")): |
||||
|
if login_user(user_): |
||||
|
return make_response( |
||||
|
marshmallow_schemas.user_schema.jsonify(user_), |
||||
|
status.HTTP_200_OK, |
||||
|
) |
||||
|
else: |
||||
|
# this shouldn't happen, but one never knows... |
||||
|
raise error.InternalServerError("Could not login user") |
||||
|
else: |
||||
|
raise error.Unauthorized("Password is incorrect") |
||||
@ -0,0 +1,23 @@ |
|||||
|
from flask import blueprints |
||||
|
from flask import jsonify |
||||
|
from flask import make_response |
||||
|
from flask_api import status |
||||
|
from flask_login import login_required |
||||
|
from flask_login import logout_user |
||||
|
|
||||
|
bp = blueprints.Blueprint("logout", __name__) |
||||
|
|
||||
|
|
||||
|
@bp.route( |
||||
|
rule="/logout", |
||||
|
methods=["DELETE"], |
||||
|
) |
||||
|
@login_required |
||||
|
def logout(): |
||||
|
# Remove the user information from the session |
||||
|
logout_user() |
||||
|
|
||||
|
return make_response( |
||||
|
jsonify(message="logout successful"), |
||||
|
status.HTTP_200_OK, |
||||
|
) |
||||
@ -0,0 +1,52 @@ |
|||||
|
from typing import Dict |
||||
|
from typing import Optional |
||||
|
from typing import Union |
||||
|
|
||||
|
from flask import Response |
||||
|
from flask import jsonify |
||||
|
from flask_api import status |
||||
|
|
||||
|
|
||||
|
class MonsunError(Exception): |
||||
|
status_code = status.HTTP_501_NOT_IMPLEMENTED |
||||
|
|
||||
|
data: Dict |
||||
|
message: Optional[str] |
||||
|
|
||||
|
def __init__( |
||||
|
self, |
||||
|
content: Union[str, Dict], |
||||
|
): |
||||
|
if isinstance(content, str): |
||||
|
self.message = content |
||||
|
self.data = {"message": self.message} |
||||
|
elif isinstance(content, Dict): |
||||
|
self.data = content |
||||
|
else: |
||||
|
raise ValueError("'content' must be either a string or an dict") |
||||
|
|
||||
|
@property |
||||
|
def response(self) -> Response: |
||||
|
response: Response = jsonify(self.data) |
||||
|
response.status_code = self.status_code |
||||
|
return response |
||||
|
|
||||
|
|
||||
|
class BadRequest(MonsunError): |
||||
|
status_code = status.HTTP_400_BAD_REQUEST |
||||
|
|
||||
|
|
||||
|
class Unauthorized(MonsunError): |
||||
|
status_code = status.HTTP_401_UNAUTHORIZED |
||||
|
|
||||
|
|
||||
|
class Forbidden(MonsunError): |
||||
|
status_code = status.HTTP_403_FORBIDDEN |
||||
|
|
||||
|
|
||||
|
class InternalServerError(MonsunError): |
||||
|
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR |
||||
|
|
||||
|
|
||||
|
class ConfigurationError(InternalServerError): |
||||
|
"""raised in case of invalid configuration""" |
||||
@ -0,0 +1,19 @@ |
|||||
|
from flask_marshmallow import Marshmallow |
||||
|
from marshmallow import fields |
||||
|
|
||||
|
from .models import User |
||||
|
|
||||
|
ma = Marshmallow() |
||||
|
|
||||
|
|
||||
|
class UserSchema(ma.SQLAlchemySchema): # type: ignore |
||||
|
class Meta: |
||||
|
model = User |
||||
|
|
||||
|
id = fields.Int() |
||||
|
email = fields.Str() |
||||
|
active = fields.Bool() |
||||
|
|
||||
|
|
||||
|
user_schema = UserSchema() |
||||
|
user_schema_public = UserSchema(only=("id", "email")) |
||||
@ -0,0 +1,3 @@ |
|||||
|
from .user_role import RoleType # noqa |
||||
|
from .user_role import User # noqa |
||||
|
from .user_role import user_datastore # noqa |
||||
@ -0,0 +1,152 @@ |
|||||
|
from __future__ import annotations |
||||
|
|
||||
|
import enum |
||||
|
from typing import Dict |
||||
|
from typing import Optional |
||||
|
from typing import Sequence |
||||
|
|
||||
|
from flask_security import RoleMixin |
||||
|
from flask_security import SQLAlchemyUserDatastore |
||||
|
from flask_security import UserMixin |
||||
|
from werkzeug.security import check_password_hash |
||||
|
from werkzeug.security import generate_password_hash |
||||
|
|
||||
|
from ..database import BaseModel |
||||
|
from ..database import db |
||||
|
|
||||
|
roles_users = db.Table( |
||||
|
"roles_users", |
||||
|
db.Column("user_id", db.Integer(), db.ForeignKey("user.id")), |
||||
|
db.Column("role_id", db.Integer(), db.ForeignKey("role.id")), |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class RoleType(enum.Enum): |
||||
|
"""The type of a role""" |
||||
|
|
||||
|
admin: int = 1 |
||||
|
|
||||
|
|
||||
|
ROLE_DESCRIPTIONS: Dict[RoleType, str] = { |
||||
|
RoleType.admin: "Has admin rights", |
||||
|
} |
||||
|
|
||||
|
|
||||
|
class Role(BaseModel, RoleMixin): |
||||
|
id: int # type: ignore |
||||
|
"""The DB id""" |
||||
|
|
||||
|
name: str = db.Column(db.String(80), unique=True) |
||||
|
"""The name of the role, see :class:`RoleType`""" |
||||
|
|
||||
|
description: str = db.Column(db.String(255)) |
||||
|
"""A short description of the role""" |
||||
|
|
||||
|
def __init__( |
||||
|
self, |
||||
|
name: str, |
||||
|
description: str, |
||||
|
): |
||||
|
""" |
||||
|
:param name: The name of the role, see :class:`RoleType` |
||||
|
:param description: A short description of the role |
||||
|
""" |
||||
|
self.name = name |
||||
|
self.description = description |
||||
|
|
||||
|
@classmethod |
||||
|
def get_role( |
||||
|
cls, |
||||
|
role_type: RoleType, |
||||
|
) -> Role: |
||||
|
"""Fetches the role from the DB or creates it if necessary |
||||
|
|
||||
|
:param role_type: The type of the role |
||||
|
:return: The role instance |
||||
|
""" |
||||
|
role_: Optional[Role] = cls.query.filter_by(name=role_type.name).first() |
||||
|
|
||||
|
if role_ is None: |
||||
|
role_ = cls( |
||||
|
name=role_type.name, |
||||
|
description=ROLE_DESCRIPTIONS[role_type], |
||||
|
) |
||||
|
role_.save() |
||||
|
|
||||
|
return role_ |
||||
|
|
||||
|
|
||||
|
class User(BaseModel, UserMixin): |
||||
|
id: int # type: ignore |
||||
|
"""The DB id""" |
||||
|
|
||||
|
email: str = db.Column(db.String(255), unique=True) |
||||
|
"""The email address""" |
||||
|
|
||||
|
password: str = db.Column(db.String(512)) |
||||
|
"""The password as hash""" |
||||
|
|
||||
|
active: bool = db.Column(db.Boolean()) |
||||
|
"""Is the user activated?""" |
||||
|
|
||||
|
roles: Sequence[Role] = db.relationship( |
||||
|
"Role", |
||||
|
secondary=roles_users, |
||||
|
backref=db.backref("users", lazy="dynamic"), |
||||
|
) |
||||
|
"""The roles which the user has""" |
||||
|
|
||||
|
def __init__( |
||||
|
self, |
||||
|
email: str, |
||||
|
password: str, |
||||
|
active: bool = True, |
||||
|
roles: Sequence[Role] = None, |
||||
|
): |
||||
|
""" |
||||
|
|
||||
|
:param email: The email address |
||||
|
:param password: The password as plain text |
||||
|
:param active: *True* if the user is active |
||||
|
:param roles: The Roles of the user. |
||||
|
""" |
||||
|
self.email = email |
||||
|
self.set_password(password=password) |
||||
|
self.active = active |
||||
|
self.roles = roles or [Role.get_role(RoleType.admin)] |
||||
|
|
||||
|
def set_password( |
||||
|
self, |
||||
|
password: str, |
||||
|
): |
||||
|
"""Create hashed password.""" |
||||
|
self.password = generate_password_hash( |
||||
|
password=password, |
||||
|
) |
||||
|
|
||||
|
def check_password( |
||||
|
self, |
||||
|
password: str, |
||||
|
) -> bool: |
||||
|
"""Check hashed password |
||||
|
|
||||
|
:param password: The password in plain text |
||||
|
:return: True if equal |
||||
|
""" |
||||
|
return bool( |
||||
|
check_password_hash( |
||||
|
pwhash=self.password, |
||||
|
password=password, |
||||
|
), |
||||
|
) |
||||
|
|
||||
|
def __repr__(self): |
||||
|
return ( |
||||
|
f"<{self.__class__.__name__} " |
||||
|
f"email={self.email!r}, " |
||||
|
f"active={self.active!r}, " |
||||
|
f"roles={self.roles!r}>" |
||||
|
) |
||||
|
|
||||
|
|
||||
|
user_datastore = SQLAlchemyUserDatastore(db, User, Role) |
||||
@ -0,0 +1,47 @@ |
|||||
|
user www-data; |
||||
|
worker_processes auto; |
||||
|
pid /run/nginx.pid; |
||||
|
|
||||
|
events { |
||||
|
worker_connections 1024; |
||||
|
use epoll; |
||||
|
multi_accept on; |
||||
|
} |
||||
|
|
||||
|
http { |
||||
|
access_log /dev/stdout; |
||||
|
error_log /dev/stdout; |
||||
|
|
||||
|
sendfile on; |
||||
|
tcp_nopush on; |
||||
|
tcp_nodelay on; |
||||
|
keepalive_timeout 65; |
||||
|
types_hash_max_size 2048; |
||||
|
|
||||
|
client_max_body_size 20M; |
||||
|
|
||||
|
include /etc/nginx/mime.types; |
||||
|
default_type application/octet-stream; |
||||
|
|
||||
|
index index.html index.htm; |
||||
|
|
||||
|
server { |
||||
|
listen 80 default_server; |
||||
|
listen [::]:80 default_server; |
||||
|
server_name localhost; |
||||
|
root /var/www/html; |
||||
|
|
||||
|
location / { |
||||
|
include uwsgi_params; |
||||
|
uwsgi_pass unix:/tmp/uwsgi.socket; |
||||
|
uwsgi_read_timeout 1h; |
||||
|
uwsgi_send_timeout 1h; |
||||
|
proxy_send_timeout 1h; |
||||
|
proxy_read_timeout 1h; |
||||
|
|
||||
|
proxy_buffer_size 128k; |
||||
|
proxy_buffers 4 256k; |
||||
|
proxy_busy_buffers_size 256k; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
[pytest] |
||||
|
addopts = -s |
||||
|
|
||||
|
markers = |
||||
|
client: required client device (dongle) |
||||
|
|
||||
|
env = |
||||
|
D:TEST_BASE_URL=https://monsun.berthoud.dev |
||||
@ -1,4 +1,13 @@ |
|||||
dependency-injector[yaml]>=4.34.0,<5 |
dependency-injector[yaml]>=4.34.0,<5 |
||||
|
email_validator |
||||
flask-api>=3.0.post1,<4 |
flask-api>=3.0.post1,<4 |
||||
|
flask-migrate |
||||
|
flask-script |
||||
|
flask-sqlalchemy |
||||
|
flask_marshmallow |
||||
|
flask_security |
||||
|
marshmallow-sqlalchemy |
||||
|
psycopg2-binary |
||||
pyserial>=3.5,<4 |
pyserial>=3.5,<4 |
||||
|
sqlalchemy-utils |
||||
uwsgi |
uwsgi |
||||
|
|||||
@ -0,0 +1,5 @@ |
|||||
|
# Remove DB data and create clean folder |
||||
|
rm -rfd db-data/ && mkdir db-data |
||||
|
|
||||
|
# Enable RW for everyone |
||||
|
chmod 666 /dev/ttyACM0 |
||||
@ -0,0 +1,74 @@ |
|||||
|
import logging |
||||
|
import os |
||||
|
|
||||
|
import pytest |
||||
|
import requests |
||||
|
from flask_api import status |
||||
|
|
||||
|
BASE_URL = os.environ.get("TEST_BASE_URL", "http://localhost") |
||||
|
LOGIN_URI = BASE_URL + "/login" |
||||
|
LOGOUT_URI = BASE_URL + "/logout" |
||||
|
ADMIN_URI = BASE_URL + "/admin" |
||||
|
|
||||
|
PASSWORD = os.environ.get("MONSUN_PASSWORD", "password") |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(autouse=True) |
||||
|
def log_variables(): |
||||
|
logger = logging.getLogger("system tests") |
||||
|
logger.info(f"BASE_URL: {BASE_URL}") |
||||
|
|
||||
|
|
||||
|
@pytest.fixture() |
||||
|
def session() -> requests.Session: |
||||
|
session = requests.Session() |
||||
|
response_login = session.post( |
||||
|
url=LOGIN_URI, |
||||
|
data={"email": "andreasberthoud@gmail.com", "password": PASSWORD}, |
||||
|
) |
||||
|
assert response_login.status_code == status.HTTP_200_OK |
||||
|
return session |
||||
|
|
||||
|
|
||||
|
def test_login_as_admin_and_accessing_admin_endpoint(): |
||||
|
session_admin_ = requests.Session() |
||||
|
response_login = session_admin_.post( |
||||
|
url=LOGIN_URI, |
||||
|
data={"email": "andreasberthoud@gmail.com", "password": PASSWORD}, |
||||
|
) |
||||
|
assert response_login.status_code == status.HTTP_200_OK |
||||
|
|
||||
|
response_admin_logged_in = session_admin_.get( |
||||
|
url=ADMIN_URI, |
||||
|
) |
||||
|
assert response_admin_logged_in.status_code == status.HTTP_200_OK |
||||
|
|
||||
|
response_logout = session_admin_.delete( |
||||
|
url=LOGOUT_URI, |
||||
|
) |
||||
|
assert response_logout.status_code == status.HTTP_200_OK |
||||
|
|
||||
|
response_admin_logged_out = session_admin_.get( |
||||
|
url=ADMIN_URI, |
||||
|
) |
||||
|
assert response_admin_logged_out.status_code == status.HTTP_403_FORBIDDEN |
||||
|
|
||||
|
|
||||
|
def test_toggle_client_led_then_status_is_ok(session): |
||||
|
response = session.post( |
||||
|
url=BASE_URL + "/client/command?cmd=led&id=red&command=toggle", |
||||
|
) |
||||
|
assert response.status_code == status.HTTP_200_OK |
||||
|
|
||||
|
|
||||
|
def test_pair_with_server_then_status_is_ok(session): |
||||
|
response = session.post(url=BASE_URL + "/client/command?cmd=gp&command_id=1") |
||||
|
assert response.status_code == status.HTTP_200_OK |
||||
|
|
||||
|
|
||||
|
def test_toggle_server_led_then_status_is_ok(session): |
||||
|
"""Requires a connected GATT server""" |
||||
|
response = session.post( |
||||
|
url=BASE_URL + "/client/command?cmd=led&id=red&command=toggle&target=server", |
||||
|
) |
||||
|
assert response.status_code == status.HTTP_200_OK |
||||
@ -0,0 +1,60 @@ |
|||||
|
import os |
||||
|
from typing import Iterator |
||||
|
from typing import Type |
||||
|
|
||||
|
import pytest |
||||
|
import utilities |
||||
|
from flask.testing import FlaskClient |
||||
|
from monsun_backend.container import get_initialize_container |
||||
|
from sqlalchemy import create_engine |
||||
|
from sqlalchemy_utils import create_database |
||||
|
from sqlalchemy_utils import database_exists |
||||
|
from utilities import setup_app |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(scope="session", autouse=True) |
||||
|
def db_test(): |
||||
|
"""makes sure that the DB exists""" |
||||
|
engine = create_engine(os.getenv("DATABASE_URI")) |
||||
|
if not database_exists(engine.url): |
||||
|
create_database(engine.url) |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(scope="class") |
||||
|
def app_class_scope(db_test): |
||||
|
with setup_app() as app: |
||||
|
yield app |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(scope="class") |
||||
|
def client_class_scope(app_class_scope): |
||||
|
return app_class_scope.test_client |
||||
|
|
||||
|
|
||||
|
@pytest.fixture() |
||||
|
def app(db_test): |
||||
|
with setup_app() as app: |
||||
|
yield app |
||||
|
|
||||
|
|
||||
|
@pytest.fixture() |
||||
|
def client(app): |
||||
|
return app.test_client |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(scope="class") |
||||
|
def as_admin( |
||||
|
client_class_scope: Type[FlaskClient], |
||||
|
) -> Iterator[utilities.TestUser]: |
||||
|
container = get_initialize_container() |
||||
|
with client_class_scope() as client_: |
||||
|
yield utilities.TestUser( |
||||
|
client_.post( |
||||
|
"/login", |
||||
|
data={ |
||||
|
"email": container.config.admin_user_email(), |
||||
|
"password": container.config.admin_user_password(), |
||||
|
}, |
||||
|
), |
||||
|
client_, |
||||
|
) |
||||
@ -0,0 +1,12 @@ |
|||||
|
version: "2" |
||||
|
|
||||
|
services: |
||||
|
tests_monsun_postgres-test: |
||||
|
image: postgres |
||||
|
environment: |
||||
|
POSTGRES_PASSWORD: pass |
||||
|
POSTGRES_USER: usr |
||||
|
POSTGRES_DB: sqlalchemy |
||||
|
POSTGRES_HOST: postgres |
||||
|
ports: |
||||
|
- 5432:5432 |
||||
@ -0,0 +1,50 @@ |
|||||
|
from flask import Response |
||||
|
from flask_api import status |
||||
|
from monsun_backend.container import get_initialize_container |
||||
|
from utilities import TestUser |
||||
|
|
||||
|
|
||||
|
def test_login_as_admin_then_get_status_ok(client): |
||||
|
container = get_initialize_container() |
||||
|
|
||||
|
response: Response = client().post( |
||||
|
"/login", |
||||
|
data={ |
||||
|
"email": container.config.admin_user_email(), |
||||
|
"password": container.config.admin_user_password(), |
||||
|
}, |
||||
|
) |
||||
|
|
||||
|
assert response.status_code == status.HTTP_200_OK |
||||
|
|
||||
|
|
||||
|
def test_logout_as_not_logged_in_user_then_get_status_found(client): |
||||
|
response: Response = client().get( |
||||
|
"/logout", |
||||
|
) |
||||
|
assert response.status_code == status.HTTP_302_FOUND |
||||
|
|
||||
|
|
||||
|
def test_logout_as_admin_then_get_status_found(as_admin: TestUser): |
||||
|
response: Response = as_admin.client.get( |
||||
|
"/logout", |
||||
|
) |
||||
|
assert response.status_code == status.HTTP_302_FOUND |
||||
|
|
||||
|
|
||||
|
def test_as_admin_access_admin_endpoint_then_get_status_ok(as_admin: TestUser): |
||||
|
response: Response = as_admin.client.get( |
||||
|
"/admin", |
||||
|
) |
||||
|
|
||||
|
assert response.status_code == status.HTTP_200_OK |
||||
|
|
||||
|
|
||||
|
def test_as_not_logged_in_user_access_admin_endpoint_then_get_status_forbidden( |
||||
|
client, |
||||
|
): # noqa |
||||
|
response: Response = client().get( |
||||
|
"/admin", |
||||
|
) |
||||
|
|
||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN |
||||
@ -0,0 +1,3 @@ |
|||||
|
pytest |
||||
|
pytest-env |
||||
|
requests |
||||
@ -0,0 +1,40 @@ |
|||||
|
from contextlib import contextmanager |
||||
|
from dataclasses import dataclass |
||||
|
from pathlib import Path |
||||
|
from typing import Iterator |
||||
|
|
||||
|
from flask import Flask |
||||
|
from flask import Response |
||||
|
from flask.testing import FlaskClient |
||||
|
from monsun_backend import access |
||||
|
from monsun_backend import create_app |
||||
|
from monsun_backend import database |
||||
|
|
||||
|
TESTS_DIR = Path(__file__).parent |
||||
|
|
||||
|
|
||||
|
@contextmanager |
||||
|
def setup_app() -> Iterator[Flask]: |
||||
|
app: Flask = create_app() |
||||
|
app.config["SERVER_NAME"] = "monsun_backend.localdomain" |
||||
|
app.config["TESTING"] = True |
||||
|
|
||||
|
# binds the app to the current context |
||||
|
with app.app_context(): |
||||
|
# create all tables |
||||
|
database.db.create_all() |
||||
|
|
||||
|
access.make_admin_user() |
||||
|
|
||||
|
yield app |
||||
|
with app.app_context(): |
||||
|
# drop all tables |
||||
|
database.db.session.remove() |
||||
|
database.db.drop_all() |
||||
|
|
||||
|
|
||||
|
@dataclass |
||||
|
class TestUser: |
||||
|
__test__ = False |
||||
|
response: Response |
||||
|
client: FlaskClient |
||||
@ -0,0 +1,12 @@ |
|||||
|
[uwsgi] |
||||
|
wsgi-file = wsgi.py |
||||
|
uid = www-data |
||||
|
gid = www-data |
||||
|
master = true |
||||
|
processes = 5 |
||||
|
|
||||
|
socket = /tmp/uwsgi.socket |
||||
|
chmod-sock = 664 |
||||
|
vacuum = true |
||||
|
|
||||
|
die-on-term = true |
||||
@ -0,0 +1,79 @@ |
|||||
|
#!/bin/sh |
||||
|
# https://github.com/vishnubob/wait-for-it |
||||
|
TIMEOUT=15 |
||||
|
QUIET=0 |
||||
|
|
||||
|
echoerr() { |
||||
|
if [ "$QUIET" -ne 1 ]; then printf "%s\n" "$*" 1>&2; fi |
||||
|
} |
||||
|
|
||||
|
usage() { |
||||
|
exitcode="$1" |
||||
|
cat << USAGE >&2 |
||||
|
Usage: |
||||
|
$cmdname host:port [-t timeout] [-- command args] |
||||
|
-q | --quiet Do not output any status messages |
||||
|
-t TIMEOUT | --timeout=timeout Timeout in seconds, zero for no timeout |
||||
|
-- COMMAND ARGS Execute command with args after the test finishes |
||||
|
USAGE |
||||
|
exit "$exitcode" |
||||
|
} |
||||
|
|
||||
|
wait_for() { |
||||
|
for i in `seq $TIMEOUT` ; do |
||||
|
nc -z "$HOST" "$PORT" > /dev/null 2>&1 |
||||
|
|
||||
|
result=$? |
||||
|
if [ $result -eq 0 ] ; then |
||||
|
if [ $# -gt 0 ] ; then |
||||
|
exec "$@" |
||||
|
fi |
||||
|
exit 0 |
||||
|
fi |
||||
|
sleep 1 |
||||
|
done |
||||
|
echo "Operation timed out" >&2 |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
while [ $# -gt 0 ] |
||||
|
do |
||||
|
case "$1" in |
||||
|
*:* ) |
||||
|
HOST=$(printf "%s\n" "$1"| cut -d : -f 1) |
||||
|
PORT=$(printf "%s\n" "$1"| cut -d : -f 2) |
||||
|
shift 1 |
||||
|
;; |
||||
|
-q | --quiet) |
||||
|
QUIET=1 |
||||
|
shift 1 |
||||
|
;; |
||||
|
-t) |
||||
|
TIMEOUT="$2" |
||||
|
if [ "$TIMEOUT" = "" ]; then break; fi |
||||
|
shift 2 |
||||
|
;; |
||||
|
--timeout=*) |
||||
|
TIMEOUT="${1#*=}" |
||||
|
shift 1 |
||||
|
;; |
||||
|
--) |
||||
|
shift |
||||
|
break |
||||
|
;; |
||||
|
--help) |
||||
|
usage 0 |
||||
|
;; |
||||
|
*) |
||||
|
echoerr "Unknown argument: $1" |
||||
|
usage 1 |
||||
|
;; |
||||
|
esac |
||||
|
done |
||||
|
|
||||
|
if [ "$HOST" = "" -o "$PORT" = "" ]; then |
||||
|
echoerr "Error: you need to provide a host and port to test." |
||||
|
usage 2 |
||||
|
fi |
||||
|
|
||||
|
wait_for "$@" |
||||
Loading…
Reference in new issue