From a24682e4fd06bcd08a3676aa950ab311e9580291 Mon Sep 17 00:00:00 2001 From: hschwaer <194898364+schwaho@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:11:20 +0100 Subject: [PATCH 1/4] docs: Explain how to use a stable release tag --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index f7adfac..00156c6 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,11 @@ git clone git@github.com:schwaho/SimpleTicketing.git cd SimpleTicketing ``` +If you want a known working state, check out the latest release tag (e.g. `v0.1.0`). +```bash +git checkout -b main-v0.1.0 v0.1.0 +``` + #### 2. Create a virtual environment ```bash From 18169abbc8e0ac2ad78440a36db0bbd56560298a Mon Sep 17 00:00:00 2001 From: hschwaer <194898364+schwaho@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:50:19 +0100 Subject: [PATCH 2/4] refactor(db): introduce centralized connection handling and transaction API Rework the database layer to behave as a simple persistence backend without embedded business logic. Key changes: - open the database connection once during application startup - reuse the active connection across the application - remove database path handling from the database implementation - introduce a context-based transaction helper ensuring automatic rollback on failure Legacy code remains temporarily in the repository to avoid CI breakage during the transition. --- src/simple_ticketing/database.py | 209 ++++++++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 1 deletion(-) diff --git a/src/simple_ticketing/database.py b/src/simple_ticketing/database.py index 770aada..8bcea1e 100644 --- a/src/simple_ticketing/database.py +++ b/src/simple_ticketing/database.py @@ -13,7 +13,9 @@ import os import logging import sqlite3 -from typing import List, Tuple, Any, Dict, Optional +from contextlib import contextmanager +from contextvars import ContextVar +from typing import List, Tuple, Any, Dict, Optional, Iterator, Union logger = logging.getLogger(__name__) @@ -30,6 +32,211 @@ "hidden": "BOOLEAN DEFAULT FALSE", } +_current_connection: ContextVar[Optional[sqlite3.Connection]] = ContextVar( + "current_db_connection", + default=None, +) + + +def open_connection(db_path: str) -> None: + """ + Open a SQLite database connection and store it in the thread-local context. + + Args: + db_path: Path to the SQLite database file. + + Raises: + sqlite3.Error: If the connection cannot be established. + """ + conn = sqlite3.connect( + db_path, + isolation_level=None, + ) + conn.row_factory = sqlite3.Row + _current_connection.set(conn) + + +def get_connection() -> sqlite3.Connection: + """ + Retrieve the current SQLite database connection from the thread-local context. + + Returns: + sqlite3.Connection: The active database connection. + + Raises: + RuntimeError: If no connection has been initialized. + """ + conn = _current_connection.get() + if conn is None: + raise RuntimeError("Database connection not initialized") + return conn + + +def close_connection() -> None: + """ + Close the current SQLite database connection and clear the thread-local context. + + Does nothing if no connection is currently open. + """ + conn = _current_connection.get() + if conn is not None: + conn.close() + _current_connection.set(None) + + +@contextmanager +def transaction() -> Iterator[None]: + """ + Execute multiple database operations atomically. + + All statements executed within this context are part of a single + database transaction. If an exception is raised, the transaction + is rolled back. Otherwise, it is committed. + + Raises: + sqlite3.DatabaseError: If committing or rolling back fails. + """ + conn = get_connection() + + try: + logger.debug("BEGIN TRANSACTION") + conn.execute("BEGIN") + + yield + + except sqlite3.DatabaseError as e: + logger.debug("ROLLBACK TRANSACTION") + conn.rollback() + raise e + + logger.debug("COMMIT TRANSACTION") + conn.commit() + + +def execute(sql: str, params: Optional[Union[Tuple[Any, ...], Dict[str, Any]]] = None) -> None: + """Execute a single SQL statement without returning a result. + + This function is the lowest-level execution primitive. It is responsible + for parameter binding, execution, error handling, and logging. It must not + encode any domain knowledge or assumptions about the queried data. + + Args: + sql: The SQL statement to execute. + params: Optional positional or named parameters for the SQL statement. + + Raises: + sqlite3.DatabaseError: If execution fails for any reason. + """ + conn = get_connection() + try: + if params is not None: + logger.debug("Executing SQL: %s with params: %s", sql, params) + conn.execute(sql, params) + else: + logger.debug("Executing SQL: %s", sql) + conn.execute(sql) + except sqlite3.DatabaseError: + logger.exception("Database execution failed.") + raise + + +def execute_many(sql: str, params: List[Union[Tuple[Any, ...], Dict[str, Any]]]) -> None: + """Execute the same SQL statement multiple times with different parameters. + + Intended for batch inserts or updates. This function does not implement + any domain-specific batching logic. + + Args: + sql: The SQL statement to execute. + params: A list of parameter sets (positional or named) to apply to the SQL statement. + + Raises: + sqlite3.DatabaseError: If execution fails for any reason. + """ + if not params: + logger.debug("execute_many called with empty params list.") + return + + conn = get_connection() + try: + logger.debug("Executing SQL many times: %s with %d parameter sets", sql, len(params)) + conn.executemany(sql, params) + except sqlite3.DatabaseError: + logger.exception("Database batch execution failed.") + raise + + +def fetch_one( + sql: str, params: Optional[Union[Tuple[Any, ...], Dict[str, Any]]] = None +) -> Optional[Dict[str, Any]]: + """Execute a SQL query and return a single row. + + If the query yields no result, None is returned. If multiple rows are + returned by the query, only the first row is returned. + + Args: + sql: The SQL SELECT statement to execute. + params: Optional positional or named parameters for the SQL statement. + + Returns: + A single row represented as a mapping, or None if no row was found. + + Raises: + sqlite3.DatabaseError: If execution fails for any reason. + """ + conn = get_connection() + cursor = conn.cursor() + try: + logger.debug("Executing SQL (fetch_one): %s with params: %s", sql, params) + if params: + cursor.execute(sql, params) + else: + cursor.execute(sql) + row = cursor.fetchone() + if row is None: + return None + return dict(row) + except sqlite3.DatabaseError: + logger.exception("Database query failed (fetch_one).") + raise + finally: + cursor.close() + + +def fetch_all( + sql: str, params: Optional[Union[Tuple[Any, ...], Dict[str, Any]]] = None +) -> List[Dict[str, Any]]: + """Execute a SQL query and return all resulting rows. + + The database layer does not interpret or post-process results. Ordering, + grouping, and semantic meaning are the responsibility of the caller. + + Args: + sql: The SQL SELECT statement to execute. + params: Optional positional or named parameters for the SQL statement. + + Returns: + A list of rows represented as mappings. The list may be empty. + + Raises: + sqlite3.DatabaseError: If execution fails for any reason. + """ + conn = get_connection() + cursor = conn.cursor() + try: + logger.debug("Executing SQL (fetch_all): %s with params: %s", sql, params) + if params: + cursor.execute(sql, params) + else: + cursor.execute(sql) + rows = cursor.fetchall() + return [dict(row) for row in rows] + except sqlite3.DatabaseError: + logger.exception("Database query failed (fetch_all).") + raise + finally: + cursor.close() + def db_get_connection() -> sqlite3.Connection: """ From 496f735ca9e11483e939aa35aea32f6d1964d337 Mon Sep 17 00:00:00 2001 From: hschwaer <194898364+schwaho@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:23:37 +0100 Subject: [PATCH 3/4] refactor(db-schema): redesign database schema Replace the initial single-table layout with a normalized multi-table schema. The new schema separates the data model into multiple dedicated tables to improve structure, extensibility and long-term maintainability. --- src/simple_ticketing/db_schema.py | 213 ++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 src/simple_ticketing/db_schema.py diff --git a/src/simple_ticketing/db_schema.py b/src/simple_ticketing/db_schema.py new file mode 100644 index 0000000..5967fe7 --- /dev/null +++ b/src/simple_ticketing/db_schema.py @@ -0,0 +1,213 @@ +""" +SQLite table schema definitions. + +This module provides helper functions for creating application tables. +Each table is defined by a dedicated function that specifies its columns +and delegates SQL generation to ``create_table``. + +The module only ensures that tables exist using ``CREATE TABLE IF NOT EXISTS``. +It does not perform schema validation, migrations, or enforce business logic. +""" + +import logging +from typing import Mapping +from simple_ticketing.database import execute + +logger = logging.getLogger(__name__) + + +def create_all_tables() -> None: + """ + Create all required application tables if they do not already exist. + + This function initializes the database schema by invoking the + individual table creation helpers defined in this module. + """ + create_tickets_table() + create_customers_table() + create_offers_table() + create_payments_table() + create_credits_table() + create_shipments_table() + + create_payment_credits_table() + create_payment_offers_table() + create_shipment_tickets_table() + create_ticket_credits_table() + + +def create_table( + *, + table_name: str, + columns: Mapping[str, str], +) -> None: + """ + Creates a SQLite table if it does not already exist. + + The function generates and executes a ``CREATE TABLE IF NOT EXISTS`` statement + based solely on the provided column definitions using the database execution layer. + No constraints, migrations, or schema validation are applied. + The database is treated as a passive storage layer without embedded business logic. + + Args: + table_name: Name of the table to be created. + columns: Mapping of column names to their SQLite column definitions + (e.g. ``{"id": "INTEGER PRIMARY KEY", "created_at": "DATETIME"}``). + """ + logger.debug( + "Creating table '%s' with %d columns.", + table_name, + len(columns), + ) + + columns_sql = ",\n ".join(f"{name} {definition}" for name, definition in columns.items()) + + sql = f""" + CREATE TABLE IF NOT EXISTS {table_name} ( + {columns_sql} + ) + """ + + logger.debug( + "Executing SQL for table '%s':\n%s", + table_name, + sql.strip(), + ) + + execute(sql) + logger.debug("Table '%s' verified/created.", table_name) + + +def create_tickets_table() -> None: + """Create the 'tickets' table if it does not exist.""" + create_table( + table_name="tickets", + columns={ + "id": "INTEGER PRIMARY KEY", + "ticket_code": "TEXT NOT NULL UNIQUE", + "seat_type": "TEXT NOT NULL", + "target_amount": "INTEGER NOT NULL", + "customer_id": "INTEGER NOT NULL", + "offer_id": "INTEGER NOT NULL", + "check_in_at": "DATETIME", + "cancelled_at": "DATETIME", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + }, + ) + + +def create_customers_table() -> None: + """Create the 'customers' table if it does not exist.""" + create_table( + table_name="customers", + columns={ + "id": "INTEGER PRIMARY KEY", + "name": "TEXT NOT NULL", + "email": "TEXT NOT NULL UNIQUE", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + }, + ) + + +def create_offers_table() -> None: + """Create the 'offers' table if it does not exist.""" + create_table( + table_name="offers", + columns={ + "id": "INTEGER PRIMARY KEY", + "offer_code": "TEXT NOT NULL UNIQUE", + "customer_id": "INTEGER NOT NULL", + "ticket_count": "INTEGER NOT NULL", + "ticket_price": "INTEGER NOT NULL", + "valid_until": "DATETIME", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + }, + ) + + +def create_payments_table() -> None: + """Create the 'payments' table if it does not exist.""" + create_table( + table_name="payments", + columns={ + "id": "INTEGER PRIMARY KEY", + "customer_id": "INTEGER NOT NULL", + "amount": "INTEGER NOT NULL", + "payment_ref": "TEXT", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + }, + ) + + +def create_payment_offers_table() -> None: + """Create the 'payment_offers' table if it does not exist.""" + create_table( + table_name="payment_offers", + columns={ + "id": "INTEGER PRIMARY KEY", + "payment_id": "INTEGER NOT NULL", + "offer_id": "INTEGER NOT NULL", + }, + ) + + +def create_credits_table() -> None: + """Create the 'credits' table if it does not exist.""" + create_table( + table_name="credits", + columns={ + "id": "INTEGER PRIMARY KEY", + "customer_id": "INTEGER NOT NULL", + "amount": "INTEGER NOT NULL", + "reason": "TEXT", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + }, + ) + + +def create_payment_credits_table() -> None: + """Create the 'payment_credits' table if it does not exist.""" + create_table( + table_name="payment_credits", + columns={ + "id": "INTEGER PRIMARY KEY", + "payment_id": "INTEGER NOT NULL", + "credit_id": "INTEGER NOT NULL", + }, + ) + + +def create_ticket_credits_table() -> None: + """Create the 'ticket_credits' table if it does not exist.""" + create_table( + table_name="ticket_credits", + columns={ + "id": "INTEGER PRIMARY KEY", + "ticket_id": "INTEGER NOT NULL", + "credit_id": "INTEGER NOT NULL", + }, + ) + + +def create_shipments_table() -> None: + """Create the 'shipments' table if it does not exist.""" + create_table( + table_name="shipments", + columns={ + "id": "INTEGER PRIMARY KEY", + "customer_id": "INTEGER NOT NULL", + "sent_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + }, + ) + + +def create_shipment_tickets_table() -> None: + """Create the 'shipment_tickets' table if it does not exist.""" + create_table( + table_name="shipment_tickets", + columns={ + "id": "INTEGER PRIMARY KEY", + "shipment_id": "INTEGER NOT NULL", + "ticket_id": "INTEGER NOT NULL", + }, + ) From ee12061d57983b8a022b00c62970905149ea1488 Mon Sep 17 00:00:00 2001 From: hschwaer <194898364+schwaho@users.noreply.github.com> Date: Sun, 8 Mar 2026 08:09:58 +0100 Subject: [PATCH 4/4] refactor(cli): initialize application using new database layer Update the CLI initialization process to use the new database schema and database root functions. --- src/simple_ticketing/cli.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/simple_ticketing/cli.py b/src/simple_ticketing/cli.py index 40b812b..e7968cf 100644 --- a/src/simple_ticketing/cli.py +++ b/src/simple_ticketing/cli.py @@ -7,7 +7,8 @@ import shutil from importlib import resources import typer as typer_module -from simple_ticketing.database import db_init, db_clear +from simple_ticketing.database import open_connection, close_connection, transaction, db_clear +from simple_ticketing.db_schema import create_all_tables from simple_ticketing.cert import create_ca, create_signed_certificate app = typer_module.Typer() @@ -62,6 +63,7 @@ def init() -> None: instance_dir = os.path.join("instance") data_dir = os.path.join(instance_dir, "data") structur = ["qr_codes", "tickets", "pdf", "tmp"] + database_path = os.path.join("instance", "data", "tickets.db") if not os.path.exists(data_dir): for path in structur: @@ -72,8 +74,11 @@ def init() -> None: copy_examples(instance_dir) - db_init() - logger.info("Database has been created.") + open_connection(database_path) + with transaction(): + create_all_tables() + logger.info("Database has been created.") + close_connection() ca_cert, ca_key = create_ca() create_signed_certificate(ca_cert, ca_key)