Skip to content
Snippets Groups Projects
Verified Commit d4ec0448 authored by Jakob Moser's avatar Jakob Moser
Browse files

Add basic Flask app structure

- Python code (app __init__.py, main.py, config model)
- Alembic migrations
- Docker config (Dockerfile, entrypoint, Docker Compose file)
- Requirements (alembic, Flask, SQLAlchemy)
- Ignore files (Git, docker)
parent 20290359
No related branches found
No related tags found
1 merge request!4Add a Flask-based backend
# Python-related
venv
**/__pycache__
.ipynb_checkpoints
# JavaScript-related
package.json
package-lock.json
node_modules
# Tests
.coverage
.pytest_cache
htmlcov
testresults.xml
cypress/videos
# PyCharm configuration
.idea
# VS Code configuration
*.code-workspace
# Local instance data and configuration
instance
# Environment files (might contain secrets)
.env
# Git and Docker
.git .git
.gitlab-ci.yml .gitlab-ci.yml
.dockerignore .dockerignore
.gitignore .gitignore
Dockerfile Dockerfile
.prettierignore
.prettierrc.json
# Python-related
venv
__pycache__
.ipynb_checkpoints
# JavaScript-related
node_modules
# Tests
.coverage
.pytest_cache
htmlcov
testresults.xml
cypress/videos
# PyCharm configuration
.idea
# VS Code configuration
*.code-workspace
# Local instance data and configuration
instance
# Environment files (might contain secrets)
.env
FROM nginx:latest FROM python:3.12 AS base
RUN sed -i -E "s#(application/javascript\s+js);#\1 mjs;#" /etc/nginx/mime.types # We will use /app as our main directory within the Docker container
WORKDIR /app
EXPOSE 5000
COPY . /usr/share/nginx/html # First, copy and install only the requirements...
RUN pip install --upgrade pip setuptools
COPY requirements.txt .
RUN pip install -r requirements.txt
# ... then the rest of the application. This allows the installation stage to be cached most of the time
# (so we don't have reinstall of all dependencies every time the container is rebuilt)
COPY . .
FROM base AS dev
ENV SERVER_TYPE=flask
ENV FLASK_DEBUG=true
ENV FLASK_APP=portal
CMD ["bash", "./entrypoint.sh"]
FROM base AS prod
ENV SERVER_TYPE=uwsgi
RUN pip install uwsgi
CMD ["bash", "./entrypoint.sh"]
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# 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
# Alembic
This project uses Alembic for database migrations, i.e. creating, updating and deleting table schemas in the database.
The migrations are _run_ automatically when starting the application (e.g. at the first start ever, a migration is run that “migrates” from an empty database to the way it should currently be; similarly, if you've pulled in an update of the code that requests e.g. a new table or a new column, the database is updated by creating the new table or column, respectively).
However, you need to _create_ the migrations semi-automatically. This always becomes necessary when you code and change something about the model. After you are satisfied with the changes, you need to run:
```bash
alembic revision --autogenerate -m "<A description of the model changes>"
```
This will automatically generate a new Python file in `versions/`, which will include an upgrade and a downgrade method for the database. Ideally, you can use this file without further changes, however, please check it first (depending on the current layout of your database, it might very well be possible that Alembic generates more commands than needed).
After having modified the file, you commit everything (i.e. your model changes and the generated migrations).
## Further reading
- https://alembic.sqlalchemy.org/en/latest/tutorial.html
- https://alembic.sqlalchemy.org/en/latest/autogenerate.html
from logging.config import fileConfig
from pathlib import Path
from sqlalchemy import create_engine
from alembic import context
# 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.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from portal import db, db_config
target_metadata = db.Model.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() -> None:
"""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 = db_config.get_database_uri(Path("./instance"))
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = create_engine(db_config.get_database_uri(Path("./instance")))
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
"""Initialize database
Revision ID: 2a60cb3fb390
Revises:
Create Date: 2024-06-02 16:00:40.676901
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2a60cb3fb390"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"flask_config",
sa.Column("key", sa.String(length=256), nullable=False),
sa.Column("value", sa.String(length=4096), nullable=True),
sa.PrimaryKeyConstraint("key"),
)
def downgrade() -> None:
op.drop_table("flask_config")
services:
app:
build:
context: .
target: dev
ports:
- 127.0.0.1:5000:5000
volumes:
# Mount this directory inside the container as /app (actually overriding the files we copied when building the image,
# see Dockerfile), so any changes made here are immediately reflected into the running container.
# Note: The "COPY . ." directive in the Dockerfile is now actually completely useless and could have been omitted.
# But: If we omit the directive, the created image is incomplete and works only when the app data is mounted. Because we
# kept it, the image is still complete and would work even without this mount.
- .:/app
#!/usr/bin/env bash
# Entrypoint script, to be called from within a Docker container.
if [ -z $SKIP_ALEMBIC_MIGRATIONS ]
then
# Run alembic migrations, unless SKIP_ALEMBIC_MIGRATIONS is set
alembic upgrade head
fi
# We expose the application to every interface (0.0.0.0) within the container.
# Otherwise it would only be listening to the container's loopback interface, i.e. be reachable under
# localhost only from within the container (and not even from the system that is hosting the container).
case ${SERVER_TYPE} in
"flask")
flask run --host 0.0.0.0;;
"uwsgi")
uwsgi --http 0.0.0.0:5000 --enable-threads -w "portal:create_app()";;
*)
exit 1;;
esac
# Taken and adapted from the flaskr tutorial. Database logic adapted from Flask SQLAlchemy Documentation
# (https://flask-sqlalchemy.palletsprojects.com/en/2.x/contexts/). See LICENSE-3RD-PARTY.md for details.
import secrets
from pathlib import Path
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from .db_config import get_database_uri, get_uri_for_sqlite
db = SQLAlchemy()
from .model import *
def create_app(test_db_path: Path = None) -> Flask:
"""
Create the Flask application.
This will also attempt to connect to a database, either a remote database or a local SQLite database.
When a test database path is provided, the application will automatically switch into testing mode. This means:
- The test database will be used, no matter what deviating configuration may be specified.
:param test_db_path: A path to the database used for unit testing purposes
:return: the Flask application object
"""
app = Flask(__name__)
app.logger.info("App instance was successfully created.")
instance_path = Path(app.instance_path)
instance_path.mkdir(exist_ok=True)
sqlalchemy_database_uri = (
get_uri_for_sqlite(test_db_path)
if test_db_path
else get_database_uri(instance_path)
)
_connect_db(app, sqlalchemy_database_uri)
_load_flask_config(app)
_register_blueprints(app)
return app
def _load_flask_config(app: Flask) -> None:
"""
Load the flask specific configuration from the database.
This will also ensure a SECRET_KEY is set, creating one randomly if necessary.
"""
with app.app_context():
for entry in FlaskConfigEntry.query.all():
entry.apply(app)
if not app.config["SECRET_KEY"]:
secret_key_entry = FlaskConfigEntry(
key="SECRET_KEY", value=secrets.token_hex()
)
secret_key_entry.apply(app)
db.session.add(secret_key_entry)
db.session.commit()
if not app.config["SECRET_KEY"]:
raise Exception(
"app.config['SECRET_KEY'] should have been set, but wasn't. Aborting!"
)
def _register_blueprints(app: Flask) -> None:
"""
Register all blueprints
"""
from . import main
app.register_blueprint(main.bp)
def _connect_db(app: Flask, sqlalchemy_database_uri: str) -> None:
"""
Connect to the database using the given URI.
"""
app.config["SQLALCHEMY_DATABASE_URI"] = sqlalchemy_database_uri
db.init_app(app)
import json
from pathlib import Path
from typing import Optional
def get_database_uri(instance_path: Path) -> str:
"""
Get the URI to the Portal application database for use with SQLAlchemy.
1. If ./instance/db.conf.json contains a "SQLALCHEMY_DATABASE_URI" key, this URI is returned.
2. Otherwise, a URI pointing to the SQLite database ./instance/portal.db is returned.
"""
return load_uri_from_config(instance_path / "db.conf.json") or get_uri_for_sqlite(
instance_path / "portal.db"
)
def get_uri_for_sqlite(db_path: Path) -> str:
return f"sqlite+pysqlite:///{db_path.absolute().resolve()}"
def load_uri_from_config(db_conf_json: Path) -> Optional[str]:
try:
with open(db_conf_json) as f:
db_conf = json.load(f)
return db_conf["SQLALCHEMY_DATABASE_URI"]
except (FileNotFoundError, KeyError):
return None
from flask import Blueprint, render_template
bp = Blueprint("main", __name__)
@bp.get("/", defaults={"_": ""})
@bp.get("/<path:_>")
def index(_):
"""
Serve the index page at /*, i.e. at any path below / and at / itself.
We completely ignore the provided path (hence the argument name "_" and not
something useful), because only the client-side JavaScript code will process it.
"""
return render_template("index.html")
from flask import Flask
from portal import db
class FlaskConfigEntry(db.Model):
"""
A configuration entry for Flask (a key-value pair) as persisted in a database
"""
__tablename__ = "flask_config"
key = db.Column(db.String(256), primary_key=True)
value = db.Column(db.String(4096))
def apply(self, app: Flask) -> None:
"""
Apply the config to the given application (i.e. set app.config[self.key] to self.value)
"""
app.config[self.key] = self.value
from .FlaskConfigEntry import FlaskConfigEntry
alembic~=1.12.1
Flask-SQLAlchemy~=3.1.1
Flask~=3.0.0
SQLAlchemy~=2.0.23
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment