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 |
|||
email_validator |
|||
flask-api>=3.0.post1,<4 |
|||
flask-migrate |
|||
flask-script |
|||
flask-sqlalchemy |
|||
flask_marshmallow |
|||
flask_security |
|||
marshmallow-sqlalchemy |
|||
psycopg2-binary |
|||
pyserial>=3.5,<4 |
|||
sqlalchemy-utils |
|||
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