diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..66c355f --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,5 @@ +# use a more expressive value range while coverage is still relatively low +coverage: + precision: 2 + round: down + range: "40...100" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2c3060e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12.2-alpine3.19 + +# Set the working directory +WORKDIR /app + +# Install PostgreSQL development packages required for psycopg +RUN apk add --no-cache postgresql-dev gcc python3-dev musl-dev + +# Copy the current directory contents into the container at /app +COPY . /app + +# Install any needed packages specified in requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Make the entrypoint script executable +RUN chmod +x /app/entrypoint.sh + +# Set the entrypoint to the script +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index 6c0637d..b66bbba 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,12 @@ This will remove packages that have been manually installed locally uv pip sync requirements.txt test_requirements.txt +## Testing the Dockerfile works + + docker run --rm -p 8080:6543 -v $PWD/config:/app/config privatim-1 config/development.ini + +then open http://127.0.0.1:8080/ + ## Miscellaneous ### Javascript dependencies @@ -90,3 +96,5 @@ These files are included in the project. They have been downloaded from these CD ``` + + diff --git a/development.ini.example b/development.ini.example index cbdc225..3e4de16 100644 --- a/development.ini.example +++ b/development.ini.example @@ -53,7 +53,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = localhost:9090 +listen = localhost:8080 ### # logging configuration diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..5ed089b --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +if [ -z "$1" ]; then + echo "No configuration file (input argument) provided. Exiting." + exit 1 +fi + +echo "test" +echo "Running the entrypoint.sh script." + +CONFIG_FILE=$1 + +# Run the upgrade script with the provided config file +python src/privatim/cli/upgrade.py "$CONFIG_FILE" + +python src/privatim/cli/initialize_db.py + +pserve "$CONFIG_FILE" diff --git a/requirements.txt b/requirements.txt index a75b648..5d14f92 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,8 @@ chameleon==4.5.4 # via pyramid-chameleon charset-normalizer==3.3.2 # via requests +click==8.1.7 + # via privatim dnspython==2.6.1 # via email-validator email-validator==2.1.1 diff --git a/setup.cfg b/setup.cfg index b3acf2a..e05f15b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,7 @@ install_requires = alembic bcrypt Babel + click email_validator fanstatic Markdown @@ -68,7 +69,9 @@ fanstatic.libraries = privatim:css = privatim.static:css_library console_scripts = - initialize_db = privatim.scripts.initialize_db:main + privatim = privatim.cli:cli + initialize_db = privatim.cli.initialize_db:main + upgrade = privatim.cli.upgrade:upgrade [options.extras_require] dev = diff --git a/src/atoz.py b/src/privatim/atoz.py similarity index 100% rename from src/atoz.py rename to src/privatim/atoz.py diff --git a/src/privatim/cli/__init__.py b/src/privatim/cli/__init__.py new file mode 100644 index 0000000..fbbc17b --- /dev/null +++ b/src/privatim/cli/__init__.py @@ -0,0 +1,40 @@ +import os +import click +from privatim.cli.add_user import add_user +from privatim.utils import first + + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from typing import Iterator + + +@click.group() +def cli() -> None: + pass + + +cli.add_command(add_user) + + +def find_ini_files() -> Iterator[str]: + current_path = os.path.dirname(os.path.abspath(__file__)) + while current_path != os.path.abspath(os.path.join(current_path, '..')): + for filename in os.listdir(current_path): + if filename.endswith('.ini'): + yield os.path.join(current_path, filename) + current_path = os.path.abspath(os.path.join(current_path, '..')) + + +def find_ini_file_or_abort() -> str: + """ Search the file system from the current location for the + development.ini or production.ini file + + Returns the absolute path to the .ini file """ + ini_file = first(find_ini_files()) + if click.confirm(f'Found {ini_file} file: continue? y/n'): + click.echo('Continuing...') + return ini_file + else: + click.echo('Stopping.') + click.get_current_context().abort() diff --git a/src/privatim/cli/add_user.py b/src/privatim/cli/add_user.py new file mode 100644 index 0000000..ca50a33 --- /dev/null +++ b/src/privatim/cli/add_user.py @@ -0,0 +1,25 @@ +import click +from pyramid.paster import bootstrap +from pyramid.paster import get_appsettings + +from privatim.models import User +from privatim.orm import get_engine, Base + + +@click.command() +@click.argument('config_uri') +@click.option('--email', prompt=True) +@click.option('--password', prompt=True, hide_input=True) +def add_user(config_uri: str, email: str, password: str) -> None: + + env = bootstrap(config_uri) + settings = get_appsettings(config_uri) + engine = get_engine(settings) + Base.metadata.create_all(engine) + + with env['request'].tm: + dbsession = env['request'].dbsession + + user = User(email=email) + user.set_password(password) + dbsession.add(user) diff --git a/src/privatim/scripts/initialize_db.py b/src/privatim/cli/initialize_db.py similarity index 95% rename from src/privatim/scripts/initialize_db.py rename to src/privatim/cli/initialize_db.py index 7afa97e..10e6aef 100644 --- a/src/privatim/scripts/initialize_db.py +++ b/src/privatim/cli/initialize_db.py @@ -39,10 +39,11 @@ def main(argv: list[str] = sys.argv) -> None: with env['request'].tm: db = env['request'].dbsession - add_placeholder_content(db) + add_example_content(db) -def add_placeholder_content(db: 'Session') -> None: +def add_example_content(db: 'Session') -> None: + users = [ User( email='admin@example.org', @@ -63,6 +64,7 @@ def add_placeholder_content(db: 'Session') -> None: last_name='Huber', ), ] + print(f'Adding users: {users}') for user in users: user.set_password('test') db.add(user) @@ -97,3 +99,7 @@ def add_placeholder_content(db: 'Session') -> None: db.add(consultation) db.add(status) db.flush() + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/src/privatim/cli/shell.py b/src/privatim/cli/shell.py new file mode 100644 index 0000000..e1a314b --- /dev/null +++ b/src/privatim/cli/shell.py @@ -0,0 +1,64 @@ +from code import InteractiveConsole +import readline +import rlcompleter + +import click +from pyramid.paster import bootstrap +from transaction import commit + +from typing import Any + +from privatim.cli import find_ini_file_or_abort + + +class EnhancedInteractiveConsole(InteractiveConsole): + """Wraps the InteractiveConsole with some basic shell features: + + - horizontal movement (e.g. arrow keys) + - history (e.g. up and down keys) + - very basic tab completion + """ + + def __init__(self, locals: dict[str, Any] | None = None): + super().__init__(locals) + self.init_completer() + + def init_completer(self) -> None: + readline.set_completer( + rlcompleter.Completer( + dict(self.locals) if self.locals else {} + ).complete + ) + readline.set_history_length(100) + readline.parse_and_bind("tab: complete") + +@click.command() +def shell() -> None: + """Enters an interactive shell.""" + + config_uri = find_ini_file_or_abort() + assert 'development.ini' in config_uri + + env = bootstrap(config_uri) + with env['request'].tm: + session = env['request'].dbsession + app = env['app'] + shell = EnhancedInteractiveConsole( + { + + 'app': app, + 'session': session, + 'commit': commit, + } + ) + shell.interact( + banner=""" + privatim Shell + ================== + + Exit the console using exit() or quit(). + + Available variables: session + Available functions: commit + """ + ) diff --git a/src/privatim/cli/upgrade.py b/src/privatim/cli/upgrade.py new file mode 100644 index 0000000..c9526a4 --- /dev/null +++ b/src/privatim/cli/upgrade.py @@ -0,0 +1,154 @@ +import logging +from enum import Enum +from typing import TYPE_CHECKING +from typing import Any + +import click +import plaster +import transaction +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import inspect +from sqlalchemy.sql import text +from zope.sqlalchemy import mark_changed +from privatim.models import get_engine +from privatim.models import get_session_factory +from privatim.orm import Base + + +if TYPE_CHECKING: + from sqlalchemy import Column as _Column + from sqlalchemy import Engine + from sqlalchemy.orm import Session + + Column = _Column[Any] + +logger = logging.getLogger('privatim.upgrade') + + +class UpgradeContext: + + def __init__(self, db: 'Session'): + self.session = db + self.engine: 'Engine' = self.session.bind # type: ignore + + self.operations_connection = db._connection_for_bind( + self.engine + ) + self.operations: Any = Operations( + MigrationContext.configure( + self.operations_connection + ) + ) + + def has_table(self, table: str) -> bool: + inspector = inspect(self.operations_connection) + return table in inspector.get_table_names() + + def drop_table(self, table: str) -> bool: + if self.has_table(table): + self.operations.drop_table(table) + return True + return False + + def has_column(self, table: str, column: str) -> bool: + inspector = inspect(self.operations_connection) + return column in {c['name'] for c in inspector.get_columns(table)} + + def add_column(self, table: str, column: 'Column') -> bool: + if self.has_table(table): + if not self.has_column(table, column.name): + self.operations.add_column(table, column) + return True + return False + + def drop_column(self, table: str, name: str) -> bool: + if self.has_table(table): + if self.has_column(table, name): + self.operations.drop_column(table, name) + return True + return False + + def get_enum_values(self, enum_name: str) -> set[str]: + if self.engine.name != 'postgresql': + return set() + + # NOTE: This is very low-level but easier than using + # the sqlalchemy bind with a regular execute(). + result = self.operations_connection.execute( + text(""" + SELECT pg_enum.enumlabel AS value + FROM pg_enum + JOIN pg_type + ON pg_type.oid = pg_enum.enumtypid + WHERE pg_type.typname = :enum_name + GROUP BY pg_enum.enumlabel + """), + {'enum_name': enum_name} + ) + return {value for (value,) in result} + + def update_enum_values(self, enum_type: type[Enum]) -> bool: + # NOTE: This only adds new values, it doesn't remove + # old values. But the latter should probably + # not be a valid use case anyways. + if self.engine.name != 'postgresql': + return False + + assert issubclass(enum_type, Enum) + # TODO: If we ever use a custom type name we need to + # be able to specify it. By default sqlalchemy + # uses the Enum type Name in lowercase. + enum_name = enum_type.__name__.lower() + existing = self.get_enum_values(enum_name) + missing = {v.name for v in enum_type} - existing + if not missing: + return False + + # HACK: ALTER TYPE has to be run outside transaction + self.operations.execute('COMMIT') + for value in missing: + # NOTE: This should be safe just by virtue of naming + # restrictions on classes and enum members + self.operations.execute( + f"ALTER TYPE {enum_name} ADD VALUE '{value}'" + ) + # start a new transaction + self.operations.execute('BEGIN') + return True + + def commit(self) -> None: + mark_changed(self.session) + transaction.commit() + + +@click.command() +@click.argument('config_uri') +@click.option('--dry', is_flag=True, default=False) +def upgrade(config_uri: str, dry: bool) -> None: + + print('upgrade') + if dry: + click.echo('Dry run') + + # Extract settings from INI config file. + # We cannot use pyramid.paster.bootstrap() because loading the application + # requires the proper DB structure. + settings = plaster.get_settings(config_uri, 'app:main') + + # Setup DB. + + engine = get_engine(settings) + Base.metadata.create_all(engine) + session_factory = get_session_factory(engine) + dbsession = session_factory() + + context = UpgradeContext(dbsession) + module = __import__('privatim', fromlist='*') + func = getattr(module, 'upgrade', None) + if func is not None: + click.echo('Upgrading privatim') + func(context) + + if not dry: + context.commit() diff --git a/src/privatim/layouts/layout.pt b/src/privatim/layouts/layout.pt index 2312f66..827e055 100644 --- a/src/privatim/layouts/layout.pt +++ b/src/privatim/layouts/layout.pt @@ -42,11 +42,16 @@ ${panel('flash')}