diff --git a/.env.example b/.env.example index 9db5eed5..4e791b01 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ ABRECHNUNG_SERVICE__URL=http://localhost:8080 -ABRECHNUNG_SERVICE__API_URL=https://localhost:8080/api ABRECHNUNG_SERVICE__NAME=Abrechnung ABRECHNUNG_DATABASE__HOST=abrechnung_postgres ABRECHNUNG_DATABASE__USER=abrechnung @@ -7,6 +6,7 @@ ABRECHNUNG_DATABASE__DBNAME=abrechnung ABRECHNUNG_DATABASE__PASSWORD=replaceme # e.g. pwgen -s 64 1 ABRECHNUNG_API__SECRET_KEY=replaceme # pwgen -s 64 1 +ABRECHNUNG_SERVICE__BASE_URL=https://localhost:8080 ABRECHNUNG_API__PORT=8080 ABRECHNUNG_API__ID=default diff --git a/.github/workflows/backend.yaml b/.github/workflows/backend.yaml index 12148aa8..fc4d6f34 100644 --- a/.github/workflows/backend.yaml +++ b/.github/workflows/backend.yaml @@ -34,8 +34,8 @@ jobs: strategy: matrix: python-version: - - "3.10" - "3.11" + - "3.12" os: [ubuntu-latest] services: postgres: diff --git a/CHANGELOG.md b/CHANGELOG.md index 940f0ff6..0c017775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ [Compare the full difference.](https://github.com/SFTtech/abrechnung/compare/v0.14.0...HEAD) +**BREAKING** +- changed config structure to only include the reverse-proxy base_url once in the `api` section +- drop python 3.10 support + +**OTHER** - add Spanish and Tamil as supported languages - rework group settings to just be a single page - allow archiving of groups diff --git a/abrechnung/admin.py b/abrechnung/admin.py index 898b609a..6142b258 100644 --- a/abrechnung/admin.py +++ b/abrechnung/admin.py @@ -3,7 +3,7 @@ from abrechnung.application.users import UserService from abrechnung.config import Config -from abrechnung.framework.database import create_db_pool +from abrechnung.database.migrations import get_database logger = logging.getLogger(__name__) @@ -16,7 +16,8 @@ async def create_user(config: Config, name: str, email: str, skip_email_check: b print("Passwords do not match!") return - db_pool = await create_db_pool(config.database) + database = get_database(config.database) + db_pool = await database.create_pool() user_service = UserService(db_pool, config) user_service.enable_registration = True if skip_email_check: diff --git a/abrechnung/application/accounts.py b/abrechnung/application/accounts.py index ad550231..9fb433ea 100644 --- a/abrechnung/application/accounts.py +++ b/abrechnung/application/accounts.py @@ -1,14 +1,16 @@ from typing import Optional, Union import asyncpg +from sftkit.database import Connection +from sftkit.error import InvalidArgument +from sftkit.service import Service, with_db_connection, with_db_transaction +from abrechnung.config import Config from abrechnung.core.auth import check_group_permissions, create_group_log from abrechnung.core.decorators import ( requires_group_permissions, with_group_last_changed_update, ) -from abrechnung.core.errors import InvalidCommand, NotFoundError -from abrechnung.core.service import Service from abrechnung.domain.accounts import ( Account, AccountType, @@ -18,13 +20,11 @@ ) from abrechnung.domain.groups import GroupMember from abrechnung.domain.users import User -from abrechnung.framework.database import Connection -from abrechnung.framework.decorators import with_db_connection, with_db_transaction from .common import _get_or_create_tag_ids -class AccountService(Service): +class AccountService(Service[Config]): @staticmethod async def _commit_revision(conn: asyncpg.Connection, revision_id: int): """Touches a revision to have all associated constraints run""" @@ -57,7 +57,7 @@ async def _check_account_permissions( account_id, ) if not result: - raise NotFoundError(f"account not found") + raise InvalidArgument(f"account not found") if can_write and not (result["can_write"] or result["is_owner"]): raise PermissionError(f"user does not have write permissions") @@ -65,7 +65,9 @@ async def _check_account_permissions( if account_type: type_check = [account_type] if isinstance(account_type, str) else account_type if result["type"] not in type_check: - raise InvalidCommand(f"Transaction type {result['type']} does not match the expected type {type_check}") + raise InvalidArgument( + f"Transaction type {result['type']} does not match the expected type {type_check}" + ) return result["group_id"], result["type"] @@ -91,7 +93,7 @@ async def _get_or_create_pending_account_change(self, conn: asyncpg.Connection, ) if last_committed_revision is None: - raise InvalidCommand(f"Cannot edit account {account_id} as it has no committed changes.") + raise InvalidArgument(f"Cannot edit account {account_id} as it has no committed changes.") # copy all existing transaction data into a new history entry await conn.execute( @@ -124,7 +126,7 @@ async def _check_account_exists(conn: asyncpg.Connection, group_id: int, account account_id, ) if not acc: - raise NotFoundError(f"Account with id {account_id}") + raise InvalidArgument(f"Account with id {account_id}") return True @@ -210,7 +212,7 @@ async def create_account( group_membership: GroupMember, ) -> int: if account.clearing_shares and account.type != AccountType.clearing: - raise InvalidCommand( + raise InvalidArgument( f"'{account.type.value}' accounts cannot have associated settlement distribution shares" ) @@ -279,7 +281,7 @@ async def update_account( ) membership = await check_group_permissions(conn=conn, group_id=group_id, user=user, can_write=True) if account.clearing_shares and account_type != AccountType.clearing.value: - raise InvalidCommand(f"'{account_type}' accounts cannot have associated settlement distribution shares") + raise InvalidArgument(f"'{account_type}' accounts cannot have associated settlement distribution shares") revision_id = await self._create_revision(conn=conn, user=user, account_id=account_id) @@ -350,7 +352,7 @@ async def delete_account( account_id, ) if row is None: - raise InvalidCommand(f"Account does not exist") + raise InvalidArgument(f"Account does not exist") # TODO: FIXME move this check into the database @@ -374,20 +376,20 @@ async def delete_account( ) if has_shares or has_usages: - raise InvalidCommand(f"Cannot delete an account that is references by a transaction") + raise InvalidArgument(f"Cannot delete an account that is references by a transaction") if has_clearing_shares: - raise InvalidCommand(f"Cannot delete an account that is references by a clearing account") + raise InvalidArgument(f"Cannot delete an account that is references by a clearing account") row = await conn.fetchrow( "select name, revision_id, deleted from account_state_valid_at() where account_id = $1", account_id, ) if row is None: - raise InvalidCommand(f"Cannot delete an account without any committed changes") + raise InvalidArgument(f"Cannot delete an account without any committed changes") if row["deleted"]: - raise InvalidCommand(f"Cannot delete an already deleted account") + raise InvalidArgument(f"Cannot delete an already deleted account") has_clearing_shares = await conn.fetchval( "select exists (select from account_state_valid_at() p where not p.deleted and $1 = any(p.involved_accounts))", @@ -395,7 +397,7 @@ async def delete_account( ) if has_clearing_shares: - raise InvalidCommand(f"Cannot delete an account that is references by another clearing account") + raise InvalidArgument(f"Cannot delete an account that is references by another clearing account") revision_id = await conn.fetchval( "insert into account_revision (user_id, account_id) values ($1, $2) returning id", diff --git a/abrechnung/application/common.py b/abrechnung/application/common.py index 05162ea2..88213476 100644 --- a/abrechnung/application/common.py +++ b/abrechnung/application/common.py @@ -1,9 +1,9 @@ from typing import Optional -import asyncpg +from sftkit.database import Connection -async def _get_or_create_tag_ids(*, conn: asyncpg.Connection, group_id: int, tags: Optional[list[str]]) -> list[int]: +async def _get_or_create_tag_ids(*, conn: Connection, group_id: int, tags: Optional[list[str]]) -> list[int]: if not tags or len(tags) <= 0: return [] tag_rows = await conn.fetch( diff --git a/abrechnung/application/groups.py b/abrechnung/application/groups.py index 38b530bb..091e02a3 100644 --- a/abrechnung/application/groups.py +++ b/abrechnung/application/groups.py @@ -1,14 +1,16 @@ from datetime import datetime import asyncpg +from sftkit.database import Connection +from sftkit.error import InvalidArgument +from sftkit.service import Service, with_db_transaction +from abrechnung.config import Config from abrechnung.core.auth import create_group_log from abrechnung.core.decorators import ( requires_group_permissions, with_group_last_changed_update, ) -from abrechnung.core.errors import InvalidCommand, NotFoundError -from abrechnung.core.service import Service from abrechnung.domain.accounts import AccountType from abrechnung.domain.groups import ( Group, @@ -18,11 +20,9 @@ GroupPreview, ) from abrechnung.domain.users import User -from abrechnung.framework.database import Connection -from abrechnung.framework.decorators import with_db_transaction -class GroupService(Service): +class GroupService(Service[Config]): @with_db_transaction async def create_group( self, @@ -116,7 +116,7 @@ async def delete_invite( group_id, ) if not deleted_id: - raise NotFoundError(f"No invite with the given id exists") + raise InvalidArgument(f"No invite with the given id exists") await create_group_log(conn=conn, group_id=group_id, user=user, type="invite-deleted") async def _create_user_account(self, conn: asyncpg.Connection, group_id: int, user: User) -> int: @@ -165,7 +165,7 @@ async def join_group(self, *, conn: Connection, user: User, invite_token: str) - invite["group_id"], ) if user_is_already_member: - raise InvalidCommand(f"User is already a member of this group") + raise InvalidArgument(f"User is already a member of this group") await conn.execute( "insert into group_membership (user_id, group_id, invited_by, can_write, is_owner) " @@ -249,7 +249,7 @@ async def update_member_permissions( is_owner: bool, ): if user.id == member_id: - raise InvalidCommand(f"group members cannot modify their own privileges") + raise InvalidArgument(f"group members cannot modify their own privileges") # not possible to have an owner without can_write can_write = can_write if not is_owner else True @@ -260,7 +260,7 @@ async def update_member_permissions( member_id, ) if membership is None: - raise NotFoundError(f"member with id {member_id} does not exist") + raise InvalidArgument(f"member with id {member_id} does not exist") if membership["is_owner"] == is_owner and membership["can_write"] == can_write: # no changes return @@ -330,7 +330,7 @@ async def delete_group(self, *, conn: Connection, user: User, group_id: int): group_id, ) if n_members != 1: - raise PermissionError(f"Can only delete a group when you are the last member") + raise InvalidArgument(f"Can only delete a group when you are the last member") await conn.execute("delete from grp where id = $1", group_id) diff --git a/abrechnung/application/transactions.py b/abrechnung/application/transactions.py index 3a0ccc29..c01af15e 100644 --- a/abrechnung/application/transactions.py +++ b/abrechnung/application/transactions.py @@ -3,15 +3,17 @@ from typing import Optional, Union import asyncpg +from sftkit.database import Connection +from sftkit.error import InvalidArgument +from sftkit.service import Service, with_db_transaction from abrechnung.application.common import _get_or_create_tag_ids +from abrechnung.config import Config from abrechnung.core.auth import create_group_log from abrechnung.core.decorators import ( requires_group_permissions, with_group_last_changed_update, ) -from abrechnung.core.errors import InvalidCommand, NotFoundError -from abrechnung.core.service import Service from abrechnung.domain.transactions import ( NewFile, NewTransaction, @@ -23,11 +25,9 @@ UpdateTransaction, ) from abrechnung.domain.users import User -from abrechnung.framework.database import Connection -from abrechnung.framework.decorators import with_db_transaction -class TransactionService(Service): +class TransactionService(Service[Config]): @staticmethod async def _check_transaction_permissions( conn: asyncpg.Connection, @@ -45,7 +45,7 @@ async def _check_transaction_permissions( transaction_id, ) if not result: - raise NotFoundError(f"user is not a member of this group") + raise InvalidArgument(f"user is not a member of this group") if can_write and not (result["can_write"] or result["is_owner"]): raise PermissionError(f"user does not have write permissions") @@ -53,7 +53,9 @@ async def _check_transaction_permissions( if transaction_type: type_check = [transaction_type] if isinstance(transaction_type, TransactionType) else transaction_type if result["type"] not in [t.value for t in type_check]: - raise InvalidCommand(f"Transaction type {result['type']} does not match the expected type {type_check}") + raise InvalidArgument( + f"Transaction type {result['type']} does not match the expected type {type_check}" + ) return result["group_id"] @@ -89,7 +91,7 @@ async def list_transactions( ) for transaction in transactions: for attachment in transaction.files: - attachment.host_url = self.cfg.service.api_url + attachment.host_url = self.config.api.base_url + "/api" return transactions @with_db_transaction @@ -102,7 +104,7 @@ async def get_transaction(self, *, conn: Connection, user: User, transaction_id: transaction_id, ) for attachment in transaction.files: - attachment.host_url = self.cfg.service.api_url + attachment.host_url = self.config.api.base_url + "/api" return transaction @staticmethod @@ -127,12 +129,12 @@ async def _add_file_to_revision( self, *, conn: Connection, revision_id: int, transaction_id: int, attachment: NewFile ) -> int: content = base64.b64decode(attachment.content) - max_file_size = self.cfg.api.max_uploadable_file_size + max_file_size = self.config.api.max_uploadable_file_size if len(content) / 1024 > max_file_size: - raise InvalidCommand(f"File is too large, maximum is {max_file_size}KB") + raise InvalidArgument(f"File is too large, maximum is {max_file_size}KB") if "." in attachment.filename: - raise InvalidCommand(f"Dots '.' are not allowed in file names") + raise InvalidArgument(f"Dots '.' are not allowed in file names") blob_id = await conn.fetchval( "insert into blob (content, mime_type) values ($1, $2) returning id", @@ -157,7 +159,7 @@ async def _update_file_in_revision( *, conn: Connection, revision_id: int, transaction_id: int, attachment: UpdateFile ) -> int: if "." in attachment.filename: - raise InvalidCommand(f"Dots '.' are not allowed in file names") + raise InvalidArgument(f"Dots '.' are not allowed in file names") blob_id = await conn.fetchval( "select blob_id from file_state_valid_at(now()) where id = $1 and transaction_id = $2", @@ -165,7 +167,7 @@ async def _update_file_in_revision( transaction_id, ) if blob_id is None: - raise NotFoundError("Transaction attachment does not exist") + raise InvalidArgument("Transaction attachment does not exist") await conn.execute( "insert into file_history (id, revision_id, filename, blob_id, deleted) values ($1, $2, $3, $4, $5)", @@ -279,7 +281,7 @@ async def _put_transaction_debitor_shares( list(debitor_shares.keys()), ) if len(debitor_shares.keys()) != n_accounts: - raise InvalidCommand("one of the accounts referenced by a debitor share does not exist in this group") + raise InvalidArgument("one of the accounts referenced by a debitor share does not exist in this group") for account_id, value in debitor_shares.items(): await conn.execute( "insert into debitor_share(transaction_id, revision_id, account_id, shares) " "values ($1, $2, $3, $4)", @@ -304,7 +306,7 @@ async def _put_transaction_creditor_shares( list(creditor_shares.keys()), ) if len(creditor_shares.keys()) != n_accounts: - raise InvalidCommand("one of the accounts referenced by a creditor share does not exist in this group") + raise InvalidArgument("one of the accounts referenced by a creditor share does not exist in this group") for account_id, value in creditor_shares.items(): await conn.execute( "insert into creditor_share(transaction_id, revision_id, account_id, shares) " @@ -331,11 +333,11 @@ async def read_file_contents( blob_id, ) if not perms: - raise InvalidCommand("File not found") + raise InvalidArgument("File not found") blob = await conn.fetchrow("select content, mime_type from blob where id = $1", blob_id) if not blob: - raise InvalidCommand("File not found") + raise InvalidArgument("File not found") return blob["mime_type"], blob["content"] @@ -354,7 +356,7 @@ async def _put_position_usages( list(usages.keys()), ) if len(usages.keys()) != n_accounts: - raise InvalidCommand("one of the accounts referenced by a position usage does not exist in this group") + raise InvalidArgument("one of the accounts referenced by a position usage does not exist in this group") for account_id, value in usages.items(): await conn.execute( "insert into purchase_item_usage(item_id, revision_id, account_id, share_amount) " @@ -614,9 +616,9 @@ async def delete_transaction(self, *, conn: Connection, user: User, transaction_ transaction_id, ) if row is None: - raise NotFoundError() + raise InvalidArgument(f"Transaction id {transaction_id} not found") if row is not None and row["deleted"]: - raise InvalidCommand(f"Cannot delete transaction {transaction_id} as it already is deleted") + raise InvalidArgument(f"Cannot delete transaction {transaction_id} as it already is deleted") await create_group_log( conn=conn, @@ -666,7 +668,7 @@ async def _create_pending_transaction_change( ) if last_committed_revision is None: - raise InvalidCommand(f"Cannot edit transaction {transaction_id} as it has no committed changes.") + raise InvalidArgument(f"Cannot edit transaction {transaction_id} as it has no committed changes.") # copy all existing transaction data into a new history entry await conn.execute( diff --git a/abrechnung/application/users.py b/abrechnung/application/users.py index 75ac0b6c..bffd7b2c 100644 --- a/abrechnung/application/users.py +++ b/abrechnung/application/users.py @@ -6,13 +6,13 @@ from jose import JWTError, jwt from passlib.context import CryptContext from pydantic import BaseModel +from sftkit.database import Connection +from sftkit.error import InvalidArgument +from sftkit.service import Service, with_db_transaction from abrechnung.config import Config -from abrechnung.core.errors import InvalidCommand, NotFoundError -from abrechnung.core.service import Service from abrechnung.domain.users import Session, User -from abrechnung.framework.database import Connection -from abrechnung.framework.decorators import with_db_transaction +from abrechnung.util import is_valid_uuid ALGORITHM = "HS256" @@ -39,12 +39,12 @@ async def _check_user_exists(*, conn: Connection, username: str, email: str): email, ) if user_exists["username_exists"]: - raise InvalidCommand("A user with this username already exists") + raise InvalidArgument("A user with this username already exists") if user_exists["email_exists"]: - raise InvalidCommand("A user with this email already exists") + raise InvalidArgument("A user with this email already exists") -class UserService(Service): +class UserService(Service[Config]): def __init__( self, db_pool: Pool, @@ -52,10 +52,10 @@ def __init__( ): super().__init__(db_pool=db_pool, config=config) - self.enable_registration = self.cfg.registration.enabled - self.require_email_confirmation = self.cfg.registration.require_email_confirmation - self.allow_guest_users = self.cfg.registration.allow_guest_users - self.valid_email_domains = self.cfg.registration.valid_email_domains + self.enable_registration = self.config.registration.enabled + self.require_email_confirmation = self.config.registration.require_email_confirmation + self.allow_guest_users = self.config.registration.allow_guest_users + self.valid_email_domains = self.config.registration.valid_email_domains self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -70,12 +70,12 @@ def _create_access_token(self, user_id: int, session_id: int): "user_id": user_id, "session_id": session_id, } - encoded_jwt = jwt.encode(data, self.cfg.api.secret_key, algorithm=ALGORITHM) + encoded_jwt = jwt.encode(data, self.config.api.secret_key, algorithm=ALGORITHM) return encoded_jwt def decode_jwt_payload(self, token: str) -> TokenMetadata: try: - payload = jwt.decode(token, self.cfg.api.secret_key, algorithms=[ALGORITHM]) + payload = jwt.decode(token, self.config.api.secret_key, algorithms=[ALGORITHM]) try: return TokenMetadata.model_validate(payload) except: @@ -103,7 +103,7 @@ async def _verify_user_password(self, user_id: int, password: str) -> bool: user_id, ) if user is None: - raise NotFoundError(f"User with id {user_id} does not exist") + raise InvalidArgument(f"User with id {user_id} does not exist") if user["deleted"] or user["pending"]: return False @@ -124,16 +124,16 @@ async def login_user( username, ) if user is None: - raise InvalidCommand(f"Login failed") + raise InvalidArgument(f"Login failed") if not self._check_password(password, user["hashed_password"]): - raise InvalidCommand(f"Login failed") + raise InvalidArgument(f"Login failed") if user["deleted"]: - raise InvalidCommand(f"User is not permitted to login") + raise InvalidArgument(f"User is not permitted to login") if user["pending"]: - raise InvalidCommand(f"You need to confirm your email before logging in") + raise InvalidArgument(f"You need to confirm your email before logging in") session_id = await conn.fetchval( "insert into session (user_id, name) values ($1, $2) returning id", @@ -152,7 +152,7 @@ async def logout_user(self, *, conn: Connection, user: User, session_id: int): user.id, ) if sess_id is None: - raise InvalidCommand(f"Already logged out") + raise InvalidArgument(f"Already logged out") @with_db_transaction async def demo_register_user(self, *, conn: Connection, username: str, email: str, password: str) -> int: @@ -165,7 +165,7 @@ async def demo_register_user(self, *, conn: Connection, username: str, email: st hashed_password, ) if user_id is None: - raise InvalidCommand(f"Registering new user failed") + raise InvalidArgument(f"Registering new user failed") return user_id @@ -175,7 +175,7 @@ def _validate_email_address(email: str) -> str: valid = validate_email(email) email = valid.normalized except EmailNotValidError as e: - raise InvalidCommand(str(e)) + raise InvalidArgument(str(e)) return email @@ -219,7 +219,7 @@ async def register_user( invite_token, ) if invite is None: - raise InvalidCommand("Invalid invite token") + raise InvalidArgument("Invalid invite token") is_guest_user = True if self.enable_registration and has_valid_email: self._validate_email_domain(email) @@ -238,7 +238,7 @@ async def register_user( requires_email_confirmation, ) if user_id is None: - raise InvalidCommand(f"Registering new user failed") + raise InvalidArgument(f"Registering new user failed") if requires_email_confirmation: await conn.execute("insert into pending_registration (user_id) values ($1)", user_id) @@ -247,6 +247,8 @@ async def register_user( @with_db_transaction async def confirm_registration(self, *, conn: Connection, token: str) -> int: + if not is_valid_uuid(token): + raise InvalidArgument(f"Invalid confirmation token") row = await conn.fetchrow( "select user_id, valid_until from pending_registration where token = $1", token, @@ -275,7 +277,7 @@ async def _get_user(conn: Connection, user_id: int) -> User: ) if user is None: - raise NotFoundError(f"User with id {user_id} does not exist") + raise InvalidArgument(f"User with id {user_id} does not exist") sessions = await conn.fetch_many( Session, @@ -297,7 +299,7 @@ async def delete_session(self, *, conn: Connection, user: User, session_id: int) user.id, ) if not sess_id: - raise NotFoundError(f"no such session found with id {session_id}") + raise InvalidArgument(f"no such session found with id {session_id}") @with_db_transaction async def rename_session(self, *, conn: Connection, user: User, session_id: int, name: str): @@ -308,7 +310,7 @@ async def rename_session(self, *, conn: Connection, user: User, session_id: int, name, ) if not sess_id: - raise NotFoundError(f"no such session found with id {session_id}") + raise InvalidArgument(f"no such session found with id {session_id}") @with_db_transaction async def change_password(self, *, conn: Connection, user: User, old_password: str, new_password: str): @@ -329,7 +331,7 @@ async def request_email_change(self, *, conn: Connection, user: User, password: valid = validate_email(email) email = valid.normalized except EmailNotValidError as e: - raise InvalidCommand(str(e)) + raise InvalidArgument(str(e)) valid_pw = await self._verify_user_password(user.id, password) if not valid_pw: @@ -343,6 +345,8 @@ async def request_email_change(self, *, conn: Connection, user: User, password: @with_db_transaction async def confirm_email_change(self, *, conn: Connection, token: str) -> int: + if not is_valid_uuid(token): + raise InvalidArgument(f"Invalid confirmation token") row = await conn.fetchrow( "select user_id, new_email, valid_until from pending_email_change where token = $1", token, @@ -361,7 +365,7 @@ async def confirm_email_change(self, *, conn: Connection, token: str) -> int: async def request_password_recovery(self, *, conn: Connection, email: str): user_id = await conn.fetchval("select id from usr where email = $1", email) if not user_id: - raise PermissionError + raise InvalidArgument("permission denied") await conn.execute( "insert into pending_password_recovery (user_id) values ($1)", @@ -370,6 +374,8 @@ async def request_password_recovery(self, *, conn: Connection, email: str): @with_db_transaction async def confirm_password_recovery(self, *, conn: Connection, token: str, new_password: str) -> int: + if not is_valid_uuid(token): + raise InvalidArgument(f"Invalid confirmation token") row = await conn.fetchrow( "select user_id, valid_until from pending_password_recovery where token = $1", token, diff --git a/abrechnung/cli/database.py b/abrechnung/cli/database.py index e83e773d..670d2bd5 100644 --- a/abrechnung/cli/database.py +++ b/abrechnung/cli/database.py @@ -4,10 +4,9 @@ import typer from abrechnung.config import Config -from abrechnung.database.migrations import apply_revisions +from abrechnung.database.migrations import get_database from abrechnung.database.migrations import list_revisions as list_revisions_ from abrechnung.database.migrations import reset_schema -from abrechnung.framework.database import create_db_pool, psql_attach database_cli = typer.Typer() @@ -15,15 +14,8 @@ @database_cli.command() def attach(ctx: typer.Context): """Get a psql shell to the currently configured database.""" - asyncio.run(psql_attach(ctx.obj.config.database)) - - -async def _migrate(cfg: Config, until_revision: Optional[str]): - db_pool = await create_db_pool(cfg.database) - try: - await apply_revisions(db_pool=db_pool, until_revision=until_revision) - finally: - await db_pool.close() + db = get_database(config=ctx.obj.config.database) + asyncio.run(db.attach()) @database_cli.command() @@ -32,14 +24,16 @@ def migrate( until_revision: Annotated[Optional[str], typer.Option(help="Only apply revisions until this version")] = None, ): """Apply all database migrations.""" - asyncio.run(_migrate(cfg=ctx.obj.config, until_revision=until_revision)) + db = get_database(config=ctx.obj.config.database) + asyncio.run(db.apply_migrations(until_migration=until_revision)) async def _rebuild(cfg: Config): - db_pool = await create_db_pool(cfg.database) + db = get_database(cfg.database) + db_pool = await db.create_pool(n_connections=2) try: await reset_schema(db_pool=db_pool) - await apply_revisions(db_pool=db_pool) + await db.apply_migrations() finally: await db_pool.close() @@ -51,7 +45,8 @@ def rebuild(ctx: typer.Context): async def _reset(cfg: Config): - db_pool = await create_db_pool(cfg.database) + db = get_database(cfg.database) + db_pool = await db.create_pool() try: await reset_schema(db_pool=db_pool) finally: @@ -67,6 +62,16 @@ def reset( @database_cli.command() -def list_revisions(): +def list_revisions( + ctx: typer.Context, +): + """List all available database revisions.""" + db = get_database(ctx.obj.config.database) + list_revisions_(db) + + +@database_cli.command() +def reload_code(ctx: typer.Context): """List all available database revisions.""" - list_revisions_() + db = get_database(ctx.obj.config.database) + asyncio.run(db.reload_code()) diff --git a/abrechnung/cli/main.py b/abrechnung/cli/main.py index b40be814..3d28fd7a 100644 --- a/abrechnung/cli/main.py +++ b/abrechnung/cli/main.py @@ -1,5 +1,4 @@ import asyncio -import json from pathlib import Path from types import SimpleNamespace from typing import Annotated @@ -7,7 +6,7 @@ import typer from abrechnung.config import read_config -from abrechnung.http.api import Api +from abrechnung.http.api import Api, print_openapi from abrechnung.mailer import Mailer from abrechnung.util import log_setup @@ -51,8 +50,7 @@ def api(ctx: typer.Context): @cli.command() def show_openapi(ctx: typer.Context): - a = Api(config=ctx.obj.config) - print(json.dumps(a.api.openapi(), indent=2)) + print_openapi(ctx.obj.config) cli.add_typer(database_cli, name="db", help="Manage everything related to the abrechnung database") diff --git a/abrechnung/config.py b/abrechnung/config.py index 9834637c..7fa21c35 100644 --- a/abrechnung/config.py +++ b/abrechnung/config.py @@ -9,14 +9,12 @@ PydanticBaseSettingsSource, SettingsConfigDict, ) - -from abrechnung.framework.database import DatabaseConfig +from sftkit.database import DatabaseConfig +from sftkit.http import HTTPServerConfig class ServiceConfig(BaseModel): - url: str name: str - api_url: str class DemoConfig(BaseModel): @@ -24,10 +22,8 @@ class DemoConfig(BaseModel): wipe_interval: timedelta = timedelta(hours=1) -class ApiConfig(BaseModel): +class ApiConfig(HTTPServerConfig): secret_key: str - host: str - port: int id: str = "default" max_uploadable_file_size: int = 1024 enable_cors: bool = True diff --git a/abrechnung/core/auth.py b/abrechnung/core/auth.py index 6a532ff6..bea4ebf9 100644 --- a/abrechnung/core/auth.py +++ b/abrechnung/core/auth.py @@ -1,8 +1,8 @@ +from sftkit.database import Connection +from sftkit.error import InvalidArgument + from abrechnung.domain.groups import GroupMember from abrechnung.domain.users import User -from abrechnung.framework.database import Connection - -from .errors import NotFoundError async def check_group_permissions( @@ -21,7 +21,7 @@ async def check_group_permissions( user.id, ) if membership is None: - raise NotFoundError(f"group not found") + raise InvalidArgument(f"group not found") if can_write and not (membership.is_owner or membership.can_write): raise PermissionError(f"write access to group denied") diff --git a/abrechnung/core/errors.py b/abrechnung/core/errors.py deleted file mode 100644 index 2f3bc2fc..00000000 --- a/abrechnung/core/errors.py +++ /dev/null @@ -1,6 +0,0 @@ -class NotFoundError(Exception): - pass - - -class InvalidCommand(Exception): - pass diff --git a/abrechnung/core/service.py b/abrechnung/core/service.py deleted file mode 100644 index 32e81d2b..00000000 --- a/abrechnung/core/service.py +++ /dev/null @@ -1,9 +0,0 @@ -import asyncpg - -from abrechnung.config import Config - - -class Service: - def __init__(self, db_pool: asyncpg.Pool, config: Config): - self.db_pool = db_pool - self.cfg = config diff --git a/abrechnung/database/migrations.py b/abrechnung/database/migrations.py index e1c9f9c2..e5e9c71e 100644 --- a/abrechnung/database/migrations.py +++ b/abrechnung/database/migrations.py @@ -1,41 +1,37 @@ from pathlib import Path import asyncpg +from sftkit.database import Database, DatabaseConfig -from abrechnung.framework.database import SchemaRevision -from abrechnung.framework.database import apply_revisions as framework_apply_revisions - -REVISION_TABLE = "schema_revision" -REVISION_PATH = Path(__file__).parent / "revisions" +MIGRATION_PATH = Path(__file__).parent / "revisions" DB_CODE_PATH = Path(__file__).parent / "code" -CURRENT_REVISION = "" +CURRENT_REVISION = "2467f144" + + +def get_database(config: DatabaseConfig) -> Database: + return Database( + config=config, + migrations_dir=MIGRATION_PATH, + code_dir=DB_CODE_PATH, + ) -def list_revisions(): - revisions = SchemaRevision.revisions_from_dir(REVISION_PATH) +def list_revisions(db: Database): + revisions = db.list_migrations() for revision in revisions: print(f"Revision: {revision.version}, requires revision: {revision.requires}, filename: {revision.file_name}") -async def check_revision_version(db_pool: asyncpg.Pool): - curr_revision = await db_pool.fetchval(f"select version from {REVISION_TABLE} limit 1") - if curr_revision != CURRENT_REVISION: +async def check_revision_version(db: Database): + revision = await db.get_current_migration_version() + if revision != CURRENT_REVISION: raise RuntimeError( - f"Invalid database revision, expected {CURRENT_REVISION}, database is at revision {curr_revision}" + f"Invalid database revision, expected {CURRENT_REVISION}, database is at revision {revision}" ) async def reset_schema(db_pool: asyncpg.Pool): async with db_pool.acquire() as conn: - async with conn.transaction(): + async with conn.transaction(isolation="serializable"): await conn.execute("drop schema public cascade") await conn.execute("create schema public") - - -async def apply_revisions(db_pool: asyncpg.Pool, until_revision: str | None = None): - await framework_apply_revisions( - db_pool=db_pool, - revision_path=REVISION_PATH, - code_path=DB_CODE_PATH, - until_revision=until_revision, - ) diff --git a/abrechnung/database/revisions/0001_initial_schema.sql b/abrechnung/database/revisions/0001_initial_schema.sql index 9a74c81b..3f28902a 100644 --- a/abrechnung/database/revisions/0001_initial_schema.sql +++ b/abrechnung/database/revisions/0001_initial_schema.sql @@ -1,4 +1,4 @@ --- revision: 62df6b55 +-- migration: 62df6b55 -- requires: null ------------------------------------------------------------------------------- diff --git a/abrechnung/database/revisions/0002_subscriptions.sql b/abrechnung/database/revisions/0002_subscriptions.sql index bcf29bbf..140ebaf6 100644 --- a/abrechnung/database/revisions/0002_subscriptions.sql +++ b/abrechnung/database/revisions/0002_subscriptions.sql @@ -1,4 +1,4 @@ --- revision: 83a50a30 +-- migration: 83a50a30 -- requires: 62df6b55 -- which table or event variant is a subscription for. diff --git a/abrechnung/database/revisions/0003_purchase_items.sql b/abrechnung/database/revisions/0003_purchase_items.sql index a67b1a2d..53e5d8b1 100644 --- a/abrechnung/database/revisions/0003_purchase_items.sql +++ b/abrechnung/database/revisions/0003_purchase_items.sql @@ -1,4 +1,4 @@ --- revision: b32893f6 +-- migration: b32893f6 -- requires: 83a50a30 -- an item in a 'purchase'-type transaction diff --git a/abrechnung/database/revisions/0004_relax_db_constraints.sql b/abrechnung/database/revisions/0004_relax_db_constraints.sql index c8340c64..63c5024f 100644 --- a/abrechnung/database/revisions/0004_relax_db_constraints.sql +++ b/abrechnung/database/revisions/0004_relax_db_constraints.sql @@ -1,2 +1,2 @@ --- revision: f133b1d3 +-- migration: f133b1d3 -- requires: b32893f6 diff --git a/abrechnung/database/revisions/0005_partial_data_fetching.sql b/abrechnung/database/revisions/0005_partial_data_fetching.sql index e2e0b25a..6159c229 100644 --- a/abrechnung/database/revisions/0005_partial_data_fetching.sql +++ b/abrechnung/database/revisions/0005_partial_data_fetching.sql @@ -1,2 +1,2 @@ --- revision: 64df13c9 +-- migration: 64df13c9 -- requires: f133b1d3 diff --git a/abrechnung/database/revisions/0006_fix_foreign_key_constraints.sql b/abrechnung/database/revisions/0006_fix_foreign_key_constraints.sql index efdd55ad..98d99c89 100644 --- a/abrechnung/database/revisions/0006_fix_foreign_key_constraints.sql +++ b/abrechnung/database/revisions/0006_fix_foreign_key_constraints.sql @@ -1,4 +1,4 @@ --- revision: a77c9b57 +-- migration: a77c9b57 -- requires: 64df13c9 alter table purchase_item drop constraint purchase_item_transaction_id_fkey; diff --git a/abrechnung/database/revisions/0007_invite_join_as_editor.sql b/abrechnung/database/revisions/0007_invite_join_as_editor.sql index d2e44c85..2bac0d7d 100644 --- a/abrechnung/database/revisions/0007_invite_join_as_editor.sql +++ b/abrechnung/database/revisions/0007_invite_join_as_editor.sql @@ -1,4 +1,4 @@ --- revision: dbcccb58 +-- migration: dbcccb58 -- requires: a77c9b57 alter table group_invite add column join_as_editor bool default false; \ No newline at end of file diff --git a/abrechnung/database/revisions/0008_file_upload.sql b/abrechnung/database/revisions/0008_file_upload.sql index 51f7bcd3..927f25e1 100644 --- a/abrechnung/database/revisions/0008_file_upload.sql +++ b/abrechnung/database/revisions/0008_file_upload.sql @@ -1,4 +1,4 @@ --- revision: 156aef63 +-- migration: 156aef63 -- requires: dbcccb58 create table if not exists blob ( diff --git a/abrechnung/database/revisions/0009_robust_notifications.sql b/abrechnung/database/revisions/0009_robust_notifications.sql index 65c39b5d..06c26b92 100644 --- a/abrechnung/database/revisions/0009_robust_notifications.sql +++ b/abrechnung/database/revisions/0009_robust_notifications.sql @@ -1,4 +1,4 @@ --- revision: bf5fbd44 +-- migration: bf5fbd44 -- requires: 156aef63 alter table transaction_revision add column version int default 0; diff --git a/abrechnung/database/revisions/0010_clearing_accounts.sql b/abrechnung/database/revisions/0010_clearing_accounts.sql index 03a5a76c..99e64df9 100644 --- a/abrechnung/database/revisions/0010_clearing_accounts.sql +++ b/abrechnung/database/revisions/0010_clearing_accounts.sql @@ -1,4 +1,4 @@ --- revision: c85ea20c +-- migration: c85ea20c -- requires: bf5fbd44 insert into account_type (name) values ('clearing'); diff --git a/abrechnung/database/revisions/0011_cascade_fixes.sql b/abrechnung/database/revisions/0011_cascade_fixes.sql index ebd39327..ac8abf54 100644 --- a/abrechnung/database/revisions/0011_cascade_fixes.sql +++ b/abrechnung/database/revisions/0011_cascade_fixes.sql @@ -1,4 +1,4 @@ --- revision: f6c9ff0b +-- migration: f6c9ff0b -- requires: c85ea20c alter table clearing_account_share drop constraint clearing_account_share_account_id_fkey; diff --git a/abrechnung/database/revisions/0012_correct_change_dates.sql b/abrechnung/database/revisions/0012_correct_change_dates.sql index a39b8e1b..97c2e3d1 100644 --- a/abrechnung/database/revisions/0012_correct_change_dates.sql +++ b/abrechnung/database/revisions/0012_correct_change_dates.sql @@ -1,2 +1,2 @@ --- revision: 5b333d87 +-- migration: 5b333d87 -- requires: f6c9ff0b diff --git a/abrechnung/database/revisions/0013_register_with_invite.sql b/abrechnung/database/revisions/0013_register_with_invite.sql index 69301845..062925bc 100644 --- a/abrechnung/database/revisions/0013_register_with_invite.sql +++ b/abrechnung/database/revisions/0013_register_with_invite.sql @@ -1,4 +1,4 @@ --- revision: c019dd21 +-- migration: c019dd21 -- requires: 5b333d87 alter table usr add column is_guest_user bool default false not null; diff --git a/abrechnung/database/revisions/0014_associate_accounts_with_users.sql b/abrechnung/database/revisions/0014_associate_accounts_with_users.sql index 8f0070c2..19a6ff0e 100644 --- a/abrechnung/database/revisions/0014_associate_accounts_with_users.sql +++ b/abrechnung/database/revisions/0014_associate_accounts_with_users.sql @@ -1,4 +1,4 @@ --- revision: 174ef0fc +-- migration: 174ef0fc -- requires: c019dd21 alter table grp add column add_user_account_on_join boolean default false not null; diff --git a/abrechnung/database/revisions/0015_metadata_fields_and_revision_changed.sql b/abrechnung/database/revisions/0015_metadata_fields_and_revision_changed.sql index 3671eb6d..59c502c4 100644 --- a/abrechnung/database/revisions/0015_metadata_fields_and_revision_changed.sql +++ b/abrechnung/database/revisions/0015_metadata_fields_and_revision_changed.sql @@ -1,4 +1,4 @@ --- revision: ee5d2b35 +-- migration: ee5d2b35 -- requires: 174ef0fc alter table account_revision diff --git a/abrechnung/database/revisions/0016_refactoring.sql b/abrechnung/database/revisions/0016_refactoring.sql index 06ef5d8a..bc4fa1e6 100644 --- a/abrechnung/database/revisions/0016_refactoring.sql +++ b/abrechnung/database/revisions/0016_refactoring.sql @@ -1,4 +1,4 @@ --- revision: a83f4798 +-- migration: a83f4798 -- requires: ee5d2b35 alter table session drop column token; diff --git a/abrechnung/database/revisions/0017_clean_non_nullable_strings.sql b/abrechnung/database/revisions/0017_clean_non_nullable_strings.sql index 300e6c92..629db27d 100644 --- a/abrechnung/database/revisions/0017_clean_non_nullable_strings.sql +++ b/abrechnung/database/revisions/0017_clean_non_nullable_strings.sql @@ -1,4 +1,4 @@ --- revision: 04424b59 +-- migration: 04424b59 -- requires: a83f4798 alter table transaction_history alter column description set default ''; diff --git a/abrechnung/database/revisions/0018_archivable_groups.sql b/abrechnung/database/revisions/0018_archivable_groups.sql index d9d44e02..3cf8e3eb 100644 --- a/abrechnung/database/revisions/0018_archivable_groups.sql +++ b/abrechnung/database/revisions/0018_archivable_groups.sql @@ -1,4 +1,4 @@ --- revision: 2467f144 +-- migration: 2467f144 -- requires: 04424b59 alter table grp add column archived boolean not null default false; diff --git a/abrechnung/demo.py b/abrechnung/demo.py index c601d44a..10dc5c86 100644 --- a/abrechnung/demo.py +++ b/abrechnung/demo.py @@ -2,7 +2,7 @@ from datetime import datetime from abrechnung.config import Config -from abrechnung.framework.database import create_db_pool +from abrechnung.database.migrations import get_database logger = logging.getLogger(__name__) @@ -14,7 +14,8 @@ async def cleanup(config: Config): deletion_threshold = datetime.now() - config.demo.wipe_interval - db_pool = await create_db_pool(config.database) + database = get_database(config.database) + db_pool = await database.create_pool() async with db_pool.acquire() as conn: async with conn.transaction(): n_rows_groups = await conn.fetchval( diff --git a/abrechnung/framework/database.py b/abrechnung/framework/database.py deleted file mode 100644 index 0880bfb8..00000000 --- a/abrechnung/framework/database.py +++ /dev/null @@ -1,393 +0,0 @@ -import asyncio -import contextlib -import json -import logging -import os -import re -import shutil -import ssl -import tempfile -from pathlib import Path -from typing import Literal, Optional, Type, TypeVar, Union - -import asyncpg -from pydantic import BaseModel - -from abrechnung import util - -logger = logging.getLogger(__name__) - -REVISION_VERSION_RE = re.compile(r"^-- revision: (?P\w+)$") -REVISION_REQUIRES_RE = re.compile(r"^-- requires: (?P\w+)$") -REVISION_TABLE = "schema_revision" - - -class DatabaseConfig(BaseModel): - user: Optional[str] = None - password: Optional[str] = None - host: Optional[str] = None - port: Optional[int] = 5432 - dbname: str - require_ssl: bool = False - sslrootcert: Optional[str] = None - - -async def psql_attach(config: DatabaseConfig): - with contextlib.ExitStack() as exitstack: - env = dict(os.environ) - env["PGDATABASE"] = config.dbname - - if config.user is None: - if config.host is not None: - raise ValueError("database user is None, but host is set") - if config.password is not None: - raise ValueError("database user is None, but password is set") - else: - - def escape_colon(value: str): - return value.replace("\\", "\\\\").replace(":", "\\:") - - if config.user is not None and config.password is not None and config.host is not None: - passfile = exitstack.enter_context(tempfile.NamedTemporaryFile("w")) - os.chmod(passfile.name, 0o600) - - passfile.write( - ":".join( - [ - escape_colon(config.host), - "*", - escape_colon(config.dbname), - escape_colon(config.user), - escape_colon(config.password), - ] - ) - ) - passfile.write("\n") - passfile.flush() - env["PGPASSFILE"] = passfile.name - env["PGHOST"] = config.host - env["PGUSER"] = config.user - - command = ["psql", "--variable", "ON_ERROR_STOP=1"] - if shutil.which("pgcli") is not None: - # if pgcli is installed, use that instead! - command = ["pgcli"] - - cwd = os.path.join(os.path.dirname(__file__)) - ret = await util.run_as_fg_process(command, env=env, cwd=cwd) - return ret - - -async def drop_all_views(conn: asyncpg.Connection, schema: str): - # TODO: we might have to find out the dependency order of the views if drop cascade does not work - result = await conn.fetch( - "select table_name from information_schema.views where table_schema = $1 and table_name !~ '^pg_';", - schema, - ) - views = [row["table_name"] for row in result] - if len(views) == 0: - return - - # we use drop if exists here as the cascade dropping might lead the view to being already dropped - # due to being a dependency of another view - drop_statements = "\n".join([f"drop view if exists {view} cascade;" for view in views]) - await conn.execute(drop_statements) - - -async def drop_all_triggers(conn: asyncpg.Connection, schema: str): - result = await conn.fetch( - "select distinct on (trigger_name, event_object_table) trigger_name, event_object_table " - "from information_schema.triggers where trigger_schema = $1", - schema, - ) - statements = [] - for row in result: - trigger_name = row["trigger_name"] - table = row["event_object_table"] - statements.append(f"drop trigger {trigger_name} on {table};") - - if len(statements) == 0: - return - - drop_statements = "\n".join(statements) - await conn.execute(drop_statements) - - -async def drop_all_functions(conn: asyncpg.Connection, schema: str): - result = await conn.fetch( - "select proname, prokind from pg_proc where pronamespace = $1::regnamespace;", - schema, - ) - drop_statements = [] - for row in result: - kind = row["prokind"].decode("utf-8") - name = row["proname"] - if kind == "f" or kind == "w": - drop_type = "function" - elif kind == "a": - drop_type = "aggregate" - elif kind == "p": - drop_type = "procedure" - else: - raise RuntimeError(f'Unknown postgres function type "{kind}"') - drop_statements.append(f"drop {drop_type} {name} cascade;") - - if len(drop_statements) == 0: - return - - drop_code = "\n".join(drop_statements) - await conn.execute(drop_code) - - -async def drop_all_constraints(conn: asyncpg.Connection, schema: str): - """drop all constraints in the given schema which are not unique, primary or foreign key constraints""" - result = await conn.fetch( - "select con.conname as constraint_name, rel.relname as table_name, con.contype as constraint_type " - "from pg_catalog.pg_constraint con " - " join pg_catalog.pg_namespace nsp on nsp.oid = con.connamespace " - " left join pg_catalog.pg_class rel on rel.oid = con.conrelid " - "where nsp.nspname = $1 and con.conname !~ '^pg_' " - " and con.contype != 'p' and con.contype != 'f' and con.contype != 'u';", - schema, - ) - constraints = [] - for row in result: - constraint_name = row["constraint_name"] - constraint_type = row["constraint_type"].decode("utf-8") - table_name = row["table_name"] - if constraint_type == "c": - constraints.append(f"alter table {table_name} drop constraint {constraint_name};") - elif constraint_type == "t": - constraints.append(f"drop constraint trigger {constraint_name};") - else: - raise RuntimeError(f'Unknown constraint type "{constraint_type}" for constraint "{constraint_name}"') - - if len(constraints) == 0: - return - - drop_statements = "\n".join(constraints) - await conn.execute(drop_statements) - - -async def drop_db_code(conn: asyncpg.Connection, schema: str): - await drop_all_triggers(conn, schema=schema) - await drop_all_functions(conn, schema=schema) - await drop_all_views(conn, schema=schema) - await drop_all_constraints(conn, schema=schema) - - -class SchemaRevision: - def __init__(self, file_name: Path, code: str, version: str, requires: Optional[str]): - self.file_name = file_name - self.code = code - self.version = version - self.requires = requires - - async def apply(self, conn): - logger.info(f"Applying revision {self.file_name.name} with version {self.version}") - if self.requires: - version = await conn.fetchval( - f"update {REVISION_TABLE} set version = $1 where version = $2 returning version", - self.version, - self.requires, - ) - if version != self.version: - raise ValueError(f"Found other revision present than {self.requires} which was required") - else: - n_table = await conn.fetchval(f"select count(*) from {REVISION_TABLE}") - if n_table != 0: - raise ValueError( - f"Could not apply revision {self.version} as there appears to be a revision present," - f"none was expected" - ) - await conn.execute(f"insert into {REVISION_TABLE} (version) values ($1)", self.version) - - # now we can actually apply the revision - try: - if len(self.code.splitlines()) > 2: # does not only consist of first two header comment lines - await conn.execute(self.code) - except asyncpg.exceptions.PostgresSyntaxError as exc: - exc_dict = exc.as_dict() - position = int(exc_dict["position"]) - message = exc_dict["message"] - lineno = self.code.count("\n", 0, position) + 1 - raise ValueError( - f"Syntax error when executing SQL code at character " - f"{position} ({self.file_name!s}:{lineno}): {message!r}" - ) from exc - - @classmethod - def revisions_from_dir(cls, revision_dir: Path) -> list["SchemaRevision"]: - """ - returns an ordered list of revisions with their dependencies resolved - """ - revisions = [] - for revision in sorted(revision_dir.glob("*.sql")): - revision_content = revision.read_text("utf-8") - lines = revision_content.splitlines() - if not len(lines) > 2: - logger.warning(f"Revision {revision} is empty") - - if (version_match := REVISION_VERSION_RE.match(lines[0])) is None: - raise ValueError( - f"Invalid version string in revision {revision}, " f"should be of form '-- revision: '" - ) - if (requires_match := REVISION_REQUIRES_RE.match(lines[1])) is None: - raise ValueError( - f"Invalid requires string in revision {revision}, " f"should be of form '-- requires: '" - ) - - version = version_match["version"] - requires: Optional[str] = requires_match["version"] - - if requires == "null": - requires = None - - revisions.append( - cls( - revision, - revision_content, - version, - requires, - ) - ) - - if len(revisions) == 0: - return revisions - - # now for the purpose of sorting the revisions according to their dependencies - first_revision = next((x for x in revisions if x.requires is None), None) - if first_revision is None: - raise ValueError("Could not find a revision without any dependencies") - - # TODO: detect revision branches - sorted_revisions = [first_revision] - while len(sorted_revisions) < len(revisions): - curr_revision = sorted_revisions[-1] - next_revision = next((x for x in revisions if x.requires == curr_revision.version), None) - if next_revision is None: - raise ValueError(f"Could not find the successor to revision {curr_revision.version}") - sorted_revisions.append(next_revision) - - return sorted_revisions - - -async def _apply_db_code(conn: asyncpg.Connection, code_path: Path): - for code_file in sorted(code_path.glob("*.sql")): - logger.info(f"Applying database code file {code_file.name}") - code = code_file.read_text("utf-8") - await conn.execute(code) - - -async def apply_revisions( - db_pool: asyncpg.Pool, - revision_path: Path, - code_path: Path, - until_revision: Optional[str] = None, -): - revisions = SchemaRevision.revisions_from_dir(revision_path) - - async with db_pool.acquire() as conn: - async with conn.transaction(): - await conn.execute(f"create table if not exists {REVISION_TABLE} (version text not null primary key)") - - curr_revision = await conn.fetchval(f"select version from {REVISION_TABLE} limit 1") - - await drop_db_code(conn=conn, schema="public") - # TODO: perform a dry run to check all revisions before doing anything - - found = curr_revision is None - for revision in revisions: - if found: - await revision.apply(conn) - - if revision.version == curr_revision: - found = True - - if until_revision is not None and revision.version == until_revision: - return - - if not found: - raise ValueError(f"Unknown revision {curr_revision} present in database") - - await _apply_db_code(conn=conn, code_path=code_path) - - -T = TypeVar("T", bound=BaseModel) - - -class Connection(asyncpg.Connection): - async def fetch_one(self, model: Type[T], query: str, *args) -> T: - result: Optional[asyncpg.Record] = await self.fetchrow(query, *args) - if result is None: - raise asyncpg.DataError("not found") - - return model.model_validate(dict(result)) - - async def fetch_maybe_one(self, model: Type[T], query: str, *args) -> Optional[T]: - result: Optional[asyncpg.Record] = await self.fetchrow(query, *args) - if result is None: - return None - - return model.model_validate(dict(result)) - - async def fetch_many(self, model: Type[T], query: str, *args) -> list[T]: - # TODO: also allow async cursor - results: list[asyncpg.Record] = await self.fetch(query, *args) - return [model.model_validate(dict(r)) for r in results] - - -async def init_connection(conn: Connection): - await conn.set_type_codec("json", encoder=json.dumps, decoder=json.loads, schema="pg_catalog") - await conn.set_type_codec("jsonb", encoder=json.dumps, decoder=json.loads, schema="pg_catalog") - - -async def create_db_pool(cfg: DatabaseConfig, n_connections=10) -> asyncpg.Pool: - """ - get a connection pool to the database - """ - pool = None - - retry_counter = 0 - next_log_at_retry = 0 - while pool is None: - try: - sslctx: Optional[Union[ssl.SSLContext, Literal["verify-full", "prefer"]]] - if cfg.sslrootcert and cfg.require_ssl: - sslctx = ssl.create_default_context( - ssl.Purpose.SERVER_AUTH, - cafile=cfg.sslrootcert, - ) - sslctx.check_hostname = True - else: - sslctx = "verify-full" if cfg.require_ssl else "prefer" - - pool = await asyncpg.create_pool( - user=cfg.user, - password=cfg.password, - database=cfg.dbname, - host=cfg.host, - max_size=n_connections, - connection_class=Connection, - min_size=n_connections, - ssl=sslctx, - # the introspection query of asyncpg (defined as introspection.INTRO_LOOKUP_TYPES) - # can take 1s with the jit. - # the introspection is triggered to create converters for unknown types, - # for example the integer[] (oid = 1007). - # see https://github.com/MagicStack/asyncpg/issues/530 - server_settings={"jit": "off"}, - init=init_connection, - ) - except Exception as e: # pylint: disable=broad-except - sleep_amount = 10 - if next_log_at_retry == retry_counter: - logger.warning( - f"Failed to create database pool: {e}, waiting {sleep_amount} seconds and trying again..." - ) - - retry_counter += 1 - next_log_at_retry = min(retry_counter * 2, 2**9) - await asyncio.sleep(sleep_amount) - - return pool diff --git a/abrechnung/framework/decorators.py b/abrechnung/framework/decorators.py deleted file mode 100644 index b292c0f0..00000000 --- a/abrechnung/framework/decorators.py +++ /dev/null @@ -1,28 +0,0 @@ -from functools import wraps - -from abrechnung.framework.database import Connection - - -def with_db_connection(func): - @wraps(func) - async def wrapper(self, **kwargs): - if "conn" in kwargs: - return await func(self, **kwargs) - - async with self.db_pool.acquire() as conn: - return await func(self, conn=conn, **kwargs) - - return wrapper - - -def with_db_transaction(func): - @wraps(func) - async def wrapper(self, **kwargs): - if "conn" in kwargs: - return await func(self, **kwargs) - - async with self.db_pool.acquire() as conn: - async with conn.transaction(): - return await func(self, conn=conn, **kwargs) - - return wrapper diff --git a/abrechnung/http/api.py b/abrechnung/http/api.py index ee6bbd0f..8e6a3676 100644 --- a/abrechnung/http/api.py +++ b/abrechnung/http/api.py @@ -1,11 +1,7 @@ +import json import logging -import asyncpg -import uvicorn -from fastapi import FastAPI, Request, status -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from starlette.exceptions import HTTPException as StarletteHTTPException +from sftkit.http import Server from abrechnung import __version__ from abrechnung.application.accounts import AccountService @@ -13,96 +9,56 @@ from abrechnung.application.transactions import TransactionService from abrechnung.application.users import UserService from abrechnung.config import Config -from abrechnung.core.errors import InvalidCommand, NotFoundError -from abrechnung.framework.database import create_db_pool +from abrechnung.database.migrations import check_revision_version, get_database -from .middleware import ContextMiddleware +from .context import Context from .routers import accounts, auth, common, groups, transactions, websocket from .routers.websocket import NotificationManager -class Api: - def __init__(self, config: Config): - self.cfg = config - - self.logger = logging.getLogger(__name__) +def get_server(config: Config): + server = Server( + title="Abrechnung API", + config=config.api, + license_name="AGPL-3.0", + version=__version__, + cors=True, + ) - self.api = FastAPI( - title="Abrechnung REST-ish API", - version=__version__, - license_info={"identifier": "AGPL-3.0", "name": "AGPL-3.0"}, - docs_url="/api/docs", - redoc_url=None, - ) + server.add_router(transactions.router) + server.add_router(groups.router) + server.add_router(auth.router) + server.add_router(accounts.router) + server.add_router(common.router) + server.add_router(websocket.router) + return server - self.api.include_router(transactions.router) - self.api.include_router(groups.router) - self.api.include_router(auth.router) - self.api.include_router(accounts.router) - self.api.include_router(common.router) - self.api.include_router(websocket.router) - self.api.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - bad_request_handler = self.make_generic_exception_handler(status.HTTP_400_BAD_REQUEST) +def print_openapi(config: Config): + server = get_server(config) + print(json.dumps(server.get_openapi_spec())) - self.api.add_exception_handler(NotFoundError, self.make_generic_exception_handler(status.HTTP_404_NOT_FOUND)) - self.api.add_exception_handler(PermissionError, self.make_generic_exception_handler(status.HTTP_403_FORBIDDEN)) - self.api.add_exception_handler(asyncpg.DataError, bad_request_handler) - self.api.add_exception_handler(asyncpg.RaiseError, bad_request_handler) - self.api.add_exception_handler( - asyncpg.IntegrityConstraintViolationError, - bad_request_handler, - ) - self.api.add_exception_handler(InvalidCommand, bad_request_handler) - self.api.add_exception_handler(StarletteHTTPException, self._http_exception_handler) - - self.uvicorn_config = uvicorn.Config( - self.api, - host=config.api.host, - port=config.api.port, - log_level=logging.root.level, - ) - @staticmethod - def _format_error_message(code: int, message: str): - return JSONResponse( - status_code=code, - content={ - "code": code, - "msg": message, - }, - ) - - def make_generic_exception_handler(self, status_code: int): - async def handler(request: Request, exc: Exception): - return self._format_error_message(status_code, str(exc)) +class Api: + def __init__(self, config: Config): + self.cfg = config - return handler + self.logger = logging.getLogger(__name__) - async def _http_exception_handler(self, request: Request, exc: Exception): - if isinstance(exc, StarletteHTTPException): - return self._format_error_message(exc.status_code, exc.detail) + self.server = get_server(config) async def _setup(self): - self.db_pool = await create_db_pool(self.cfg.database) + db = get_database(config=self.cfg.database) + self.db_pool = await db.create_pool() + await check_revision_version(db) self.user_service = UserService(db_pool=self.db_pool, config=self.cfg) self.transaction_service = TransactionService(db_pool=self.db_pool, config=self.cfg) self.account_service = AccountService(db_pool=self.db_pool, config=self.cfg) self.group_service = GroupService(db_pool=self.db_pool, config=self.cfg) - self.notification_manager = NotificationManager(config=self.cfg) - - await self.notification_manager.initialize(db_pool=self.db_pool) - - self.api.add_middleware( - ContextMiddleware, + self.notification_manager = NotificationManager(config=self.cfg, db_pool=self.db_pool) + await self.notification_manager.initialize() + self.context = Context( config=self.cfg, - db_pool=self.db_pool, user_service=self.user_service, transaction_service=self.transaction_service, account_service=self.account_service, @@ -116,9 +72,7 @@ async def _teardown(self): async def run(self): await self._setup() - try: - webserver = uvicorn.Server(self.uvicorn_config) - await webserver.serve() + await self.server.run(self.context) finally: await self._teardown() diff --git a/abrechnung/http/context.py b/abrechnung/http/context.py new file mode 100644 index 00000000..c663211a --- /dev/null +++ b/abrechnung/http/context.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + +from abrechnung.application.accounts import AccountService +from abrechnung.application.groups import GroupService +from abrechnung.application.transactions import TransactionService +from abrechnung.application.users import UserService +from abrechnung.config import Config +from abrechnung.http.routers.websocket import NotificationManager + + +@dataclass +class Context: + """ + provides access to data injected by the ContextMiddleware + into each request. + """ + + config: Config + user_service: UserService + transaction_service: TransactionService + account_service: AccountService + group_service: GroupService + notification_manager: NotificationManager diff --git a/abrechnung/http/dependencies.py b/abrechnung/http/dependencies.py index 6739b478..c8a5c8b6 100644 --- a/abrechnung/http/dependencies.py +++ b/abrechnung/http/dependencies.py @@ -9,11 +9,11 @@ def get_config(request: Request) -> Config: - return request.state.config + return request.state.context.config def get_db_pool(request: Request) -> asyncpg.Pool: - return request.state.db_pool + return request.state.context.db_pool async def get_db_conn( @@ -32,16 +32,16 @@ async def get_db_transaction( def get_user_service(request: Request) -> UserService: - return request.state.user_service + return request.state.context.user_service def get_group_service(request: Request) -> GroupService: - return request.state.group_service + return request.state.context.group_service def get_account_service(request: Request) -> AccountService: - return request.state.account_service + return request.state.context.account_service def get_transaction_service(request: Request) -> TransactionService: - return request.state.transaction_service + return request.state.context.transaction_service diff --git a/abrechnung/http/middleware.py b/abrechnung/http/middleware.py deleted file mode 100644 index 7ffa09d3..00000000 --- a/abrechnung/http/middleware.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Union - -import asyncpg -from starlette.requests import Request -from starlette.types import ASGIApp, Receive, Scope, Send -from starlette.websockets import WebSocket - -from abrechnung.application.accounts import AccountService -from abrechnung.application.groups import GroupService -from abrechnung.application.transactions import TransactionService -from abrechnung.application.users import UserService -from abrechnung.config import Config -from abrechnung.http.routers.websocket import NotificationManager - - -class ContextMiddleware: - def __init__( - self, - app: ASGIApp, - config: Config, - db_pool: asyncpg.Pool, - user_service: UserService, - transaction_service: TransactionService, - account_service: AccountService, - group_service: GroupService, - notification_manager: NotificationManager, - ) -> None: - self.app = app - - self.config = config - self.db_pool = db_pool - self.user_service = user_service - self.transaction_service = transaction_service - self.account_service = account_service - self.group_service = group_service - self.notification_manager = notification_manager - - async def __call__(self, scope: Scope, receive: Receive, send: Send): - if scope["type"] == "http": - req: Union[Request, WebSocket] = Request(scope, receive, send) - elif scope["type"] == "websocket": - req = WebSocket(scope, receive, send) - else: - return await self.app(scope, receive, send) - - req.state.config = self.config - req.state.db_pool = self.db_pool - req.state.user_service = self.user_service - req.state.transaction_service = self.transaction_service - req.state.account_service = self.account_service - req.state.group_service = self.group_service - req.state.notification_manager = self.notification_manager - - await self.app(scope, receive, send) diff --git a/abrechnung/http/routers/auth.py b/abrechnung/http/routers/auth.py index f45332d8..aaa66e6c 100644 --- a/abrechnung/http/routers/auth.py +++ b/abrechnung/http/routers/auth.py @@ -6,7 +6,6 @@ from abrechnung.application.users import InvalidPassword, UserService from abrechnung.config import Config -from abrechnung.core.errors import InvalidCommand from abrechnung.domain.users import User from abrechnung.http.auth import get_current_session_id, get_current_user from abrechnung.http.dependencies import get_config, get_user_service @@ -122,10 +121,7 @@ async def confirm_registration( payload: ConfirmRegistrationPayload, user_service: UserService = Depends(get_user_service), ): - try: - await user_service.confirm_registration(token=payload.token) - except (PermissionError, InvalidCommand) as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + await user_service.confirm_registration(token=payload.token) @router.get("/v1/profile", summary="fetch user profile information", response_model=User, operation_id="get_profile") diff --git a/abrechnung/http/routers/websocket.py b/abrechnung/http/routers/websocket.py index f4f4b541..d060b08c 100644 --- a/abrechnung/http/routers/websocket.py +++ b/abrechnung/http/routers/websocket.py @@ -22,7 +22,7 @@ def make_error_msg(code: int, msg: str) -> dict: class NotificationManager: - def __init__(self, config: Config): + def __init__(self, config: Config, db_pool: asyncpg.Pool): self.logger = logging.getLogger(__name__) self.config = config @@ -34,11 +34,10 @@ def __init__(self, config: Config): self.channel_id: Optional[int] = None self.channel_name: Optional[str] = None - self.db_pool: Optional[asyncpg.Pool] = None + self.db_pool = db_pool self.connection: Optional[asyncpg.Connection] = None - async def initialize(self, db_pool: asyncpg.Pool): - self.db_pool = db_pool + async def initialize(self): self.connection = await self.db_pool.acquire() await self.connection.set_type_codec("json", encoder=json.dumps, decoder=json.loads, schema="pg_catalog") await self.connection.set_type_codec("jsonb", encoder=json.dumps, decoder=json.loads, schema="pg_catalog") diff --git a/abrechnung/mailer.py b/abrechnung/mailer.py index 7d230723..bbf27bb1 100644 --- a/abrechnung/mailer.py +++ b/abrechnung/mailer.py @@ -7,10 +7,10 @@ from typing import Optional, Type import asyncpg - -from abrechnung.framework.database import Connection, create_db_pool +from sftkit.database import Connection from .config import Config +from .database.migrations import get_database class Mailer: @@ -18,6 +18,7 @@ def __init__(self, config: Config): self.config = config self.events: Optional[asyncio.Queue] = None self.psql: Connection | None = None + self.database = get_database(config.database) self.mailer = None self.logger = logging.getLogger(__name__) @@ -42,7 +43,7 @@ async def run(self): if self.events is None: raise RuntimeError("something unexpected happened, self.events is None") - db_pool = await create_db_pool(self.config.database, n_connections=1) + db_pool = await self.database.create_pool(n_connections=1) self.psql = await db_pool.acquire() assert self.psql is not None self.psql.add_termination_listener(self.terminate_callback) @@ -165,7 +166,7 @@ async def on_pending_registration_notification(self): "", "To complete your registration, visit", "", - f"{self.config.service.url}/confirm-registration/{row['token']}", + f"{self.config.api.base_url}/confirm-registration/{row['token']}", "", f"Your request will time out {row['valid_until']}.", "If you do not want to create a user account, just ignore this email.", @@ -199,7 +200,7 @@ async def on_user_password_recovery_notification(self): "", "To set a new one, visit", "", - f"{self.config.service.url}/confirm-password-recovery/{row['token']}", + f"{self.config.api.base_url}/confirm-password-recovery/{row['token']}", "", f"Your request will time out {row['valid_until']}.", "If you do not want to reset your password, just ignore this email.", @@ -252,7 +253,7 @@ async def on_user_email_update_notification(self): "", "To confirm, visit", "", - f"{self.config.service.url}/confirm-email-change/{row['token']}", + f"{self.config.api.base_url}/confirm-email-change/{row['token']}", "", f"Your request will time out {row['valid_until']}.", "If you do not want to change your email, just ignore this email.", diff --git a/abrechnung/util.py b/abrechnung/util.py index 333b3645..5c068247 100644 --- a/abrechnung/util.py +++ b/abrechnung/util.py @@ -1,30 +1,8 @@ -import asyncio import logging -import os import re -import signal -import sys -import termios - -# the vt100 CONTROL SEQUENCE INTRODUCER +import uuid from datetime import datetime, timedelta, timezone -CSI = "\N{ESCAPE}[" - - -def SGR(code=""): - """ - Returns a SELECT GRAPHIC RENDITION code sequence - for the given code. - See https://en.wikipedia.org/wiki/ANSI_escape_code - """ - return f"{CSI}{code}m" - - -BOLD = SGR(1) -RED = SGR(31) -NORMAL = SGR() - postgres_timestamp_format = re.compile( r"(?P\d{4})-(?P\d{2})-(?P\d{2})T(?P\d{2}):(?P\d{2}):(?P\d{2})" r"(\.(?P\d+))?(?P[-+])(?P\d{2}):(?P\d{2})" @@ -59,13 +37,6 @@ def parse_postgres_datetime(dt: str) -> datetime: raise ValueError("invalid format") -def format_error(text): - """ - Formats an error text for printing in a terminal - """ - return f"\N{PILE OF POO} {RED}{BOLD}{text}{NORMAL}" - - def log_setup(setting, default=1): """ Perform setup for the logger. @@ -95,80 +66,9 @@ def clamp(number, smallest, largest): return max(smallest, min(number, largest)) -async def run_as_fg_process(args, **kwargs): - """ - the "correct" way of spawning a new subprocess: - signals like C-c must only go - to the child process, and not to this python. - - the args are the same as subprocess.Popen - - returns Popen().wait() value - - Some side-info about "how ctrl-c works": - https://unix.stackexchange.com/a/149756/1321 - - fun fact: this function took a whole night - to be figured out. - """ - - old_pgrp = os.tcgetpgrp(sys.stdin.fileno()) - old_attr = termios.tcgetattr(sys.stdin.fileno()) - - user_preexec_fn = kwargs.pop("preexec_fn", None) - - def new_pgid(): - if user_preexec_fn: - user_preexec_fn() - - # set a new process group id - os.setpgid(os.getpid(), os.getpid()) - - # generally, the child process should stop itself - # before exec so the parent can set its new pgid. - # (setting pgid has to be done before the child execs). - # however, Python 'guarantee' that `preexec_fn` - # is run before `Popen` returns. - # this is because `Popen` waits for the closure of - # the error relay pipe '`errpipe_write`', - # which happens at child's exec. - # this is also the reason the child can't stop itself - # in Python's `Popen`, since the `Popen` call would never - # terminate then. - # `os.kill(os.getpid(), signal.SIGSTOP)` - +def is_valid_uuid(val: str): try: - # fork the child - child = await asyncio.create_subprocess_exec(*args, preexec_fn=new_pgid, **kwargs) - - # we can't set the process group id from the parent since the child - # will already have exec'd. and we can't SIGSTOP it before exec, - # see above. - # `os.setpgid(child.pid, child.pid)` - - # set the child's process group as new foreground - os.tcsetpgrp(sys.stdin.fileno(), child.pid) - # revive the child, - # because it may have been stopped due to SIGTTOU or - # SIGTTIN when it tried using stdout/stdin - # after setpgid was called, and before we made it - # forward process by tcsetpgrp. - os.kill(child.pid, signal.SIGCONT) - - # wait for the child to terminate - ret = await child.wait() - - finally: - # we have to mask SIGTTOU because tcsetpgrp - # raises SIGTTOU to all current background - # process group members (i.e. us) when switching tty's pgrp - # it we didn't do that, we'd get SIGSTOP'd - hdlr = signal.signal(signal.SIGTTOU, signal.SIG_IGN) - # make us tty's foreground again - os.tcsetpgrp(sys.stdin.fileno(), old_pgrp) - # now restore the handler - signal.signal(signal.SIGTTOU, hdlr) - # restore terminal attributes - termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_attr) - - return ret + uuid.UUID(val) + return True + except ValueError: + return False diff --git a/api/openapi.json b/api/openapi.json index 397e3f5b..e69de29b 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1,3807 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "Abrechnung REST-ish API", - "license": { - "name": "AGPL-3.0", - "identifier": "AGPL-3.0" - }, - "version": "0.14.0" - }, - "paths": { - "/api/v1/groups/{group_id}/transactions": { - "get": { - "tags": [ - "transactions" - ], - "summary": "list all transactions in a group", - "operationId": "list_transactions", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - }, - { - "name": "min_last_changed", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Min Last Changed" - } - }, - { - "name": "transaction_ids", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Transaction Ids" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Transaction" - }, - "title": "Response List Transactions" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "transactions" - ], - "summary": "create a new transaction", - "operationId": "create_transaction", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewTransaction" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Transaction" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/{group_id}/transactions/{transaction_id}": { - "get": { - "tags": [ - "transactions" - ], - "summary": "get transaction details", - "operationId": "get_transaction", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - }, - { - "name": "transaction_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Transaction Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Transaction" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "transactions" - ], - "summary": "update transaction details", - "operationId": "update_transaction", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - }, - { - "name": "transaction_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Transaction Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateTransaction" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Transaction" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "transactions" - ], - "summary": "delete a transaction", - "operationId": "delete_transaction", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - }, - { - "name": "transaction_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Transaction Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Transaction" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/{group_id}/transactions/{transaction_id}/positions": { - "post": { - "tags": [ - "transactions" - ], - "summary": "update transaction positions", - "operationId": "update_transaction_positions", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - }, - { - "name": "transaction_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Transaction Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdatePositionsPayload" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Transaction" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/files/{file_id}/{blob_id}": { - "get": { - "tags": [ - "transactions" - ], - "summary": "fetch the (binary) contents of a transaction attachment", - "operationId": "get_file_contents", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "File Id" - } - }, - { - "name": "blob_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Blob Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response" - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/preview": { - "post": { - "tags": [ - "groups" - ], - "summary": "preview a group before joining using an invite token", - "operationId": "preview_group", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PreviewGroupPayload" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GroupPreview" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/join": { - "post": { - "tags": [ - "groups" - ], - "summary": "join a group using an invite token", - "operationId": "join_group", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PreviewGroupPayload" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Group" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - } - }, - "/api/v1/groups": { - "get": { - "tags": [ - "groups" - ], - "summary": "list the current users groups", - "operationId": "list_groups", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/Group" - }, - "type": "array", - "title": "Response List Groups" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - }, - "post": { - "tags": [ - "groups" - ], - "summary": "create a group", - "operationId": "create_group", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GroupPayload" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Group" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - } - }, - "/api/v1/groups/{group_id}": { - "get": { - "tags": [ - "groups" - ], - "summary": "fetch group details", - "operationId": "get_group", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Group" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "groups" - ], - "summary": "update group details", - "operationId": "update_group", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GroupPayload" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Group" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "groups" - ], - "summary": "delete a group", - "operationId": "delete_group", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/{group_id}/leave": { - "post": { - "tags": [ - "groups" - ], - "summary": "leave a group", - "operationId": "leave_group", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/{group_id}/members": { - "get": { - "tags": [ - "groups" - ], - "summary": "list all members of a group", - "operationId": "list_members", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GroupMember" - }, - "title": "Response List Members" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "groups" - ], - "summary": "update the permissions of a group member", - "operationId": "update_member_permissions", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateGroupMemberPayload" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GroupMember" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/{group_id}/logs": { - "get": { - "tags": [ - "groups" - ], - "summary": "fetch the group log", - "operationId": "list_log", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GroupLog" - }, - "title": "Response List Log" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/{group_id}/send_message": { - "post": { - "tags": [ - "groups" - ], - "summary": "post a message to the group log", - "operationId": "send_group_message", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GroupMessage" - } - } - } - }, - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/{group_id}/invites": { - "get": { - "tags": [ - "groups" - ], - "summary": "list all invite links of a group", - "operationId": "list_invites", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GroupInvite" - }, - "title": "Response List Invites" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "groups" - ], - "summary": "create a new group invite link", - "operationId": "create_invite", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateInvitePayload" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GroupInvite" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/{group_id}/invites/{invite_id}": { - "delete": { - "tags": [ - "groups" - ], - "summary": "delete a group invite link", - "operationId": "delete_invite", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - }, - { - "name": "invite_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Invite Id" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/{group_id}/archive": { - "post": { - "tags": [ - "groups" - ], - "summary": "archive a group", - "operationId": "archive_group", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/{group_id}/un-archive": { - "post": { - "tags": [ - "groups" - ], - "summary": "un-archive a group", - "operationId": "unarchive_group", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/auth/token": { - "post": { - "tags": [ - "auth" - ], - "summary": "login with username and password", - "operationId": "get_token", - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Body_get_token" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Token" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/auth/login": { - "post": { - "tags": [ - "auth" - ], - "summary": "login with username and password", - "operationId": "login", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginPayload" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Token" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/auth/logout": { - "post": { - "tags": [ - "auth" - ], - "summary": "sign out of the current session", - "operationId": "logout", - "responses": { - "204": { - "description": "Successful Response" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - } - }, - "/api/v1/auth/register": { - "post": { - "tags": [ - "auth" - ], - "summary": "register a new user", - "operationId": "register", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterPayload" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/auth/confirm_registration": { - "post": { - "tags": [ - "auth" - ], - "summary": "confirm a pending registration", - "operationId": "confirm_registration", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfirmRegistrationPayload" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/profile": { - "get": { - "tags": [ - "auth" - ], - "summary": "fetch user profile information", - "operationId": "get_profile", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - } - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - } - }, - "/api/v1/profile/change_password": { - "post": { - "tags": [ - "auth" - ], - "summary": "change password", - "operationId": "change_password", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordPayload" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - } - }, - "/api/v1/profile/change_email": { - "post": { - "tags": [ - "auth" - ], - "summary": "change email", - "operationId": "change_email", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeEmailPayload" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - } - }, - "/api/v1/auth/confirm_email_change": { - "post": { - "tags": [ - "auth" - ], - "summary": "confirm a pending email change", - "operationId": "confirm_email_change", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfirmEmailChangePayload" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/auth/recover_password": { - "post": { - "tags": [ - "auth" - ], - "summary": "recover password", - "operationId": "recover_password", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecoverPasswordPayload" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/auth/confirm_password_recovery": { - "post": { - "tags": [ - "auth" - ], - "summary": "confirm a pending password recovery", - "operationId": "confirm_password_recovery", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfirmPasswordRecoveryPayload" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/auth/delete_session": { - "post": { - "tags": [ - "auth" - ], - "summary": "delete a given user session", - "operationId": "delete_session", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteSessionPayload" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - } - }, - "/api/v1/auth/rename_session": { - "post": { - "tags": [ - "auth" - ], - "summary": "rename a given user session", - "operationId": "rename_session", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RenameSessionPayload" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - } - }, - "/api/v1/groups/{group_id}/accounts": { - "get": { - "tags": [ - "accounts" - ], - "summary": "list all accounts in a group", - "operationId": "list_accounts", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ClearingAccount" - }, - { - "$ref": "#/components/schemas/PersonalAccount" - } - ] - }, - "title": "Response List Accounts" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "accounts" - ], - "summary": "create a new group account", - "operationId": "create_account", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewAccount" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/ClearingAccount" - }, - { - "$ref": "#/components/schemas/PersonalAccount" - } - ], - "title": "Response Create Account" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/groups/{group_id}/accounts/{account_id}": { - "get": { - "tags": [ - "accounts" - ], - "summary": "fetch a group account", - "operationId": "get_account", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - }, - { - "name": "account_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Account Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/ClearingAccount" - }, - { - "$ref": "#/components/schemas/PersonalAccount" - } - ], - "title": "Response Get Account" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "accounts" - ], - "summary": "update an account", - "operationId": "update_account", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - }, - { - "name": "account_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Account Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewAccount" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/ClearingAccount" - }, - { - "$ref": "#/components/schemas/PersonalAccount" - } - ], - "title": "Response Update Account" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "accounts" - ], - "summary": "delete an account", - "operationId": "delete_account", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "group_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Group Id" - } - }, - { - "name": "account_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Account Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/ClearingAccount" - }, - { - "$ref": "#/components/schemas/PersonalAccount" - } - ], - "title": "Response Delete Account" - } - } - } - }, - "401": { - "description": "unauthorized" - }, - "403": { - "description": "forbidden" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/version": { - "get": { - "tags": [ - "common" - ], - "summary": "Get Version", - "operationId": "get_version", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VersionResponse" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "AccountType": { - "type": "string", - "enum": [ - "personal", - "clearing" - ], - "title": "AccountType" - }, - "Body_get_token": { - "properties": { - "grant_type": { - "anyOf": [ - { - "type": "string", - "pattern": "password" - }, - { - "type": "null" - } - ], - "title": "Grant Type" - }, - "username": { - "type": "string", - "title": "Username" - }, - "password": { - "type": "string", - "title": "Password" - }, - "scope": { - "type": "string", - "title": "Scope", - "default": "" - }, - "client_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Client Id" - }, - "client_secret": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Client Secret" - } - }, - "type": "object", - "required": [ - "username", - "password" - ], - "title": "Body_get_token" - }, - "ChangeEmailPayload": { - "properties": { - "email": { - "type": "string", - "format": "email", - "title": "Email" - }, - "password": { - "type": "string", - "title": "Password" - } - }, - "type": "object", - "required": [ - "email", - "password" - ], - "title": "ChangeEmailPayload" - }, - "ChangePasswordPayload": { - "properties": { - "new_password": { - "type": "string", - "title": "New Password" - }, - "old_password": { - "type": "string", - "title": "Old Password" - } - }, - "type": "object", - "required": [ - "new_password", - "old_password" - ], - "title": "ChangePasswordPayload" - }, - "ClearingAccount": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "group_id": { - "type": "integer", - "title": "Group Id" - }, - "type": { - "const": "clearing", - "title": "Type" - }, - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description" - }, - "date_info": { - "type": "string", - "format": "date", - "title": "Date Info" - }, - "tags": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Tags" - }, - "clearing_shares": { - "additionalProperties": { - "type": "number" - }, - "type": "object", - "title": "Clearing Shares" - }, - "last_changed": { - "type": "string", - "format": "date-time", - "title": "Last Changed" - }, - "deleted": { - "type": "boolean", - "title": "Deleted" - } - }, - "type": "object", - "required": [ - "id", - "group_id", - "type", - "name", - "description", - "date_info", - "tags", - "clearing_shares", - "last_changed", - "deleted" - ], - "title": "ClearingAccount" - }, - "ConfirmEmailChangePayload": { - "properties": { - "token": { - "type": "string", - "title": "Token" - } - }, - "type": "object", - "required": [ - "token" - ], - "title": "ConfirmEmailChangePayload" - }, - "ConfirmPasswordRecoveryPayload": { - "properties": { - "token": { - "type": "string", - "title": "Token" - }, - "new_password": { - "type": "string", - "title": "New Password" - } - }, - "type": "object", - "required": [ - "token", - "new_password" - ], - "title": "ConfirmPasswordRecoveryPayload" - }, - "ConfirmRegistrationPayload": { - "properties": { - "token": { - "type": "string", - "title": "Token" - } - }, - "type": "object", - "required": [ - "token" - ], - "title": "ConfirmRegistrationPayload" - }, - "CreateInvitePayload": { - "properties": { - "description": { - "type": "string", - "title": "Description" - }, - "single_use": { - "type": "boolean", - "title": "Single Use" - }, - "join_as_editor": { - "type": "boolean", - "title": "Join As Editor" - }, - "valid_until": { - "type": "string", - "format": "date-time", - "title": "Valid Until" - } - }, - "type": "object", - "required": [ - "description", - "single_use", - "join_as_editor", - "valid_until" - ], - "title": "CreateInvitePayload" - }, - "DeleteSessionPayload": { - "properties": { - "session_id": { - "type": "integer", - "title": "Session Id" - } - }, - "type": "object", - "required": [ - "session_id" - ], - "title": "DeleteSessionPayload" - }, - "FileAttachment": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "filename": { - "type": "string", - "title": "Filename" - }, - "blob_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Blob Id" - }, - "mime_type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Mime Type" - }, - "host_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Host Url" - }, - "deleted": { - "type": "boolean", - "title": "Deleted" - } - }, - "type": "object", - "required": [ - "id", - "filename", - "blob_id", - "mime_type", - "deleted" - ], - "title": "FileAttachment" - }, - "Group": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description" - }, - "currency_symbol": { - "type": "string", - "title": "Currency Symbol" - }, - "terms": { - "type": "string", - "title": "Terms" - }, - "add_user_account_on_join": { - "type": "boolean", - "title": "Add User Account On Join" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "created_by": { - "type": "integer", - "title": "Created By" - }, - "last_changed": { - "type": "string", - "format": "date-time", - "title": "Last Changed" - }, - "archived": { - "type": "boolean", - "title": "Archived" - } - }, - "type": "object", - "required": [ - "id", - "name", - "description", - "currency_symbol", - "terms", - "add_user_account_on_join", - "created_at", - "created_by", - "last_changed", - "archived" - ], - "title": "Group" - }, - "GroupInvite": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "created_by": { - "type": "integer", - "title": "Created By" - }, - "token": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Token" - }, - "single_use": { - "type": "boolean", - "title": "Single Use" - }, - "join_as_editor": { - "type": "boolean", - "title": "Join As Editor" - }, - "description": { - "type": "string", - "title": "Description" - }, - "valid_until": { - "type": "string", - "format": "date-time", - "title": "Valid Until" - } - }, - "type": "object", - "required": [ - "id", - "created_by", - "token", - "single_use", - "join_as_editor", - "description", - "valid_until" - ], - "title": "GroupInvite" - }, - "GroupLog": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "user_id": { - "type": "integer", - "title": "User Id" - }, - "logged_at": { - "type": "string", - "format": "date-time", - "title": "Logged At" - }, - "type": { - "type": "string", - "title": "Type" - }, - "message": { - "type": "string", - "title": "Message" - }, - "affected": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Affected" - } - }, - "type": "object", - "required": [ - "id", - "user_id", - "logged_at", - "type", - "message", - "affected" - ], - "title": "GroupLog" - }, - "GroupMember": { - "properties": { - "user_id": { - "type": "integer", - "title": "User Id" - }, - "username": { - "type": "string", - "title": "Username" - }, - "is_owner": { - "type": "boolean", - "title": "Is Owner" - }, - "can_write": { - "type": "boolean", - "title": "Can Write" - }, - "description": { - "type": "string", - "title": "Description" - }, - "joined_at": { - "type": "string", - "format": "date-time", - "title": "Joined At" - }, - "invited_by": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Invited By" - } - }, - "type": "object", - "required": [ - "user_id", - "username", - "is_owner", - "can_write", - "description", - "joined_at", - "invited_by" - ], - "title": "GroupMember" - }, - "GroupMessage": { - "properties": { - "message": { - "type": "string", - "title": "Message" - } - }, - "type": "object", - "required": [ - "message" - ], - "title": "GroupMessage" - }, - "GroupPayload": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description", - "default": "" - }, - "currency_symbol": { - "type": "string", - "title": "Currency Symbol" - }, - "add_user_account_on_join": { - "type": "boolean", - "title": "Add User Account On Join", - "default": false - }, - "terms": { - "type": "string", - "title": "Terms", - "default": "" - } - }, - "type": "object", - "required": [ - "name", - "currency_symbol" - ], - "title": "GroupPayload" - }, - "GroupPreview": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description" - }, - "currency_symbol": { - "type": "string", - "title": "Currency Symbol" - }, - "terms": { - "type": "string", - "title": "Terms" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "invite_single_use": { - "type": "boolean", - "title": "Invite Single Use" - }, - "invite_valid_until": { - "type": "string", - "format": "date-time", - "title": "Invite Valid Until" - }, - "invite_description": { - "type": "string", - "title": "Invite Description" - } - }, - "type": "object", - "required": [ - "id", - "name", - "description", - "currency_symbol", - "terms", - "created_at", - "invite_single_use", - "invite_valid_until", - "invite_description" - ], - "title": "GroupPreview" - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "type": "array", - "title": "Detail" - } - }, - "type": "object", - "title": "HTTPValidationError" - }, - "LoginPayload": { - "properties": { - "username": { - "type": "string", - "title": "Username" - }, - "password": { - "type": "string", - "title": "Password" - }, - "session_name": { - "type": "string", - "title": "Session Name" - } - }, - "type": "object", - "required": [ - "username", - "password", - "session_name" - ], - "title": "LoginPayload" - }, - "NewAccount": { - "properties": { - "type": { - "$ref": "#/components/schemas/AccountType" - }, - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description", - "default": "" - }, - "owning_user_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Owning User Id" - }, - "date_info": { - "anyOf": [ - { - "type": "string", - "format": "date" - }, - { - "type": "null" - } - ], - "title": "Date Info" - }, - "deleted": { - "type": "boolean", - "title": "Deleted", - "default": false - }, - "tags": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Tags", - "default": [] - }, - "clearing_shares": { - "additionalProperties": { - "type": "number" - }, - "type": "object", - "title": "Clearing Shares", - "default": {} - } - }, - "type": "object", - "required": [ - "type", - "name" - ], - "title": "NewAccount" - }, - "NewFile": { - "properties": { - "filename": { - "type": "string", - "title": "Filename" - }, - "mime_type": { - "type": "string", - "title": "Mime Type" - }, - "content": { - "type": "string", - "title": "Content" - } - }, - "type": "object", - "required": [ - "filename", - "mime_type", - "content" - ], - "title": "NewFile" - }, - "NewTransaction": { - "properties": { - "type": { - "$ref": "#/components/schemas/TransactionType" - }, - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description" - }, - "value": { - "type": "number", - "title": "Value" - }, - "currency_symbol": { - "type": "string", - "title": "Currency Symbol" - }, - "currency_conversion_rate": { - "type": "number", - "title": "Currency Conversion Rate" - }, - "billed_at": { - "type": "string", - "format": "date", - "title": "Billed At" - }, - "tags": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Tags", - "default": [] - }, - "creditor_shares": { - "additionalProperties": { - "type": "number" - }, - "type": "object", - "title": "Creditor Shares" - }, - "debitor_shares": { - "additionalProperties": { - "type": "number" - }, - "type": "object", - "title": "Debitor Shares" - }, - "new_files": { - "items": { - "$ref": "#/components/schemas/NewFile" - }, - "type": "array", - "title": "New Files", - "default": [] - }, - "new_positions": { - "items": { - "$ref": "#/components/schemas/NewTransactionPosition" - }, - "type": "array", - "title": "New Positions", - "default": [] - } - }, - "type": "object", - "required": [ - "type", - "name", - "description", - "value", - "currency_symbol", - "currency_conversion_rate", - "billed_at", - "creditor_shares", - "debitor_shares" - ], - "title": "NewTransaction" - }, - "NewTransactionPosition": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "price": { - "type": "number", - "title": "Price" - }, - "communist_shares": { - "type": "number", - "title": "Communist Shares" - }, - "usages": { - "additionalProperties": { - "type": "number" - }, - "type": "object", - "title": "Usages" - } - }, - "type": "object", - "required": [ - "name", - "price", - "communist_shares", - "usages" - ], - "title": "NewTransactionPosition" - }, - "PersonalAccount": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "group_id": { - "type": "integer", - "title": "Group Id" - }, - "type": { - "const": "personal", - "title": "Type" - }, - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description" - }, - "owning_user_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Owning User Id" - }, - "deleted": { - "type": "boolean", - "title": "Deleted" - }, - "last_changed": { - "type": "string", - "format": "date-time", - "title": "Last Changed" - } - }, - "type": "object", - "required": [ - "id", - "group_id", - "type", - "name", - "description", - "owning_user_id", - "deleted", - "last_changed" - ], - "title": "PersonalAccount" - }, - "PreviewGroupPayload": { - "properties": { - "invite_token": { - "type": "string", - "title": "Invite Token" - } - }, - "type": "object", - "required": [ - "invite_token" - ], - "title": "PreviewGroupPayload" - }, - "RecoverPasswordPayload": { - "properties": { - "email": { - "type": "string", - "title": "Email" - } - }, - "type": "object", - "required": [ - "email" - ], - "title": "RecoverPasswordPayload" - }, - "RegisterPayload": { - "properties": { - "username": { - "type": "string", - "title": "Username" - }, - "password": { - "type": "string", - "title": "Password" - }, - "email": { - "type": "string", - "format": "email", - "title": "Email" - }, - "invite_token": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Invite Token" - } - }, - "type": "object", - "required": [ - "username", - "password", - "email" - ], - "title": "RegisterPayload" - }, - "RegisterResponse": { - "properties": { - "user_id": { - "type": "integer", - "title": "User Id" - } - }, - "type": "object", - "required": [ - "user_id" - ], - "title": "RegisterResponse" - }, - "RenameSessionPayload": { - "properties": { - "session_id": { - "type": "integer", - "title": "Session Id" - }, - "name": { - "type": "string", - "title": "Name" - } - }, - "type": "object", - "required": [ - "session_id", - "name" - ], - "title": "RenameSessionPayload" - }, - "Session": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "valid_until": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Valid Until" - }, - "last_seen": { - "type": "string", - "format": "date-time", - "title": "Last Seen" - } - }, - "type": "object", - "required": [ - "id", - "name", - "valid_until", - "last_seen" - ], - "title": "Session" - }, - "Token": { - "properties": { - "user_id": { - "type": "integer", - "title": "User Id" - }, - "access_token": { - "type": "string", - "title": "Access Token" - } - }, - "type": "object", - "required": [ - "user_id", - "access_token" - ], - "title": "Token" - }, - "Transaction": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "group_id": { - "type": "integer", - "title": "Group Id" - }, - "type": { - "$ref": "#/components/schemas/TransactionType" - }, - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description" - }, - "value": { - "type": "number", - "title": "Value" - }, - "currency_symbol": { - "type": "string", - "title": "Currency Symbol" - }, - "currency_conversion_rate": { - "type": "number", - "title": "Currency Conversion Rate" - }, - "billed_at": { - "type": "string", - "format": "date", - "title": "Billed At" - }, - "tags": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Tags" - }, - "deleted": { - "type": "boolean", - "title": "Deleted" - }, - "creditor_shares": { - "additionalProperties": { - "type": "number" - }, - "type": "object", - "title": "Creditor Shares" - }, - "debitor_shares": { - "additionalProperties": { - "type": "number" - }, - "type": "object", - "title": "Debitor Shares" - }, - "last_changed": { - "type": "string", - "format": "date-time", - "title": "Last Changed" - }, - "positions": { - "items": { - "$ref": "#/components/schemas/TransactionPosition" - }, - "type": "array", - "title": "Positions" - }, - "files": { - "items": { - "$ref": "#/components/schemas/FileAttachment" - }, - "type": "array", - "title": "Files" - } - }, - "type": "object", - "required": [ - "id", - "group_id", - "type", - "name", - "description", - "value", - "currency_symbol", - "currency_conversion_rate", - "billed_at", - "tags", - "deleted", - "creditor_shares", - "debitor_shares", - "last_changed", - "positions", - "files" - ], - "title": "Transaction" - }, - "TransactionPosition": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "price": { - "type": "number", - "title": "Price" - }, - "communist_shares": { - "type": "number", - "title": "Communist Shares" - }, - "usages": { - "additionalProperties": { - "type": "number" - }, - "type": "object", - "title": "Usages" - }, - "id": { - "type": "integer", - "title": "Id" - }, - "deleted": { - "type": "boolean", - "title": "Deleted" - } - }, - "type": "object", - "required": [ - "name", - "price", - "communist_shares", - "usages", - "id", - "deleted" - ], - "title": "TransactionPosition" - }, - "TransactionType": { - "type": "string", - "enum": [ - "mimo", - "purchase", - "transfer" - ], - "title": "TransactionType" - }, - "UpdateFile": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "filename": { - "type": "string", - "title": "Filename" - }, - "deleted": { - "type": "boolean", - "title": "Deleted" - } - }, - "type": "object", - "required": [ - "id", - "filename", - "deleted" - ], - "title": "UpdateFile" - }, - "UpdateGroupMemberPayload": { - "properties": { - "user_id": { - "type": "integer", - "title": "User Id" - }, - "can_write": { - "type": "boolean", - "title": "Can Write" - }, - "is_owner": { - "type": "boolean", - "title": "Is Owner" - } - }, - "type": "object", - "required": [ - "user_id", - "can_write", - "is_owner" - ], - "title": "UpdateGroupMemberPayload" - }, - "UpdatePositionsPayload": { - "properties": { - "positions": { - "items": { - "$ref": "#/components/schemas/TransactionPosition" - }, - "type": "array", - "title": "Positions" - } - }, - "type": "object", - "required": [ - "positions" - ], - "title": "UpdatePositionsPayload" - }, - "UpdateTransaction": { - "properties": { - "type": { - "$ref": "#/components/schemas/TransactionType" - }, - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description" - }, - "value": { - "type": "number", - "title": "Value" - }, - "currency_symbol": { - "type": "string", - "title": "Currency Symbol" - }, - "currency_conversion_rate": { - "type": "number", - "title": "Currency Conversion Rate" - }, - "billed_at": { - "type": "string", - "format": "date", - "title": "Billed At" - }, - "tags": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Tags", - "default": [] - }, - "creditor_shares": { - "additionalProperties": { - "type": "number" - }, - "type": "object", - "title": "Creditor Shares" - }, - "debitor_shares": { - "additionalProperties": { - "type": "number" - }, - "type": "object", - "title": "Debitor Shares" - }, - "new_files": { - "items": { - "$ref": "#/components/schemas/NewFile" - }, - "type": "array", - "title": "New Files", - "default": [] - }, - "new_positions": { - "items": { - "$ref": "#/components/schemas/NewTransactionPosition" - }, - "type": "array", - "title": "New Positions", - "default": [] - }, - "changed_files": { - "items": { - "$ref": "#/components/schemas/UpdateFile" - }, - "type": "array", - "title": "Changed Files", - "default": [] - }, - "changed_positions": { - "items": { - "$ref": "#/components/schemas/TransactionPosition" - }, - "type": "array", - "title": "Changed Positions", - "default": [] - } - }, - "type": "object", - "required": [ - "type", - "name", - "description", - "value", - "currency_symbol", - "currency_conversion_rate", - "billed_at", - "creditor_shares", - "debitor_shares" - ], - "title": "UpdateTransaction" - }, - "User": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "username": { - "type": "string", - "title": "Username" - }, - "email": { - "type": "string", - "title": "Email" - }, - "registered_at": { - "type": "string", - "format": "date-time", - "title": "Registered At" - }, - "deleted": { - "type": "boolean", - "title": "Deleted" - }, - "pending": { - "type": "boolean", - "title": "Pending" - }, - "sessions": { - "items": { - "$ref": "#/components/schemas/Session" - }, - "type": "array", - "title": "Sessions" - }, - "is_guest_user": { - "type": "boolean", - "title": "Is Guest User" - } - }, - "type": "object", - "required": [ - "id", - "username", - "email", - "registered_at", - "deleted", - "pending", - "sessions", - "is_guest_user" - ], - "title": "User" - }, - "ValidationError": { - "properties": { - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "type": "array", - "title": "Location" - }, - "msg": { - "type": "string", - "title": "Message" - }, - "type": { - "type": "string", - "title": "Error Type" - } - }, - "type": "object", - "required": [ - "loc", - "msg", - "type" - ], - "title": "ValidationError" - }, - "VersionResponse": { - "properties": { - "version": { - "type": "string", - "title": "Version" - }, - "major_version": { - "type": "integer", - "title": "Major Version" - }, - "minor_version": { - "type": "integer", - "title": "Minor Version" - }, - "patch_version": { - "type": "integer", - "title": "Patch Version" - } - }, - "type": "object", - "required": [ - "version", - "major_version", - "minor_version", - "patch_version" - ], - "title": "VersionResponse", - "example": { - "major_version": 1, - "minor_version": 3, - "patch_version": 2, - "version": "1.3.2" - } - } - }, - "securitySchemes": { - "OAuth2PasswordBearer": { - "type": "oauth2", - "flows": { - "password": { - "scopes": {}, - "tokenUrl": "api/v1/auth/token" - } - } - } - } - } -} diff --git a/docker/abrechnung.yaml b/docker/abrechnung.yaml index f6fd8c2f..95ea1d62 100644 --- a/docker/abrechnung.yaml +++ b/docker/abrechnung.yaml @@ -1,6 +1,5 @@ service: url: "https://localhost" - api_url: "https://localhost/api" name: "Abrechnung" database: @@ -10,6 +9,7 @@ database: api: host: "0.0.0.0" + base_url: "https://localhost" port: 8080 id: default diff --git a/docs/usage/configuration.rst b/docs/usage/configuration.rst index f1f1b91f..9a611450 100644 --- a/docs/usage/configuration.rst +++ b/docs/usage/configuration.rst @@ -37,37 +37,35 @@ Apply all database migrations with :: General Options --------------- -Some options need to be configured globally such as the base https endpoints (api and web ui) to be used in mail -delivery and proper api resource URLs. If ``abrechnung.example.lol`` is your domain adjust the ``service`` section as follows. -In case your change your API endpoint to another domain, port or subpath make sure to also change it here. .. code-block:: yaml service: - url: "https://abrechnung.example.lol" - api_url: "https://abrechnung.example.lol/api" name: "Abrechnung" The ``name`` is used to populate the email subjects as ``[] ``. API Config --------------- -Typically the config for the http API does not need to be changed much apart from one critical setting! +Typically the config for the http API does not need to be changed much apart from two important settings! In the ``api`` section make sure to insert a newly generated secret key, e.g. with :: pwgen -S 64 1 -The config will then look like +Additionally you need to configure your base https endpoints to be used in mail +delivery and proper api resource URLs. If ``abrechnung.example.lol`` is your domain adjust the ``api`` section as follows. .. code-block:: yaml api: + id: "default" secret_key: "" host: "localhost" port: 8080 - id: default + # base url is given by the domain the abrechnung instance is hosted at + base_url: "https://abrechnung.example.lol" -In most cases there is no need to adjust either the ``host``, ``port`` or ``id`` options. +In most cases there is no need to adjust the ``host``, ``port`` or ``id`` options. E-Mail Delivery --------------- diff --git a/frontend/libs/types/src/lib/transactions.ts b/frontend/libs/types/src/lib/transactions.ts index e68ebaf3..40d973c9 100644 --- a/frontend/libs/types/src/lib/transactions.ts +++ b/frontend/libs/types/src/lib/transactions.ts @@ -29,7 +29,7 @@ export const PurchaseValidator = z .object({ creditor_shares: z .record(z.number()) - .refine((shares) => Object.keys(shares).length !== 1, "somebody has payed for this"), + .refine((shares) => Object.keys(shares).length === 1, "somebody has payed for this"), debitor_shares: z.record(z.number()).refine((shares) => Object.keys(shares).length > 0, "select at least one"), }) .merge(BaseTransactionValidator) diff --git a/pyproject.toml b/pyproject.toml index 41c2f751..b625ce23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,13 +19,10 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "typer~=0.15.1", - "fastapi==0.115.6", + "sftkit==0.3.0", "pydantic[email]~=2.10.3", "pydantic-settings==2.6.1", - "uvicorn[standard]~=0.32.1", "python-jose[cryptography]~=3.3.0", - "asyncpg~=0.30.0", "passlib[bcrypt]~=1.7.0", "websockets~=14.1", "python-multipart~=0.0.19", @@ -114,3 +111,7 @@ files = [ { filename = "CHANGELOG.md", search = "Unreleased", replace = "{new_version} ({now:%Y-%m-%d})"}, { filename = "CHANGELOG.md", search = "v{current_version}...HEAD", replace = "v{current_version}...v{new_version}"}, ] + +[tool.sftkit] +db_code_dir = "abrechnung/database/code" +db_migrations_dir = "abrechnung/database/revisions" \ No newline at end of file diff --git a/tests/common.py b/tests/common.py index 9131efdf..6e33412e 100644 --- a/tests/common.py +++ b/tests/common.py @@ -6,6 +6,7 @@ import asyncpg from asyncpg.pool import Pool +from sftkit.database import DatabaseConfig from abrechnung.application.users import UserService from abrechnung.config import ( @@ -15,20 +16,20 @@ RegistrationConfig, ServiceConfig, ) -from abrechnung.database.migrations import apply_revisions, reset_schema +from abrechnung.database.migrations import get_database, reset_schema from abrechnung.domain.users import User -from abrechnung.framework.database import DatabaseConfig, create_db_pool lock = asyncio.Lock() def get_test_db_config() -> DatabaseConfig: return DatabaseConfig( - user=os.environ.get("TEST_DB_USER", "abrechnung-test"), - password=os.environ.get("TEST_DB_PASSWORD", "asdf1234"), - host=os.environ.get("TEST_DB_HOST", "localhost"), - dbname=os.environ.get("TEST_DB_DATABASE", "abrechnung-test"), + user=os.environ.get("TEST_DB_USER"), + password=os.environ.get("TEST_DB_PASSWORD"), + host=os.environ.get("TEST_DB_HOST"), + dbname=os.environ.get("TEST_DB_DATABASE", "abrechnung_test"), port=int(os.environ.get("TEST_DB_PORT", 5432)), + sslrootcert=None, ) @@ -42,13 +43,13 @@ def get_test_db_config() -> DatabaseConfig: secret_key="asdf", host="localhost", port=8000, + base_url="https://abrechnung.example.lol", ), registration=RegistrationConfig(enabled=True), database=get_test_db_config(), service=ServiceConfig( - url="https://abrechnung.example.lol", - api_url="https://abrechnung.example.lol", name="Test Abrechnung", + url="https://abrechnung.example.lol", ), ) @@ -57,10 +58,11 @@ async def get_test_db() -> Pool: """ get a connection pool to the test database """ - pool = await create_db_pool(TEST_CONFIG.database) + database = get_database(TEST_CONFIG.database) + pool = await database.create_pool() await reset_schema(pool) - await apply_revisions(pool) + await database.apply_migrations() return pool diff --git a/tests/http_tests/common.py b/tests/http_tests/common.py index 6db1d08c..694666af 100644 --- a/tests/http_tests/common.py +++ b/tests/http_tests/common.py @@ -1,5 +1,6 @@ # pylint: disable=attribute-defined-outside-init from httpx import ASGITransport, AsyncClient +from sftkit.http._context import ContextMiddleware from abrechnung.http.api import Api from tests.common import TEST_CONFIG, BaseTestCase @@ -12,7 +13,13 @@ async def asyncSetUp(self) -> None: self.http_service = Api(config=self.test_config) await self.http_service._setup() - self.transport = ASGITransport(app=self.http_service.api) + # workaround for bad testability in sftkit + self.http_service.server.api.add_middleware( + ContextMiddleware, + context=self.http_service.context, + ) + + self.transport = ASGITransport(app=self.http_service.server.api) self.client = AsyncClient(transport=self.transport, base_url="https://abrechnung.sft.lol") self.transaction_service = self.http_service.transaction_service self.account_service = self.http_service.account_service diff --git a/tests/http_tests/test_auth.py b/tests/http_tests/test_auth.py index 92c54ffa..62b9c6df 100644 --- a/tests/http_tests/test_auth.py +++ b/tests/http_tests/test_auth.py @@ -175,7 +175,7 @@ async def test_reset_password(self): f"/api/v1/auth/recover_password", json={"email": "fooo@stusta.de"}, ) - self.assertEqual(403, resp.status_code) + self.assertEqual(400, resp.status_code) resp = await self.client.post( f"/api/v1/auth/recover_password", diff --git a/tests/http_tests/test_groups.py b/tests/http_tests/test_groups.py index 941e4349..5a719b7a 100644 --- a/tests/http_tests/test_groups.py +++ b/tests/http_tests/test_groups.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from abrechnung.domain.accounts import AccountType, NewAccount from abrechnung.domain.transactions import NewTransaction, TransactionType @@ -51,7 +51,7 @@ async def test_create_group(self): group = await self._fetch_group(group_id) self.assertEqual("name", group["name"]) - await self._fetch_group(13333, 404) + await self._fetch_group(13333, 400) resp = await self._post( f"/api/v1/groups/{group_id}", @@ -128,12 +128,12 @@ async def test_delete_group(self): ) resp = await self._delete(f"/api/v1/groups/{group_id}") - self.assertEqual(403, resp.status_code) + self.assertEqual(400, resp.status_code) resp = await self._post(f"/api/v1/groups/{group_id}/leave") self.assertEqual(204, resp.status_code) - await self._fetch_group(group_id, expected_status=404) + await self._fetch_group(group_id, expected_status=400) resp = await self.client.delete( f"/api/v1/groups/{group_id}", @@ -345,7 +345,7 @@ async def test_get_account(self): self.assertEqual(422, resp.status_code) resp = await self._get(f"/api/v1/groups/{group_id}/accounts/13232") - self.assertEqual(404, resp.status_code) + self.assertEqual(400, resp.status_code) async def test_invites(self): group_id = await self.group_service.create_group( diff --git a/tests/http_tests/test_websocket.py b/tests/http_tests/test_websocket.py index 91b98cb7..1b60e108 100644 --- a/tests/http_tests/test_websocket.py +++ b/tests/http_tests/test_websocket.py @@ -21,7 +21,7 @@ async def asyncSetUp(self) -> None: self.http_service = Api(config=self.test_config) await self.http_service._setup() - self.client = TestClient(self.http_service.api) + self.client = TestClient(self.http_service.server.api) self.transaction_service = self.http_service.transaction_service self.account_service = self.http_service.account_service self.group_service = self.http_service.group_service diff --git a/tools/create_revision.py b/tools/create_revision.py deleted file mode 100755 index cac330eb..00000000 --- a/tools/create_revision.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import os - -from abrechnung.database.migrations import REVISION_PATH -from abrechnung.framework.database import SchemaRevision - - -def main(name: str): - revisions = SchemaRevision.revisions_from_dir(REVISION_PATH) - filename = f"{str(len(revisions) + 1).zfill(4)}_{name}.sql" - new_revision_version = os.urandom(4).hex() - file_path = REVISION_PATH / filename - with file_path.open("w+") as f: - f.write(f"-- revision: {new_revision_version}\n" f"-- requires: {revisions[-1].version}\n") - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(prog="DB utilty", description="Utility to create new database revision files") - parser.add_argument("name", type=str) - args = parser.parse_args() - main(args.name) diff --git a/tools/generate_dummy_data.py b/tools/generate_dummy_data.py index d5fcd7a3..683aaf2f 100755 --- a/tools/generate_dummy_data.py +++ b/tools/generate_dummy_data.py @@ -10,13 +10,13 @@ from abrechnung.application.transactions import TransactionService from abrechnung.application.users import UserService from abrechnung.config import read_config +from abrechnung.database.migrations import get_database from abrechnung.domain.accounts import AccountType, NewAccount from abrechnung.domain.transactions import ( NewTransaction, NewTransactionPosition, TransactionType, ) -from abrechnung.framework.database import create_db_pool def random_date() -> date: @@ -35,7 +35,8 @@ async def main( ): config = read_config(Path(config_path)) - db_pool = await create_db_pool(config.database) + database = get_database(config.database) + db_pool = await database.create_pool() user_service = UserService(db_pool, config) group_service = GroupService(db_pool, config) account_service = AccountService(db_pool, config)