diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..22806df --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,12 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose +{ + "name": "Dev Container", + "dockerComposeFile": [ + "../docker-compose.yml", + "docker-compose.yml" + ], + "service": "app", + "workspaceFolder": "/app", + "postStartCommand": "git config --global --add safe.directory /app" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..fcd3f6a --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,6 @@ +version: '3' +services: + app: + volumes: + - .:/app:cached + command: flask run --debug --host=0.0.0.0 -p 80 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5ff0edb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12 + +COPY requirements.txt /tmp/requirements.txt +RUN pip install --upgrade pip +RUN pip install --upgrade wheel +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +COPY ./ /app +WORKDIR /app + +ENV PYTHONPATH=/app +ENV MODULE_NAME=wsgi +EXPOSE 80 +ENTRYPOINT ["./entrypoint.sh"] +CMD ["./start.sh"] diff --git a/README.md b/README.md index debc9c5..fb5df30 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ Import database backup: sudo -u postgres psql matheueben < backup.sql ``` +or in the container: +```bash +psql -U db_user -h postgres -p 5432 -d db_database < backup.sql +``` + Clean up database: ```bash flask fab security-cleanup diff --git a/app.env b/app.env new file mode 100644 index 0000000..262408c --- /dev/null +++ b/app.env @@ -0,0 +1,11 @@ +FLASK_SECRET_KEY=ONLY_FOR_DEVELOPMENT +FLASK_SQLALCHEMY_DATABASE_URI=postgresql://db_user:db_password@postgres:5432/db_database +FLASK_RECAPTCHA_PUBLIC_KEY=10000000-ffff-ffff-ffff-000000000001 +FLASK_RECAPTCHA_PRIVATE_KEY=0x0000000000000000000000000000000000000000 +FLASK_MAIL_SERVER=TODO +FLASK_MAIL_PORT=587 +FLASK_MAIL_USE_TLS=False +FLASK_MAIL_USERNAME=TODO +FLASK_MAIL_PASSWORD=TODO +FLASK_MAIL_DEFAULT_SENDER=TODO +FLASK_ADMIN_PASSWORD=TODO diff --git a/app/__init__.py b/app/__init__.py index 0557d6c..a3f0c89 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -9,7 +9,7 @@ from flask_debugtoolbar import DebugToolbarExtension from flask_migrate import Migrate from sentry_sdk.integrations.flask import FlaskIntegration - +from sqlalchemy import inspect from app.models.achievements import achievements from app.models.general import Achievement, Question from app.tools.mail import send_mail @@ -41,29 +41,31 @@ def create_app(config="config"): db.init_app(app) # TODO: Only necessary until SQLAlchemy 2 is used. - result = db.session.execute( - "SELECT * FROM pg_collation WHERE collname = 'numeric';" - ) - if not result.first(): + if inspect(db.engine).has_table(Question.__tablename__): + result = db.session.execute( + "SELECT * FROM pg_collation WHERE collname = 'numeric';" + ) + if not result.first(): + db.session.execute( + "CREATE COLLATION numeric (provider = icu, locale = 'de_DE@colNumeric=yes');" + ) db.session.execute( - "CREATE COLLATION numeric (provider = icu, locale = 'de_DE@colNumeric=yes');" + f'ALTER TABLE "{Question.__tablename__}" ' + f'ALTER COLUMN "{Question.external_id.name}" type VARCHAR COLLATE numeric;' ) - db.session.execute( - f'ALTER TABLE "{Question.__tablename__}" ' - f'ALTER COLUMN "{Question.external_id.name}" type VARCHAR COLLATE numeric;' - ) # Init achievements - for achievement in achievements: - result = ( - db.session.query(Achievement).filter_by(name=achievement.name).first() - ) - if not result: - db.session.add(achievement) - else: - result.title = achievement.title - result.description = achievement.description - db.session.commit() + if inspect(db.engine).has_table(Achievement.__tablename__): + for achievement in achievements: + result = ( + db.session.query(Achievement).filter_by(name=achievement.name).first() + ) + if not result: + db.session.add(achievement) + else: + result.title = achievement.title + result.description = achievement.description + db.session.commit() migrate.init_app(app, db) appbuilder.init_app(app, db.session) diff --git a/config.py b/config.py index 32cc8fc..1a4916e 100644 --- a/config.py +++ b/config.py @@ -104,7 +104,6 @@ ["ExtendedUserDBModelView", "can_confirm_account_delete"], ["ExtendedUserDBModelView", "can_export_data"], ["ExtendedUserDBModelView", "export_data_action"], - ["QuestionRandom", "can_random_question_redirect"], ["IdToForm", "can_id_to_form"], ["Verwaltung", "menu_access"], ["classes", "menu_access"], diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f7287ff --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + app: + build: ./ + restart: always + ports: + - 5000:80 + env_file: + - app.env + environment: + - GUNICORN_CONF=/app/gunicorn_conf_dev.py + depends_on: + postgres: + condition: service_healthy + volumes: + - ./:/app + - static:/app/app/static + + postgres: + image: postgres:alpine + restart: always + environment: + - POSTGRES_PASSWORD=db_password + - POSTGRES_DB=db_database + - POSTGRES_USER=db_user + volumes: + - postgres:/var/lib/postgresql/data + - ./:/app + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U db_user -d db_database" ] + start_period: 10s + interval: 5s + timeout: 5s + retries: 10 + +volumes: + static: + postgres: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..c04c77a --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh +set -e + +if [ -f /app/app/main.py ]; then + DEFAULT_MODULE_NAME=app.main +elif [ -f /app/main.py ]; then + DEFAULT_MODULE_NAME=main +fi +MODULE_NAME=${MODULE_NAME:-$DEFAULT_MODULE_NAME} +VARIABLE_NAME=${VARIABLE_NAME:-app} +export APP_MODULE=${APP_MODULE:-"$MODULE_NAME:$VARIABLE_NAME"} + +DEFAULT_GUNICORN_CONF=/app/gunicorn_conf.py +export GUNICORN_CONF=${GUNICORN_CONF:-$DEFAULT_GUNICORN_CONF} + +exec "$@" diff --git a/gunicorn_conf.py b/gunicorn_conf.py new file mode 100644 index 0000000..83d8538 --- /dev/null +++ b/gunicorn_conf.py @@ -0,0 +1,40 @@ +import multiprocessing +import os + +workers_per_core_str = os.getenv("WORKERS_PER_CORE", "2") +web_concurrency_str = os.getenv("WEB_CONCURRENCY", None) +host = os.getenv("HOST", "0.0.0.0") +port = os.getenv("PORT", "80") +bind_env = os.getenv("BIND", None) +use_loglevel = os.getenv("LOG_LEVEL", "info") +if bind_env: + use_bind = bind_env +else: + use_bind = "{host}:{port}".format(host=host, port=port) + +cores = multiprocessing.cpu_count() +workers_per_core = float(workers_per_core_str) +default_web_concurrency = workers_per_core * cores +if web_concurrency_str: + web_concurrency = int(web_concurrency_str) + assert web_concurrency > 0 +else: + web_concurrency = int(default_web_concurrency) + +# Gunicorn config variables +loglevel = use_loglevel +workers = web_concurrency +bind = use_bind +keepalive = 120 +errorlog = "-" + +# For debugging and testing +log_data = { + "loglevel": loglevel, + "workers": workers, + "bind": bind, + # Additional, non-gunicorn variables + "workers_per_core": workers_per_core, + "host": host, + "port": port, +} diff --git a/gunicorn_conf_dev.py b/gunicorn_conf_dev.py new file mode 100644 index 0000000..0e1d566 --- /dev/null +++ b/gunicorn_conf_dev.py @@ -0,0 +1,19 @@ +import json +from gunicorn_conf import * + +reload = True + +# For debugging and testing +log_data = { + "loglevel": loglevel, + "workers": workers, + "bind": bind, + # Additional, non-gunicorn variables + "workers_per_core": workers_per_core, + "host": host, + "port": port, + "reload": reload, +} + + +print(json.dumps(log_data)) diff --git a/matheueben.service b/matheueben.service deleted file mode 100644 index 2965e9d..0000000 --- a/matheueben.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=matheueben.service - A Flask application run with Gunicorn. -After=network.target - -[Service] -User=www-data -Group=www-data -WorkingDirectory=/var/www/matheueben/ -EnvironmentFile=/var/www/matheueben/.env -ExecStart=/var/www/matheueben/venv/bin/gunicorn --workers 5 --bind unix:/var/www/matheueben/matheueben.sock wsgi:application - -[Install] -WantedBy=multi-user.target diff --git a/prestart.sh b/prestart.sh new file mode 100755 index 0000000..06570f2 --- /dev/null +++ b/prestart.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +if flask db current | grep "(head)"; then + echo "Running database upgrade." + flask db upgrade +else + echo "Initializing database." + flask fab create-db + flask db stamp + # TODO: Fix! user creation functionality broken! + flask fab create-admin \ + --username admin \ + --firstname admin \ + --lastname admin \ + --email admin@matheworkout.at \ + --password ${FLASK_ADMIN_PASSWORD:-$(tr -dc A-Za-z0-9