diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 8961b2d..670d4f9 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -13,7 +13,7 @@ jobs: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v3.0.0 + uses: dependabot/fetch-metadata@v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.gitignore b/.gitignore index 5aabd7e..efd50c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /.idea /.vscode .DS_Store +backend/.env.local diff --git a/CHANGELOG.md b/CHANGELOG.md index 763d6b5..a14eff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## 3.0.0-alpha.11 - 2026-04-16 + +### Added + +- **Initial user bootstrap** — Script and docs to bootstrap an initial user in the DB; tests and refactors around that flow (merged via [#111](https://github.com/kilobyteno/LANMS/pull/111) — adjust org/repo if your remote differs). + +### Changed + +- **Documentation** — Centralized developer docs under `docs/` (`docs/README.md`, `docs/backend/README.md`, `docs/frontend/README.md`); moved `LEGACY-CODEBASE.md` to `docs/legacy/`; root and app READMEs now point at `docs/` instead of long inline guides. +- **Backend docs** — Environment variables documented; docs updated for env, tests, and bootstrap. +- **CI / release** — Release workflow: add **uv** (`astral-sh/setup-uv@v7`) and run `uv lock` in the backend so `uv.lock` is refreshed during release; ensures uv is available before Node setup. +- **Dependencies** — `uv.lock` updated. + +### Fixed + +- **Uvicorn** — Replace deprecated `uvicorn_log_config` with `log_config`. +- **Build** — Build script runs steps **sequentially** (fix for parallel/ordering issues). +- Minor typo fix. + ## 3.0.0-alpha.10 - 2026-04-14 ### Features & product diff --git a/backend/.env.example b/backend/.env.example index e570952..0f04b77 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,7 +1,7 @@ ENV=local JWT_PUBLIC_KEY="" JWT_PRIVATE_KEY="" -OTP_SECRET_KEY=NNUWY33CPF2GKLTON4====== +OTP_SECRET_KEY= PORTAL_URL=http://localhost:3000 # Optional variables @@ -20,7 +20,7 @@ JWT_ALGORITHM=RS256 ACCESS_TOKEN_EXPIRE_MINUTES=60 REFRESH_TOKEN_EXPIRE_MINUTES=43200 -FROM_EMAIL=hello@lanms.net +FROM_EMAIL= SENTRY_DSN= SENDGRID_API_KEY= POSTMARK_API_KEY= diff --git a/backend/.gitignore b/backend/.gitignore index 7ec1834..52c9c3d 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -163,3 +163,4 @@ cython_debug/ .ruff_cache .run/* requirements-audit.txt +logs/ \ No newline at end of file diff --git a/backend/.version b/backend/.version index 5b29944..8018e77 100644 --- a/backend/.version +++ b/backend/.version @@ -1 +1 @@ -3.0.0-alpha.10 +3.0.0-alpha.11 diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 53129de..79a42c5 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,11 +1,11 @@ -import importlib from logging.config import fileConfig from sqlalchemy import engine_from_config, pool from alembic import context -# from app.models.other_models import Base +import app.models # noqa: F401 - register all ORM models on Base.metadata +from app.models.base import Base from config import Config # this is the Alembic Config object, which provides @@ -17,15 +17,8 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) -# add your model's MetaData object here -# for 'autogenerate' support -# target_metadata = None -# target_metadata = Base.metadata -models_package = importlib.import_module('app.models') -models = [getattr(models_package, name) for name in dir(models_package) if not name.startswith('__')] - -# Assuming your models use Base.metadata -target_metadata = [model.Base.metadata for model in models] +# Single declarative Base from app.models.base; model modules subclass BaseModel on this Base. +target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: diff --git a/backend/alembic/versions/3c54be1b70e2_add_article.py b/backend/alembic/versions/3c54be1b70e2_add_article.py deleted file mode 100644 index c4df9b2..0000000 --- a/backend/alembic/versions/3c54be1b70e2_add_article.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Add article - -Revision ID: 3c54be1b70e2 -Revises: 9ef2bf852502 -Create Date: 2024-12-29 16:58:40.321861 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '3c54be1b70e2' -down_revision: Union[str, None] = '9ef2bf852502' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('articles', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('title', sa.String(length=255), nullable=False), - sa.Column('slug', sa.String(length=255), nullable=False), - sa.Column('content', sa.Text(), nullable=False), - sa.Column('event_id', sa.UUID(), nullable=False), - sa.Column('created_by_id', sa.UUID(), nullable=False), - sa.Column('published_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), - sa.ForeignKeyConstraint(['event_id'], ['events.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('id') - ) - op.create_index(op.f('ix_articles_slug'), 'articles', ['slug'], unique=False) - op.create_index(op.f('ix_articles_title'), 'articles', ['title'], unique=False) - op.create_unique_constraint(None, 'event_interests', ['id']) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'event_interests', type_='unique') - op.drop_index(op.f('ix_articles_title'), table_name='articles') - op.drop_index(op.f('ix_articles_slug'), table_name='articles') - op.drop_table('articles') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/615c51060ea1_add_organisations.py b/backend/alembic/versions/615c51060ea1_add_organisations.py deleted file mode 100644 index 4a6b215..0000000 --- a/backend/alembic/versions/615c51060ea1_add_organisations.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Add organisations - -Revision ID: 615c51060ea1 -Revises: dd25f52e9279 -Create Date: 2024-12-15 18:56:59.101754 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '615c51060ea1' -down_revision: Union[str, None] = 'dd25f52e9279' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('organisations', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('contact_email', sa.String(length=255), nullable=True), - sa.Column('contact_phone', sa.String(length=50), nullable=True), - sa.Column('address_street', sa.String(length=255), nullable=True), - sa.Column('address_city', sa.String(length=255), nullable=True), - sa.Column('address_postal_code', sa.String(length=50), nullable=True), - sa.Column('address_country', sa.String(length=255), nullable=True), - sa.Column('website', sa.String(length=255), nullable=True), - sa.Column('created_by_id', sa.UUID(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('id') - ) - op.create_index(op.f('ix_organisations_name'), 'organisations', ['name'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_organisations_name'), table_name='organisations') - op.drop_table('organisations') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/8430b1ac53fe_add_event_model.py b/backend/alembic/versions/8430b1ac53fe_add_event_model.py deleted file mode 100644 index 27bee55..0000000 --- a/backend/alembic/versions/8430b1ac53fe_add_event_model.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Add event model - -Revision ID: 8430b1ac53fe -Revises: 615c51060ea1 -Create Date: 2024-12-19 22:11:13.394387 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '8430b1ac53fe' -down_revision: Union[str, None] = '615c51060ea1' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('events', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('title', sa.String(length=255), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('max_participants', sa.Integer(), nullable=True), - sa.Column('website', sa.String(length=255), nullable=True), - sa.Column('contact_email', sa.String(length=255), nullable=True), - sa.Column('contact_phone_code', sa.String(length=12), nullable=True, comment='Phone number with country code, e.g. +47'), - sa.Column('contact_phone_number', sa.String(length=32), nullable=True, comment='Phone number with country code, e.g. 99887766'), - sa.Column('maps_url', sa.String(length=255), nullable=True), - sa.Column('address_street', sa.String(length=255), nullable=True), - sa.Column('address_city', sa.String(length=255), nullable=True), - sa.Column('address_postal_code', sa.String(length=50), nullable=True), - sa.Column('address_country', sa.String(length=255), nullable=True), - sa.Column('start_at', sa.DateTime(), nullable=False), - sa.Column('end_at', sa.DateTime(), nullable=False), - sa.Column('organisation_id', sa.UUID(), nullable=False), - sa.Column('created_by_id', sa.UUID(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), - sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('id') - ) - op.create_index(op.f('ix_events_title'), 'events', ['title'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_events_title'), table_name='events') - op.drop_table('events') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/9ef2bf852502_add_event_interest.py b/backend/alembic/versions/9ef2bf852502_add_event_interest.py deleted file mode 100644 index d0643a2..0000000 --- a/backend/alembic/versions/9ef2bf852502_add_event_interest.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Add event interest - -Revision ID: 9ef2bf852502 -Revises: 8430b1ac53fe -Create Date: 2024-12-29 16:53:42.535531 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '9ef2bf852502' -down_revision: Union[str, None] = '8430b1ac53fe' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('event_interests', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('event_id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.Column('status', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['event_id'], ['events.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('event_interests') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/dd25f52e9279_add_missing_changes_from_last_migration.py b/backend/alembic/versions/dd25f52e9279_add_missing_changes_from_last_migration.py deleted file mode 100644 index ce99014..0000000 --- a/backend/alembic/versions/dd25f52e9279_add_missing_changes_from_last_migration.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Add missing changes from last migration - -Revision ID: dd25f52e9279 -Revises: ddb42266651d -Create Date: 2024-12-15 18:54:40.808693 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'dd25f52e9279' -down_revision: Union[str, None] = 'ddb42266651d' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_unique_constraint(None, 'otp', ['id']) - op.create_unique_constraint(None, 'users', ['id']) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'users', type_='unique') - op.drop_constraint(None, 'otp', type_='unique') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/ddb42266651d_initial_db.py b/backend/alembic/versions/ddb42266651d_initial_db.py deleted file mode 100644 index e968507..0000000 --- a/backend/alembic/versions/ddb42266651d_initial_db.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Initial db - -Revision ID: ddb42266651d -Revises: -Create Date: 2024-12-15 14:09:22.373005 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'ddb42266651d' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('otp', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('code', sa.String(length=6), nullable=True), - sa.Column('email', sa.String(length=320), nullable=True), - sa.Column('used_at', sa.DateTime(), nullable=True), - sa.Column('expires_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('id') - ) - op.create_table('users', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('name', sa.String(length=256), nullable=False), - sa.Column('email', sa.String(length=320), nullable=True), - sa.Column('password', sa.String(length=256), nullable=False), - sa.Column('phone_code', sa.String(length=12), nullable=True, comment='Phone number with country code, e.g. +47'), - sa.Column('phone_number', sa.String(length=32), nullable=True, comment='Phone number with country code, e.g. 99887766'), - sa.Column('referrer', sa.String(), nullable=True, comment='Who/what referred this user'), - sa.Column('photo_url', sa.String(), nullable=True), - sa.Column('email_verified_at', sa.DateTime(), nullable=True), - sa.Column('privacy_policy_accepted_at', sa.DateTime(), nullable=True), - sa.Column('terms_of_service_accepted_at', sa.DateTime(), nullable=True), - sa.Column('refresh_token', sa.String(), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('id'), - sa.UniqueConstraint('phone_code', 'phone_number', name='_phone_code_phone_number_uc') - ) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_users_email'), table_name='users') - op.drop_table('users') - op.drop_table('otp') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/f4b1dcaf253e_initial_database.py b/backend/alembic/versions/f4b1dcaf253e_initial_database.py new file mode 100644 index 0000000..6113e98 --- /dev/null +++ b/backend/alembic/versions/f4b1dcaf253e_initial_database.py @@ -0,0 +1,147 @@ +"""Initial database + +Revision ID: f4b1dcaf253e +Revises: +Create Date: 2026-04-16 17:19:15.780553 + +Consolidated schema (formerly split across follow-up revisions): ``users.name`` is +nullable for optional display name at signup. + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f4b1dcaf253e' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('otp', + sa.Column('code', sa.String(length=6), nullable=True), + sa.Column('email', sa.String(length=320), nullable=True), + sa.Column('used_at', sa.DateTime(), nullable=True), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('name', sa.String(length=256), nullable=True), + sa.Column('email', sa.String(length=320), nullable=True), + sa.Column('password', sa.String(length=256), nullable=False), + sa.Column('phone_code', sa.String(length=12), nullable=True, comment='Phone number with country code, e.g. +47'), + sa.Column('phone_number', sa.String(length=32), nullable=True, comment='Phone number with country code, e.g. 99887766'), + sa.Column('referrer', sa.String(), nullable=True, comment='Who/what referred this user'), + sa.Column('photo_url', sa.String(), nullable=True), + sa.Column('email_verified_at', sa.DateTime(), nullable=True), + sa.Column('privacy_policy_accepted_at', sa.DateTime(), nullable=True), + sa.Column('terms_of_service_accepted_at', sa.DateTime(), nullable=True), + sa.Column('refresh_token', sa.String(), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('phone_code', 'phone_number', name='_phone_code_phone_number_uc') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('organisations', + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('contact_email', sa.String(length=255), nullable=True), + sa.Column('contact_phone', sa.String(length=50), nullable=True), + sa.Column('address_street', sa.String(length=255), nullable=True), + sa.Column('address_city', sa.String(length=255), nullable=True), + sa.Column('address_postal_code', sa.String(length=50), nullable=True), + sa.Column('address_country', sa.String(length=255), nullable=True), + sa.Column('website', sa.String(length=255), nullable=True), + sa.Column('created_by_id', sa.UUID(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_organisations_name'), 'organisations', ['name'], unique=False) + op.create_table('events', + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('max_participants', sa.Integer(), nullable=True), + sa.Column('website', sa.String(length=255), nullable=True), + sa.Column('contact_email', sa.String(length=255), nullable=True), + sa.Column('contact_phone_code', sa.String(length=12), nullable=True, comment='Phone number with country code, e.g. +47'), + sa.Column('contact_phone_number', sa.String(length=32), nullable=True, comment='Phone number with country code, e.g. 99887766'), + sa.Column('maps_url', sa.String(length=255), nullable=True), + sa.Column('address_street', sa.String(length=255), nullable=True), + sa.Column('address_city', sa.String(length=255), nullable=True), + sa.Column('address_postal_code', sa.String(length=50), nullable=True), + sa.Column('address_country', sa.String(length=255), nullable=True), + sa.Column('start_at', sa.DateTime(), nullable=False), + sa.Column('end_at', sa.DateTime(), nullable=False), + sa.Column('organisation_id', sa.UUID(), nullable=False), + sa.Column('created_by_id', sa.UUID(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_events_title'), 'events', ['title'], unique=False) + op.create_table('articles', + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('slug', sa.String(length=255), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('event_id', sa.UUID(), nullable=False), + sa.Column('created_by_id', sa.UUID(), nullable=False), + sa.Column('published_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['event_id'], ['events.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_articles_slug'), 'articles', ['slug'], unique=False) + op.create_index(op.f('ix_articles_title'), 'articles', ['title'], unique=False) + op.create_table('event_interests', + sa.Column('event_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('status', sa.Integer(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['event_id'], ['events.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('event_interests') + op.drop_index(op.f('ix_articles_title'), table_name='articles') + op.drop_index(op.f('ix_articles_slug'), table_name='articles') + op.drop_table('articles') + op.drop_index(op.f('ix_events_title'), table_name='events') + op.drop_table('events') + op.drop_index(op.f('ix_organisations_name'), table_name='organisations') + op.drop_table('organisations') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_table('otp') + # ### end Alembic commands ### diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 3fb58aa..aa45f7c 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -23,6 +23,12 @@ def get_db_engine(url: str = Config.SQLALCHEMY_DATABASE_URI, pool_size: int = 10 :param pool_size: Pool size for SQLAlchemy. Default 10. :return: SQLAlchemy database engine. """ + if url.startswith('sqlite'): + return create_engine( + url=url, + connect_args={'check_same_thread': False}, + echo=Config.DATABASE_DEBUG, + ) return create_engine( url=url, pool_size=pool_size, # Set pool size to 10 connections diff --git a/backend/app/models/base.py b/backend/app/models/base.py index e26dd0c..6524209 100644 --- a/backend/app/models/base.py +++ b/backend/app/models/base.py @@ -1,8 +1,8 @@ -import uuid from datetime import UTC, datetime from sqlalchemy import UUID, Column, DateTime, func from sqlalchemy.orm import declarative_base +from uuid6 import uuid7 Base = declarative_base() @@ -12,7 +12,7 @@ class BaseModel(Base): __abstract__ = True # Ensures that this class is not mapped to a table. - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, unique=True, nullable=False) + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid7, nullable=False) created_at = Column(DateTime, server_default=func.now(), nullable=True) updated_at = Column(DateTime, onupdate=func.now(), nullable=True) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 18f1bda..70008cc 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -11,7 +11,7 @@ class User(BaseModel): __tablename__ = 'users' - name = Column(String(256), nullable=False) + name = Column(String(256), nullable=True) email = Column(String(320), unique=True, index=True) password = Column(String(256), nullable=False) phone_code = Column(String(12), nullable=True, comment='Phone number with country code, e.g. +47') @@ -35,7 +35,9 @@ class User(BaseModel): @property def phone(self): """Return phone number with country code""" - return f'{self.phone_code}{self.phone_number}' + if self.phone_code and self.phone_number: + return f'{self.phone_code}{self.phone_number}' + return None class Otp(BaseModel): diff --git a/backend/app/v3/articles/endpoints.py b/backend/app/v3/articles/endpoints.py index 126fd70..8fc0309 100644 --- a/backend/app/v3/articles/endpoints.py +++ b/backend/app/v3/articles/endpoints.py @@ -1,5 +1,3 @@ -from uuid import UUID - from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from sqlalchemy.orm import Session @@ -14,7 +12,11 @@ get_articles, update_article, ) +from app.v3.articles.service import ( + get_all_articles as fetch_all_event_articles, +) from app.v3.auth.utils import get_current_user +from app.v3.uuid_types import UUID7 router = APIRouter() @@ -25,7 +27,7 @@ response_model=ArticleResponse, ) async def post_create_article( - event_id: UUID, article_data: ArticleCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) + event_id: UUID7, article_data: ArticleCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ) -> JSONResponse: """Create a new article""" return create_article(db=db, event_id=event_id, current_user=current_user, article_data=article_data) @@ -36,17 +38,27 @@ async def post_create_article( name='EA-2', response_model=list[ArticleResponse], ) -async def get_articles_list(event_id: UUID, skip: int = 0, limit: int = 100, db: Session = Depends(get_db)) -> JSONResponse: +async def get_articles_list(event_id: UUID7, skip: int = 0, limit: int = 100, db: Session = Depends(get_db)) -> JSONResponse: """Get published articles for an event""" return get_articles(db=db, event_id=event_id, skip=skip, limit=limit) +@router.get( + '/events/{event_id}/articles/all', + name='EA-6', + response_model=list[ArticleResponse], +) +async def get_all_articles_for_event(event_id: UUID7, db: Session = Depends(get_db)) -> JSONResponse: + """Get all articles for an event (including drafts). Static path must be registered before /{article_id}.""" + return fetch_all_event_articles(db=db, event_id=event_id) + + @router.get( '/events/{event_id}/articles/{article_id}', name='EA-3', response_model=ArticleResponse, ) -async def get_article_by_id(event_id: UUID, article_id: UUID, db: Session = Depends(get_db)) -> JSONResponse: +async def get_article_by_id(event_id: UUID7, article_id: UUID7, db: Session = Depends(get_db)) -> JSONResponse: """Get article by ID""" return get_article(db=db, event_id=event_id, article_id=article_id) @@ -57,7 +69,7 @@ async def get_article_by_id(event_id: UUID, article_id: UUID, db: Session = Depe response_model=ArticleResponse, ) async def put_update_article( - event_id: UUID, article_id: UUID, article_data: ArticleUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) + event_id: UUID7, article_id: UUID7, article_data: ArticleUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ) -> JSONResponse: """Update article""" return update_article(db=db, event_id=event_id, article_id=article_id, current_user=current_user, article_data=article_data) @@ -67,16 +79,11 @@ async def put_update_article( '/events/{event_id}/articles/{article_id}', name='EA-5', ) -async def delete_article_by_id(event_id: UUID, article_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> JSONResponse: +async def delete_article_by_id( + event_id: UUID7, + article_id: UUID7, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> JSONResponse: """Delete article""" return delete_article(db=db, event_id=event_id, article_id=article_id, current_user=current_user) - - -@router.get( - '/events/{event_id}/articles/all', - name='EA-6', - response_model=list[ArticleResponse], -) -async def get_all_articles(event_id: UUID, db: Session = Depends(get_db)) -> JSONResponse: - """Get all articles for an event""" - return get_all_articles(db=db, event_id=event_id) diff --git a/backend/app/v3/articles/schemas.py b/backend/app/v3/articles/schemas.py index 20c6b52..7a1d161 100644 --- a/backend/app/v3/articles/schemas.py +++ b/backend/app/v3/articles/schemas.py @@ -1,9 +1,9 @@ from datetime import datetime -from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from app.v3.auth.schemas import UserResponse +from app.v3.uuid_types import UUID7 class ArticleBase(BaseModel): @@ -30,18 +30,15 @@ class ArticleUpdate(ArticleBase): class ArticleResponse(ArticleBase): """Article response model""" - id: UUID - event_id: UUID + model_config = ConfigDict(from_attributes=True) + + id: UUID7 + event_id: UUID7 created_by: UserResponse created_at: datetime updated_at: datetime deleted_at: datetime | None - class Config: - """Pydantic config""" - - orm_mode = True - class ArticleListResponse(BaseModel): """Article list response model""" diff --git a/backend/app/v3/articles/service.py b/backend/app/v3/articles/service.py index dbf982e..38f6261 100644 --- a/backend/app/v3/articles/service.py +++ b/backend/app/v3/articles/service.py @@ -1,7 +1,7 @@ import logging +from uuid import UUID from pydantic import TypeAdapter -from pydantic.v1 import UUID4 from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from tunsberg.responses import ( @@ -21,7 +21,7 @@ log = logging.getLogger(__name__) -def create_article(db: Session, event_id: UUID4, current_user: User, article_data: ArticleCreate): +def create_article(db: Session, event_id: UUID, current_user: User, article_data: ArticleCreate): """Create a new article""" # Check if event exists event = db.query(Event).filter(Event.id == event_id).first() @@ -42,7 +42,7 @@ def create_article(db: Session, event_id: UUID4, current_user: User, article_dat return response_conflict(message='Error creating article') -def get_articles(db: Session, event_id: UUID4, skip: int = 0, limit: int = 100): +def get_articles(db: Session, event_id: UUID, skip: int = 0, limit: int = 100): """Get published articles for an event""" # Check if event exists event = db.query(Event).filter(Event.id == event_id).first() @@ -63,7 +63,7 @@ def get_articles(db: Session, event_id: UUID4, skip: int = 0, limit: int = 100): return response_success(message='Articles retrieved', data=data) -def get_article(db: Session, event_id: UUID4, article_id: UUID4): +def get_article(db: Session, event_id: UUID, article_id: UUID): """Get article by ID""" # Check if event exists event = db.query(Event).filter(Event.id == event_id).first() @@ -78,7 +78,7 @@ def get_article(db: Session, event_id: UUID4, article_id: UUID4): return response_success(message='Article retrieved', data=adapter.dump_json(article)) -def update_article(db: Session, event_id: UUID4, article_id: UUID4, current_user: User, article_data: ArticleUpdate): +def update_article(db: Session, event_id: UUID, article_id: UUID, current_user: User, article_data: ArticleUpdate): """Update article""" # Check if event exists event = db.query(Event).filter(Event.id == event_id).first() @@ -104,7 +104,7 @@ def update_article(db: Session, event_id: UUID4, article_id: UUID4, current_user return response_conflict(message='Error updating article') -def delete_article(db: Session, event_id: UUID4, article_id: UUID4, current_user: User): +def delete_article(db: Session, event_id: UUID, article_id: UUID, current_user: User): """Delete article""" article = db.query(Article).filter(Article.id == article_id, Article.event_id == event_id, Article.deleted_at.is_(None)).first() if not article: @@ -120,7 +120,7 @@ def delete_article(db: Session, event_id: UUID4, article_id: UUID4, current_user return response_bad_request(message='Could not delete article') -def get_all_articles(db: Session, event_id: UUID4): +def get_all_articles(db: Session, event_id: UUID): """Get all articles for an event""" articles = db.query(Article).filter(Article.event_id == event_id, Article.deleted_at.is_(None)).all() adapter = TypeAdapter(list[ArticleResponse]) diff --git a/backend/app/v3/auth/schemas.py b/backend/app/v3/auth/schemas.py index 3bd19dd..6141c18 100644 --- a/backend/app/v3/auth/schemas.py +++ b/backend/app/v3/auth/schemas.py @@ -1,10 +1,11 @@ +import re from datetime import datetime -from uuid import UUID -from pydantic import BaseModel, EmailStr, SecretStr, constr, field_validator +from pydantic import BaseModel, ConfigDict, EmailStr, SecretStr, constr, field_validator, model_validator from starlette import status from app.v3.utils import CustomExceptionError +from app.v3.uuid_types import UUID7 from config import Config @@ -39,28 +40,21 @@ class A6Output(BaseModel): class A3Input(BaseModel): """Input model user sign up""" - name: str - phone_code: constr(pattern=r'^\+\d{1,3}$') # e.g., +44, +1, +91, +505 - phone_number: str + name: str | None = None + phone_code: str | None = None + phone_number: str | None = None email: EmailStr password: SecretStr referrer: str | None = None - @field_validator('phone_code') + @field_validator('name') @classmethod - def validate_phone_code(cls, value): - """Validate phone code""" - if not value.startswith('+'): - raise CustomExceptionError(status_code=status.HTTP_400_BAD_REQUEST, message='Phone code must start with a "+" symbol.') - return value - - @field_validator('phone_number') - @classmethod - def validate_phone_number(cls, value): - """Validate phone number""" - if not value.isdigit(): - raise CustomExceptionError(status_code=status.HTTP_400_BAD_REQUEST, message='Phone number must contain only digits.') - return value + def normalize_name(cls, value: str | None): + """Strip whitespace; treat blank as absent.""" + if value is None: + return None + stripped = value.strip() + return stripped if stripped else None @field_validator('password') @classmethod @@ -73,6 +67,34 @@ def validate_password(cls, value): ) return value + @model_validator(mode='after') + def validate_phone_optional_pair(self): + """If either phone field is set, both must be set and valid.""" + code = self.phone_code + num = self.phone_number + code_set = code is not None and str(code).strip() != '' + num_set = num is not None and str(num).strip() != '' + if code_set ^ num_set: + raise CustomExceptionError( + status_code=status.HTTP_400_BAD_REQUEST, + message='Both phone_code and phone_number are required when providing a phone number.', + ) + if not code_set and not num_set: + self.phone_code = None + self.phone_number = None + return self + code = str(code).strip() + num = str(num).strip() + if not re.match(r'^\+\d{1,3}$', code): + raise CustomExceptionError(status_code=status.HTTP_400_BAD_REQUEST, message='Phone code must match + followed by 1-3 digits.') + if not code.startswith('+'): + raise CustomExceptionError(status_code=status.HTTP_400_BAD_REQUEST, message='Phone code must start with a "+" symbol.') + if not num.isdigit(): + raise CustomExceptionError(status_code=status.HTTP_400_BAD_REQUEST, message='Phone number must contain only digits.') + self.phone_code = code + self.phone_number = num + return self + class A4Input(BaseModel): """Input model user login""" @@ -112,7 +134,7 @@ class A6Input(BaseModel): class UserBase(BaseModel): """Base user model""" - name: constr(max_length=256) + name: constr(max_length=256) | None = None email: EmailStr | None = None phone_code: constr(max_length=12) | None = None phone_number: constr(max_length=32) | None = None @@ -130,17 +152,14 @@ def phone(self) -> str | None: class UserResponse(UserBase): """User response model""" - id: UUID + model_config = ConfigDict(from_attributes=True) + + id: UUID7 email_verified_at: datetime | None = None privacy_policy_accepted_at: datetime | None = None terms_of_service_accepted_at: datetime | None = None refresh_token: str | None = None - class Config: - """Pydantic config""" - - orm_mode = True # Enables compatibility with SQLAlchemy models - class A10Input(BaseModel): """Input model for resending OTP""" diff --git a/backend/app/v3/auth/service.py b/backend/app/v3/auth/service.py index 4c2de40..d28212e 100644 --- a/backend/app/v3/auth/service.py +++ b/backend/app/v3/auth/service.py @@ -45,6 +45,7 @@ verify_reset_token, ) from app.v3.utils import get_avatar_url, get_portal_url, send_email +from app.v3.uuid_types import parse_uuid7 from config import Config log = logging.getLogger(__name__) @@ -53,7 +54,7 @@ def signup_generate_otp(request_data: A1Input, db: Session): """Start signup by generating an OTP""" # Check if the user already exists - user = db.query(User).filter(User.email == request_data.email, User.email_verified_at.is_(None)).first() + user = db.query(User).filter(User.email == request_data.email, User.deleted_at.is_(None)).first() if user: return response_conflict(message='User with the email already exists') @@ -94,7 +95,7 @@ def signup_generate_otp(request_data: A1Input, db: Session): def signup_verify_otp(request_data: A2Input, db: Session): """Verify an OTP""" # Check if the user already exists - user = db.query(User).filter(User.email == request_data.email, User.email_verified_at.is_(None)).first() + user = db.query(User).filter(User.email == request_data.email, User.deleted_at.is_(None)).first() if user: return response_conflict(message='User with the email already exists') @@ -132,11 +133,11 @@ def signup_details(request_data: A3Input, db: Session): """Signup details for a user""" # Build filter conditions based on the request data filter_conditions = [User.email == request_data.email] - if request_data.phone_number: - filter_conditions.append(User.phone_number == request_data.phone_number) + if request_data.phone_code and request_data.phone_number: + filter_conditions.append((User.phone_code == request_data.phone_code) & (User.phone_number == request_data.phone_number)) # Query for user - # This query checks if a user exists with either the given email or phone number if provided + # This query checks if a user exists with either the given email or phone pair if provided user = db.query(User).filter(or_(*filter_conditions), User.deleted_at.is_(None)).first() if user: return response_conflict(message='User with the email or phone number already exists') @@ -146,6 +147,7 @@ def signup_details(request_data: A3Input, db: Session): if not otp: return response_bad_request(message='Please verify your email with the OTP sent to your email') + now = datetime.now(tz=UTC) # Create the user user = User( name=request_data.name, @@ -153,10 +155,11 @@ def signup_details(request_data: A3Input, db: Session): phone_number=request_data.phone_number, email=request_data.email, password=get_hashed_password(request_data.password), - photo_url=get_avatar_url(request_data.name), + photo_url=get_avatar_url(request_data.name, request_data.email), referrer=request_data.referrer, - terms_of_service_accepted_at=datetime.now(tz=UTC), - privacy_policy_accepted_at=datetime.now(tz=UTC), + email_verified_at=now, + terms_of_service_accepted_at=now, + privacy_policy_accepted_at=now, ) try: db.add(user) @@ -195,7 +198,7 @@ def post_refresh_token(request_data: A6Input, db: Session): payload = validate_token(request_data.refresh_token) if not payload: return response_unauthorized(message='Invalid token') - user_id: int = payload.get('sub') + user_id = parse_uuid7(str(payload.get('sub'))) user = db.query(User).filter_by(id=user_id).one_or_none() if user and user.refresh_token != request_data.refresh_token: return response_unauthorized(message='Please log in again!') diff --git a/backend/app/v3/auth/utils.py b/backend/app/v3/auth/utils.py index 3c4b264..1d054b2 100644 --- a/backend/app/v3/auth/utils.py +++ b/backend/app/v3/auth/utils.py @@ -2,6 +2,7 @@ import secrets import string from datetime import UTC, datetime, timedelta +from uuid import UUID import jwt from fastapi import Depends, Request @@ -14,6 +15,7 @@ from app.dependencies import get_db from app.models.user import User from app.v3.utils import CustomExceptionError +from app.v3.uuid_types import parse_uuid7 from config import Config log = logging.getLogger(__name__) @@ -160,7 +162,16 @@ def validate_token(token: str) -> dict or None: log.debug(f'exp: {payload["exp"]}') log.debug(f'now: {datetime.now(UTC).timestamp()}') log.debug(f'expires in seconds: {payload["exp"] - datetime.now(UTC).timestamp()}') - return payload if payload['exp'] >= datetime.now(UTC).timestamp() else None + if payload['exp'] < datetime.now(UTC).timestamp(): + return None + sub = payload.get('sub') + if sub is None: + return None + try: + parse_uuid7(str(sub)) + except ValueError: + return None + return payload except jwt.ExpiredSignatureError: return None except jwt.InvalidTokenError: @@ -214,9 +225,13 @@ async def get_current_user(token: str = Depends(JWTBearer()), db: Session = Depe """ try: payload = jwt.decode(token, Config.JWT_PUBLIC_KEY, algorithms=[Config.JWT_ALGORITHM]) - user_id: str = payload.get('sub') - if user_id is None: + sub = payload.get('sub') + if sub is None: raise CustomExceptionError(status_code=status.HTTP_403_FORBIDDEN, message='Invalid token.') + try: + user_id = parse_uuid7(str(sub)) + except ValueError: + raise CustomExceptionError(status_code=status.HTTP_403_FORBIDDEN, message='Invalid token.') from None user = db.query(User).filter(User.id == user_id).one_or_none() if user is None: raise CustomExceptionError(status_code=status.HTTP_403_FORBIDDEN, message='User not found.') @@ -252,13 +267,16 @@ def verify_reset_token(token: str) -> bool: """ try: payload = jwt.decode(token, Config.JWT_PUBLIC_KEY, algorithms=[Config.JWT_ALGORITHM]) - user_id = payload.get('sub') - if user_id is None or payload.get('exp') < datetime.now(UTC).timestamp(): - raise CustomExceptionError(status_code=status.HTTP_403_FORBIDDEN, message='Invalid or expired token.') + sub = payload.get('sub') + if sub is None or payload.get('exp') < datetime.now(UTC).timestamp(): + return False + parse_uuid7(str(sub)) except jwt.ExpiredSignatureError: return False except jwt.InvalidTokenError: return False + except ValueError: + return False return True @@ -267,14 +285,17 @@ def format_email_from_input(email: str) -> str: return email.lower().strip() -def get_user_id_from_token(token: str) -> str: +def get_user_id_from_token(token: str) -> UUID: """ Get user ID from token :param token: Token :type token: str - :return: User ID - :rtype: str + :return: User ID (UUID version 7) + :rtype: UUID """ payload = jwt.decode(token, Config.JWT_PUBLIC_KEY, algorithms=[Config.JWT_ALGORITHM]) - return payload.get('sub') + sub = payload.get('sub') + if sub is None: + raise ValueError('Missing subject') + return parse_uuid7(str(sub)) diff --git a/backend/app/v3/event_interests/endpoints.py b/backend/app/v3/event_interests/endpoints.py index 5c9923f..b281a75 100644 --- a/backend/app/v3/event_interests/endpoints.py +++ b/backend/app/v3/event_interests/endpoints.py @@ -1,5 +1,3 @@ -from uuid import UUID - from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from sqlalchemy.orm import Session @@ -15,6 +13,7 @@ get_user_interest, update_interest, ) +from app.v3.uuid_types import UUID7 router = APIRouter() @@ -25,7 +24,7 @@ response_model=EventInterestResponse, ) async def post_create_interest( - event_id: UUID, interest_data: EventInterestCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) + event_id: UUID7, interest_data: EventInterestCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ) -> JSONResponse: """Register interest in an event""" return create_interest(db=db, event_id=event_id, current_user=current_user, interest_data=interest_data) @@ -36,7 +35,7 @@ async def post_create_interest( name='EI-2', response_model=list[EventInterestResponse], ) -async def get_interests_list(event_id: UUID, skip: int = 0, limit: int = 100, db: Session = Depends(get_db)) -> JSONResponse: +async def get_interests_list(event_id: UUID7, skip: int = 0, limit: int = 100, db: Session = Depends(get_db)) -> JSONResponse: """Get all interests for an event""" return get_event_interests(db=db, event_id=event_id, skip=skip, limit=limit) @@ -47,7 +46,7 @@ async def get_interests_list(event_id: UUID, skip: int = 0, limit: int = 100, db response_model=EventInterestResponse, ) async def put_update_interest( - event_id: UUID, interest_data: EventInterestUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) + event_id: UUID7, interest_data: EventInterestUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ) -> JSONResponse: """Update interest status""" return update_interest(db=db, event_id=event_id, current_user=current_user, interest_data=interest_data) @@ -58,12 +57,12 @@ async def put_update_interest( name='EI-4', response_model=EventInterestResponse, ) -async def get_my_interest(event_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> JSONResponse: +async def get_my_interest(event_id: UUID7, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> JSONResponse: """Get current user's interest status for an event""" return get_user_interest(db=db, event_id=event_id, current_user=current_user) @router.get('/events/{event_id}/interests/count', name='EI-5', response_model=EventInterestCountResponse) -async def get_interest_count(event_id: UUID, db: Session = Depends(get_db)) -> JSONResponse: +async def get_interest_count(event_id: UUID7, db: Session = Depends(get_db)) -> JSONResponse: """Get count of interests for an event""" return get_event_interest_count(db=db, event_id=event_id) diff --git a/backend/app/v3/event_interests/schemas.py b/backend/app/v3/event_interests/schemas.py index 7d09a1e..88b2843 100644 --- a/backend/app/v3/event_interests/schemas.py +++ b/backend/app/v3/event_interests/schemas.py @@ -1,7 +1,8 @@ from datetime import datetime -from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + +from app.v3.uuid_types import UUID7 class EventInterestBase(BaseModel): @@ -25,18 +26,15 @@ class EventInterestUpdate(EventInterestBase): class EventInterestResponse(EventInterestBase): """Event interest response model""" - id: UUID - event_id: UUID - user_id: UUID + model_config = ConfigDict(from_attributes=True) + + id: UUID7 + event_id: UUID7 + user_id: UUID7 created_at: datetime updated_at: datetime deleted_at: datetime | None - class Config: - """Pydantic config""" - - orm_mode = True - class EventInterestCount(BaseModel): """Event interest count model""" diff --git a/backend/app/v3/events/endpoints.py b/backend/app/v3/events/endpoints.py index 27dbf09..5e1c26d 100644 --- a/backend/app/v3/events/endpoints.py +++ b/backend/app/v3/events/endpoints.py @@ -1,5 +1,3 @@ -from uuid import UUID - from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from sqlalchemy.orm import Session @@ -15,6 +13,7 @@ get_events, update_event, ) +from app.v3.uuid_types import UUID7 router = APIRouter() @@ -44,7 +43,7 @@ async def get_events_list(skip: int = 0, limit: int = 100, db: Session = Depends name='E-3', response_model=EventResponse, ) -async def get_event_by_id(event_id: UUID, db: Session = Depends(get_db)) -> JSONResponse: +async def get_event_by_id(event_id: UUID7, db: Session = Depends(get_db)) -> JSONResponse: """Get event by ID""" return get_event(db=db, event_id=event_id) @@ -55,7 +54,7 @@ async def get_event_by_id(event_id: UUID, db: Session = Depends(get_db)) -> JSON response_model=EventResponse, ) async def put_update_event( - event_id: UUID, event_data: EventUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) + event_id: UUID7, event_data: EventUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ) -> JSONResponse: """Update event""" return update_event(db=db, event_id=event_id, current_user_id=current_user.id, event_data=event_data) @@ -65,6 +64,6 @@ async def put_update_event( '/{event_id}', name='E-5', ) -async def delete_event_by_id(event_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> JSONResponse: +async def delete_event_by_id(event_id: UUID7, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> JSONResponse: """Delete event""" return delete_event(db=db, event_id=event_id, current_user_id=current_user.id) diff --git a/backend/app/v3/events/schemas.py b/backend/app/v3/events/schemas.py index 3ecc501..e725f15 100644 --- a/backend/app/v3/events/schemas.py +++ b/backend/app/v3/events/schemas.py @@ -1,10 +1,10 @@ from datetime import datetime -from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from app.v3.auth.schemas import UserResponse from app.v3.organisations.schemas import OrganisationResponse +from app.v3.uuid_types import UUID7 class EventBase(BaseModel): @@ -29,7 +29,7 @@ class EventBase(BaseModel): start_at: datetime end_at: datetime - organisation_id: UUID + organisation_id: UUID7 class EventCreate(EventBase): @@ -47,7 +47,9 @@ class EventUpdate(EventBase): class EventResponse(EventBase): """Event response model""" - id: UUID + model_config = ConfigDict(from_attributes=True) + + id: UUID7 title: str description: str | None @@ -76,11 +78,6 @@ class EventResponse(EventBase): updated_at: datetime deleted_at: datetime | None - class Config: - """Pydantic config""" - - orm_mode = True - class EventListResponse(BaseModel): """Event list response model""" diff --git a/backend/app/v3/events/service.py b/backend/app/v3/events/service.py index ad0e667..d9e7391 100644 --- a/backend/app/v3/events/service.py +++ b/backend/app/v3/events/service.py @@ -1,7 +1,7 @@ import logging +from uuid import UUID from pydantic import TypeAdapter -from pydantic.v1 import UUID4 from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from tunsberg.responses import ( @@ -45,7 +45,7 @@ def get_events(db: Session, skip: int = 0, limit: int = 100): return response_success(message='Events retrieved', data=data) -def get_event(db: Session, event_id: UUID4): +def get_event(db: Session, event_id: UUID): """Get event by ID""" event = db.query(Event).filter(Event.id == event_id, Event.deleted_at.is_(None)).first() if not event: @@ -55,7 +55,7 @@ def get_event(db: Session, event_id: UUID4): return response_success(message='Event retrieved', data=adapter.dump_json(event)) -def update_event(db: Session, event_id: UUID4, current_user_id: int, event_data: EventUpdate): +def update_event(db: Session, event_id: UUID, current_user_id: UUID, event_data: EventUpdate): """Update event""" event = db.query(Event).filter(Event.id == event_id, Event.deleted_at.is_(None)).first() if not event: @@ -79,7 +79,7 @@ def update_event(db: Session, event_id: UUID4, current_user_id: int, event_data: return response_conflict(message='Error updating event') -def delete_event(db: Session, event_id: UUID4, current_user_id: int): +def delete_event(db: Session, event_id: UUID, current_user_id: UUID): """Delete event""" event = db.query(Event).filter(Event.id == event_id, Event.deleted_at.is_(None)).first() if not event: diff --git a/backend/app/v3/organisations/endpoints.py b/backend/app/v3/organisations/endpoints.py index b4372e6..7102841 100644 --- a/backend/app/v3/organisations/endpoints.py +++ b/backend/app/v3/organisations/endpoints.py @@ -1,6 +1,5 @@ from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse -from pydantic import UUID4 from sqlalchemy.orm import Session from app.dependencies import get_db @@ -17,6 +16,7 @@ get_organisations, update_organisation, ) +from app.v3.uuid_types import UUID7 router = APIRouter() @@ -48,7 +48,7 @@ async def get_organisations_list(skip: int = 0, limit: int = 100, db: Session = name='O-3', response_model=OrganisationResponse, ) -async def get_organisation_by_id(organisation_id: UUID4, db: Session = Depends(get_db)) -> JSONResponse: +async def get_organisation_by_id(organisation_id: UUID7, db: Session = Depends(get_db)) -> JSONResponse: """Get organisation by ID""" return get_organisation(db=db, organisation_id=organisation_id) @@ -59,7 +59,7 @@ async def get_organisation_by_id(organisation_id: UUID4, db: Session = Depends(g response_model=OrganisationResponse, ) async def put_update_organisation( - organisation_id: int, organisation_data: OrganisationUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) + organisation_id: UUID7, organisation_data: OrganisationUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ) -> JSONResponse: """Update organisation""" return update_organisation(db=db, organisation_id=organisation_id, current_user=current_user, organisation_data=organisation_data) @@ -69,18 +69,18 @@ async def put_update_organisation( '/{organisation_id}', name='O-5', ) -async def delete_organisation_by_id(organisation_id: UUID4, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> JSONResponse: +async def delete_organisation_by_id(organisation_id: UUID7, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> JSONResponse: """Delete organisation""" return delete_organisation(db=db, organisation_id=organisation_id, current_user=current_user) @router.get('/{organisation_id}/events', name='O-6', response_model=list[EventResponse]) -async def get_organisation_events(organisation_id: UUID4, db: Session = Depends(get_db)) -> JSONResponse: +async def get_organisation_events(organisation_id: UUID7, db: Session = Depends(get_db)) -> JSONResponse: """Get non-deleted events associated with the organisation""" return fetch_organisation_events(organisation_id=organisation_id, db=db) @router.get('/{organisation_id}/events/all', name='O-7', response_model=list[EventResponse]) -async def get_organisation_events_all(organisation_id: UUID4, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> JSONResponse: +async def get_organisation_events_all(organisation_id: UUID7, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> JSONResponse: """Get all events associated with the organisation""" return fetch_organisation_events_all(organisation_id=organisation_id, current_user=current_user, db=db) diff --git a/backend/app/v3/organisations/schemas.py b/backend/app/v3/organisations/schemas.py index 1173bd4..13251dd 100644 --- a/backend/app/v3/organisations/schemas.py +++ b/backend/app/v3/organisations/schemas.py @@ -1,7 +1,8 @@ from datetime import datetime -from uuid import UUID -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, ConfigDict, EmailStr + +from app.v3.uuid_types import UUID7 class OrganisationBase(BaseModel): @@ -33,17 +34,14 @@ class OrganisationUpdate(OrganisationBase): class OrganisationResponse(OrganisationBase): """Organisation response model""" - id: UUID - created_by_id: UUID + model_config = ConfigDict(from_attributes=True) + + id: UUID7 + created_by_id: UUID7 created_at: datetime updated_at: datetime deleted_at: datetime | None - class Config: - """Pydantic config""" - - orm_mode = True - class OrganisationListResponse(BaseModel): """Organisation list response model""" diff --git a/backend/app/v3/organisations/service.py b/backend/app/v3/organisations/service.py index 9d56107..13d3554 100644 --- a/backend/app/v3/organisations/service.py +++ b/backend/app/v3/organisations/service.py @@ -1,7 +1,8 @@ import logging from datetime import datetime +from uuid import UUID -from pydantic import UUID4, TypeAdapter +from pydantic import TypeAdapter from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from tunsberg.responses import ( @@ -47,7 +48,7 @@ def get_organisations(db: Session, skip: int = 0, limit: int = 100): return response_success(message='Organisations retrieved', data=data) -def get_organisation(db: Session, organisation_id: int): +def get_organisation(db: Session, organisation_id: UUID): """Get organisation by ID""" organisation = db.query(Organisation).filter(Organisation.id == organisation_id).first() if not organisation: @@ -57,7 +58,7 @@ def get_organisation(db: Session, organisation_id: int): return response_success(message='Organisation retrieved', data=adapter.dump_json(organisation)) -def update_organisation(db: Session, organisation_id: UUID4, current_user: User, organisation_data: OrganisationUpdate): +def update_organisation(db: Session, organisation_id: UUID, current_user: User, organisation_data: OrganisationUpdate): """Update organisation""" organisation = db.query(Organisation).filter(Organisation.id == organisation_id).first() if not organisation: @@ -83,7 +84,7 @@ def update_organisation(db: Session, organisation_id: UUID4, current_user: User, return response_conflict(message='Organisation with this name already exists') -def delete_organisation(db: Session, organisation_id: UUID4, current_user: User): +def delete_organisation(db: Session, organisation_id: UUID, current_user: User): """Delete organisation""" organisation = db.query(Organisation).filter(Organisation.id == organisation_id).first() if not organisation: @@ -102,7 +103,7 @@ def delete_organisation(db: Session, organisation_id: UUID4, current_user: User) return response_bad_request(message='Could not delete organisation') -def fetch_organisation_events(organisation_id: UUID4, db: Session): +def fetch_organisation_events(organisation_id: UUID, db: Session): """Get events associated with the organisation""" events = db.query(Event).filter(Event.organisation_id == organisation_id, Event.deleted_at.is_(None)).all() @@ -112,7 +113,7 @@ def fetch_organisation_events(organisation_id: UUID4, db: Session): return response_success(message='Events successfully fetched', data=events) -def fetch_organisation_events_all(organisation_id: UUID4, current_user: User, db: Session): +def fetch_organisation_events_all(organisation_id: UUID, current_user: User, db: Session): """Get events associated with the organisation""" events = db.query(Event).filter(Event.organisation_id == organisation_id).all() diff --git a/backend/app/v3/utils.py b/backend/app/v3/utils.py index ec0c6c2..34f1192 100644 --- a/backend/app/v3/utils.py +++ b/backend/app/v3/utils.py @@ -142,21 +142,29 @@ def get_portal_url(path: str = '') -> str: :return: The portal URL for the specified environment. :rtype: str """ - return f'{Config.PORTAL_URL}{path}' + base = Config.PORTAL_URL or '' + return f'{base}{path}' -def get_avatar_url(name: str) -> str: +def get_avatar_url(name: str | None, email: str | None = None) -> str: """ Get the URL for the avatar based on the name provided. :param name: Name to be used for the avatar - :type name: str + :type name: str | None + :param email: Fallback label from email local-part when name is empty + :type email: str | None :return: The URL for the avatar :rtype: str """ - name = name.replace(' ', '+') # Url encode the name - return f'https://ui-avatars.com/api/?name={name}?background=random' + label = (name or '').strip() + if not label and email and '@' in email: + label = email.split('@', 1)[0] + if not label: + label = 'User' + label = label.replace(' ', '+') # Url encode the name + return f'https://ui-avatars.com/api/?name={label}?background=random' class CustomParams(Params): diff --git a/backend/app/v3/uuid_types.py b/backend/app/v3/uuid_types.py new file mode 100644 index 0000000..b3fdd5f --- /dev/null +++ b/backend/app/v3/uuid_types.py @@ -0,0 +1,30 @@ +"""UUID version 7 validation for API identifiers.""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from pydantic import AfterValidator + +RFC9562_UUID_VERSION_7 = 7 + + +def require_uuid7(value: UUID) -> UUID: + """Reject UUIDs whose version field is not 7 (RFC 9562).""" + if value.version != RFC9562_UUID_VERSION_7: + raise ValueError('UUID must be version 7') + return value + + +UUID7 = Annotated[UUID, AfterValidator(require_uuid7)] + + +def parse_uuid7(value: str) -> UUID: + """Parse a string as a UUID and require version 7.""" + try: + u = UUID(value) + except (ValueError, TypeError) as e: + raise ValueError('Invalid UUID') from e + require_uuid7(u) + return u diff --git a/backend/config.py b/backend/config.py index 44ff778..6a76b2a 100644 --- a/backend/config.py +++ b/backend/config.py @@ -112,6 +112,8 @@ class Config: if ENV != 'test' else f'{getenv("SQLALCHEMY_DATABASE_URI")}' ) + if IN_LOCAL_DEVELOPMENT_ENV: + log.debug(f'SQLALCHEMY_DATABASE_URI: {SQLALCHEMY_DATABASE_URI}') # API Docs API_DOCS_TITLE: str = f'{MICRO_SERVICE_NAME_FOR_HUMANS} API' @@ -125,13 +127,18 @@ class Config: API_DOCS_OPENAPI_URL: str | None = None if MICRO_SERVICE_IN_PRODUCTION else '/openapi.json' API_DOCS_URL: str = '/docs' - # Portal - PORTAL_URL: str = getenv('PORTAL_URL') + # Portal (empty when unset; required in live envs via check_required_env_vars) + PORTAL_URL: str = getenv('PORTAL_URL') or '' - # CORS - CORS_ALLOW_ORIGIN: str = PORTAL_URL + # CORS — header values must be str; getenv can be missing when ENV is not a local dev value if IN_LOCAL_DEVELOPMENT_ENV: - CORS_ALLOW_ORIGIN = '*' + CORS_ALLOW_ORIGIN: str = '*' + else: + CORS_ALLOW_ORIGIN: str = PORTAL_URL if PORTAL_URL else '*' + if not PORTAL_URL: + log.warning( + 'PORTAL_URL is not set; CORS Allow-Origin is *. Set PORTAL_URL for locked-down CORS in non-local environments.' + ) # JWT Token ACCESS_TOKEN_EXPIRE_MINUTES: int = int(getenv('ACCESS_TOKEN_EXPIRE_MINUTES', '60')) # 1 hour @@ -175,9 +182,6 @@ class Config: if not POSTMARK_API_KEY: log.warning('Postmark API Key not set!') - # Portal - PORTAL_URL: str = getenv('PORTAL_URL') - # Validation PASSWORD_MIN_LENGTH: int = int(getenv('PASSWORD_MIN_LENGTH', '12')) diff --git a/backend/main.py b/backend/main.py index 2fe05c3..d094768 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,27 +1,24 @@ import json import logging from logging.config import dictConfig -from typing import TYPE_CHECKING from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from sqlalchemy.exc import OperationalError, SQLAlchemyError +from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.middleware.cors import CORSMiddleware +from starlette.responses import JSONResponse, Response from tunsberg.responses import response_bad_request, response_custom, response_internal_server_error -from app.dependencies import get_db_engine, get_local_session -from app.models.base import Base +from app.dependencies import get_local_session from app.v3.api import router as api_v1_router from app.v3.utils import CustomExceptionError from config import Config log = logging.getLogger(__name__) -if TYPE_CHECKING: - from requests import Response - # We need both this and the custom cors handler below middleware = [ Middleware( @@ -85,7 +82,8 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): response = await call_next(request) except SQLAlchemyError as e: request.state.db.rollback() - raise e + log.error(f'SQLAlchemyError in SQLAlchemySessionMiddleware: {e}', exc_info=True) + return response_internal_server_error() except CustomExceptionError as e: message = getattr(e, 'message', 'Internal Server Error') status_code = getattr(e, 'status_code', 500) @@ -97,7 +95,9 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): log.error(f'Error in SQLAlchemySessionMiddleware: {e}', exc_info=True) return response_internal_server_error() finally: - request.state.db.close() + db = getattr(request.state, 'db', None) + if db is not None: + db.close() return response @@ -105,19 +105,30 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): dictConfig(Config.UVICORN_LOG_CONFIG) # Create all tables stored in this metadata -Base.metadata.create_all(bind=get_db_engine()) +# Base.metadata.create_all(bind=get_db_engine()) # Add the SQLAlchemySessionMiddleware to the app app.add_middleware(SQLAlchemySessionMiddleware) # Custom CORS handler, needs to be at the end of the middleware list -@app.middleware('http') -async def cors_handler(request: Request, call_next): - """Add CORS headers to the response.""" - response: Response = await call_next(request) +def _apply_cors_headers(response: Response) -> None: + """Ensure browsers can read error responses from another origin.""" response.headers['Access-Control-Allow-Credentials'] = 'true' response.headers['Access-Control-Allow-Origin'] = Config.CORS_ALLOW_ORIGIN response.headers['Access-Control-Allow-Methods'] = '*' response.headers['Access-Control-Allow-Headers'] = '*' + + +@app.middleware('http') +async def cors_handler(request: Request, call_next): + """Add CORS headers to every response, including errors that escape inner layers.""" + try: + response: Response = await call_next(request) + except StarletteHTTPException as exc: + response = JSONResponse({'detail': exc.detail}, status_code=exc.status_code) + except Exception: + log.exception('Unhandled exception while handling request') + response = response_internal_server_error() + _apply_cors_headers(response) return response diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 28fc1c4..ecd8b08 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,35 +1,36 @@ [project] name = "lanms-backend" -version = "3.0.0a10" +version = "3.0.0a11" requires-python = ">=3.12,<3.13" dependencies = [ "alembic~=1.18.4", - "fastapi[standard]~=0.135.3", - "pydantic[email]~=2.13.0", + "fastapi[standard]~=0.136.3", + "pydantic[email]~=2.13.4", "pydantic-extra-types~=2.11.1", "requests~=2.33.1", - "psycopg2-binary~=2.9.11", - "sentry-sdk[fastapi,sqlalchemy,starlette]~=2.58.0", - "sqlalchemy~=2.0.49", + "psycopg2-binary~=2.9.12", + "sentry-sdk[fastapi,sqlalchemy,starlette]~=2.61.1", + "sqlalchemy~=2.0.50", "pyjwt[crypto]~=2.12.1", "pyotp~=2.9.0", "passlib~=1.7.4", "bcrypt>=4.0.1,<5", - "pandas~=3.0.2", - "fastapi-pagination~=0.15.12", + "pandas~=3.0.3", + "fastapi-pagination~=0.15.14", "fastapi-filter[sqlalchemy]~=2.0.1", "sendgrid~=6.12.5", "python-dotenv~=1.2.2", "tunsberg~=0.3.0", - "phonenumbers~=9.0.28", + "phonenumbers~=9.0.31", "postmarker~=1.0.0", + "uuid6~=2025.0.1", ] [dependency-groups] dev = [ - "pre-commit~=4.5.1", + "pre-commit~=4.6.0", "pytest>=9.0.3", - "ruff~=0.15.10", + "ruff~=0.15.15", ] [tool.uv] @@ -105,6 +106,11 @@ ignore = [ "B008", # Do not perform function calls in argument defaults ] +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "D103", # test functions do not need docstrings + "PLR2004", # HTTP status codes and similar are clear in context +] [tool.ruff.format] quote-style = "single" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e4837cf..6d02fe9 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -7,6 +7,15 @@ # Test env: required-env checks only apply to live environments and CODE_BUILD. os.environ.setdefault('ENV', 'test') os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:') +# Auth and docs config (used when tests import ``config`` / ``main``). +os.environ.setdefault('JWT_ALGORITHM', 'HS256') +os.environ.setdefault( + 'JWT_PRIVATE_KEY', + 'test-jwt-secret-key-at-least-32-characters-long-for-hs256', +) +os.environ.setdefault('JWT_PUBLIC_KEY', os.environ['JWT_PRIVATE_KEY']) +os.environ.setdefault('OTP_SECRET_KEY', 'test-otp-secret-key-32-characters-minimum') +os.environ.setdefault('PORTAL_URL', 'http://localhost:3000') import pytest from sqlalchemy import create_engine diff --git a/backend/tests/test_api_endpoints.py b/backend/tests/test_api_endpoints.py new file mode 100644 index 0000000..abb995a --- /dev/null +++ b/backend/tests/test_api_endpoints.py @@ -0,0 +1,193 @@ +"""HTTP integration tests for v3 API routes (TestClient + isolated SQLite per test).""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta + +import pytest +from pydantic import SecretStr +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.pool import StaticPool +from starlette.testclient import TestClient +from uuid6 import uuid7 + +import main as main_module +from app.models.base import Base +from app.models.user import User +from app.v3.auth.utils import get_hashed_password + + +@pytest.fixture +def api_client(monkeypatch): + """App middleware session backed by a fresh in-memory SQLite DB per test.""" + engine = create_engine( + 'sqlite://', + connect_args={'check_same_thread': False}, + poolclass=StaticPool, + ) + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + session_factory = scoped_session(sessionmaker(bind=engine, autoflush=False, autocommit=False)) + + def fake_get_local_session(url=None): + return session_factory + + monkeypatch.setattr(main_module, 'get_local_session', fake_get_local_session) + with TestClient(main_module.app) as client: + yield client, session_factory + session_factory.remove() + engine.dispose() + + +def _seed_user(session_factory, *, email: str = 'api-user@example.com', password: str = 'password12345') -> User: + db = session_factory() + try: + user = User( + name='API Test User', + email=email, + password=get_hashed_password(SecretStr(password)), + ) + db.add(user) + db.commit() + db.refresh(user) + return user + finally: + db.close() + + +def _parse_response_data(body: dict): + raw = body.get('data') + if raw is None: + return None + if isinstance(raw, str): + return json.loads(raw) + return raw + + +def test_system_up(api_client): + client, _ = api_client + r = client.get('/v3/system/up') + assert r.status_code == 200 + body = r.json() + assert body['status_code'] == 200 + assert 'service' in body['message'].lower() or 'up' in body['message'].lower() + + +def test_organisation_get_rejects_uuidv4_path(api_client): + client, _ = api_client + v4 = '123e4567-e89b-12d3-a456-426614174000' + r = client.get(f'/v3/organisations/{v4}') + assert r.status_code == 400 + assert 'Validation Error' in r.json()['message'] or 'version 7' in r.json()['message'].lower() + + +def test_organisation_get_rejects_malformed_uuid_path(api_client): + client, _ = api_client + r = client.get('/v3/organisations/not-a-uuid') + assert r.status_code == 400 + + +def test_event_get_unknown_uuid7_returns_404(api_client): + client, _ = api_client + r = client.get(f'/v3/events/{uuid7()}') + assert r.status_code == 404 + assert 'not found' in r.json()['message'].lower() + + +def test_get_events_list_returns_200(api_client): + client, _ = api_client + r = client.get('/v3/events') + assert r.status_code == 200 + data = _parse_response_data(r.json()) + assert data is not None + + +def test_get_event_articles_all_returns_200(api_client): + client, _ = api_client + r = client.get(f'/v3/events/{uuid7()}/articles/all') + assert r.status_code == 200 + + +def test_login_rejects_invalid_credentials(api_client): + client, _ = api_client + r = client.post( + '/v3/auth/login', + json={'email': 'missing@example.com', 'password': 'wrongpassword1'}, + ) + assert r.status_code == 400 + assert 'credential' in r.json()['message'].lower() + + +def test_user_me_requires_authentication(api_client): + client, _ = api_client + r = client.get('/v3/user/me') + assert r.status_code == 401 + + +def test_login_and_user_me(api_client): + client, session_factory = api_client + email = 'me@example.com' + password = 'password12345' + _seed_user(session_factory, email=email, password=password) + + r = client.post('/v3/auth/login', json={'email': email, 'password': password}) + assert r.status_code == 200 + token = r.json()['data']['access_token'] + + r2 = client.get('/v3/user/me', headers={'Authorization': f'Bearer {token}'}) + assert r2.status_code == 200 + assert r2.json()['data']['email'] == email + + +def test_create_organisation_and_event_roundtrip(api_client): + client, session_factory = api_client + email = 'builder@example.com' + password = 'password12345' + _seed_user(session_factory, email=email, password=password) + + r = client.post('/v3/auth/login', json={'email': email, 'password': password}) + token = r.json()['data']['access_token'] + headers = {'Authorization': f'Bearer {token}'} + + r_org = client.post('/v3/organisations', headers=headers, json={'name': 'Test Org'}) + assert r_org.status_code == 201 + org_id = _parse_response_data(r_org.json())['id'] + + start = datetime.now(tz=UTC) + end = start + timedelta(hours=2) + r_ev = client.post( + '/v3/events', + headers=headers, + json={ + 'title': 'Conference', + 'description': None, + 'max_participants': None, + 'website': None, + 'contact_email': None, + 'contact_phone_code': None, + 'contact_phone_number': None, + 'maps_url': None, + 'address_street': None, + 'address_city': None, + 'address_postal_code': None, + 'address_country': None, + 'start_at': start.isoformat(), + 'end_at': end.isoformat(), + 'organisation_id': org_id, + }, + ) + assert r_ev.status_code == 201 + event_id = _parse_response_data(r_ev.json())['id'] + + r_get = client.get(f'/v3/events/{event_id}') + assert r_get.status_code == 200 + assert _parse_response_data(r_get.json())['title'] == 'Conference' + + +def test_event_interest_count_requires_uuid7(api_client): + client, _ = api_client + v4 = '123e4567-e89b-12d3-a456-426614174000' + r = client.get(f'/v3/events/{v4}/interests/count') + assert r.status_code == 400 diff --git a/backend/uv.lock b/backend/uv.lock index ccc940d..82609cb 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -247,7 +247,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.3" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -256,15 +256,16 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [package.optional-dependencies] standard = [ { name = "email-validator" }, { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, { name = "httpx" }, { name = "jinja2" }, { name = "pydantic-extra-types" }, @@ -332,16 +333,16 @@ sqlalchemy = [ [[package]] name = "fastapi-pagination" -version = "0.15.12" +version = "0.15.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi" }, { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/71/7381bf08f9fb6a890ec41a7ee5191ca564e0af94b899c2006fddaf07d78f/fastapi_pagination-0.15.12.tar.gz", hash = "sha256:914b41e07b8556de34c12d3568c9b7137eb62a3558420061a4acbebf7e729a08", size = 595227, upload-time = "2026-03-28T12:51:03.14Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/23/a6ede2d55fb92be2bff1568e84027e374311c8b1262cd908da3db65f0025/fastapi_pagination-0.15.14.tar.gz", hash = "sha256:61209b30172f928887a2537a85d144a2ae970edfadf160aab7c1fb15676dd651", size = 608334, upload-time = "2026-05-30T12:16:35.469Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/2f/644fd77ecac100da965221751ae4f7604e149c58c46c1d96c37e828bb5f7/fastapi_pagination-0.15.12-py3-none-any.whl", hash = "sha256:758e21157b2844feecb2409072f1433e24f2dc9526ae7906aa1a1b28622a970a", size = 60921, upload-time = "2026-03-28T12:51:04.288Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3d/9e2ee25fd0a1a2c63995967885b2ff5d4f0cc40823817a1c745351ea745c/fastapi_pagination-0.15.14-py3-none-any.whl", hash = "sha256:b1c2ae46ae9952199f75d07726e3f11909ecd32bf12701a11f3e1080f05c4e91", size = 65778, upload-time = "2026-05-30T12:16:34.384Z" }, ] [[package]] @@ -456,11 +457,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -486,7 +487,7 @@ wheels = [ [[package]] name = "lanms-backend" -version = "3.0.0a10" +version = "3.0.0a11" source = { virtual = "." } dependencies = [ { name = "alembic" }, @@ -509,6 +510,7 @@ dependencies = [ { name = "sentry-sdk", extra = ["fastapi", "sqlalchemy", "starlette"] }, { name = "sqlalchemy" }, { name = "tunsberg" }, + { name = "uuid6" }, ] [package.dev-dependencies] @@ -522,43 +524,44 @@ dev = [ requires-dist = [ { name = "alembic", specifier = "~=1.18.4" }, { name = "bcrypt", specifier = ">=4.0.1,<5" }, - { name = "fastapi", extras = ["standard"], specifier = "~=0.135.3" }, + { name = "fastapi", extras = ["standard"], specifier = "~=0.136.3" }, { name = "fastapi-filter", extras = ["sqlalchemy"], specifier = "~=2.0.1" }, - { name = "fastapi-pagination", specifier = "~=0.15.12" }, - { name = "pandas", specifier = "~=3.0.2" }, + { name = "fastapi-pagination", specifier = "~=0.15.14" }, + { name = "pandas", specifier = "~=3.0.3" }, { name = "passlib", specifier = "~=1.7.4" }, - { name = "phonenumbers", specifier = "~=9.0.28" }, + { name = "phonenumbers", specifier = "~=9.0.31" }, { name = "postmarker", specifier = "~=1.0.0" }, - { name = "psycopg2-binary", specifier = "~=2.9.11" }, - { name = "pydantic", extras = ["email"], specifier = "~=2.13.0" }, + { name = "psycopg2-binary", specifier = "~=2.9.12" }, + { name = "pydantic", extras = ["email"], specifier = "~=2.13.4" }, { name = "pydantic-extra-types", specifier = "~=2.11.1" }, { name = "pyjwt", extras = ["crypto"], specifier = "~=2.12.1" }, { name = "pyotp", specifier = "~=2.9.0" }, { name = "python-dotenv", specifier = "~=1.2.2" }, { name = "requests", specifier = "~=2.33.1" }, { name = "sendgrid", specifier = "~=6.12.5" }, - { name = "sentry-sdk", extras = ["fastapi", "sqlalchemy", "starlette"], specifier = "~=2.58.0" }, - { name = "sqlalchemy", specifier = "~=2.0.49" }, + { name = "sentry-sdk", extras = ["fastapi", "sqlalchemy", "starlette"], specifier = "~=2.61.1" }, + { name = "sqlalchemy", specifier = "~=2.0.50" }, { name = "tunsberg", specifier = "~=0.3.0" }, + { name = "uuid6", specifier = "~=2025.0.1" }, ] [package.metadata.requires-dev] dev = [ - { name = "pre-commit", specifier = "~=4.5.1" }, + { name = "pre-commit", specifier = "~=4.6.0" }, { name = "pytest", specifier = ">=9.0.3" }, - { name = "ruff", specifier = "~=0.15.10" }, + { name = "ruff", specifier = "~=0.15.15" }, ] [[package]] name = "mako" -version = "1.3.10" +version = "1.3.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] @@ -640,23 +643,23 @@ wheels = [ [[package]] name = "pandas" -version = "3.0.2" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" }, - { url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" }, - { url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" }, - { url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, ] [[package]] @@ -670,11 +673,11 @@ wheels = [ [[package]] name = "phonenumbers" -version = "9.0.28" +version = "9.0.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/72/97fd1961e3d6f8323b1396669c346557a8581874e2c2a478a83dc5d616e7/phonenumbers-9.0.28.tar.gz", hash = "sha256:f1d810aaa43fbf3a5cb1ee54733218f8333a2a92c85c4d579a810403d6260a8c", size = 2298779, upload-time = "2026-04-13T14:19:50.741Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/20/51f0eafd5819e923d09c6f508a764b84d00e7e7d96c4087627a1adab8d30/phonenumbers-9.0.31.tar.gz", hash = "sha256:287dbd012b12cf9bcc4803b55a29e0d204db5a0df747d38c9130fee1b1b6b0aa", size = 2306679, upload-time = "2026-05-23T06:09:06.338Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/bd/5e55cea252bf21402a2d5f2479edd7e24a4c9ebbe59f197080d0d4cd9093/phonenumbers-9.0.28-py2.py3-none-any.whl", hash = "sha256:ee8caabab4fd554efb6119e7b95cb69da40e0f04050611730eed839d93f39920", size = 2585077, upload-time = "2026-04-13T14:16:09.853Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d3/17268202ea3fa5a597c98ca5dd178ca013f96b42373c54604e59b3bb8d02/phonenumbers-9.0.31-py2.py3-none-any.whl", hash = "sha256:f308c1f425e2637b92ff34a174f0888ddf47aacfa6199531f7097e00ad90f57c", size = 2595455, upload-time = "2026-05-23T06:09:03.35Z" }, ] [[package]] @@ -709,7 +712,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.5.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -718,28 +721,28 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] name = "psycopg2-binary" -version = "2.9.11" +version = "2.9.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, - { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, - { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, - { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, ] [[package]] @@ -753,7 +756,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.0" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -761,9 +764,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/6b/69fd5c7194b21ebde0f8637e2a4ddc766ada29d472bfa6a5ca533d79549a/pydantic-2.13.0.tar.gz", hash = "sha256:b89b575b6e670ebf6e7448c01b41b244f471edd276cd0b6fe02e7e7aca320070", size = 843468, upload-time = "2026-04-13T10:51:35.571Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/d7/c3a52c61f5b7be648e919005820fbac33028c6149994cd64453f49951c17/pydantic-2.13.0-py3-none-any.whl", hash = "sha256:ab0078b90da5f3e2fd2e71e3d9b457ddcb35d0350854fbda93b451e28d56baaf", size = 471872, upload-time = "2026-04-13T10:51:33.343Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [package.optional-dependencies] @@ -773,32 +776,32 @@ email = [ [[package]] name = "pydantic-core" -version = "2.46.0" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/0a/9414cddf82eda3976b14048cc0fa8f5b5d1aecb0b22e1dcd2dbfe0e139b1/pydantic_core-2.46.0.tar.gz", hash = "sha256:82d2498c96be47b47e903e1378d1d0f770097ec56ea953322f39936a7cf34977", size = 471441, upload-time = "2026-04-13T09:06:33.813Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/d2/206c72ad47071559142a35f71efc29eb16448a4a5ae9487230ab8e4e292b/pydantic_core-2.46.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66ccedb02c934622612448489824955838a221b3a35875458970521ef17b2f9c", size = 2117060, upload-time = "2026-04-13T09:04:47.443Z" }, - { url = "https://files.pythonhosted.org/packages/17/2c/7a53b33f91c8b77e696b1a6aa3bed609bf9374bdc0f8dcda681bc7d922b8/pydantic_core-2.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a44f27f4d2788ef9876ec47a43739b118c5904d74f418f53398f6ced3bbcacf2", size = 1951802, upload-time = "2026-04-13T09:05:34.591Z" }, - { url = "https://files.pythonhosted.org/packages/fc/20/90e548c1f6d38800ef11c915881525770ce270d8e5e887563ff046a08674/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26a1032bcce6ca4b4670eb3f7d8195bd0a8b8f255f1307823e217ca3cfa7c27", size = 1976621, upload-time = "2026-04-13T09:04:03.909Z" }, - { url = "https://files.pythonhosted.org/packages/20/3c/9c5810ca70b60c623488cdd80f7e9ee1a0812df81e97098b64788719860f/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b8d1412f725060527e56675904b17a2d421dddcf861eecf7c75b9dda47921a4", size = 2056721, upload-time = "2026-04-13T09:04:40.992Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a3/d6e5f4cdec84278431c75540f90838c9d0a4dfe9402a8f3902073660ff28/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc3d1569edd859cabaa476cabce9eecd05049a7966af7b4a33b541bfd4ca1104", size = 2239634, upload-time = "2026-04-13T09:03:52.478Z" }, - { url = "https://files.pythonhosted.org/packages/46/42/ef58aacf330d8de6e309d62469aa1f80e945eaf665929b4037ac1bfcebc1/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38108976f2d8afaa8f5067fd1390a8c9f5cc580175407cda636e76bc76e88054", size = 2315739, upload-time = "2026-04-13T09:05:04.971Z" }, - { url = "https://files.pythonhosted.org/packages/8b/86/c63b12fafa2d86a515bfd1840b39c23a49302f02b653161bf9c3a0566c50/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5a06d8ed01dad5575056b5187e5959b336793c6047920a3441ee5b03533836", size = 2098169, upload-time = "2026-04-13T09:07:27.151Z" }, - { url = "https://files.pythonhosted.org/packages/76/19/b5b33a2f6be4755b21a20434293c4364be255f4c1a108f125d101d4cc4ee/pydantic_core-2.46.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:04017ace142da9ce27cafd423a480872571b5c7e80382aec22f7d715ca8eb870", size = 2170830, upload-time = "2026-04-13T09:04:39.448Z" }, - { url = "https://files.pythonhosted.org/packages/99/ae/7559f99a29b7d440012ddb4da897359304988a881efaca912fd2f655652e/pydantic_core-2.46.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2629ad992ed1b1c012e6067f5ffafd3336fcb9b54569449fabb85621f1444ed3", size = 2203901, upload-time = "2026-04-13T09:04:01.048Z" }, - { url = "https://files.pythonhosted.org/packages/dd/0e/b0ef945a39aeb4ac58da316813e1106b7fbdfbf20ac141c1c27904355ac5/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3068b1e7bd986aebc88f6859f8353e72072538dcf92a7fb9cf511a0f61c5e729", size = 2191789, upload-time = "2026-04-13T09:06:39.915Z" }, - { url = "https://files.pythonhosted.org/packages/90/f4/830484e07188c1236b013995818888ab93bab8fd88aa9689b1d8fd22220d/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:1e366916ff69ff700aa9326601634e688581bc24c5b6b4f8738d809ec7d72611", size = 2344423, upload-time = "2026-04-13T09:05:12.252Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ba/e455c18cbdc333177af754e740be4fe9d1de173d65bbe534daf88da02ac0/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:485a23e8f4618a1b8e23ac744180acde283fffe617f96923d25507d5cade62ec", size = 2384037, upload-time = "2026-04-13T09:06:24.503Z" }, - { url = "https://files.pythonhosted.org/packages/78/1f/b35d20d73144a41e78de0ae398e60fdd8bed91667daa1a5a92ab958551ba/pydantic_core-2.46.0-cp312-cp312-win32.whl", hash = "sha256:520940e1b702fe3b33525d0351777f25e9924f1818ca7956447dabacf2d339fd", size = 1967068, upload-time = "2026-04-13T09:05:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/4b6252e9606e8295647b848233cc4137ee0a04ebba8f0f9fb2977655b38c/pydantic_core-2.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:90d2048e0339fa365e5a66aefe760ddd3b3d0a45501e088bc5bc7f4ed9ff9571", size = 2071008, upload-time = "2026-04-13T09:05:21.392Z" }, - { url = "https://files.pythonhosted.org/packages/39/95/d08eb508d4d5560ccbd226ee5971e5ef9b749aba9b413c0c4ed6e406d4f6/pydantic_core-2.46.0-cp312-cp312-win_arm64.whl", hash = "sha256:a70247649b7dffe36648e8f34be5ce8c5fa0a27ff07b071ea780c20a738c05ce", size = 2036634, upload-time = "2026-04-13T09:05:48.299Z" }, - { url = "https://files.pythonhosted.org/packages/74/0c/106ed5cc50393d90523f09adcc50d05e42e748eb107dc06aea971137f02d/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:bc0e2fefe384152d7da85b5c2fe8ce2bf24752f68a58e3f3ea42e28a29dfdeb2", size = 2104968, upload-time = "2026-04-13T09:06:26.967Z" }, - { url = "https://files.pythonhosted.org/packages/f5/71/b494cef3165e3413ee9bbbb5a9eedc9af0ea7b88d8638beef6c2061b110e/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a2ab0e785548be1b4362a62c4004f9217598b7ee465f1f420fc2123e2a5b5b02", size = 1940442, upload-time = "2026-04-13T09:06:29.332Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3e/a4d578c8216c443e26a1124f8c1e07c0654264ce5651143d3883d85ff140/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16d45aecb18b8cba1c68eeb17c2bb2d38627ceed04c5b30b882fc9134e01f187", size = 1999672, upload-time = "2026-04-13T09:04:42.798Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c1/9114560468685525a21770138382fd0cb849aaf351ff2c7b97f760d121e0/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5078f6c377b002428e984259ac327ef8902aacae6c14b7de740dd4869a491501", size = 2154533, upload-time = "2026-04-13T09:04:50.868Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] [[package]] @@ -816,16 +819,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] @@ -921,11 +924,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.26" +version = "0.0.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" }, ] [[package]] @@ -1013,27 +1016,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, ] [[package]] @@ -1052,15 +1055,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.58.0" +version = "2.61.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/b3/fb8291170d0e844173164709fc0fa0c221ed75a5da740c8746f2a83b4eb1/sentry_sdk-2.58.0.tar.gz", hash = "sha256:c1144d947352d54e5b7daa63596d9f848adf684989c06c4f5a659f0c85a18f6f", size = 438764, upload-time = "2026-04-13T17:23:26.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/3b/4bc6b348bbd331daa14d4babe9f2b99bc854f4da41560eefb9488d78481d/sentry_sdk-2.61.1.tar.gz", hash = "sha256:9c6adccb3feefa9ba032c8d295ca477575c2f11896046a2b0ad686c47c4af555", size = 459429, upload-time = "2026-06-01T07:24:18.875Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/eb/d875669993b762556ae8b2efd86219943b4c0864d22204d622a9aee3052b/sentry_sdk-2.58.0-py2.py3-none-any.whl", hash = "sha256:688d1c704ddecf382ea3326f21a67453d4caa95592d722b7c780a36a9d23109e", size = 460919, upload-time = "2026-04-13T17:23:24.675Z" }, + { url = "https://files.pythonhosted.org/packages/df/54/c9218db183846e08efaf68534889ef42e499dde432778881104a42f7071b/sentry_sdk-2.61.1-py3-none-any.whl", hash = "sha256:fa36eaf4b8ad708f718500d4bdcc1532637526a22beb874d88cbc0a46458b5ae", size = 483735, upload-time = "2026-06-01T07:24:17.027Z" }, ] [package.optional-dependencies] @@ -1094,35 +1097,35 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.49" +version = "2.0.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, - { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, + { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, ] [[package]] name = "starlette" -version = "1.0.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -1186,11 +1189,20 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid6" +version = "2025.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/b7/4c0f736ca824b3a25b15e8213d1bcfc15f8ac2ae48d1b445b310892dc4da/uuid6-2025.0.1.tar.gz", hash = "sha256:cd0af94fa428675a44e32c5319ec5a3485225ba2179eefcf4c3f205ae30a81bd", size = 13932, upload-time = "2025-07-04T18:30:35.186Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/93faaab7962e2aa8d6e174afb6f76be2ca0ce89fde14d3af835acebcaa59/uuid6-2025.0.1-py3-none-any.whl", hash = "sha256:80530ce4d02a93cdf82e7122ca0da3ebbbc269790ec1cb902481fa3e9cc9ff99", size = 6979, upload-time = "2025-07-04T18:30:34.001Z" }, ] [[package]] diff --git a/docker-compose.yml b/docker-compose.yml index 1141673..89c5c8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,11 +7,13 @@ services: dockerfile: Dockerfile args: - BUILD_ENV=${BUILD_ENV:-production} + - NEXT_PUBLIC_ENV=${NEXT_PUBLIC_ENV:-production} + - NEXT_PUBLIC_CORE_API_URL=${NEXT_PUBLIC_CORE_API_URL:-http://backend:8000/v3} ports: - "${FRONTEND_PORT:-8080}:8080" environment: - - VITE_ENV=${VITE_ENV:-production} - - VITE_CORE_API_URL=${VITE_CORE_API_URL:-http://backend:8000/v3} + - NEXT_PUBLIC_ENV=${NEXT_PUBLIC_ENV:-production} + - NEXT_PUBLIC_CORE_API_URL=${NEXT_PUBLIC_CORE_API_URL:-http://backend:8000/v3} networks: - app-network depends_on: diff --git a/docs/frontend/README.md b/docs/frontend/README.md index 21af869..ef28245 100644 --- a/docs/frontend/README.md +++ b/docs/frontend/README.md @@ -6,10 +6,10 @@ Run the commands below from the `frontend/` directory at the repository root. ## Development -Make a copy of the `.env.example` file and rename it to `.env`. Fill in the required environment variables. +Make a copy of the [`.env.example`](../../frontend/.env.example) file and rename it to `.env.local` (or `.env`). Set `NEXT_PUBLIC_CORE_API_URL` to your LANMS API base URL (for example `http://127.0.0.1:8001/v3`) and optional `NEXT_PUBLIC_ENV`. ```bash -cp .env.example .env +cp .env.example .env.local ``` Install the dependencies: @@ -18,12 +18,21 @@ Install the dependencies: npm install ``` -Run the development server: +Run the development server (Next.js): ```bash npm run dev ``` +Production build and local run: + +```bash +npm run build +npm run start +``` + +Sandbox/staging builds load env from `.env.sandbox` / `.env.staging` via `npm run build:sandbox` and `npm run build:staging`. + Update the translation files: ```bash diff --git a/frontend/.cursor/mcp.json b/frontend/.cursor/mcp.json new file mode 100644 index 0000000..bd98b4f --- /dev/null +++ b/frontend/.cursor/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + } + } +} diff --git a/frontend/.env.example b/frontend/.env.example index a5a891d..403e3d4 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,2 @@ -VITE_ENV=local -VITE_CORE_API_URL=http://127.0.0.1:8001/v3 +NEXT_PUBLIC_ENV=local +NEXT_PUBLIC_CORE_API_URL=http://127.0.0.1:8001/v3 diff --git a/frontend/.env.sandbox b/frontend/.env.sandbox index 73161b5..24cdb03 100644 --- a/frontend/.env.sandbox +++ b/frontend/.env.sandbox @@ -1 +1 @@ -VITE_ENV=sandbox +NEXT_PUBLIC_ENV=sandbox diff --git a/frontend/.env.staging b/frontend/.env.staging index 94529fb..214fbb8 100644 --- a/frontend/.env.staging +++ b/frontend/.env.staging @@ -1 +1 @@ -VITE_ENV=staging +NEXT_PUBLIC_ENV=staging diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs deleted file mode 100644 index 3d504cb..0000000 --- a/frontend/.eslintrc.cjs +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - root: true, - env: {browser: true, es2020: true}, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - {allowConstantExport: true}, - ], - }, -} diff --git a/frontend/.gitignore b/frontend/.gitignore index 50288d8..169ae8f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,26 +1,46 @@ -# Logs -logs -*.log +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug npm-debug.log* yarn-debug.log* yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local -/.vite - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) .env +.env.* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# .idea +/.idea diff --git a/frontend/.nvmrc b/frontend/.nvmrc deleted file mode 100644 index 2bd5a0a..0000000 --- a/frontend/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -22 diff --git a/frontend/.prettierrc b/frontend/.prettierrc deleted file mode 100644 index 35cf61e..0000000 --- a/frontend/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "tabWidth": 1, - "useTabs": false -} diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f3b6882..7893a8e 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,29 +1,24 @@ ARG BUILD_ENV=production ARG NODE=node:22-alpine -ARG NGINX=nginx:stable-alpine -FROM ${NODE} as node +FROM ${NODE} AS builder -# Needed to pass the ENV variable to the build stage, so stupid ARG BUILD_ENV -# Reference: https://qmacro.org/blog/posts/2024/05/13/using-arg-in-a-dockerfile-beware-the-gotcha/ +ARG NEXT_PUBLIC_ENV=production +ARG NEXT_PUBLIC_CORE_API_URL -# Set working directory -WORKDIR /app +ENV NEXT_PUBLIC_ENV=$NEXT_PUBLIC_ENV +ENV NEXT_PUBLIC_CORE_API_URL=$NEXT_PUBLIC_CORE_API_URL -# Copy source code -COPY . /app +WORKDIR /app -# Update npm -RUN npm install -g npm@latest +COPY package.json package-lock.json* ./ +RUN npm install -g npm@latest && npm ci -# Install dependencies -RUN npm install +COPY . . -# Build for production or staging RUN echo "Building for environment: $BUILD_ENV" >&2 -# Override build command if build mode is staging RUN if [ "$BUILD_ENV" = "sandbox" ]; then \ npm run build:sandbox; \ elif [ "$BUILD_ENV" = "staging" ]; then \ @@ -32,21 +27,23 @@ RUN if [ "$BUILD_ENV" = "sandbox" ]; then \ npm run build:production; \ fi -# Build Nginx image -FROM ${NGINX} +FROM ${NODE} AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=8080 +ENV HOSTNAME=0.0.0.0 -# Copy Nginx configuration file into appropriate location (see docs of nginx image) -COPY nginx.conf /etc/nginx/conf.d/default.conf +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs -# Copy static files to nginx work folder -COPY --from=node app/dist /var/www/portal/ +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -# Add redirecting requests and error logs to docker log -RUN ln -sf /dev/stdout /var/log/nginx/portal.access.log \ - && ln -sf /dev/stderr /var/log/nginx/portal.error.log +USER nextjs -# Expose port EXPOSE 8080 -# Start nginx -CMD ["nginx", "-g", "daemon off;"] +CMD ["node", "server.js"] diff --git a/frontend/README.md b/frontend/README.md index 5f794be..032c7dd 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,3 +1,3 @@ # LANMS — Frontend -Documentation for setup, development, and deployment lives in **[docs/frontend/README.md](../docs/frontend/README.md)**. +Documentation for setup, development, and deployment lives in **[docs/frontend/README.md](../docs/frontend/README.md)**. \ No newline at end of file diff --git a/frontend/app/(home)/layout.tsx b/frontend/app/(home)/layout.tsx new file mode 100644 index 0000000..d680ccc --- /dev/null +++ b/frontend/app/(home)/layout.tsx @@ -0,0 +1,14 @@ +import { Toaster } from "@/components/ui/sonner" + +export default function HomeLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <> + {children} + + + ) +} diff --git a/frontend/app/(home)/page.tsx b/frontend/app/(home)/page.tsx new file mode 100644 index 0000000..98ff035 --- /dev/null +++ b/frontend/app/(home)/page.tsx @@ -0,0 +1,19 @@ +import { Button } from "@/components/ui/button" + +export default function Page() { + return ( +
+
+
+

Project ready!

+

You may now add components and start building.

+

We've already added the button component for you.

+ +
+
+ (Press d to toggle dark mode) +
+
+
+ ) +} diff --git a/frontend/app/auth/change-password/page.tsx b/frontend/app/auth/change-password/page.tsx new file mode 100644 index 0000000..eec1c95 --- /dev/null +++ b/frontend/app/auth/change-password/page.tsx @@ -0,0 +1,5 @@ +import { ChangePasswordForm } from "@/components/auth/change-password-form" + +export default function ChangePasswordPage() { + return +} diff --git a/frontend/app/auth/forgot-password/page.tsx b/frontend/app/auth/forgot-password/page.tsx new file mode 100644 index 0000000..54b4d81 --- /dev/null +++ b/frontend/app/auth/forgot-password/page.tsx @@ -0,0 +1,5 @@ +import { ForgotPasswordForm } from "@/components/auth/forgot-password-form" + +export default function ForgotPasswordPage() { + return +} diff --git a/frontend/app/auth/layout.tsx b/frontend/app/auth/layout.tsx new file mode 100644 index 0000000..f88a325 --- /dev/null +++ b/frontend/app/auth/layout.tsx @@ -0,0 +1,20 @@ +import { AuthBrand } from "@/components/auth/auth-brand" +import { Toaster } from "@/components/ui/sonner" + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <> +
+
+ + {children} +
+
+ + + ) +} diff --git a/frontend/app/auth/login/page.tsx b/frontend/app/auth/login/page.tsx new file mode 100644 index 0000000..e63bffb --- /dev/null +++ b/frontend/app/auth/login/page.tsx @@ -0,0 +1,5 @@ +import { LoginForm } from "@/components/login-form" + +export default function AuthLoginPage() { + return +} diff --git a/frontend/app/auth/reset-password/page.tsx b/frontend/app/auth/reset-password/page.tsx new file mode 100644 index 0000000..a5c2041 --- /dev/null +++ b/frontend/app/auth/reset-password/page.tsx @@ -0,0 +1,10 @@ +import { ResetPasswordForm } from "@/components/auth/reset-password-form" + +export default async function ResetPasswordPage({ + searchParams, +}: { + searchParams: Promise<{ reset_token?: string }> +}) { + const sp = await searchParams + return +} diff --git a/frontend/app/auth/signup/page.tsx b/frontend/app/auth/signup/page.tsx new file mode 100644 index 0000000..f7b399f --- /dev/null +++ b/frontend/app/auth/signup/page.tsx @@ -0,0 +1,5 @@ +import { SignupForm } from "@/components/signup-form" + +export default function AuthSignupPage() { + return +} diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/app/favicon.ico differ diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..d9d499f --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,129 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --card: oklch(1 0 0); + --card-foreground: oklch(0.147 0.004 49.25); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.147 0.004 49.25); + --primary: oklch(0.553 0.195 38.402); + --primary-foreground: oklch(0.98 0.016 73.684); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.97 0.001 106.424); + --muted-foreground: oklch(0.553 0.013 58.071); + --accent: oklch(0.97 0.001 106.424); + --accent-foreground: oklch(0.216 0.006 56.043); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.923 0.003 48.717); + --input: oklch(0.923 0.003 48.717); + --ring: oklch(0.709 0.01 56.259); + --chart-1: oklch(0.879 0.169 91.605); + --chart-2: oklch(0.769 0.188 70.08); + --chart-3: oklch(0.666 0.179 58.318); + --chart-4: oklch(0.555 0.163 48.998); + --chart-5: oklch(0.473 0.137 46.201); + --radius: 0.625rem; + --sidebar: oklch(0.985 0.001 106.423); + --sidebar-foreground: oklch(0.147 0.004 49.25); + --sidebar-primary: oklch(0.646 0.222 41.116); + --sidebar-primary-foreground: oklch(0.98 0.016 73.684); + --sidebar-accent: oklch(0.97 0.001 106.424); + --sidebar-accent-foreground: oklch(0.216 0.006 56.043); + --sidebar-border: oklch(0.923 0.003 48.717); + --sidebar-ring: oklch(0.709 0.01 56.259); + --background: oklch(1 0 0); + --foreground: oklch(0.147 0.004 49.25); +} + +.dark { + --background: oklch(0.147 0.004 49.25); + --foreground: oklch(0.985 0.001 106.423); + --card: oklch(0.216 0.006 56.043); + --card-foreground: oklch(0.985 0.001 106.423); + --popover: oklch(0.216 0.006 56.043); + --popover-foreground: oklch(0.985 0.001 106.423); + --primary: oklch(0.47 0.157 37.304); + --primary-foreground: oklch(0.98 0.016 73.684); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.268 0.007 34.298); + --muted-foreground: oklch(0.709 0.01 56.259); + --accent: oklch(0.268 0.007 34.298); + --accent-foreground: oklch(0.985 0.001 106.423); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.553 0.013 58.071); + --chart-1: oklch(0.879 0.169 91.605); + --chart-2: oklch(0.769 0.188 70.08); + --chart-3: oklch(0.666 0.179 58.318); + --chart-4: oklch(0.555 0.163 48.998); + --chart-5: oklch(0.473 0.137 46.201); + --sidebar: oklch(0.216 0.006 56.043); + --sidebar-foreground: oklch(0.985 0.001 106.423); + --sidebar-primary: oklch(0.705 0.213 47.604); + --sidebar-primary-foreground: oklch(0.98 0.016 73.684); + --sidebar-accent: oklch(0.268 0.007 34.298); + --sidebar-accent-foreground: oklch(0.985 0.001 106.423); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.553 0.013 58.071); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..c6e4216 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,33 @@ +import { Geist, Geist_Mono, Inter } from "next/font/google" + +import "./globals.css" +import { ThemeProvider } from "@/components/theme-provider" +import { TooltipProvider } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils"; + +const inter = Inter({subsets:['latin'],variable:'--font-sans'}) + +const fontMono = Geist_Mono({ + subsets: ["latin"], + variable: "--font-mono", +}) + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + {children} + + + + ) +} diff --git a/frontend/app/organisor/events/[eventId]/articles/[articleId]/edit/edit-article-form.tsx b/frontend/app/organisor/events/[eventId]/articles/[articleId]/edit/edit-article-form.tsx new file mode 100644 index 0000000..bcb98d2 --- /dev/null +++ b/frontend/app/organisor/events/[eventId]/articles/[articleId]/edit/edit-article-form.tsx @@ -0,0 +1,276 @@ +"use client" + +import Link from "next/link" +import { useParams, useRouter } from "next/navigation" +import { + startTransition as startReactTransition, + useEffect, + useState, + useTransition, +} from "react" +import { + asArticleRecord, + getArticle, + putArticle, +} from "@/lib/api/articles" +import { getAccessToken } from "@/lib/auth/session" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Skeleton } from "@/components/ui/skeleton" + +function slugFromTitle(title: string): string { + const s = title + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, "") + return s.length > 0 ? s : "article" +} + +function isoToDatetimeLocalValue(iso: string | null): string { + if (!iso) return "" + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return "" + const pad = (n: number) => String(n).padStart(2, "0") + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +export function EditArticleForm() { + const params = useParams() + const router = useRouter() + const eventId = typeof params.eventId === "string" ? params.eventId : "" + const articleId = + typeof params.articleId === "string" ? params.articleId : "" + + const [pending, startTransition] = useTransition() + const [loadError, setLoadError] = useState(null) + const [submitError, setSubmitError] = useState(null) + const [loading, setLoading] = useState(() => !!(eventId && articleId)) + const [title, setTitle] = useState("") + const [slug, setSlug] = useState("") + const [content, setContent] = useState("") + const [publishedLocal, setPublishedLocal] = useState("") + + useEffect(() => { + if (!eventId || !articleId) return + let cancelled = false + startReactTransition(() => { + if (!cancelled) { + setLoading(true) + setLoadError(null) + } + }) + const token = getAccessToken() + ;(async () => { + const res = await getArticle(eventId, articleId, token ?? undefined) + if (cancelled) return + if (!res.ok) { + startReactTransition(() => { + if (!cancelled) { + setLoadError(res.message) + setLoading(false) + } + }) + return + } + const article = asArticleRecord(res.data) + if (!article) { + startReactTransition(() => { + if (!cancelled) { + setLoadError("Could not read article from the server.") + setLoading(false) + } + }) + return + } + startReactTransition(() => { + if (cancelled) return + setTitle(article.title) + setSlug(article.slug ?? "") + setContent(article.content) + setPublishedLocal(isoToDatetimeLocalValue(article.published_at)) + setLoadError(null) + setLoading(false) + }) + })() + return () => { + cancelled = true + } + }, [eventId, articleId]) + + if (!eventId || !articleId) { + return ( + + Invalid article or event. + + ) + } + + if (loading) { + return ( + + + + + + + + + + + + + ) + } + + if (loadError) { + return ( + + {loadError} + + ) + } + + return ( + + + Edit article + + Update content, slug, or publishing time. Clear the publish field to + save as a draft. + + + +
{ + e.preventDefault() + setSubmitError(null) + const token = getAccessToken() + if (!token) { + setSubmitError("You must be signed in.") + return + } + const titleTrim = title.trim() + const contentTrim = content.trim() + const slugRaw = slug.trim() + if (!titleTrim || !contentTrim) { + setSubmitError("Title and content are required.") + return + } + const slugValue = + slugRaw.length > 0 ? slugRaw : slugFromTitle(titleTrim) + const published_at = + publishedLocal.trim().length > 0 + ? new Date(publishedLocal).toISOString() + : null + startTransition(async () => { + const res = await putArticle( + eventId, + articleId, + { + title: titleTrim, + slug: slugValue, + content: contentTrim, + published_at, + }, + token, + ) + if (!res.ok) { + setSubmitError(res.message) + return + } + router.push(`/organisor/events/${eventId}/articles`) + }) + }} + > + + {submitError ? ( + + {submitError} + + ) : null} + + Title + setTitle(e.target.value)} + /> + + + Slug (optional) + setSlug(e.target.value)} + /> + + URL-friendly id; generated from title if left blank. + + + + Content +